CSAPP-程序的机器级表示

在使用高级语言如C语言进行编程的时候我们大多数情况下都屏蔽了程序具体的机器级实现。相比之下在使用汇编语言编写程序时程序员必须明确指定程序应该如何管理和使用存储器。这一章主要以intel的IA32指令集为例子讲解C,汇编代码以及目标代码之间的关系。然后会讲到IA32的细节,并且会给出一些使用GDB调试器来检查机器级程序运行时行为的技巧。

1. 历史观点

对于intel的处理器存在下面特征,每个时间上的相继处理器都是向后兼容的—–也就是说较早版本编译的代码可以在较新的处理器上运行。同时intel在IA32的发展过程中加入了许多处理小整数和浮点数向量的格式和指令来提高多媒体应用程序的性能,但是目前的GCC版本都不会使用这些新特性。实际上,在默认启动方式下,GCC会假定代码是一个为i386机器产生的代码,编译器并不会试图使用许多添加到现在看来已经非常古老的体系结构的扩展特性。

2. 程序编码

这里首先假设我们通过下面的命令编译我们编写的C代码:

1
gcc -o2 -o p p1.c p2.c

这里编译器主要做了下面几件事情:

  1. C语言预处理器会扩展源代码,插入所有#include指定的内容,并扩展所有的宏指令
  2. 编译器将产生的两个源文件汇编
  3. 汇编器将汇编代码转换成二进制目标文件p1.op2.o
  4. 最后链接器将两个目标文件和库文件的代码(printf)合并,并产生最终的可执行文件。

上面编译命令中的-o2是指明编译器的优化选项,通常来说,提高优化等级会使得最终程序运行的更快但是编译时间也会更长。

使用-S可以指定让编译器将C文件编译成汇编代码。如gcc -O2 -S code.c;使用-c可以指明让编译器编译并汇编c代码。如gcc -O2 -c code.c;linux中可以使用反汇编器来将二进制代码转换成汇编代码,如objdump -d code.o

3. 数据格式

Intel汇编用语中”字(word)”表示16位数据类型。因此32位的数字被称为“双字(double words)“,称64位数位为”四字(quad words)“。下表给出了x86-64机器上的表示:

C声明 Intel数据类型 汇编代码后缀 大小(字节)
char 字节 b 1
short w 2
int 双字 l 4
long 四字 q 8
char* 四字 q 8
float 单精度 s 4
double 双精度 l 8

C语言数据类型在x86-64的64位机器中指针长度为8个字节。

4. 访问信息

对于一个x86-64架构的中央处理器单元来说其中包含了一组16个可以存储64位值的通用寄存器。这些寄存器包括:%rax(返回值),%rbx(被调用者保存),%rcx(第4个参数),%rdx(第3个参数),%rsi(第2个参数),%rdi(第1个参数),%rbp(被调用者保存),%rsp(栈指针),%r8(第5个参数),%r9(第5个参数),%r10(调用者保存),%r11(调用者保存),%r12(被调用者保存),%r13(被调用者保存),%r14(被调用者保存),%r15(被调用者保存).

指令可以对这16个寄存器进行不同数据大小的操作,如字节级操作可以访问最低字节,16位操作可以访问最低的2个字节,32位操作可以访问最低的4个字节,尔64位操作可以访问整个寄存器。

对于操作指令来说,如果操作后结果的对象小于8个字节,存在下面两条规则来约束:

  1. 生成1字节和2字节数字的指令会保持剩下的字节不变;
  2. 生成4字节数字的指令会把高位4个字节置为0.

