[csapp] 程序的机器级表示

第三章的内容还是很多的,而且也比较难;主要是讲了IA32、x86-64的程序的汇编形式,以及C代码变成汇编语言是什么样子。设计很多汇编的细节。不过,我想自己倒是还不用掌握书中的汇编,毕竟自己主要还是接触ARM,和Intel的汇编有所不同。所以,在这里罢第三章的目录罗列出来,只简单总结一下吧。

3.1 历史观点

本章基于两种相关的机器语言:Intel IA32和x86-64。
IA32 从最初的16位发展而来,现在为32位架构,俗称x86。GCC为32位执行的默认调用,仍假设是为i386(1985年)机器生成代码。
x86-64 最初由AMD开发,采用了IA32的64位扩展。有时也称为amd64。 32位机器只能使用4GB(2^32字节)的内存。64位机器目前能使用256TB(2^48字节)内存。

3.2 程序编码

C语言编译过程:
预处理器展开头文件和宏 –> 编译器产生汇编代码 –> 汇编器产生目标二进制文件 –> 链接器将目标文件与库函数代码合并,产生可执行文件

3.2.1 机器级代码

对机器级编程来说,其中两种抽象尤为重要。
第一种是机器级程序的格式和行为,定义为 指令集体系结构 (Instruction set architecture, ISA),它定义了处理器的状态、指令的格式,以及每条指令对状态的影响。
第二种抽象是,机器级程序使用的存储器地址是虚拟地址,提供的存储器模型看上去是一个非常大的字节数组。

3.2.2 代码示例

这里没有太多可说的。再大致记录一下gcc的那三个选项: -E 宏展开,生成.i文件
-S 生成汇编代码.s文件
-c 生成二进制.o文件

3.2.3 关于格式的注解

汇编代码中所有以“.”开始的行都是指导汇编器和链接器的命令。我们通常可以忽略这些行。

3.3 数据格式

由于是从16位体系结构扩展成32位的,Intel用术语“字” (word) 表示16位数据类型。因此,称32位数为“双字” (double words),称64位数为“四字” (quad words)。大多数汇编指令都有一个字符后缀,表明操作数的大小。例如:movb, movw, movl. b代表字节8bit,w代表字16bit,l代表双字32bit。

3.4 访问信息

IA32的CPU包含一组8个32位值的寄存器。P112展示了这8个寄存器。
在大多数情况下,前6个寄存器都可以看成通用寄存器,对它们的使用没有限制。后两个%ebp和%esp分别用作“帧指针”和“栈指针”——程序栈中的重要指针。

3.4.1 操作数指示符

操作数的三种类型:
立即数 也就是常数,在’$’后面跟一个标准C表示法的整数。例如,$-577或$0x1F。任何一个能在32位二进制表示出的数值都可以用作立即数。
寄存器 表示某个寄存器的内容。
存储器 即内存地址。
寻址模式,参见P113的表格,前三行为上述三种基本的类型。

3.4.2 数据传送指令

数据传送指令 mov,参见P114
压栈弹栈指令 push, pop,参见P115
另外,如P115图中所示:画栈的时候,高地址在上,为栈底;低地址在下,为栈顶。压栈时,栈顶指针“增长”,栈指针SP的值是减小的。弹栈时,栈顶指针“回缩”,栈指针SP的值时增大的。

3.4.3 数据传送示例

指针的一些描述:
* 执行指针的间接引用。间接引用就是将该指针放在一个寄存器中,对数据引用时,则通过这个寄存器进行。
& 创建一个指针。

3.5 算术和逻辑操作

3.5.1 加载有效地址

加载有效地址命令 lead,获得某存储器的地址,参见P118、P119

3.5.2 一元操作和二元操作

整数算术操作命令,参见P119

3.5.3 移位操作

移位命令 sal, shl, sar, shr,参见P120、P119

3.5.4 讨论

3.5.5 特殊的算术操作

两个32位数字的全64位乘法以及整数除法,参见P122
特殊就是特殊在,这几条指令要求某个寄存器为参与运算的数据,或保存运算结果;却不在指令行中展示出这个寄存器,即默认使用某个寄存器。

