image

# CSAPP 第三章 程序的机器级表示

编译器基于编程语言的规则、操作系统的惯例、目标机器的指令集生成机器代码。

汇编代码是机器代码的一种形式,它是机器代码的文本表示。

高级代码可移植性好,而汇编代码与特定机器密切相关。

能够阅读汇编代码:

好处:可以理解编译器的优化能力,并分析代码中隐含的低效率

条件:了解编译器将高级语言转换为机器代码的转换方式。

精通细节很重要,是理解更深和更基本概念的先决条件。要认真研究示例、完成练习。

32位机器可以使用约 4GB 的随机访问存储器,64位机器可以使用 256TB(2^48) 的内存空间(这里说的是主存)。

# 3.2 程序编码

汇编器产生的目标代码是机器代码的一种形式,它包含二进制形式表示的所有指令,但还没有填入全局值的地址。

# 3.2.1 机器级代码

影响机器级程序的两种抽象:

指令集架构:定义了处理器状态、指令的格式、指令对状态的影响。

虚拟地址:机器代码将内存看成一个按字节寻址的数组。

对机器代码可见的处理器状态:

程序计数器

整数寄存器文件:保存临时数据或重要的程序状态

条件码寄存器:保存最近执行的算术或逻辑指令的状态信息。

一组向量寄存器:保存一个或多个整数或浮点数值

C 语言中的数组和结构,在机器代码中用一组连续的字节来表示。

汇编代码不区分有符号数和无符号数,不区分指针的不同类型,不区分指针和整数

一条机器指令只执行一个非常基本的操作。

# 3.2.2 代码示例

反汇编

使用反汇编器可以根据机器代码产生汇编代码。如:48 89 d3 → mov %rdx,%rbx

机器代码与反汇编表示的特性:

x86-64 的指令长度范围为 1~15 字节。常用指令和操作数少的指令所需字节少。

从十六进制字节值到汇编指令,格式为:某个数字唯一地对应某个汇编指令,比如 mov 指令以 48 开头。

指令结尾的 'q' 是大小指示符,大多数情况下可以省略。

从源程序转换来的可执行目标文件中,除了程序过程的代码,还包含启动和终止程序的代码,与操作系统交互的代码。

链接器的任务之一就是为函数调用找到匹配的函数的可执行代码的位置。

# 3.2.3 关于格式的注解

在汇编代码中,以 ‘.’ (点) 开头的行是指导汇编器和链接器工作的伪指令。

# 3.3 数据格式

字节:byte,8位;字:word,16位;双字:double words,32位;四字:quad words,64位。

对应的指令后缀:movb, movw, movl, movq。

这里说的都是整数,浮点数使用一组完全不同的指令和寄存器。

# 3.4 访问信息

一个 64 位 CPU 中包含一组 16 个存储 64 位值的通用目的寄存器,用来存储整数和指针。

16 个寄存器标号为 rax​**<sub>rbp,r8</sub>r15**

16 个寄存器的低位部分都可以作为字节、字、双字、四字来单独访问。分别表示为 al, ax, eax, rax。

低位操作的规则:

将寄存器作为目标位置时,生成字节和字的指令会保持剩下的字节不变

生成双字的指令会把高位四字节置为 0.

16个寄存器的作用

rax:返回值

rsp:栈指针

rdi, rsi, rdx, rcx, r8, r9:第 1 到第 6 个参数

rbx, rbp, r12~r15:被调用者保存

r10, r11:调用者保存

# 3.4.1 操作数指示符

指令的操作数有三种类型:立即数寄存器内存引用

最常用的寻址方式:Imm(rb, ri, s):Imm + rb + ri*s

s 为比例因子,只能是 1,2,4,8 中的某一个

# 3.4.2 数据传送指令

mov类

mov 只会更新目的操作数指定的寄存器字节或内存位置。

mov 类是最简单的数据传送指令,mov 类有 5 种:

movb, movw, movl:传送字节、字、双字

movq:传送四字。如果源操作数是立即数,只能是双字,然后符号扩展到四字(假的四字)

movabsq:传送绝对的四字。只能以立即数作为源操作数,以寄存器为目的。可以传送任意 64 位立即数。