同时所有的寄存器的低位部分都可以作为字节,字(16位),双字(32位)和四字(64位)来访问,下面以%rax 为例说明这一表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
63                            31              15      7       0
+-----------------------------+---------------+-------+-------+
|%rax |%eax |%ax |%al | 返回值
+-----------------------------+---------------+-------+-------+
|%rbx |%ebx |%bx |%bl | 被调用者保存
+-----------------------------+---------------+-------+-------+
|%rcx |%ecx |%cx |%cl | 第4个参数
+-----------------------------+---------------+-------+-------+
|%rdx |%edx |%dx |%dl | 第3个参数
+-----------------------------+---------------+-------+-------+
|%rsi |%esi |%si |%sil | 第2个参数
+-----------------------------+---------------+-------+-------+
|%rdi |%edi |%di |%dil | 第1个参数
+-----------------------------+---------------+-------+-------+
|%rbp |%ebp |%bp |%bpl | 被调用者保存
+-----------------------------+---------------+-------+-------+
|%rsp |%esp |%sp |%spl | 栈指针
+-----------------------------+---------------+-------+-------+
|%r8 |%r8d |%r8w |%r8b | 第5个参数
+-----------------------------+---------------+-------+-------+
|%r9 |%r9d |%r9w |%r9b | 第6个参数
+-----------------------------+---------------+-------+-------+
|%r10 |%r10d |%r10w |%r10b | 调用者保存
+-----------------------------+---------------+-------+-------+
|%r11 |%r11d |%r11w |%r11b | 调用者保存
+-----------------------------+---------------+-------+-------+
|%r12 |%r12d |%r12w |%r12b | 被调用者保存
+-----------------------------+---------------+-------+-------+
|%r13 |%r13d |%r13w |%r13b | 被调用者保存
+-----------------------------+---------------+-------+-------+
|%r14 |%r14d |%r14w |%r14b | 被调用者保存
+-----------------------------+---------------+-------+-------+
|%r15 |%r15d |%r15w |%r15b | 被调用者保存
+-----------------------------+---------------+-------+-------+

4.1 操作数指示符

对于大多数指令来说都有一个或者多个操作数,用来指示出执行一个操作中要使用的源数据的值,以及放置操作结果的位置。这里各种不同的操作数的可能性被分为三类:

  1. 立即数 用来表示常数,在汇编语言中的书写方式为$后面跟一个标准C表示发的整数,如$0x1f
  2. 寄存器 用来表示某个寄存器的内容
  3. 内存引用 根据计算出来的地址(通常为有效地址)访问某个内存位置。这里经常将内存看作是一个很大的数组。

下表中表示了x86-64 系统中常见的操作格式:(这里比例因子s必须是1、2、4或者8)

类型 格式 操作数值 名称
立即数 $Imm Imm 立即数寻址
寄存器 r_a R[r_a] 寄存器寻址
存储器 Imm M[Imm] 绝对寻址
存储器 (r_a) M[R[r_a]] 间接寻址
存储器 Imm(r_b) M[Imm + R[r_b]] (基址+偏移量)寻址
存储器 (r_b, r_i) M[R[r_b] + R[r_i]] 变址寻址
存储器 Imm(r_b, r_i) M[Imm + R[r_b] + R[r_i]] 变址寻址
存储器 (,r_i,s) M[R[r_i]*s] 比例变址寻址
存储器 Imm(,r_i,s) M[Imm + R[r_i]*s] 比例变址寻址
存储器 (r_b, r_i, s) M[R[r_b]+R[r_i]*s] 比例变址寻址
存储器 Imm(r_b, r_i, s) M[Imm+R[r_b]+R[r_i]*s] 比例变址寻址

4.2 数据传送指令

在编程中最频繁使用的一个操作指令是将数据从一个位置复制到另一个位置。在汇编语言中,将不同的指令划分成指令类,每一类中指令执行相同的操作,只不过操作数大小不同。这里介绍最简单形式的数据传送指令 MOV 类指令,这些指令将数据从源位置复制到目的位置,不做出任何变化。MOV类指令如下表:

指令 效果 描述
MOV S, D D <<– S 传送
movb 传送字节
movw 传送字
movl 传送双字
movq 传送四字
movabsq I, R R <<– I 传送绝对的四字(只能以寄存器作为目的)

在x86-64 中传送指令的两个操作不能都是指向内存地址,所以将一个值从一个内存地址复制到另一个内存地址需要两个操作指令。

这里在强制类型转换或者两个寄存器大小不一致的情况下可能会用到下面零扩展或者符号扩展的操作指令。

指令 效果 描述
MOVZ S, D D <<– S(零扩展) 以零扩展进行传送
movzbw 将做了零扩展的字节传送到字
movzbl 将做了零扩展的字节传送到双字
movzwl 将做了零扩展的字传送到双字
movzbq 将做了零扩展的字节传送到四字
movzwq 将做了零扩展的字传送到四字
指令 效果 描述
MOVS S, D D <<– S(符号扩展) 传送符号扩展的字节
movsbw 将做了符号扩展的字节传送到字
movsbl 将做了符号扩展的字节传送到双字
movswl 将做了符号扩展的字传送到双字
movsbq 将做了符号扩展的字节传送到四字
movswq 将做了符号扩展的字传送到四字
movslq 将做了符号扩展的双字传送到四字
cltq %rax<<– 符号扩展(%eax) %eax符号扩展到%rax

