文章目录
- csapp 学习记录一
- 第一章:计算机系统漫游
-
-
- 信息就是位 上下文
- 从C文件到可执行目标文件的整个翻译过程分为四个阶段
- 程序执行的过程:
- 摩尔定律:
- HELLO WORLD 生成可执行程序
- 理解编译过程和原理的意义是什么?
- 可执行程序hello在计算机上执行的过程
- 程序执行过程中的启示
- 系统的硬件组成
- 高速缓存
- 存储设备形成层次结构
- 操作系统管理硬件
-
- 进程
- 线程
- 虚拟内存
- 并发和并行
- 线程级并发
- 指令级并行
- 第二章:信息表示和处理
-
- 2.1 信息存储
-
- 2.1.1 十六进制表示法
- 2.1.2 字
- 2.1.3 数据大小
- 2.1.4 找址和字节顺序
- 2.1.5 表示字符串
- 2.1.6 表示代码
- 2.1.7 布尔代数简介
- 2.1.8 C语言中的等级运算
- 2.1.9 C语言中的逻辑操作
- 2.1.10 C语言中的移位操作
- 2.2 整数表示
-
- 2.2.1整数据类型
- 2.2.2 无符号编码
- 2.2.3 补码编码
- 2.2.4 符号数与无符号数之间的转换
- 2.2.5 C语言中的符号数和无符号数
- 2.2.6 扩展一个数字的位表示
- 2.2.7 截断数字
- 2.3 整数运算
-
- 2.3.1 无符号加法
- 2.3.2 补码加法
- 2.3.3 补码的非
- 2.3.4 无符号乘法
- 2.3.5 补码乘法
- 2.3.6 乘以常数
- 2.3.7 除以2的幂
- 2.3.8 思考整数运算
- 2.4 浮点数
-
- 2.4.1 二进制小数
- 2.4.2 IEEE浮点表示
- 2.4.3 数字示例
- 第 3 章:程序的机器级表示
- 3.1 历史观点
- 3.2 程序编码
-
- 3.2.1 机器级代码
- 3.2.2 代码示例
- 3.2.3 格式注释
- 3.3 数据格式
- 3.4访问信息
-
- 3.4.1操作数指示符
- 3.4.2数据传输指令
- 3.4.3数据传输示例
- 3.4.压入和弹出栈数据
- 3.5算术和逻辑操作
-
- 3.5.加载有效地址
- 3.5.21元和2元操作
- 3.5.3移位操作
- 3.5.特殊算术操作
- 3.6控制
-
- 3.6.1条件码
- 3.6.2访问条件码
- 3.6.3跳转指令
- 3.6.编码跳转指令
- 3.6.用条件控制实现条件分支
- 3.6.使用条件传输实现条件分支
- 3.6.7循环
- 3.6.8switch
- 3.7过程
-
- 3.7.1运行时栈
- 3.7.2转移控制
- 3.7.3数据传送
- 3.7.局部存储在四栈上
- 3.7.55寄存器中的局部存储空间
- 递归过程
- 3.8数据的分配和访问
- 3.9异质数据结构
-
- 3.9.1结构
- 3.9.2联合
- 3.9.3数据对齐
- 3.将控制与数据结合在机器级程序中
-
- 3.10.1理解指针
- 3.10.2使用GDB调试器
- 3.10.3内存越界引用和缓冲区溢出
- 3.10.4对抗缓冲区溢出攻击
- 3.10.支持可变栈帧
- 3.11 浮点代码
-
- 3.11.1 浮点传输和转换操作
- 3.11.2 浮点代码的过程
- 3.11.3 浮点操作
- 3.11.4 浮点常数的定义和使用
- 3.11.5 在浮点代码中使用等级操作
- 3.11.6 浮点比较操作
- 第6章 存储层次结构
-
- 6.1存储技术
- 6.1.随机访问存储器
-
- SRAM
- DRAM
- SRAM 与 DRAM 比较
- 传统的 DRAM
- 内存模块
- 增强的 DRAM
- 非易失性存储器
- 访问主存
- 6.1磁盘存储
-
- 磁盘构造
- 磁盘容量
- 磁盘操作
- 逻辑磁盘块
- 连接 I/O 设备
- 6.1.3固态硬盘
- 6.1.4存储技术趋势
- 6.2局部性
-
- 6.2.1引用程序数据的局部性
- 6.2.取指令的局部性
- 6.2.3局部性小结
- 6.三是存储层次结构
- 6.3.1缓存在存储器层次结构中
- 6.4高速缓存储器
-
-
- 6.4.一般高速缓存储器的组织结构
- 6.4.高速缓存直接映射
- 6.4.三组高速高速缓存
- 6.4.全相联高速缓存
- 6.4.5关于写作的问题
- 6.4.解剖真实的高速缓存层次结构
- 6.4.7高速缓存参数的性能影响
-
- 第7章 链接
-
-
- 念
- 为什么需要了解链接器
- 7.1编译器驱动程序
- 7.2静态链接
- 7.3目标文件
- 7.4可重定位目标文件
- 7.5符号和符号表
- 7.6符号解析
-
- 7.6.1如何解析多重定义的全局符号
- 7.6.2与静态库链接
- 7.6.3链接器如何解析引用
- 7.7重定位
-
- 7.7.1重定位条目
- 7.7.2重定位符号引用
- 7.8可执行目标文件
- 7.9加载可执行目标文件
- 7.10**动态链接共享库**
- 7.11从应用程序中加载和链接共享库
- 7.12位置无关代码
- 7.13库打桩机制
- 7.14处理目标文件的工具
- 7.15总结
-
- 第8章 异常控制流
-
- 8.1异常
-
- 8.1.1异常处理
- 8.1.2异常的类别
-
- 中断
- 陷阱和系统调用
- 故障
- 终止
- 8.1.3Linux/x86-64系统中的异常
- 8.2进程
-
- 8.2.1逻辑控制流
- 8.2.2并发流
- 8.2.3私有地址空间
- 8.2.4用户模式和内核模式
- 8.2.5上下文切换
- 8.3系统调用错误处理
- 8.4进程控制
-
- 获取进程ID
- 创建与终止进程
- 回收子进程
- 让进程休眠
- 加载并运行程序
- 利用fork和execve运行程序
- lab1 Data Lab
-
- 题目列表
-
- 准备
- 题解
-
- bitXor(x,y)
- tmin()
- isTmax(x)
- allOddBits(x)
- negate(x)
- isAsciiDigit(x)
- conditional(x, y, z)
- isLessOrEqual(x,y)
- logicalNeg(x)
- howManyBits(x)
- floatScale2(f)
- floatFloat2Int(f)
- floatPower2(x)
- lab2 Bomb Lab
-
-
- 准备
- 题解
-
- **phase_1**
- **phase_2**
- phase_3
- **phase_4**
- **phase_5**
- **phase_6**
- Secret_phase
-
- **Lab3 ATTACK Lab**
-
-
- 准备
- 题解
- 第一部分:代码注入攻击
-
- level-1
- level-2
- level-3
-
csapp 学习记录一
第1章:计算机系统漫游
信息就是位+上下文
系统中的所有信息,都是一串比特组成的。区分不同数据对象的唯一方法是联系他们的上下文。
从一个c文件,到可执行目标文件整个翻译过程分为4个阶段
-
预处理阶段 预处理器 cpp 根据字符# 开头的命令,修改原始的C程序。
比如include<stdio.h> 命令告诉预处理器,读取系统头文件 stdio.h的内容。并把它插入到程序中。结果得到了另一个c程序。通常为.i作为文件拓展。
-
编译阶段。 编译器(ccl)将hello.i 翻译成文本文件 hello.s 翻译是将.i 转为汇编。
-
汇编阶段, 汇编器(as)将hello.s 翻译成机器语言指令,将这些指令打包成可重定位目标程序的格式。并将结果保存至目标 hello.o中(改文件为二进制文件,它包含的17个字节是main的执行编码)。如果文本编辑器打开,则为一堆乱码
-
链接阶段 : 比如说hello程序调用了printf函数,是每个C编译器都提供的标准C库中的一个函数,printf 函数保存在一个名为printf.o 的单独预编译完成的目标文件中。 这个文件必须以某种方式合并到我们程序中。链接器(ld)就负责这种合并,于是得到hello文件,它是一个可执行文件,可以直接加载到内存中执行。
程序执行的过程:
shell程序执行指令,等待我们的命令,输入命令并回车之后, shell将字符都逐一读进寄存器。再把它放到内存中。
(利用DMA:直接存储器读取)技术可以数据不经过处理器而直接从磁盘到达主存。
一旦目标文件hello的代码和数据被加载到内存,处理器就开始执行。hello程序中main程序中的机器语言指令。再从寄存器文件中复制到显示设备,最终显示在屏幕上。
摩尔定律:
HELLO WORLD 可执行程序的产生
hello.c 文本文件的创建:
#include<stdio.h>
int main()
{
printf("hello world\n");
return 0;
}
对源代码进行编译,生成可执行文件hello
理解编译过程及原理的意义何在
整体来看有三个方面的原因:
1、优化程序性能
2、理解程序链接时的错误,有助于我们解决各种奇奇怪怪的错误
3、避免安全漏洞,比如缓冲区溢出漏洞等
可执行程序hello在计算机上执行的过程
此过程用几副抽象出来图片来说明一下
图中“hello”由usb键盘输入,通过I/O总线传递给cpu,其处理后将获得的数据存至内存中
图中磁盘与内存之间通过DMA(Direct Memory Access)直接存储器存取技术将需要的hello程序数据,从磁盘中读入内存中
图中CPU读取内存中的程序指令及数据,处理后将计算后的数据交给显示设备,显示设备收到数据后进行显示
程序执行过程中的几点启示
1、程序执行时数据需要进行多点交换,整个过程需要消耗“大量”时间。
2、按照目前大部分计算机来说,计算机数据存储单元按照读写速度由大到小比较:寄存器>高速缓存L1>高速缓存L2>高速缓存L3>内存>磁盘,而按照数据存储大小来看正好相反。
3、本例中,hello程序的执行并非是自己将自己交给处理器进行执行,而是通过shell程序将各种参数交给操作系统,操作系统再对计算机资源进行调度,然后将hello程序交给计算机组件进行处理输出。
4、计算机在进行资源调度时,会为新的程序创建一个进程,而每个进程看到的内存都是一样的,因为程序在链接的时候会对内存地址进行分配,所以操作系统为每个进程统一内存,还原一个程序所需要的内存环境,我们称之为虚拟地址空间。如下图(虚拟地址空间)的分布。
系统的硬件组成
典型系统的硬件构成
-
贯穿整个系统的是一组电子管道,称为总线 ,它携带信息字节,并负责在各个部件之间传递,,字中的字节数(字长)是一个基本的系统参数,现在大多数机器字长有
-
,控制器是I/O设备本身或者系统的主印制电路板(主板)上的芯片组,而适配器则是一块插在主板插槽上的卡,无论如何,它们的功能
-
主存是一个,在处理器执行程序的时候,,从物理上说,主存是由一组动态随机存取存储器(DRAM)芯片组成的。从逻辑上说,存储器是一个线型的字节数组,每个字节都有其唯一的索引(数组索引),这些地址是从零开始的,一般来说,组成程序的每条机器指令都由不同数量的字节构成,比如在运行Linux的x86-64机器上,short类型的数据需要2个字节,int和float类型需要4个字节,而long和double需要8个字节
-
中央处理单元(CPU),简称处理器,是解释(或运行)存储在主存中指令的引擎。处理器的核心是一个大小为一个字的存储设备(或寄存器),称为程序计数器(PC)。在任何时刻,PC都指向主存中某条机器语言指令(即含有该条指令的地址)
处理器看上去是一个非常简单的指令执行模型来操作的,这个模型是由指令集架构决定的,在这个模型中,指令按照严格的顺序执行,而执行一条指令包含一系列的步骤,处理器从程序计数器指向的内存处读取指令,解释指令中的位,执行该指令指示的简单操作,然后更新PC,使其指向下一条指令,而这条指令并不一定和在内存中刚刚执行的指令相邻
这样简单的操作并不多,它们围绕着主存、寄存器文件和算数/逻辑单元(ALU)进行。寄存器文件是一个小的存储设备,由一些单个字长的寄存器组成,每个寄存器都有唯一的名字, ALU计算新的数据和地址值。下面是一些简单操作的例子,CPU在指令的要求下可能会执行这些操作
- 加载:从主存复制一个字节或者一个字到寄存器,以覆盖寄存器原来内容
- 存储:从寄存器赋值一个字节或者一个字到主存的某个位置,以覆盖这个位置上原来的内容
- 操作:把两个寄存器的内容复制到ALU,ALU对这两个字做算术运算,并将结果存放到一个寄存器中,以覆盖该寄存器中原来的内容
- 跳转:从之灵本身中抽取一个字,并将这个字复制到程序计数器(PC)中,以覆盖PC中原来的值
高速缓存
系统化了大把时间把信息从一个地方挪动到另一个地方,hello程序从最初的在磁盘上,当程序加载时,它们被复制到主存,当处理器运行程序的时候,指令又从主存复制到了处理器上,这些复制就是开销,减慢了程序真正的工作
根据机械原理,较大的存储设备要比较小的存储设备运行得慢,而快速设备的造价远高于同类的低速设备,类似的,一个典型的寄存器文件只存储几百字节的信息,而主存中可存放几十亿字节,然而,处理器从寄存器文件中读数据比从主存中读取数据快乐至少100倍
位于处理器芯片上的L1高速缓存的容量可以达到数万字节,访问速度几乎和访问寄存器文件一样快,一个容量为数十万的到数百万的L2高速缓存通过一条特殊的总线连接到处理器,进程访问L2高速缓存的时间要比访问L1高速缓存的时间长5倍,但是这仍比访问主存的时间快5~10倍,,这些高速缓存的速度快是因为利用了高速缓存的局部性原理,
存储设备形成层次结构
在处理器和一个较大较慢的设备(例如主存)之间插入一个更小更快的存储设备(例如高速缓存)的想法已经成为了一个普遍的观念。
存储层次结构
在这个层次结构中,从上至下,设备的访问速度越来越慢,但是容量越来越大,并且每字节的造价也越来越便宜,寄存器文件在层次结构中位于最顶部,也就是第0级或者记为L0
,比如寄存器文件就是L1的高速缓存,L1就是L2的高速缓存以此类推
操作系统管理硬件
当shell加载和运行hello的时候,以及hello程序输出自己的信息的时候,shell和hello程序都没有直接访问键盘、显示器、磁盘或者主存,取而代之的是,它们依靠操作系统提供的服务,
操作系统的两个基本功能
- 防止硬件被失控的应用程序滥用
- 向应用程序提供简单一致的机制来控制复杂而又通常大不相同的低级硬件设备
操作系统通过几个基本的抽象概念(进程、虚拟内存和文件)来实现这两个功能,文件是对I/O设备的抽象表示,虚拟内存是对主存和磁盘I/O的抽象表示,进程则是对处理器、主存和I/O设备的抽象表示
操作系统的抽象表示
进程
像hello这样的程序在现代系统上运行的时候,操作系统会提供一种假象,就好像系统上只有这个程序在运行。程序看上去是独占地使用处理器、主存和I/O设备,处理器看上去就像在不间断地处理一条接一条地执行程序中的指令,即改程序的代码和数据是系统内存中唯一的对象
,在大多数系统中,需要运行的进程数量是多于可以运行它们的CPU个数的,传统系统在一个时刻只能执行一个程序,而现今的多核处理器同时可以执行多个程序,无论是在单核还是多核的系统中,一个CPU看上去都像是在并发地执行多个经常,这是通过处理器在进程间切换来实现的,操作系统实现这种交错执行的机制称为上下文切换
,包括许多信息,比如PC和寄存器文件的当前值,以及主存的内容。
举一个shell和hello两个进程并发的例子,最开始,只有shell进程在运行,即等待命令行上的输入,当我们让他运行hello程序时,shell通过调用一个专门的函数,即系统调用
系统调用会将控制权传递给操作系统,操作系统保存shell进程的上下文,创建一个新的hello进程及其上下文,然后将控制权传递给hello进程,hello进程终止后,操作系统恢复shell进程的上下文,并将控制权传回给它,shell进程会继续等待下一个命令行输入
进程的上下文切换
线程
一个进程实际可以由多个称为线程的执行单元构成,每个线程运行在进程的上下文中,并共享同样的代码和全局数据,县城成为越来越重要的编程模型,因为多线程之间比多进程之间更容易共享数据,也因为线程一般来说都比进程更高效,当有多处理器可用的时候,多线程也是一种使得程序可以运行得更快的方法
虚拟内存
虚拟内存是一个抽象概念,它为每个进程提供了一个假象,即每个进程都在独占的使用主存。每个进程看到的内存都是一致的,称为虚拟地址空间
进程的虚拟地址空间
在Linux系统中,地址空间最上面的区域是保留给操作系统中代码和数据的,这对所有进程来说都是一样,地址空间底部区域存放用户进程定义的代码和数据,上图中地址是从下向上增大的
- 程序代码和数据 对所有进程来说,代码是从同一固定的地址开始,紧接着的适合C区安居变量相对应的数据位置,代码和数据区是直接是直接按照可执行目标文件的内容初始化的
- 堆 代码和数据区在进程一开始运行时就被指定了大小,与此不同,当调用像malloc和free这样的C标准函数的时候,堆可以在运动时动态的拓展和收缩
- 共享库 大约在地址空间的中间部分是一块用来存放C标准库和数学库这样的共享库的代码和数据的区域
- 栈 位于用户虚拟地址空间顶部的是用户栈,编译器用它来实现函数调用,和堆一样,栈在程序执行期间可以动态扩展和收缩,当调用一个函数的时候栈就会被扩展,一个函数返回的时候栈就会收缩
- 内核虚拟内存 地址空间顶部是为内核保留的,不允许应用程序读写这个区域的内容或者直接调用内核代码定义的函数,它们必须通过内核来执行这些操作,虚拟内存的运作基本思想是把一个进程虚拟内存的内容存储在磁盘上,然后用主存作为磁盘的高速缓存
并发和并行
我们用的术语是一个通用的概念,指一个同时具有多个活动的系统;而术语指的是用并发来使一个系统运行得更快,并行可以在计算机系统的多个抽象层次上运用
线程级并发
传统意义上线程级别上的并发只是模拟出来的,是通过是一台计算机在它正在执行的进程间快速切换来实现的,就好像一个杂耍艺人保持多颗杂技球在空中飞舞一样
指令级并行
在较低的抽象层次上,现代处理器可以同时执行多条指令的属性叫做指令级并行,
第2章:信息表示和处理
2.1 信息存储
前言:大致的数的表示,数的运算在机组课上已经有老师带领全部学习了一遍,这里主要以复习提升为主。
- 重要概念
1)
字节
:大多数计算机使用8位的块(字节),作为最小的可寻址的内存单位
,而不是访问内存中单独的位。 2)虚拟内存
:机器级程序将内存视作一个很大的字节数组
,称作虚拟内存。 \3)地址
:内存的每一个字节都有一个唯一的数字来标识,称为它的地址。 4)虚拟地址空间
:所有可能的地址的集合称为虚拟地址空间。
- 这个虚拟地址空间只是一个展示给机器级程序的概念性映像。
- 实际的实现(见第9章)是将动态随机访问存储器(DRAM)、闪存、磁盘存储器、特殊硬件和操作系统软件结合起来,为应用程序提供一个看上去统一的字节数组。
5)
程序对象
:程序数据、指令和控制信息。
2.1.1 十六进制表示法
- 为什么选择十六进制
1)一个字节由8位组成,在二进制表示法中,它的值域是 0000000 0 2 ∼ 1111111 1 2 00000000_2\sim 11111111_2 000000002∼111111112,如果看成十进制就是 0 10 ∼ 25 5 10 0_{10}\sim 255_{10} 010∼25510. 2)两种表示法对于描述位模式都十分不方便。二进制表示法太冗长,十进制表示法与位模式的转化十分麻烦。
- 十六进制表示法
1)十六进制数:
十六进制使用0~9,以及字符A ~ F来表示16个可能的值, 一个字节的值域为 0 0 16 ∼ F F 16 00_{16}\sim FF_{16} 0016∼FF16 在C语言中,以0x或者0X开头的数字常量被认为是十六进制的数。字符‘A’ ~ ‘F’既可以是大写也可以是小写,例如我们可以将 F A 1 D 37 B 16 FA1D37B_{16} FA1D37B16写作 0 x F A 1 D 37 B 0xFA1D37B 0xFA1D37B,或者 0 x f a 1 d 37 b 0xfa1d37b 0xfa1d37b
2)十六进制和二进制之间的转换
注意:将二进制数字转化为十六进制的时候,要把二进制数字分割为每四个一组,如果总数不是四的倍数,最左边一组可以少于四位,前面用零补足。然后将每个四位组转化为相应的十六进制数字。 当值x是2的幂时,也就是,对于某个n,x= 2 n 2^n 2n,我们可以很容易地将x写成十六进制形式。只要记住x的二进制表示就是1后面跟n个零。十六进制数字О代表四个二进制0。所以,对于被写成i+4j形式的n来说,其中0≤i≤3,我们可以把x写成开头的十六进制数字为1(i=0)、2(=1)、4 ( i=2)或者8(i=3),后面跟随着j个十六进制的0。比如,x=2048= 2 11 2^{11} 211,我们有n=11 =3+4·2,从而得到十六进制表示0x800。
3)十六进制和十进制之间的转换
十进制和十六进制表示之间的转换需要使用乘法或者除法来处理一般情况。将一个十进制数字x转换为十六进制,我们可以反复地用16除x,得到一个商q和一个余数r,也就是x=q· 16+r。然后,我们用十六进制数字表示的r作为最低位数字,并且通过对q反复进行这个过程得到剩下的数字。例如,考虑十进制314156的转换: 314156 = 19634 ⋅ 16 + 12 ( C ) 19634 = 1227 ⋅ 16 + 2 ( 2 ) 1227 = 76 ⋅ 16 + 11 ( B ) 76 = 4 ⋅ 16 + 12 ( C ) 4 = 0 ⋅ 16 + 4 ( 4 ) 314156196341227764=19634⋅16+12(C)=1227⋅16+2(2)=76⋅16+11(B)=4⋅16+12(C)=0⋅16+4(4)
314156196341227764=19634⋅16+12©=1227⋅16+2(2)=76⋅16+11(B)=4⋅16+12©=0⋅16+4(4) 从这里,我们能读出十六进制表示为0x4CB2C. 反过来,将一个十六进制数字转换为十进制数字,我们可以用相应的16的幂乘以每个十六进制数字。比如,给定数字Ox7AF,我们计算它对应的十进制值为716+ 1016+ 15=7256+1016+ 15= 1792+ 160+ 15= 1967。
2.1.2 字
每台计算机都有一个字长( word size),指明整数和指针数据的标称大小( nominal size)。因为虚拟地址是以这样的字来编码的,所以字长决定的最重要的系统参数就是虚拟地址空间的最大大小。也就是说,对于一个字长为n位的机器而言,虚拟地址的范围为0~ 2 n 2^n 2n-1,程序最多访问 2 n 2^n 2n字节。
2.1.3 数据大小
2.1.4 寻址和字节顺序
1.地址
- 在几乎所有的机器上,多字节对象被存储为连续的字节序列,对象的地址为所使用的的字节序列的最小地址。
- 例如,假设一个类型为int 的变量x的地址为0x100,也就是说,地址表达式&x的值为0x100。那么,x的四字节将被存储在存储器的0x100、0x101、0x102和0x103位置。
2.字节排序的两个通用规则:
- 对表示一个对象的字节序列排序,有两个通用的规则。考虑一个w位的整数,有位表示 [ x w − 1 , x w − 2 , ⋯ , x 1 , x 0 ] [\left.x_{w-1},x_{w-2}, \cdots, x_{1}, x_{0}\right] [xw−1,xw−2,⋯,x1,x0],其中 x w − 1 x_{w-1} xw−1是最高有效位,而 x 0 x_{0} x0是最低有效位。假设w是8的倍数,这些位就能被分组成为字节,其中最高有效字节包含位 [ x w − 1 , x w − 2 , ⋯ , x w − 8 ] \left[x_{w-1}, x_{w-2}, \cdots, x_{w-8}\right] [xw−1,xw−2,⋯,xw−8],而最低有效字节包含位 [ x 7 , x 6 , ⋯ x 0 ] \left[x_{7}, x_{6}, \cdots x_0\right] [x7,x6,⋯x0],其他字节包含中间的位。某些机器选择在存储器中按照从最低有效字节到最高有效字节的顺序存储对象,而另一些机器则按照从最高有效字节到最低有效字节的顺序存储。前一种规则—–最低有效字节在最前面的方式被称为小端法(little endian)。大多数源自以前的Digital Equipment 公司(现在是Compaq公司的一部分)的机器,以及 Intel的机器都采用这种规则。后一种规则(最高有效字节在最前面的方式)被称为大端法(big endian)。IBM、Motorola和Sun Microsystems 的大多数机器都采用这种规则。注意我们说的是“大多数”。这些规则并没有严格按照企业界限来划分。比如,IBM制造的个人计算机使用的是Intel兼容的处理器,因此就是小端法。许多微处理器芯片,包括Alpha和Motorola的 PowerPC,能够运行在任一种模式中,其取决于芯片加电启动时确定的字节顺序规则。
- 继续我们前面的示例,假设变量x类型为int,位于地址0x100 处,有一个十六进制值为0x01234567。地址范围0x100~0x103的字节顺序依赖于机器的类型: 注意,在字0x01234567中,高位字节的十六进制值为0x01,而低位字节值为0x67。
3.字节顺序变得重要的三种情况
1)小端法机器产生的数据被发送到大端法机器或者反之时,接收程序会发现,字里的字节变成了反序。为了避免这类问题,网络应用程序必须建立关于字节顺序的规则,以确保发送机器将它的内部表示转换为网络标准,而接收方机器则将网络标准转换为它的内部表示。 2)字节顺序变得重要的第二种情况是当阅读表示整数数据的字节序列时。这通常发生在检查机器级程序时。作为一个示例,从某个文件中摘出了下面这行代码,该文件给出了一个针对Intel 处理器的机器级代码的文本表示: 80483 b d : 01 05 64 94 04 08 add % eax, 0 × 8049464 80483bd:010564940408 add % eax, 0×8049464 80483bd:010564940408 add % eax, 0×8049464这一行是由反汇编器((disassembler)生成的,反汇编器是一种确定可执行程序文件所表示的指令序列的工具。我们将在下一章中学习有关这些工具的更多知识,以及怎样解释像这样的行。而现在,我们只是注意这行表述了十六进制字节串01 05 64 94 04 08是一条指令的字节级表示,这条指令是增加一个字宽的数据到存储在主存地址Ox8049464的值上。如果我们取出这个序列的最后四字节:64940408,并且按照相反的顺序写出,我们得到08049464。去掉开头的零,我们就得到值Ox8049464,就是右边写着的数值。当阅读像此例中一样的小端法机器生成的机器级程序表示时,经常会将字节按照相反的顺序显示。书写字节序列的自然方式是最低位字节在左边,而最高位字节在右边,但是这和书写数字时最高有效位在左边,最低有效位在右边的通常方式是相反的。 3)字节顺序变得重要的第三种情况是当编写规避正常的类型系统的程序时。在C语言中,可以通过使用强制类型转换或者联合来允许以一种数据类型来引用一个对象,而这种数据类型与创建这个对象时的定义的数据类型不同。
2.1.5 表示字符串
- C语言中的字符串被编码成一个以null(其值为零)字符结尾的字符数组。每个字符都由某个标准编码来表示,最常见的是ASCII编码。
- 在使用ASCII码作为字符码的任何系统上运行show_bytes程序,都将得到相同的结果,与字节顺序和字的大小规则无关。 -因而文本数据比二进制数据具有更强的平台独立性。
2.1.6 表示代码
- 指令编码是不同的。
- 不同的机器类型使用不同的且不兼容的指令和编码类型。
- 完全一样的进程,运行在不同的操作系统上也有不同的编码规则。因此二进制代码是不兼容的。
2.1.7 布尔代数简介
布尔代数
:围绕数值0和1的数学知识体系。- :
位向量
的运算:
用位向量表示有限集合:
a = [ 01101001 ] 可 以 表 示 A = { 0 , 3 , 5 , 6 } a=[01101001]可以表示A=\{0,3,5,6\} a=[01101001]可以表示A={0,3,5,6} b = [ 01010101 ] 可 以 表 示 B = { 0 , 2 , 4 , 6 } b=[01010101]可以表示B=\{0,2,4,6\} b=[01010101]可以表示B={0,2,4,6} 布尔运算 ∣ | ∣和 & \& &分别对应于集合的并和交,而 ∼ \sim ∼对应于集合的补
2.1.8 C语言中的位级运算
- 示例
- 掩码运算:
- 例如: x = 0 x 89 A B C D E F 做 掩 码 运 算 x & 0 x F F = 0 x 000000 E F x=0x89ABCDEF做掩码运算x& 0xFF=0x000000EF x=0x89ABCDEF做掩码运算x&0xFF=0x000000EF
2.1.9 C语言中的逻辑运算
- 逻辑运算容易与位级运算混淆,注意比较以下例子:
- 位级运算与逻辑运算的区别:
- 逻辑运算认为所有非零的参数都表示TRUE,参数零表示FALSE,返回值为1或者0.
- 逻辑运算符如果对第一个参数求值就能确定表达式的值,那么逻辑运算符就不会对第二个参数求值。
2.1.10 C语言中的移位运算
- 示例
- 移位运算从左往右可结合
- 右移运算包括
逻辑右移
和算数右移
2.2 整数表示
2.2.1整数数据类型
2.2.2 无符号数编码
无符号编码属于相对较简单的格式,因为它符合我们的惯性思维,上述定义其实就是对二进制转化为十进制的公式而已,只不过在一向严格的数学领域来说,是要给予明确的含义的。
2.2.3 补码编码
最常见的有符号数的计算机表示方式就是补码形式。在这个定义中,将字的最高有效位解释为负权,我们用函数B2T来表示。java中使用的就是补码。
我们观察这个公式,不难看出,补码格式下,对于一个w位的二进制序列来说,当最高位为1,其余位全为0时,得到的就是补码格式的最小值,即 而当最高位为0,其余位全为1时,得到的就是补码格式的最大值,根据等比数列的求和公式,即
2.2.4 有符号数和无符号数之间的转换
在C语言当中,我们经常会使用强制类型转换,而在之前的章节中,也提到过强制类型转换。强制类型转换不会改变二进制序列,但是会改变数据类型的大小以及解释方式,那么考虑相同整数类型的无符号编码和补码编码,数据类型的大小是没有任何变化的,变化的就是它们的解释方式。比如1000这个二进制序列,如果用无符号编码解释的话就是表示8,而若采用补码编码解释的话,则是表示-8。
2.2.5 C语言中的有符号数和无符号数
有符号数和无符号数的本质区别其实就是采用的编码不同,前者采用补码编码,后者采用无符号编码。
在C语言中,有符号数和无符号数是可以隐式转换的,不需要手动实施强制类型转换。不过也正是因为如此,可能你不小心将一个无符号数赋给了有符号数,就会造成出乎意料的结果,就像下面这样。
#include <stdio.h>
int main(){
short i = -12345;
unsigned short u = i;
printf("%d %d\n",i,u);
}
1234567
输出结果为-12345,53191。一个不小心,一个负数就变成正数了。
再看下面这个程序,它展示了在进行关系运算时,由于有符号数和无符号数的隐式转换所导致的违背常规的结果。
#include <stdio.h>
int main(){
printf("%d\n",-1 < 0U);
printf("%d\n",-12345 < 12345U);
}
123456
两个结果都为0,也就是false,这与我们直观的理解是违背的,由于C语言对同时包含有符号和无符号数表达式的这种处理方式,出现了一些奇特的行为。当执行一个运算时,如果它的一个运算数是有符号的而另一个是无符号的,那么C语言会隐式地将有符号参数强制类型转换为无符号数,并假设这两个数都是非负的,来执行这个运算。就像我们将要看到的,这种方法对于标准的算术运算来说并无多大差异,但是对于像<和>这样的关系运算符来说,它会导致非直观的结果。
2.2.6 扩展一个数字的位表示
当我们将一个短整型的变量转换为整型变量时,就涉及到了位的扩展,此时由两个字节扩充为四个字节。
在进行位的扩展时,最容易想到的就是在高位全部补0,也就是将原来的二进制序列前面加入若干个0,也称为。还有一种方式比较特别,是,也就是针对有符号数的方式,它是直接扩展符号位,也就是将二进制序列的前面加入若干个最高位。
- 无符号数的零扩展:要将一个无符号数转换为一个更大的数据类型,我们只要简单地在表示的开头添加0。这种运算被称为零扩展。
- 补码数的符号扩展:要将一个补码数字转换为一个更大的数据类型,可以执行一个符号扩展,在表示中添加最高有效位的值。
对于零扩展来说,很明显扩展之后的值与原来的值是相等的,而对于符号扩展来说,也是一样,只不过没有零扩展来的直观。。因此当我们对一个有符号的负数进行符号扩展时,前面加入若干个1,在取反之后都为0,因此依旧会保持原有的数值。
总之,在对位进行扩展时,是不会改变原有数值的。
2.2.7 截断数字
截断与扩展相反,它是将一个多位二进制序列截断至较少的位数,也就是与扩展是相反的过程。截断可能会导致数据的失真。
不难看出,具有有符号和无符号数的语言,可能会因此引起一些不必要的麻烦,而且无符号数除了能表示的最大值更大以外,似乎并没有太大的好处。因此有很多语言是不支持无符号数的。如Java语言,就只有有符号数,这样省去了很多不必要的麻烦。无符号数很多时候只是为了表示一些无数值意义的标识,比如我们的内存地址,此时的无符号数就有点类似于数据库主键或者说键值对中的键值的概念,仅仅是一个标识而已。
2.3 整数运算
2.3.1 无符号加法
考虑两个非负整数x和y,满足0<=x,y<2w-1。每个数都能表示为w位无符号数字。然而,如果计算它们的和,我们就有一个可能的范围0<=x+y<=2w+1-2。表示这个和可能需要w+1位。例如,图示展示了当x和y有4位表示时,函数x+y的坐标图。参数(显示在水平轴上)的取值范围为015,但是和的取值范围为030。如果保持和为一个w+1位的数字,并且把它加上另外一个数值,我们可能需要w+2个位,以此类推。这种持续的“字长膨胀”意味着,要想完整的表示算术运算的结果,我们不能对字长做任何限制。一些编程语言,例如Lisp,实际上就支持无限精度的运算,允许任意的(在机器的内存限制内)整数运算。更常见的是,编程语言支持固定精度的运算,因此像“加法”和“乘法”这样的运算不同于它们在整数上的相应运算。
让我们为参数x和y定义运算,其中0<=x,y<2w,该操作是把整数和x+y截断为w位得到的结果,再把这个结果看做是一个无符号数。这可以被视为一种形式的模运算,对x+y的位级表示,简单丢弃任何权重大于2w-1的位就可以计算出和模2w。比如,考虑一个4位数字表示,x=9和y=12的位表示分别为[1001]和[1100]。它们的和是21,5位的表示为[10101]。但是如果丢弃最高位,我们就得到[0101],也就是说,十进制值的5。这就和值21mod16=5一致。
说明公式两种情况,左边的和x+y映射到右边的无符号w位的和x+。正常情况下x+y的值保持不变,而溢出情况则是该和数减去2w的结果。
推导:无符号数加法
一般而言,我们可以看到。如果 x+y<2w,和的w+1位表示中的最高位会等于0,因此丢弃它不会改变这个数值。另一方面,如果2w<=x+y<2w+1,和的w+1位表示中的最高位会等于1,因此丢弃它就相当于从和中减去了2w。
当执行C程序是,不会将溢出作为错误而发信号。不过有的时候,我们可能希望判定是否发生了溢出。
原理:检测无符号数加法中的溢出
对在范围0<=x,y<=UMaxw中的x和y,令s=x+。则对计算s,当且仅当s<x(或者等价的s<y)时,发生了溢出。
作为说明,在前面的示例中,我们看到9+412=5。由于5<9,我们可以看出发生了溢出。
2.3.2 补码加法
对于补码加法,我们必须确定当结果太大(为正)或者太小(为负)时,应该做些什么。给定在范围-2w-1<=x,y<2w-1-1之内的整数值x和y,它们的和范围-2w<x+y<2w-2之内,要想准备表示,可能需要w+1位。我们仍通过将表示截断到w位,来避免数据大小的不断扩张。然而,结果却不像模数加法那样在数学上感觉很熟悉。定义x+为整数和x+y被截断为w位的结果,并将这个结果看做是补码数。
当和x+y超过TMaxw时,我们说发生了正溢出。在这种情况下,截断的结果是从和数中减去2w。当和x+y小于TMinw时,我们说发生了正溢出。在这种情况下,截断的结果是把和数加上2w。
两个数的w位补码之和与无符号之和有完全相同的位级表示。实际上,大多数计算机使用同样的机器指令来执行无符号或者有符号加法。
2.3.3 补码的非
我们看到范围在TMinw<=x<=TMaxw中的每个数字x都有下的加法逆元,我们将表示如下。
也就是说,对w位的补码加法来说,Tminw是自己的加法的逆,而对其他任何数值x都有-x作为其加法的逆。
推导:补码的非
观察发现TMinw+TMinw = -2w-1+(-2w-1)=-2w。这就导致负溢出,因此TMinw+=-2w+2w=0。对满足x>TMinw的x,数值-x可以表示为一个w位的补码,它们的和-x+x=0。
2.3.4 无符号乘法
范围在0 <=x,y<=2w-1内的整数x和y可以被表示为w位的无符号数,但是它们的乘积x*y的取值范围为0到(2w-1)2=22w-2w+1+1之间。这可能需要2w位来表示。不过,C语言中的无符号乘法被定义为产生W位的值,就是2W位的整数乘积的低w位表示的值。
将一个无符号数截断为w位等价于计算该值模2w,得到:
2.3.5 补码乘法
范围在-2w-1<=x,y<=2w-1-1内的整数x和y可以被表示为w位的补码数字,但是它们的乘积xy的取值范围为-2w-1(2w-1-1)=-22w-2+2w-1到-2w-1 *-2w-1 = -22w-2之间。要想用补码来表示这个乘积,可能需要2w位。然而,C语言中的有符号乘法是通过将2w位的乘积截断为w位来实现的。我们将这个数值表示为。将一个补码数截断为w为相当于先计算该值模2w,再把无符号数转换为补码,得到:
2.3.6 乘以常数
以往,在大多数机器上,整数乘法指令相当慢,需要10个或者更多的时钟周期,然而其他整数运算(例如加法、减法、位级运算和移位)只需要一个时钟周期。即使在Inter Core i7上,其整数乘法也需要三个时钟周期。因此,编译器使用了一项重要的优化,试着用移位和加法运算的组合来代替乘以常数因子的乘法。首先,我们会考虑乘以2的幂的情况,然后再概况成乘以任意常数。
因此,比如,当w=4时,11可以被表示为[1011]。k=2时将其左移得到6位向量[101100],即可编码为无符号数11*4=44。
注意,无论是无符号运算还是补码运算,乘以2的幂都可能会导致溢出。结果表明,即使溢出的时候我们通过移位得到的结果也是一样的,如上例,我们将4位模式1011左移两位得到101100。将这个值截断为4位得到[1011](数值为12=44mod16)。
由于整数乘法比移位和加法的代价要大得多,许多C语言编译器试图以移位、加法和减法的组合来消除很多整数常数的情况。例如,假设一个程序包含表达式x*14。利用14=23+22+21,编译器会将乘法重写为(x<<3)+(x<<2)+(x<<1),将一个乘法替换为三个移位和两个加法。无论x是无符号的还是补码,甚至当乘法会导致溢出时,两个计算都会得到一样的结果。(根据整数运算的熟悉可以证明)。更好的是,编译器还可以利用属性14=24-21,将乘法重写为(x<<4)-(x<<1),这时只需要两个移位和一个减法。
2.3.7 除以2的幂
在大多数机器上,整数除法要比整数乘法更慢–需要30个或者更多的时钟周期。除以2的幂也可以用移位运算来实现。只不过用的是右移,而不是左移。无符号和补码数分别使用逻辑移位和算术移位来达到目的。
2.3.8 关于整数运算的思考
计算机执行的“整数”运算实际上是一种模运算形式。表示数字的有限字长限制了可能的值的取值范围,结果运算可能溢出。我们还看到,补码表示提供了一种既能表示负数也能表示正数的灵活方法,同时使用了与执行无符号算术相同的位级实现,这些运算包括像加法、减法、乘法,甚至除法,无论运算数是以无符号形式还是以补码形式表示的,都有完全一样或者非常类似的位级行为。
我们看到了C语言中的某些规定可能会产生令人意想不到的结果,而这些结果可能是难以察觉或理解的缺陷的源头。我们特别看待了unsigned数据类型,虽然它概念上很简单,但可能导致即使资深程序员都意想不到的行为。
2.4 浮点数
2.4.1 二进制小数
理解浮点数的第一步是考虑含有小数值的二进制数字。首先,我们来看看更熟悉的十进制表示法。十进制表示法使用如下形式的表示:dmdm-1…d1d0.d-1d-2d-n。其中每个十进制数di的取值范围是0~9。这个表达描述的数值d定义如下:
数字权的定义与十进制小数点符号(’.’ ),这意味着小数点左边的数字的权是10的正幂,得到整数值,而小数点右边的数字的权是10的负幂,得到小数值。例如,12.3410表示数字1101+2100+310-1+410-2=
类似,考虑一个形如bmbm-1…b1b0.b-1b-2…b-n-1b-n的表示法,其中每个二进制数字,或者成为位,bi的范围是0和1,这种表示方法表示的数b的定义如下:
符号’.’ 现在变成了二进制的点,点左边的位的权是2的正幂,点右边的权是2的负幂。例如,101.112表示数字122+021+120+12-1+1*2-2=。
从上式可以看出,二进制小数点向左移动一位相当于这个数被2除。例如,101.112表示数,而10.1112表示数。类似,二进制小数点像右移动一位相当于该值乘2。例如1011.12表示数。
注意,形如0.11…12的数表示的是刚好小于1的数。例如,0.1111112表示,我们将用简单的表达法1.0-来表示这样的数值。
假定我们仅考虑有限长度的编码,那么十进制表示法不能准备地表达像1/3和5/7这样的数。类似,小数的二进制表示法只能表示那些能够被写成x*2y的数。其他的值只能够被近似地表示。例如,数字1/5可以用十进制小数0.20精确表示。不过,我们并不能把它准备地表示为一个二进制小数,我们只能近似的表示它,增加二进制的长度可以提高表示的精度。
练习题2.45 填写下表中的缺失的信息
小数值 | 二进制表示 | 十进制表示 |
---|---|---|
1/8 | 0.001 | 0.125 |
3/4 | 0.11 | 0.75 |
25/16 | 1.1001 | 1.5625 |
43/16 | 10.1011 | 2.6785 |
9/8 | 1.001 | 1.125 |
47/8 | 101.111 | 5.875 |
51/16 | 11.0011 | 3.1875 |
回到顶部
2.4.2 IEEE浮点表示
定点表示法不能很有效的表示非常大的数字。例如,表达式52100是用101后面跟随100个零的位模式来表示。相反,我们希望通过给定x和y的值,来表示形如x2y的数。
IEEE浮点标准用V=(-1)sM2E的形式来表示一个数:
- 符号(sign) s决定这数是负数(s=1)还是正数(s=0),而对于数值0的符号位解释作为特殊情况处理。
- 尾数(significand) M是一个二进制小数,它的范围是 12-,或者是01-。
- 阶码(exponent) E的作用是对浮点数加权,这个权重是2的E次幂(可能是负数)。将 浮点数的位表示划分为三个阶段,分别对这些值进行编码:
- 一个单独的符号位s直接编码符号s。
- k位的阶码字段exp = ek-1…e1e0编码阶码E。
- n位的小数字段frac=fn-1…f1f0编码尾数M,但是编码出来的值也依赖于阶码字段的值是否等于0。
图示给出了将三个装进字中最常见的格式。在单精度浮点格式(C语言中的float)中,s、exp、和frac字段分别为1位、k=8和n=23位,得到一个32位的表示。在双进度浮点格式(C语言的double)中,s、exp和frac字段分别为1位、k=11位和n=52位,得到一个64位的表示
给定位表示,根据exp的值,被编码的值可以分成三种不同的情况(最后一种情况有