movq 用来传送寄存器和内存引用中的四字,movabsq 用来传送四字的立即数

mov 类的源操作数和目的操作数不能同时为内存,即不能将值从内存复制到内存。

mov 指令中寄存器的大小必须与 mov 的后缀字符大小匹配。

movb $-17, %al

movz类

movz 系列和 movs 系列可以把较小的源值复制到较大的目的,目的都是寄存器

movz 将目的寄存器剩余字节做零扩展,movs 做符号扩展

movz类:movzbw, movzbl, movzbq, movzwl, movzwq(movzbw 即从字节复制到字,其他类似)

movs类:movsbw, movsbl, movsbq, movswl, movswq, movslq, cltq

cltq: 没有操作数,将 eax 符号扩展到 rax,等价于 movslq %eax,%rax

# 3.4.3 数据传送示例

局部变量通常保存在寄存器中。

函数返回指令 ret 返回的值为寄存器 rax 中的值

强制类型转换是通过 mov 指令实现的。

当指针存在寄存器中时,a = *p 的汇编指令为: mov (rdi), rax

# 3.4.4 压入和弹出栈数据

栈向下增长,栈顶的地址是栈中元素地址中最低的。栈指针 rsp 保存栈顶元素的地址。

出入栈指令:

pushq rax:压栈,栈指针减 8 并将 rax 中的值写入新的栈顶地址,等价于:subq $8, (rsp) ; movq rax,(rsp)。

popq rax:出栈,栈指针加 8 并将出栈的值写入 rax 中,等价于:movq (rsp),rax ; add $8,(rasp)

使用 mov 指令和标准的内存寻址方法可以访问栈内的任意位置,而非仅限于栈顶。

# 3.5 算术和逻辑操作

x86-64 的每个指令类都有对应四种不同大小数据的指令

算术和逻辑操作共有四组:

加载有效地址

leaq S, D:将 S 的地址保存到 D 中,D 必须是寄存器

一元操作

inc D: D+1

dec D: D-1

neg D:取负

not 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<<k 左移,算术右移,左端补符号位

shr k,d: d=d<<k 左移,逻辑右移,左端补零

# 3.5.1 加载有效地址

leaq 实际上是 movq 指令的变形。操作是从内存读数据地址到寄存器。

leaq 在实际应用中常常不用来取地址,而用来计算加法和有限形式的乘法

leaq 9(rdi, rsi, 4), rax;//x in rdi,y in rsi。此操作实际上等于将 x+4*y+9 的结果存入 rax

# 3.5.2 一元和二元操作

一元操作中的操作数既是源又是目的。

二元操作中的第二个操作数既是源又是目的。

因为不能从内存到内存,因此当第二个操作数是内存地址时,要先从内存读出值,执行操作后再把结果写回去。

注意 sub s,d 是 d-s 而不是 s-d

# 3.5.3 移位操作

移位操作的移位量可以是一个立即数或放在单字节寄存器 cl 中。

当移位量大于目的数的长度时,只取移位量低字节中的值(小于目的数长度)来作为真实的移位量。

# 3.5.4 特殊的算术操作

两个 64 位数的乘积需要 128 位来表示,x86-64指令集可以有限的支持对 128 位数的操作,包括乘法和除法。

128 位数需要两个寄存器来存储,移动时也需要两个 movq 指令来移动。

这种情况对于有符号数和无符号数采用了不同的指令。

# 3.6 控制

条件语句、循环语句、分支语句都要求有条件的执行。

机器代码提供两种低级机制来实现有条件的行为:

测试数据值,然后根据测试的结果来改变控制流或数据流

使用 jump 指令进行跳转

# 3.6.1 条件码

条件码寄存器都是单个位的,是不同于整数寄存器的另一组寄存器。

条件码描述了最近的算术或逻辑操作的属性,可以通过检测这些寄存器来执行条件分支指令。

常用条件码:

CF:进位标志。 最近的操作使最高位产生了进位。可以用来检查无符号数的溢出

ZF:零标志。 最近的操作的结果为 0

SF:符号标志。 最近的操作的结果为负数。

OF:溢出标志。 最近的操作导致了补码溢出