在C语言中所谓的指针就是指的地址。间接引用就是将该指针先放在一个寄存器中,然后在内存引用中使用这个寄存器。C操作符*执行指针的间接引用,操作符&是取址操作符。

4.3 压入和弹出栈数据

这里要讲的最后两个数据传送操作是讲数据压入程序栈中,以及从程序栈中弹出数据。在 x86-64 中,程序栈存放在内存的某个区域中

指令 效果 描述
pushq S R[%rsp] <-- R[%rsp]-8 M[R[%rsp]] <-- S 将四字压入栈
popq D D <-- M[R[%rsp]] R[%rsp] <-- R[%rsp]+8 将四字弹出栈

%rsp 的指向总是指向栈顶。

在 x86-64 的设计中栈向地址低的方向增长,所以这里当执行压入栈的操作的时候%rsp(栈指针)的指会减小。

5. 算数和逻辑运算

算数逻辑运算有下面几类,这些指令都带有各种带有不同操作数的变种。这里指令被分成四组:加载有效地址、一元操作、二元操作和移位操作》

| 类别 | 指令 | 效果 | 描述 |
|:- :- | :- | :- |
| 加载有效地址 |leaq S,D | D <-- &S | 加载有效地址 |
| 一元操作 | INC D | D <-- D+1 | 加1 |
| | DEC D | D <-- D-1 | 减1 |
| | NEG D | D <-- -D | 取负 |
| | NOT D | D <-- -D | 取补 |
| 二元操作 | ADD S,D | D <-- D+S | 加 |
| | SUB S,D | D <-- D-S | 减 |
| | IMUL S,D | D <-- D*S | 乘 |
| | XOR S,D | D <-- D^S | 异或|
| | OR S,D | D <-- D | S | 或 |
| | AND S,D | D <-- D&S | 与 |
| 移位操作 | SAL k,D | D <-- D<<k | 左移 |
| | SHL k,D | D <-- D<<k | 左移(等同SAL)|
| | SAR k,D | D <-- D >>_A k | 算数右移 |
| | SHR k,D | D <-- DD >>_L k | 逻辑右移 |

5.1 加载有效地址

leaq指令形式是从内存读取数据到寄存器,该指令不是从指定位置读取数据而是将有效地址写入到目的的操作数。这条指令可以为后面的内存引用产生指针。例如:假设%rdx的值为x则汇编表达式leaq 7(%rdx,%rdx,4), %rax这里就是将%rax的值设置为5x+7

5.2 一元和二元操作

一元操作只有一个操作数,这个操作数即是源又是目的。同时这个操作数既可以是一个寄存器也可以是一个内存地址。

二元操作中,第二个操作数即是源又是目的地址。当第二个操作是内存地址的时候,处理器必须从内存中读出值,执行操作,再把结果写回到内存。

5.3 移位操作

在移位操作中,先给出的是移位量,然后第二项给出的是要移位的数。可以进行算数和逻辑右移。在x86-64中移位操作如果是针对w位长度的数值进行操作,移位量由k的低m位决定,这里2^m = w

5.4 一个例子

常常可以看到下面形式的汇编代码行:

1
xorq %rdx , %rdx

但是这里在产生这段汇编代码的C代码中并没有出现异或这样的操作指令,

  1. 解释这条特殊的异或操作的指令效果,并说明这条指令实现了什么操作?

这里主要是实现了将寄存器%rdx的值设置为0的操作,应为x^x = 0对应C语言x=0

  1. 比这条指令更加直接的汇编操作代码是什么?

这里更加直接的操作是:movq $0, %rdx

  1. 比较同样的一个操作的两种不同实现的编码字节长度。

但是这里异或操作的版本比通过movq进行直接操作的版本使用更少的内存空间

通常编译器产生代码中会用一个寄存器存放多个程序值,还会在寄存器之间传送程序值。

5.5 特殊的算数操作

在某些运算中两个64位的有符号或者无符号数的乘法运算的结果需要128位来表示。这里x86-64指令集对16字节数仅仅提供了有限的支持。这里将16字节称为8字(oct word)。下表描述了支持产生两个64位数字的全128位乘法及除法的指令:

指令 效果 描述
imulq S R[%rdx]:R[%rax] <-- S*R[%rax] 有符号全乘法
mulq S R[%rdx]:R[%rax] <-- S*R[%rax] 无符号全乘法
clto R[%rdx]:R[%rax] <-- 符号扩展(R[%rax]) 转换为八字
idivq R[%rdx] <-- R[%rdx]:R[%rax] mod S R[%rdx] <-- R[%rdx]:R[%rax] / S 有符号除法
divq S R[%rdx] <-- R[%rdx]:R[%rax] mod S R[%rdx] <-- R[%rdx]:R[%rax] / S 无符号除法

这里这些操作提供了有符号和无符号数的全128位乘法和除法,一对寄存器%rdx%rax组成一个128位的8字。

对于大多数64位除法应用来说,除数也常常是一个64位的值,这个值应该存放在%rax中,%rdx的所有位都应该设置为0(无符号运算)或者%rax的符号位(有符号运算)。

6. 控制

前面我们只讨论了直线代码结构的行文,这一节我们讨论如何执行条件操作,然后描述如何表达循环和switch语句的方法。

6.1 条件码

除了上面描述的整数寄存器,CPU里面还维护着一组单个位的条件码(condition code)寄存器,它们描述了最近的算数或逻辑操作的属性,可以检测这些寄存器来执行条件分支指令,这里常用的有:

  • CF :进位标志。最近的操作使最高位产生进位。可以用来检测无符号数操作的溢出。
  • ZF :零位标志。最近的操作得出的结果为0.
  • SF :符号标志。最近的操作得到的结果为负数。
  • OF :溢出标志。最近的操作导致了一个补码溢出—-既可以为正溢出也可以为负溢出。

对于前面的运算指令会隐式的设置条件码,同时还有两类指令可以设置条件码,同时不改变任何其他寄存器。

指令 基于 描述
CMP S1,S2 S2 - S1 比较(除了只设置条件码外,CMP指令的行为与SUB指令的行为一致)
TEST S1,S2 S1&S2 测试(除了只设置条件码外,TEST指令的行为与AND指令一致)

6.2 访问条件码

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

  1. 可以根据条件码的某种组合,将一个字节设置为0或者1;这里通常使用set指令来进行这一操作
  2. 可以条件跳转到程序的某个其他的部分;
  3. 可以有条件地传送数据。
指令 同义名 效果 设置条件
sete D setz D <-- ZF 相等/零
setne D setnz D <-- ZF 不等/非零
sets D D <-- SF 负数
setns D D <-- SF 非负数
setg D setnle D <-- ~(SF^OF) & ~ZF 大于(有符号)
setge D setnl D <-- ~(SF^OF) 大于等于(有符号)
setl D setnge D <-- SF^OF 小于(有符号)
setle D setng `D <– (SF^OF) ZF`
seta D setnbe D <-- ~CF & ~ZF 大于(无符号)
setae D setnb D <-- ~CF 大于等于(无符号)
setb D setnae D <-- CF 小于(无符号)
setbe D setna `D <– CF ZF`

对于底层的汇编指令代码,常常会出现同样的指令有多个名字的情况,这里我们称之为“同义名(synonym)”。

6.3 跳转指令

跳转指令可以导致执行程序切换到一个全新的位置,可以理解为C语言中的goto语句

jump指令:

指令 同义名 跳转条件 描述
jmp Label 1 直接跳转到标号所在的位置
jmp *Operand 1 间接跳转(间接跳转的写法是*后面跟上一个操作指示符)
je Label jz ZF 相等/零
jne Label jnz -ZF 不相等/零
js Label SF 负数
jns Label -SF 非负数
jg Label jnle ~(SF^OF) & ~ZF 大于(有符号)
jge Label jnl ~(SF^OF) 大于等于(有符号)
jl Label jnge SF^OF 小于(有符号)
jle Label jng `(SF^OF) ZF`
ja Label jnbe ~CF & ~ZF 大于(无符号)
jae Label jnb ~CF 大于等于(无符号)
jb Label jnae CF 小于(无符号)
jbe Label jna `CF ZF`

6.4 跳转指令的编码

跳转指令有几种不同的编码方式,但是在实际场景中比较常用的都是相对编码。也就是说,它们会将目标指令的地址与紧跟在跳转指令后面那条指令之间的差作为编码。这些编码的偏移量可以编码为1、2或4个字节。

6.5 用条件控制来实现条件分支

对于C语言中的 if-else语句的通用形式模版如下:

1
2
3
4
if (test-expr)
then-statement
else
else-statement

对于这种形式的C语句,编译器通常会将它转换成下面的形式,这里用C语言来描述对应的控制流:

1
2
3
4
5
6
7
8
    t = test-expr
if(!t)
goto false;
then-statement
goto done;
false:
else-statement
done:

6.6 用条件传送来实现条件分支

传统的计算机实现上面是通过使用控制的方式来实现条件的转移。当计算机程序条件满足的时候,程序沿着一条执行路径来执行,当条件代码不满足的情况,计算机就执行另外的一条指令。对于现代计算机的体系来说由于流水线机制的存在,这种执行方式会非常低效。当处理器的分支预测出错,处理器会丢掉错误的预测导致的所有预处理操作,然后重新开始从正确的位置开始填充流水线。

 在流水线的系统结构中,一条指令要经过一系列的阶段,每一个阶段只需要操作很小的一部分(例如,从内存中读取指令,确定指令类型、从内存中读取数据、执行算数运算、向内存中写入数据,以及更新程序计数器)。这里流水线通过重叠连续指令的步骤来获得高性能,例如再取一条指令的时候执行它前面一条指令的算数运算。

所以这里采用的一种策略就是使用数据转移指令。这种计算方法计算出一个条件操作的两种结果,然后再根据条件是否满足从中选取一个。这个方法只有在一些受限制的情况下才可行,但是如果可以实现,就可以使用一条简单的条件传送指令来实现,这种条件传送的方法更加符合现代处理器的性能特征。下表列举出了x86-64上面一些可用的条件传送指令。这里的每一条指令都有两个操作数:源寄存器或者内存地址S和目的寄存器R。与SET指令不同的是,这些指令的结果取决与条件码的值。源值可以从内存或者源寄存器中读取,但是只有在特定的条件满足的情况下,才会被复制到目的寄存器中。

指令 同义名 传送条件 描述
cmove S,R cmovz ZF 相等/零
cmovne S,R cmovnz ~ZF 不相等/非零
cmovs S,R SF 负数
cmovns S,R ~SF 非负数
cmovg S,R cmovnle ~(SF^OF)&~ZF 大于(有符号)
cmovge S,R cmovnl ~(SF^OF) 大于或等于(有符号)
cmovl S,R cmovnge SF^OF 小于(有符号)
cmovle S,R cmovz `(SF^OF) ZF`
cmova S,R cmovnbe ~CF&~ZF 大于(无符号)
cmovae S,R cmovnb ~CF 大于等于(无符号)
cmovb S,R cmovnae CF 小于(无符号)
cmovbe S,R cmovna `CF ZF`

条件传送指令。当传送条件满足的时候,指令将源值S复制到目的R。这里处理器不需要预测测试的结果就可以执行条件传送。处理器只是读取原值,检查条件码,然后要么更新目的寄存器,要么保持数据不变。

条件传送的伪代码形式如下:

  • 原始条件控制语句
1
2
3
4
5
6
  if (!test-expr)
goto false;
v = then-expr;
false:
v = else-expr;
done:
  • 条件转移语句伪代码:
1
2
3
4
v = then-expr;
ve = else-expr;
t = test-expr;
if (!t) v = ve;

总的来数,条件数据传送提供了一种条件控制转移来实现条件操作的替代策略。它们只能用于非常受限的情况。

6.7 循环

在C语言中程序提供了多种循环结构,其中包括do-whilewhilefor。在汇编语句中没有相应的指令存在,可以使用条件测试和跳转组合起来实现循环的效果。GCC和其他汇编器产生的循环代码主要基于两种基本的循环模式。

6.7.1 do-while 循环

do-while 语句的通用形式如下:

1
2
3
do
body-statement
while(test-expr);
6.7.2 while循环

while循环语句的C语言形式如下:

1
2
while (test-expr)
body-statement

这里对应的汇编版本主要有两种:

  1. 跳转中间(jump to middle),它执行一个无条件跳转跳到循环结尾处测试,以此来执行初始的测试内容。

    1
    2
    3
    4
    5
    6
    7
      goto test;
    loop:
    body-statement
    test:
    t = test-expr;
    if (t)
    goto loop;
  2. 第二种方法,我们称之为guarded-do,首先使用条件分支,如果条件分支不成立就跳过循环。

1
2
3
4
5
6
7
8
9
t = test-expr;
if(!t)
goto done;
do
body-statement;
t = test-expr;
if(t)
goto loop;
done:
6.7.3 for循环

for循环的通用形式如下。 对于for循环我们可以可以等价转化为while循环,所以对于汇编的转换也是等同于while语句。