3.6 控制

3.6.1 条件码

除了前面说到的整数寄存器,CPU还维护着一组单个位的条件码寄存器,用于描述最近的算术或逻辑操作的属性。
CF 进位标志。最近的操作使最高位产生了进位(无符号数的溢出)。
ZF 零标志。最近的操作结果为零。
SF 符号标志。最近的操作结果为负数。
OF 溢出标志。最近的操作导致一个补码溢出——正溢出或负溢出。
P119表中的指令(整数算术操作)中,除了计算地址的leal外,其他的指令都会设置条件码。
还有两类指令(cmp和test,见P124)只改变条件码,不会改变任何其他寄存器。

3.6.2 访问条件码

条件码通常不会直接读取,常用的使用的方法有三种:

  1. 根据条件码的某种组合,将一个指定的字节设置为0或1 (set指令 P125)。
  2. 条件跳转。
  3. 条件传输数据。

    3.6.3 跳转指令及其编码

    无条件跳转,jmp指令。
    可以直接跳转(使用标号);也可以间接跳转,跳转的目标是从寄存器或存储器中读出的。P127
    有条件跳转,参见P128 jmp系指令。
    有条件跳转指令会默认去检查某些条件码的组合是否满足某条件,满足的话,进行跳转。

    3.6.4 翻译条件分支

    3.6.5 循环

    大多数汇编器根据一个循环的do-while形式来产生循环代码;其他的循环会首先转换成do-while形式,然后再编译成机器代码。P132
    但有一个例外,就是循环中存在continue,参见P137, P139。

    3.6.6 条件传送指令

    条件操作的传统方法是利用 控制 的条件转移:条件满足时,走一条路径;条件不满足时,走另一条路径。但这在现代处理器上,可能效率非常低。
    数据 的条件转移是一种替代策略:这种方法先计算出一个条件操作的两种结果,然后根据条件是否满足从中选择一个结果。这只在一些情况下可行,但是如果可行,就会更好得匹配现代处理器的性能特性。因为现代处理器的 流水线 特性,控制流不依赖数据,这使得处理器更容易保持流水线是满的,减少错误预测的惩罚,获得更好的性能。
    P142 条件传送指令cmov。

    3.6.7 switch语句

    switch语句可以通过使用 跳转表 使得实现更为高效,和使用一组很长的if-else语句相比,使用跳转表的优点是执行开关语句的时间与开关情况的数量无关。
    GCC提供对跳转表的支持——有一个新的运算符&&(单目运算符),这个运算符创建一个指向代码位置的指针。参见P145.

3.7 过程

一个过程调用包括将数据(以过程参数和返回值的形式)和控制从代码的一部分传递到另一部分。另外,它还必须在进入时为过程的局部变量分配空间,并在退出时释放这些空间。

3.7.1 栈帧结构

为单个过程分配的那部分称为 栈帧
最顶端的栈帧以两个指针界定,寄存器%ebp为帧指针,寄存器%esp为栈指针。参见P149

3.7.2 转移控制

call指令,参见P150,效果是将返回地址入栈,并跳转到被调用过程的起始处。“返回地址”就是在程序中紧跟在call指令后面的那条指令的地址。(就是在call指令返回时,接着运行下面的指令。)
ret指令,参见P150,从栈中弹出地址,并跳转的这个位置。(所以,要真正的return,需要栈最上面保存的是call指令存储的返回地址,即,使栈“做好准备”,这需要leave指令。)
leave指令,参见P150,将栈指针指向前面call指令存储的返回地址的位置。 这里,leave指令的作用,一时搞得有点糊涂,因为书上说它等价于:
movl %ebp, %esp (1)
popl %ebp (2)
这里再捋一下,我的理解:
调用者P,被调用者Q
P在调用Q之前,P就有一个帧指针,即%ebp保存了一个值BP1。P调用Q,call指令将“返回地址”入栈,并跳转到Q的起始地址。
为Q过程分配栈帧。此时首先栈增长,并保存%ebp中的值(BP1)到此位置,然后%ebp中的值更新为当前的位置,即BP2。接下来%esp继续增长,保存寄存器、本地变量、临时变量等,这些不难理解。 Q运行完毕,返回时,执行leave指令,效果为:(1)将%ebp中的值赋给%esp,即栈顶指针%esp和%ebp指向同一位置;而这个位置中保存的是BP1;然后,(2)此位置弹栈,即,将地址BP1给了%ebp,即此%ebp时回到了P调用前的样子,弹栈完,%esp回缩,正好指向“返回地址”——即,Q()后的第一条指令。
接下来就是ret,将“返回地址”弹栈并跳转了。