除了 leaq 指令外,其余的所有算术和逻辑指令都会根据运算结果设置条件码

此外还有两类特殊的指令,他们只设置条件码不更新目的寄存器:

cmp s1, s2: 除了不更新目的寄存器外与 sub 指令的行为相同

test s1, s2: 除了不更新目的寄存器外与 and 指令的行为相同

# 3.6.2 访问条件码

条件码一般不直接读取,常用的使用方法有 3 种:

根据条件码的某种组合,使用 set 指令类将一个字节设置为 0 或 1。

条件跳转到程序的某个其他部分

有条件地传送数据

set 指令类

set 指令的目的操作数是低位单字节寄存器元素或一个字节的内存位置。set 会将该字节设置为 0 或 1

set 指令类的后缀指明了所考虑的条件码的组合,如 setl (set less) 表示“小于时设置”

注意到上图中,set 指令对于大于、小于的比较分为了有符号和无符号两类。

大多数时候,机器代码对无符号和有符号两种情况使用一样的指令。

使用不同指令来处理无符号和有符号操作的情况:

不同的条件码组合:

不同版本的右移:sar 和 shr

不同的乘法和除法指令

汇编语言中数据本身不区分有符号和无符号,通过不同的指令来区分有符号操作和无符号操作。

注意在汇编代码中,8字节的操作数可能是 long,long long 或 指针

# 3.6.3 跳转指令

跳转指令的目的地由一个标号指明

jmp .L1 ;//跳转到 .L1 。在实际的跳转指令中,.L1 会直接编码为跳转目标的地址。

movq (rax),rdx

.L1:

popq rdx

jmp 可以是直接跳转,即操作数为标号。也可以间接跳转,即操作数是寄存器或内存引用,这种情况下跳转到寄存器中存储的地址处。

跳转指令分为有条件跳转无条件跳转,只有 jmp 是无条件跳转。有条件跳转都只能是直接跳转。

有条件跳转类似 set 指令系列,根据条件码寄存器的值来判断是否进行跳转。

# 3.6.4 跳转指令的编码

跳转指令的机器编码(就是纯粹数字表示的机器语言)有几种方式,其中两种如下:

PC 相对跳转:使用目标地址与跳转指令之后下一条指令的地址之间的差来编码。可以用 1、2 或 4 个字节来编码。

绝对地址编码:使用目标的绝对地址。用 4 个字节直接指出。

汇编器和链接器会自己选择适当的编码方式

# 3.6.5 用条件控制来实现条件分支

汇编代码层面的条件控制类似于 c 语言的 goto 语句。

汇编语言使用条件码和条件跳转来起到和 c 语言中 if 相似的作用

'C 语言'

if( x<y ) { i++ }

else { i-- }

'汇编'

cmpq rsi,rdi

jge .L2

incl rax;

.L2:

    decl rax;

# 3.6.6 用条件传送来实现条件分支

条件传送不会改变控制流,而是根据条件码决定是否改写目的寄存器。

常见指令形式为 cmovXX S, D

其中源操作数可以是寄存器或内存,目的操作数只能是寄存器

条件传送常用于实现简单的条件表达式,例如:

v = x < y ? x : y;

使用条件传送的好处:

避免分支预测失败带来的代价

在两种结果都容易提前算出的情况下,通常比真正跳转更高效

条件传送的局限:

两个分支对应的值通常都要先算出来

如果某一分支有副作用,或者计算代价很大,就不适合用条件传送

# 3.6.7 循环

机器级代码中没有 for、while、do-while 这样的“循环语法”,只有测试 + 跳转

do-while 最自然,因为它本来就是“先执行一次,再判断”

它通常会被翻译成:

执行循环体

测试条件

条件成立时跳回循环开头

while 往往会被改写成带条件跳转的 do-while 形式

也就是先跳到测试代码,再根据测试结果决定是否进入循环体

for 本质上也可以拆成:

初始化部分

条件测试部分

循环体部分

更新部分

因此在汇编层面,for 和 while 通常没有本质区别

break 和 continue

break 对应跳到循环结束位置

continue 对应跳到下一轮测试或更新位置

# 3.6.8 switch语句