1
2
for(init-expr; test-expr; update-expr)
body-statement

6.8 switch语句

switch语句可以根据一个整数的索引值进行多重分支。主要是通过跳转表(jump table)这种数据结构使得实现更为高效。跳转表是一个数组,表项i是一个代码段的地址,这个代码段实现当开关索引值等于i时程序应该采用的动作。与一组很长的if-else语句相比,使用跳转表的优点是执行开关语句的时间与开关情况的数量无关。GCC会根据条件的数量来翻译开关语句。当开关情况数量比较多,并且跨度范围比较小的时候,就会使用跳表。

7. 过程

过程是软件开发中一种很重要的抽象。它提供一种封装代码的方式,使用一组指定的参数和一个可选的返回值实现某种功能。然后,可以在程序中不同的地方调用这个函数。设计良好的软件使用过程作为抽象机制,隐藏某个行为的具体实现,同时又提供清晰简洁的接口定义,说明要计算的是哪些值,过程会对程序状态产生那些影响。不同的编程语言中,过程的形式多样:函数、方法、子例程,处理函数等等,但是它们有一些共同的特性。

要提供对过程的机器级支持,需要处理许多不同的属性。为了讨论方便,假设过程P调用过程Q,Q执行后返回到P。这里面的动作将包括下面的一个或多个机制:

  • 传递控制。在进入过程Q的时候,程序计数器必须被设置为Q的代码起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址。
  • 传递数据。P必须能够向Q提供一个或者多个参数,Q必须能够向P返回一个值。
  • 分配和释放内存。在开始的时候,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些存储空间。

对于大多数的过程的情况传递的参数都没有超过处理器大小的本地存储区域。不过有些时候,局部数据必须存放在内存中,常见的情况包括:

  • 寄存器不足够存放所有的本地数据。
  • 对于一个局部数据使用地址运算符“&”, 因此必须要能够为它产生一个地址。
  • 对于局部变量是数组或者结构体,因此必须能够通过数组或者结构引用来被访问到。

对于过程来说寄存器组是唯一被所有过程共享的资源。虽然在给定的时刻只有一个过程是在活动的,但是我们仍然会有需要确保当中断或者另外一个过程,被调用者不会覆盖调用者稍后要使用的寄存器。

在x86-64的系统中,寄存器%rbx %rbp r12~`r15`被划分成为被调用者保存寄存器。当过程P调用过程Q时,Q必须保存这些寄存器的值,保证它们的值在Q返回到P的时候与Q调用时一致。对于过程Q来说保存一个寄存器的值不变,要么就是根本不去改变它,要么就是将原始的值压入程序栈中,去改变寄存器的值,然后在返回前从栈中弹出旧的值。压入寄存器的值会在栈帧中创建标号为“保存的寄存器”的标签。

8. 数组的分配和访问

C语言中的数组是一种将标量数据聚集成更大数据类型的方式。C语言实现数组的方式非常简单,因此很容易被翻译成机器代码。C语言的一个不同寻常的特点是可以产生指向数组中的元素指针,并对这些指针进行运算。在机器代码中个,这些指针会被翻译成地址计算。

优化编译器非常善于简化数组索引所使用的地址计算。不过这使得C代码和它到机器代码的翻译之间的对应关系有些难以理解。

在C语言中允许对指针进行运算,而计算出来的值会根据该指针引用的数据类型的大小进行伸缩。也就是说,如果p是一个指向类型T的数据指针,p的值为x_p,那么表达式p+i的值也就是x_p + L*i(这里L是数据类型T的大小)。

&*可以产生指针和间接引用指针。也就是说对于一个表示某个对象的表达式Expr&Expr是给出该对象地址的一个指针。对于一个表示地址的表达式AExpr*AExpr给出了该地址处的值。因此表达式Expr* &Expr相互等价。

对于数组来说,数组引用A[i]等同于表达式* (A + i)。 这里它计算第i个数组元素的地址,然后访问这个内存位置。

8.1 嵌套数组

对于多位数组来说,数组的分配和引用的一般原则也是成立的,例如,声明数组:

1
int A[5][3];

等价于下面的声明

1
2
typedef int row3_t[3];
row3_t A[5];

多维数组在内存中是按照“行优先”的方式顺序排列的,这种排序方式是嵌套声明导致的。

C语言编译器会主动优化定长的多维数组上的操作代码。