3.7.3 寄存器使用惯例

寄存器组是唯一能被所有过程共享的资源。必须保证调用者过程的寄存器值,不会被被调用者过程覆盖。所以,IA32统一了寄存器使用惯例:一部分由调用者保存,另一部分由被调用者保存。参见P151。

3.7.4 过程示例

一个过程一般有三个部分:

  1. “建立”部分,初始化栈帧。
  2. “主体”部分,执行过程的实际计算。
  3. “结束”部分,恢复栈的状态,以及过程返回。

    3.7.5 递归过程

    递归调用一个函数本身与调用其他函数是一样的。

3.8 数组分配和访问

我在这里没怎么写和汇编有关的东西。

3.8.1 基本原则

T A[N]
首先,这在内存中分配了L·N字节的连续区域,L为数据类型T的大小,单位为字节;xA代表这段内存的起始位置。其次,它引入了标识符A;可以用A作为指向数组开头的指针,这个指针的值就是xA。

3.8.2 指针运算

单目运算符&和*可以产生指针和间接引用指针。
&Expr是给出该对象的一个指针。
*Expr是各处改地址处的值。
数组引用A[i]等同于表达式*(A+i),它计算第i个数组元素的地址,然后访问这个内存位置。

3.8.3 嵌套的数组

就是二维数组,略

3.8.4 定长数组

3.8.5 变长数组

ISO C99引入了一种能力,允许数组的维度是表达式,在数组被分配的时候才计算出来。
参见P163
int A[expr1][expr2] ,它可以作为一个局部变量,也可以作为一个函数的参数,然后在遇到这个声明的时候,通过对表达式expr1和expr2求值来确定数组的维度。

` int var_ele(int n, int A[n][n], int i, int j) {
return A[i][j];
}
`

参数n必须在参数A[n][n]之前。

3.9 异质的数据结构

3.9.1 结构

机器代码不会包含关于字段声明或者字段名字的信息。结构的各个字段的选取完全是在编译时处理的。

3.9.2 联合

当用联合将各种不同大小的数据类型结合到一起时,字节顺序问题就变得很重要了。
再提一下:
小端:低地址存低数据。
大端:低地址存高数据。

3.9.3 数据对齐

Linux沿用的对齐策略时,2字节数据类型(例如short)的地址必须是2的倍数,而较大的数据类型(例如int、int *、float和double)的地址必须是4的倍数。
注意,这个要求就意味着一个short类型对象的地址最低一位必须是0b0。类似地,任何int类型的对象或指针的地址的最低二位必须是0b00。
对于包含结构的代码,编译器可能需要在字段的分配中插入间隙,以保证每个结构元素都满足它的对齐要求。而结构本身对它的起始地址也有一些对齐要求。

3.10 综合:理解指针

我感觉我蛮理解的。

3.11 应用:使用GDB调试器

相关命令参见P175

3.12 存储器的越界引用和缓冲区溢出

C语言对于数组引用不进行任何边界检查,而且局部变量和状态信息都存放在栈中。当使用的长度超出分配的长度,就是缓冲区溢出。
为对抗缓冲区溢出攻击,GCC提供的机制:

  1. 栈随机化 使栈的位置在程序每次运行时都有变化。
  2. 栈破坏检测 在栈帧的局部缓冲区与栈状态之间,加一个随机产生的金丝雀值,通过这个值是否被改变,来确认是否被攻击。
  3. 限制可执行代码区域 只有保存编译器产生的代码的那部分存储器才是可执行的。

3.13 x86-64:将IA32扩展到64位

3.14 浮点程序的机器表示

Written on April 1, 2020