switch 语句是一种多重分支

编译器实现 switch 的方式主要有两种:

使用一连串 cmp 和 jump

使用跳转表(jump table)

当 case 的值分布比较稠密时,编译器更倾向于使用跳转表

做法通常是:

先检查开关值是否越界

若越界则跳到 default

若不越界,则用开关值作为索引,从表中取出目标地址并进行间接跳转

跳转表的本质是一个地址数组

当 case 值很稀疏时,使用跳转表会浪费空间,这时编译器通常改用比较和分支

# 3.7 过程

过程调用是机器级程序中最核心的机制之一。

一个过程调用需要处理 3 件事:

传递控制

传递数据

为局部数据分配和释放空间

# 3.7.1 运行时栈

过程调用依赖于运行时栈

当过程 P 调用过程 Q 时,控制和数据都通过栈来组织

每次过程调用都会在栈上分配一块空间,称为栈帧

栈帧中通常保存:

返回地址

被保存的寄存器

局部变量

为调用其他过程准备的参数空间

栈向低地址方向增长,因此分配栈帧通常表现为rsp 减小

过程返回时,对应栈帧被释放,rsp 恢复

# 3.7.2 转移控制

过程调用和返回分别由 callret 指令实现

call label

先把返回地址压入栈中

再跳转到被调用过程的开始位置

ret

从栈中弹出返回地址

然后跳转到该地址继续执行

call 可以是:

直接调用:目标是一个标号

间接调用:目标地址保存在寄存器或内存中。函数指针调用就是这种形式

# 3.7.3 数据传送

过程之间传递数据主要依赖寄存器和栈

x86-64 中,整数和指针参数的前 6 个通常通过寄存器传递:

rdi, rsi, rdx, rcx, r8, r9

如果参数超过 6 个,其余参数通过栈传递

返回值通常通过 rax 返回

有些较大的返回结果不会直接放在寄存器里,而是通过调用者提供的一块内存区域来返回

调用者保存和被调用者保存

有些寄存器属于调用者保存

如果调用者希望这些值在 call 后仍然有效,就必须自己先保存

有些寄存器属于被调用者保存

如果被调用过程要使用它们,就要先保存,结束前再恢复

这套约定保证了不同过程编译出来后仍然能够正确协作

# 3.7.4 栈上的局部存储

当局部变量太多,或者必须有内存地址时,编译器会把它们放在栈帧

例如:

局部数组

结构体局部变量

需要取地址的局部变量

为了给这些数据分配空间,过程开始时通常会执行类似:

subq $32, %rsp

这表示在栈上开辟 32 字节空间

函数结束前再通过 addq 或直接恢复 rsp 来释放这些空间

# 3.7.5 寄存器中的局部存储空间

如果局部变量是简单的标量值,而且生命周期合适,编译器更愿意把它们保存在寄存器

这样做的优点是:

访问速度快

不需要内存读写

便于编译器做优化

因此,优化后的代码里常常看不到“局部变量名”,只能看到若干寄存器在承担变量角色

# 3.7.6 递归过程

递归并不需要特殊的指令支持。

它只是一个过程直接或间接调用自己

每次递归调用都会产生一个新的栈帧,因此:

每一层调用都有自己独立的返回地址

每一层调用都有自己独立的局部变量

递归之所以可行,本质上依赖于栈对“多个未完成调用”的保存能力

递归层次过深会导致栈空间耗尽

这就是栈溢出的一个常见来源

# 3.8 数组分配和访问

数组在机器级程序中表现为一段连续分配的内存

数组访问的关键问题是:如何从基址下标算出元素地址

# 3.8.1 基本原则

若数组 A 的基址为 x,元素类型大小为 L,那么:

&A[i] = x + i * L

这说明数组访问本质上是地址计算

编译器会尽量利用 x86-64 的比例寻址模式来完成这样的计算

如果元素大小是 1、2、4、8 字节,那么通常可以直接利用硬件支持的比例因子

# 3.8.2 指针运算

在 C 语言中,数组名在很多场景下会退化为指向首元素的指针

因此:

A[i]

*(A + i)

本质上是同一件事

指针加法并不是按字节递增,而是按所指对象的大小递增