当程序员希望使用一个常数作为数组的纬度或者缓冲区的大小的时候,一个好的习惯是通过#define声明来将这个常数与一个名字联系起来,然后在后面一直使用这个名字代替常数的数值。

8.2 变长数组

C语言只支持大小在编译时就能确定的多维数组。ISO C99中引入了一种功能,允许数组的纬度是表达式,但是需要在数组分配之前能获取该表达式的结果。如:int A[expr1][expr2]

在允许优化的情况下,GCC能够识别出程序访问多维数组的元素的步长。

9. 异类数据结构

C语言提供了两种不同类型的对象组合到一起创建数据类型的机制:结构体(structure), 用关键字struct来声明,将多个对象集合在一个单位中;联合体(union),用关键字union来声明,允许用几种不同的类型来引用一个对象。

9.1 结构体

C语言通过关键字struct声明创建一个数据类型,将可能不同类型的数据聚合在一个对象中。用名字来引用结构的各个组成部分。类似于数组的实现,结构的所有的组成部分都存放在内存中的一段连续区域内,而指向结构的指针就是结构第一个字节的地址。编译器维护关于每个结构的信息,指向每个字段的地址的字节偏移。它以这些偏移作为内存引用指令中的位移,从而产生对结构元素的引用。

9.2 联合体

C语言通过联合体union提供一种方式,规避C语言的类型系统,允许,多种类型来引用同一个对象。

9.3 数据对齐

绝大多数计算机系统都会对基本数据类型的合法地址做出一些限制,要求某种类型对象的地址必须是某个值K(通常是2、4或者8的倍数)。这种对齐现实简化了形成处理器处理器和内存系统之间的接口硬件设计。

例如,假设一个处理器总是从内存中取8个字节,则地址必须为8的倍数。如果我们能保证将所有的double类型的数据地址对齐成8的倍数,那么就可以用一个内存操作来读或者写值了。否则我们可能需要执行两次内存访问,因为对象可被分放在两个8字节的内存块中。
ß
同时可以在汇编代码中找到下面类似的命令.align 8。保证后面的数据是按8的倍数全局对齐的。

10. 算数和逻辑运算

到目前为止,我们已经分别讨论了机器代码如何实现程序的控制部分和如何实现不同的数据结构。在这一节,我们会讨论数据和控制是如何交互的。

10.1 理解指针

指针是C语言的一个核心特色。采用一种统一的方式,对不同的数据结构中的元素产生引用。这里介绍一些指针和它们映射到机器代码中的关键原则:

  • 每个指针都对应一个类型。这个类型表明该指针指向哪一类对象。
  • 每个指针都应该有一个值。这个值是某个指定类型对象的地址。特殊的NULL值表示该指针没有指向任何地方。
  • 指针采用&运算符创建。
  • * 操作符用于间接引用指针。
  • 数组的操作与指针紧密联系。
  • 将指针从一种类型强制转换为另一种类型,只改变它的类型,而不改变它的值。
  • 指针也可以指向函数。

10.2 使用GDB调试器

GDB调试器提供了许多有用的特性,用来支持机器级程序的运行时评估和分析。例如可以通过下面命令启动gdb并调试我们的程序

1
gdb prog

10.3 内存越界引用和缓冲区溢出

因为C语言对于数组的边界不进行任何边界检查,而且局部变量和状态信息都存放在程序栈中。这两种情况结合在一起就能导致严重的程序错误,对越界数组元素的写操作会破坏存储在程序栈中的状态信息。当程序使用这个被破坏的状态,试图重新加载寄存器或者执行ret指令时就会出现严重错误(如缓冲区溢出)。

缓冲区溢出的一个更加致命的使用就是让程序执行它本来不愿意执行的函数。这是一种常见的通过计算机网络攻击系统安全的方法。

10.4 对抗缓冲区溢出

缓冲区溢出攻击的普遍发生给计算机造成了许多的麻烦。现代编译器和操作系统实现了很多机制,用来避免遭受这些攻击。

  • 栈随机化:程序在栈中的位置每次运行都有变化。因此计算机在运行同样的代码的时候,程序的栈地址都是不同的。具体实现方式是,在栈上分配一段0~n个字节的随机大小的空间,真正的程序代码不使用这段空间,但是它会导致程序每次执行时后续栈的位置发生变化。
  • 栈破坏检测:计算机能够检测何时栈被破坏。在缓冲区溢出的破坏形式中,破坏通常发生在超越局部缓冲区边界时。在C语言中,没有可靠的方法来防止对数组的越界写。但是,我们能够在发生写越界的时候,在造成任何有害的结果之前,尝试检测到它。
  • 限制可执行代码的区域:这种方法限制那些内存区域能够存放可执行代码。在典型的程序中,只有保存编译器产生的代码的那部分内存才是需要可执行的。其他部分可以被限制为只允许读或者写。