例如 int *p 中,p+1 实际上是地址加 4(假设 int 为 4 字节)

两个同类型指针相减,得到的也不是字节差,而是元素个数差

# 3.8.3 嵌套的数组

多维数组在内存中仍然是按线性方式存放的

C 语言采用行优先(row-major) 次序

例如二维数组 AR 中,Ai 的地址为:

&A[i][j] = x + (i * C + j) * L

其中 x 是数组起始地址,L 是每个元素的大小

因此访问二维数组时,编译器要先算出“第 i 行的起始偏移”,再加上列偏移

# 3.8.4 定长数组

如果数组维度在编译时就已知,那么很多地址计算都可以在编译阶段部分化简

例如一行有多少字节,可以直接当作常数使用

这样生成的汇编代码通常更简洁,也更容易优化

这就是为什么固定维度数组经常能得到更高效的机器代码

# 3.8.5 变长数组

变长数组(VLA)的维度直到运行时才知道

这意味着:

行大小要在运行时计算

整个数组所需空间也要在运行时计算

栈帧大小因此不再固定

编译器通常会动态调整 rsp 来为它分配空间

由于涉及更多运行时计算,变长数组对应的机器代码往往比定长数组复杂

# 3.9 异质的数据结构

数组中的元素类型完全相同,而结构体和联合允许把不同类型的数据组织在一起

理解它们的机器级表示,对于分析内存布局很重要

# 3.9.1 结构

结构体的各个字段按照一定顺序存放在一段连续内存中

每个字段相对于结构起始地址都有一个固定偏移量

因此访问结构字段时,机器代码所做的事情就是:

基址 + 固定偏移

例如 p->x 往往会被编译成“先取出 p,再访问某个固定偏移位置”

嵌套结构的访问,本质上只是多层偏移量的叠加

# 3.9.2 联合

联合(union)的所有字段共享同一段存储空间

也就是说,不同字段的起始地址相同

联合的大小至少要能够容纳它的最大成员

联合的用途之一是:

用同一段位模式,从不同类型的角度来解释数据

但程序员必须自己保证“当前读取的字段”与“最近写入的字段”在语义上是合理的

# 3.9.3 数据对齐

为了提高存取效率,很多数据对象都会按某种对齐要求存放

例如 4 字节对象通常希望地址是 4 的倍数,8 字节对象通常希望地址是 8 的倍数

对齐会带来一个结果:填充字节(padding)

填充可能出现在:

结构字段之间

结构体尾部

结构体尾部补齐的原因是:如果这个结构体再组成数组,那么数组中每个元素的起始地址仍然要满足对齐约束

因此,字段顺序不同,结构体大小也可能不同

# 3.10 在机器级程序中将控制与数据结合起来

前面分别讨论了控制和数据。

真正的程序错误,往往出现在二者结合的地方,尤其是指针、数组、栈帧和控制转移交织时

# 3.10.1 理解指针

指针保存的是地址

但“这个地址该按什么类型解释”,由指针类型决定

同一个地址:

作为 char * 读取,含义可能是一个字节

作为 int * 读取,含义可能是 4 个字节组成的整数

因此,指针既强大也危险

空指针、悬垂指针、越界指针都会导致不可预期的行为

从机器级角度看,处理器只是在使用某个地址,它并不知道这个地址在 C 语言语义下是否合法

# 3.10.2 应用:使用GDB调试器

GDB 可以帮助我们把高级语言和机器级执行过程联系起来

常见用途:

查看寄存器:info registers

反汇编函数:disas

查看内存:x/16gx addr

单步执行:stepi​、nexti

设置断点:break

用 GDB 观察程序时,可以清楚地看到:

参数进入了哪些寄存器

栈帧是如何变化的

条件跳转究竟依据了什么条件码

这对于理解汇编代码和排查底层错误都非常重要

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

数组和缓冲区本质上都是连续内存区域

如果程序写入超过边界的数据,就会覆盖相邻内存内容

这可能破坏:

其他局部变量

保存的寄存器值

返回地址

这类错误之所以危险,是因为机器指令通常不会自动进行边界检查

因此一个看似普通的数组写操作,可能最终改变控制流

# 3.10.4 对抗缓冲区溢出攻击

现代系统使用了多种机制来减轻缓冲区溢出的危害:

栈随机化(ASLR)

栈不可执行

栈保护值(canary)

这些机制的作用是:

增加攻击者预测地址的难度

阻止把栈中的数据直接当成代码执行

在返回前检查栈帧是否被破坏

但最根本的方法仍然是:写出不发生越界访问的程序

# 3.10.5 支持变长栈帧

有些过程的栈帧大小在编译时并不固定,例如:

使用变长数组

使用 alloca 在运行时申请栈空间

这会让 rsp 在过程执行期间发生额外变化

在这种情况下,如果仍然需要稳定地访问某些局部变量或保存值,编译器更可能使用 rbp 作为帧指针

也就是说:

rsp 更适合表示“当前栈顶”

rbp 更适合表示“本过程栈帧中的固定参考位置”

# 3.11 浮点代码

浮点数据的处理和整数明显不同。

x86-64 中,现代编译器通常使用 XMM 寄存器 和一套专门的浮点指令来处理单精度和双精度数

# 3.11.1 浮点传送和转换操作

浮点数据的传送常使用:

movss:传送单精度浮点数

movsd:传送双精度浮点数

整数和浮点数之间的转换则使用专门的转换指令

例如:

cvtsi2sd:整数转双精度

cvttsd2si:双精度转整数(带截断)

这说明浮点和整数虽然都存放在寄存器里,但它们的处理路径并不相同

# 3.11.2 过程中的浮点代码

浮点参数和返回值通常通过 XMM 寄存器 传递

常见约定是:

前若干个浮点参数放在 xmm0 ~ xmm7

浮点返回值放在 xmm0

因此一个过程如果同时接收整数参数和浮点参数,编译器需要分别管理两套寄存器约定

# 3.11.3 浮点运算操作

浮点加减乘除也有各自的专用指令,例如:

addss​ / addsd

subss​ / subsd

mulss​ / mulsd

divss​ / divsd

这些指令处理的是浮点编码后的位模式,但语义遵循 IEEE 浮点规则

因此结果会受到舍入、无穷大、NaN 等规则影响

# 3.11.4 定义和使用浮点常数

和整数立即数不同,很多浮点常数不会直接编码在运算指令里

编译器通常把它们放在某个只读数据区中

使用时先把常数加载到寄存器,再参与计算

所以在反汇编中,经常能看到某条浮点指令访问一个常量表中的地址

# 3.11.5 在浮点代码中使用位级操作

虽然浮点数有自己的算术规则,但它们本质上仍然是一组位

因此也可以通过某些按位操作来完成特殊处理

例如:

修改符号位来实现取负

清除符号位来实现绝对值

这种做法依赖于对 IEEE 浮点表示格式的理解

# 3.11.6 浮点比较操作

浮点比较通常使用专门的比较指令,如 ucomiss​、ucomisd

它们会设置条件码,然后再配合 set 或 jump 指令使用

与整数比较不同的是,浮点比较还要考虑 NaN

一旦操作数中出现 NaN,比较结果会涉及“无序(unordered)”这一特殊情况

因此浮点比较的行为比整数比较更复杂

# 3.11.7 对浮点代码的观察结论

浮点代码与整数代码相比,有几个明显特征:

使用不同的寄存器集合

使用不同的传送、转换、比较和运算指令

必须遵守 IEEE 浮点语义,而不能简单套用整数规则

因此分析浮点程序时,不能只靠“整数运算直觉”去理解它

# 3.12 小结

本章讨论了 C 程序如何被翻译成机器级表示。

核心内容包括:

寄存器和数据传送

算术与逻辑运算

条件码、跳转、循环和 switch

过程调用、栈帧和递归

数组、结构体、联合和对齐

缓冲区溢出等底层安全问题

浮点代码的专用表示方式

理解这些内容的意义不只是“会读汇编”。

更重要的是:

能理解编译器到底做了什么

能分析程序性能和隐藏的低效率

能更准确地定位底层 bug

能从机器级角度理解安全问题产生的原因

这正是学习“程序的机器级表示”的价值所在