10.5 支持变长栈帧

到目前为止前面讲解的所有机器代码都有一个共同特征,编译器能够预先确定需要为栈帧分配多少空间。但是有一些函数,需要的局部存储是变长的。例如当函数调用alloca的时候就会发生这种情况。

为了管理变长帧, x86-64代码使用寄存器%rbp作为帧指针。

11. 浮点代码

处理器的浮点体系结构包含多个方面,会影响对浮点数据操作的程序如何被映射到机器上,包括:

  • 如何存储和访问浮点数值。通常是通过某些特定的寄存器方式来完成。
  • 对浮点数据操作的指令。
  • 向函数传递浮点参数和从函数返回浮点数结果的规则。
  • 函数调用过程中保存寄存器的规则 —- 例如,一些寄存器被指定为调用者保存,而其他的被指定为被调用者保存。

x86-64 的浮点数处理是基于SSE或者AVX的,包括传递过程参数和返回值规则。类似的理解多数嵌入式系统或者嵌入式处理器中浮点数的处理是基于FPU。

对于AVX来说,它的浮点数体系结构允许数据存储在16个YMM寄存器中,它们的名字为%ymm0 ~~ %ymm15。每个YMM寄存器都是256位(32字节)的。当对标量数据操作的时候,这些寄存器都只保存浮点数,而且只用第32位(float)或者64位(double)。汇编代码用寄存器 SSE XMM寄存器名字 %xmm0 ~~ %xmm15 来引用它们,每个 XMM 寄存器都是对应YMM寄存器的低128位(16字节)。

浮点代码采用vmovss(传递单精度数) vmovsd(传递双精度数) vmovaps(传递对齐封装好的单/双精度数据)。其中vmovaps可以理解为将数据从一个寄存器中复制到另一个。 vmovss \ vmovsd表示将数据从内存中复制到XMM寄存器或者从XMM寄存器复制到内存中。

浮点数向整数转换或者不同的浮点数之间的转换,指令会执行截断,将数值向0舍入。如指令 vcvttss2si(float2int) vcvttsd2si(double2int) vcvttss2siq(float2long) vcvttsd2siq(double2long) vcvtsi2ss(int2float) vcvtsi2sd(int2double) vcvtsi2ssq(long2float) vcvtsi2sdq(long2double)

在x86-64中, XMM寄存器用来向函数传递浮点参数,以及从函数返回浮点值。主要有下面的几条规则:

  • XMM寄存器%xmm0 ~~ %xmm7 最多可以传递8个浮点参数。按照参数列出的顺序使用这些寄存器。可以通过栈传递额外的浮点参数
  • 函数使用寄存器%xmm0来返回浮点值
  • 所有的XMM寄存器都是调用者保存。被调用者可以不用保存就覆盖这些寄存器中的任意一个。

当函数包含指针、整数和浮点数混合参数的时候,指针和整数通过通用寄存器传递,而浮点数的值通过XMM寄存器传递。

对于浮点的算数操作运算主要有下面的几条运算指令: vaddss(float加法) vaddsd(double加法) vsubss(float减法) vsubsd(double减法) vmulss(float乘法) vmulsd(double乘法) vdivss(float除法) vdivsd(double除法) vmaxss(float求浮点数最大值) vmaxsd(double求浮点数最大值) vminss(float求浮点数最小值) vminsd(double求浮点数最小值) sqrtss(float平方根) sqrtsd(double平方根)

和整型运算操作不同,AVX浮点操作不能以立即数值作为操作数。相反,编译器必须为所有的常量值分配和初始化存储空间。然后代码在吧这些值从内存读入。

对于浮点数可以通过下面命令进行位操作:vxorps(float按位异或) vorpd(double按位异或) vandps(float按位与) andpd(double按位与)

对于浮点数可以通过下面命令进行浮点比较操作:ucomsis(比较单精度CSAPP) ucomisd(比较双精度)

总的来说使用AVX2位浮点数上的操作产生的机器代码风格类似于整数上的操作产生的代码风格。它们都使用一组寄存器来保存和操作数据值,也都使用这些寄存器来传递函数参数。