第 3 章:程序的机器级表示

主题:程序的机器级表示 (Machine-Level Representation of Programs)
目标:能读懂 x86-64 汇编,理解 C 程序如何映射到指令、寄存器、栈、控制流、过程调用和浮点运算。

1. 从 C 到机器代码

flowchart LR
    A["hello.c<br/>C 源程序"] --> B["预处理<br/>cpp"]
    B --> C["编译<br/>gcc -S"]
    C --> D["hello.s<br/>汇编代码"]
    D --> E["汇编<br/>as"]
    E --> F["hello.o<br/>可重定位目标文件"]
    F --> G["链接<br/>ld"]
    G --> H["可执行文件"]

常用命令:

命令作用
gcc -Og -S a.c生成调试友好的汇编 .s
gcc -O1/-O2 -S a.c生成更优化的汇编,可能出现 cmov 等指令
gcc -c a.c生成目标文件 .o
objdump -d a.out反汇编可执行文件
gdb ./a.out调试程序

注意:CSAPP 多用 AT&T 汇编语法,操作数顺序是 源, 目的,例如 movq %rax, %rbx 表示 %rbx = %rax

2. x86-64 寄存器速查

64 位32 位16 位8 位常见用途
%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-%r11---调用者保存
%r12-%r15---被调用者保存

寄存器保存规则:

类型寄存器谁负责恢复
调用者保存 (Caller-Saved)%rax, %rdi, %rsi, %rdx, %rcx, %r8-%r11调用函数前的调用者
被调用者保存 (Callee-Saved)%rbx, %rbp, %r12-%r15被调用函数
特殊%rsp始终指向栈顶

3. 操作数与寻址方式

形式含义示例
$Imm立即数$0x10
Reg寄存器%rax
Imm绝对地址0x4005d0
(Reg)内存 [Reg](%rax)
Imm(Reg)内存 [Reg + Imm]8(%rbp)
(Rb,Ri)内存 [Rb + Ri](%rax,%rdx)
Imm(Rb,Ri,s)内存 [Rb + Ri*s + Imm]8(%rbx,%rcx,4)

地址计算公式:

地址 = Imm + Rb + Ri * s
s 只能是 1、2、4、8

leaq 是重点:

指令本质常见用途
leaq S, D把地址表达式的值写入 D地址计算、整数线性表达式计算

示例:

leaq 7(%rdi,%rdi,4), %rax

等价于:

rax = 5 * rdi + 7;

4. 数据传送指令

指令含义
movb/movw/movl/movq传送 1/2/4/8 字节
movzbl零扩展:小整数转大整数,高位补 0
movsbl符号扩展:小整数转大整数,高位补符号位
pushq Srsp -= 8; M[rsp] = S
popq DD = M[rsp]; rsp += 8

易错点:写入 32 位寄存器会自动把对应 64 位寄存器高 32 位清零。
例如 movl $0, %eax 会让 %rax 变成 0。

5. 算术与逻辑操作

指令C 类比说明
addq S, DD += S加法
subq S, DD -= S减法
imulq S, DD *= S有符号乘法
salq/shlq k, DD <<= k左移
sarq k, DD >>= k算术右移,保留符号
shrq k, DD >>= k逻辑右移,高位补 0
xorq S, DD ^= S常用 xor %eax,%eax 清零
andq S, DD &= S位与
orq S, DD |= S位或
incq DD++不影响 CF
decq DD--不影响 CF
negq DD = -D取负
notq DD = ~D按位取反

移位计数可以是立即数,也可以放在 %cl 中。

6. 条件码

条件码名称含义
CFCarry Flag无符号溢出/借位
ZFZero Flag结果为 0
SFSign Flag结果为负
OFOverflow Flag有符号溢出

常见设置方式:

指令效果
cmpq S1, S2计算 S2 - S1,只设置条件码
testq S1, S2计算 S2 & S1,只设置条件码
add/sub/and/xor计算结果并设置条件码
leaq不设置条件码

有符号与无符号比较:

C 表达式有符号跳转无符号跳转
a == bjeje
a != bjnejne
a < bjljb
a <= bjlejbe
a > bjgja
a >= bjgejae

7. 控制流

7.1 条件跳转

flowchart TD
    A["cmp/test 设置条件码"] --> B{"条件成立?"}
    B -- 是 --> C["跳转到目标标签"]
    B -- 否 --> D["继续顺序执行"]

典型 if-else

if (x < y)
    result = y - x;
else
    result = x - y;

可能翻译为:

cmpq %rsi, %rdi
jge  .L2
movq %rsi, %rax
subq %rdi, %rax
ret
.L2:
movq %rdi, %rax
subq %rsi, %rax
ret

7.2 条件传送

条件传送 (Conditional Move) 用 cmovXX 避免分支。

cmpq  %rsi, %rdi
cmovl %rdx, %rax

适合条件:

适合 cmov不适合 cmov
两边表达式都很简单分支有副作用
分支预测困难某个分支计算代价很大
两边都可以安全执行可能触发异常,如非法内存访问

注意-Og 偏向调试友好,常保留跳转;-O1/-O2 更容易生成 cmov

7.3 循环

循环常被转成 goto 风格。

flowchart TD
    A["初始化"] --> B{"测试条件"}
    B -- 假 --> E["退出"]
    B -- 真 --> C["循环体"]
    C --> D["更新"]
    D --> B

do-while 最接近汇编结构,因为先执行循环体,再判断是否回跳。

7.4 switch

switch 可能使用跳转表 (Jump Table):

flowchart LR
    A["索引 i"] --> B["边界检查"]
    B --> C["跳转表"]
    C --> D["case 0"]
    C --> E["case 1"]
    C --> F["case 2"]
    C --> G["default"]

适合跳转表的情况:

条件原因
case 值比较密集表大小可控
case 数量较多比连续比较更快
范围可提前检查防止越界跳转

8. 过程调用

8.1 参数传递

前 6 个整数或指针参数:

参数序号寄存器
1%rdi
2%rsi
3%rdx
4%rcx
5%r8
6%r9

第 7 个及之后的参数放到栈上。

返回值:

返回类型位置
整数/指针%rax
浮点数%xmm0

8.2 栈帧结构

flowchart TB
    A["高地址"] --> B["调用者栈帧"]
    B --> C["参数 7、8..."]
    C --> D["返回地址"]
    D --> E["保存的寄存器"]
    E --> F["局部变量"]
    F --> G["临时空间"]
    G --> H["低地址<br/>%rsp"]

调用流程:

指令等价行为
call label压入返回地址,然后跳转
ret弹出返回地址,然后跳转

重点:栈向低地址增长,push 会减小 %rsppop 会增大 %rsp

8.3 递归

递归本质是多个栈帧共存:

flowchart TB
    A["fact(3) 栈帧"] --> B["fact(2) 栈帧"]
    B --> C["fact(1) 栈帧"]
    C --> D["逐层返回"]

每一层调用都有自己的局部变量、返回地址和保存寄存器。

9. 数组、指针与结构体

9.1 数组

C 数组访问:

A[i]

地址计算:

&A[i] = A + i * sizeof(A[0])

二维数组:

int A[R][C];

地址:

&A[i][j] = A + sizeof(int) * (i * C + j)

9.2 结构体

结构体字段通过固定偏移访问:

struct S {
    int x;
    long y;
};

可能布局:

字段偏移大小
x04
填充44
y88

对齐 (Alignment) 会插入填充字节,结构体大小通常是最大对齐要求的倍数。

9.3 union

联合体 (Union) 的所有字段共享同一段内存。

特点说明
大小等于最大字段大小,外加对齐
偏移所有字段通常从 0 开始
风险用错误字段解释数据会产生低层表示相关行为

10. 浮点代码

现代 x86-64 通常使用 SSE/AVX 指令和 %xmm 寄存器处理 floatdouble,而不是使用通用寄存器。更早的 x87 浮点栈主要用于兼容旧代码和处理部分 long double 场景。

本节调用约定以 Linux 上的 System V AMD64 ABI 为准;Windows x64 的参数寄存器分配规则不同。

10.1 浮点寄存器与数据格式

XMM 寄存器宽 128 位,YMM 寄存器宽 256 位。标量指令只计算寄存器低位的一个元素,打包指令则并行计算多个元素。

后缀数据形式XMM 中的有效元素
ssScalar Single,标量 float低 32 位的 1 个单精度数
sdScalar Double,标量 double低 64 位的 1 个双精度数
psPacked Single,打包 float4 个单精度数
pdPacked Double,打包 double2 个双精度数

例如,addss 是标量单精度加法,addsd 是标量双精度加法,addpsaddpd 则执行向量化加法。

10.2 数据传送与算术指令

指令族作用
movss / movsd传送标量单精度 / 双精度数
addss / addsd浮点加法
subss / subsd浮点减法
mulss / mulsd浮点乘法
divss / divsd浮点除法
sqrtss / sqrtsd浮点平方根
xorps / xorpd按位异或,常用于把 XMM 寄存器清零

传统 SSE 指令通常采用两操作数形式,目的寄存器同时也是一个源操作数:

subsd %xmm1, %xmm0        # xmm0 = xmm0 - xmm1

AVX 指令通常在名称前增加 v,并使用三操作数形式:

vsubsd %xmm1, %xmm0, %xmm2   # xmm2 = xmm0 - xmm1

AT&T 语法仍然遵循“源在前、目的在后”。对于 vsubsd S1, S2, D,结果是 D = S2 - S1,别把两个源操作数看反。

x86 浮点算术指令通常不能直接使用浮点立即数,编译器会把常量放到只读数据区,再通过 RIP 相对寻址加载:

movsd .LC0(%rip), %xmm0

10.3 整数与浮点数转换

指令族转换方向
cvtsi2ss / cvtsi2sd有符号整数转 float / double
cvttss2si / cvttsd2sifloat / double 截断后转有符号整数
cvtss2sdfloatdouble
cvtsd2ssdoublefloat,可能发生舍入

cvtt 中的第二个 t 表示 truncate,即向零截断,与 C 的浮点数转整数规则一致。整数源或目的的宽度还会由具体指令后缀或操作数宽度决定。

当浮点值为 NaN、无穷或超出目标整数范围时,不要根据某台机器的转换结果推断可移植的 C 行为;这类转换可能超出语言允许的表示范围。

10.4 浮点比较与 NaN

ucomissucomisd 分别比较单精度、双精度浮点数,并设置整数条件码 ZFPFCF。在 AT&T 语法中,ucomisd S1, S2 比较的是 S2S1

比较结果ZFPFCF
S2 > S1000
S2 < S1001
S2 == S1100
无序(至少一个操作数为 NaN)111

浮点比较比整数比较多出“无序”状态,因此编译器必须正确处理 PF,或选择能自然排除无序情况的跳转组合:

  • 与 NaN 做 <<=>>=== 比较均为假。
  • 与 NaN 做 != 比较为真。
  • 仅根据 ZF = 1 判断浮点相等会把 NaN 的无序状态误当成相等。

10.5 浮点调用约定

System V AMD64 使用两组相互独立的参数寄存器:整数和指针依次使用 %rdi%r9,浮点参数依次使用 %xmm0%xmm7

内容位置
前 8 个可用的浮点参数%xmm0%xmm7
浮点返回值%xmm0
超出寄存器容量的参数
%xmm0-%xmm15调用者保存

混合参数分别占用各自类别的寄存器。例如:

double f(int i, double x, float y);

在 System V AMD64 下,i 位于 %edix 位于 %xmm0y 位于 %xmm1,并不会因为 i 是第一个参数就让 x 改用 %xmm1

10.6 从 C 代码还原浮点汇编

double scaled_delta(double x, double y, int scale)
{
    return (x - y) * (double)scale;
}

一种可能的 SSE 汇编是:

scaled_delta:
    subsd      %xmm1, %xmm0      # x - y
    pxor       %xmm1, %xmm1      # xmm1 清零
    cvtsi2sdl  %edi, %xmm1       # (double) scale
    mulsd      %xmm1, %xmm0      # (x - y) * scale
    ret                            # 结果位于 xmm0

读浮点汇编时按以下顺序检查:

  1. 根据 sssdpspd 判断数据类型和标量/向量形式。
  2. 查看 %xmm0-%xmm7,确定浮点参数及返回值。
  3. 遇到 cvt 指令时确认转换方向、整数宽度和舍入方式。
  4. 遇到 ucomis 时同时考虑 NaN 对 PF 的影响。
  5. 区分 SSE 两操作数和 AVX 三操作数形式,尤其注意减法、除法的源操作数顺序。

GDB 中可查看 XMM 寄存器的不同解释:

info registers xmm0 xmm1
p $xmm0.v2_double[0]
p $xmm1.v4_float[0]
info registers mxcsr

MXCSR 保存 SSE/AVX 浮点运算的舍入模式、异常标志和异常屏蔽位。

11. 内存越界与攻击

栈缓冲区溢出:

flowchart TB
    A["局部数组 buf"] --> B["保存的寄存器"]
    B --> C["返回地址"]
    C --> D["调用者栈帧"]
    E["写入超过 buf 边界"] --> B
    E --> C

风险:

问题后果
数组越界写覆盖局部变量、保存寄存器、返回地址
不检查输入长度栈溢出
执行栈数据代码注入
修改返回地址控制流劫持

常见防护:

机制作用
栈随机化 (Stack Randomization)地址难预测
栈保护 (Stack Protector)检测返回地址附近是否被破坏
栈不可执行 (NX)阻止执行栈上注入代码
地址空间布局随机化 (ASLR)随机化代码/库/栈地址

12. 读汇编的推荐顺序

flowchart TD
    A["看函数名和参数寄存器"] --> B["找返回值 %rax / %xmm0"]
    B --> C["识别栈帧和局部变量"]
    C --> D["识别 cmp/test 与跳转"]
    D --> E["还原 if/loop/switch"]
    E --> F["识别数组和结构体偏移"]

速查步骤:

  1. 先看函数入口:参数在哪些寄存器。
  2. 再看 %rax%xmm0:整数/指针与浮点返回值如何形成。
  3. 遇到 cmp/test + jXX:还原条件判断。
  4. 遇到 leaq:优先当作算术表达式或地址计算。
  5. 遇到 call:检查调用前的通用参数寄存器和 XMM 参数寄存器。
  6. 遇到 (%rdi,%rax,4):联想到数组下标。
  7. 遇到固定偏移如 8(%rdi):联想到结构体字段。

13. GDB 调试机器级程序

GDB 适合把汇编、寄存器、栈和内存状态连起来看。

13.1 编译建议

自己写的小程序建议这样编译:

gcc -Og -g -fno-omit-frame-pointer -no-pie test.c -o test
选项作用
-Og保留较清晰的源代码结构,适合调试
-g生成调试信息
-fno-omit-frame-pointer保留 %rbp 栈帧指针,方便观察栈帧
-no-pie关闭位置无关可执行文件,地址更稳定

启动:

gdb -q ./test

13.2 运行控制

命令简写作用
runr启动程序
run arg1 arg2r arg1 arg2带命令行参数启动
start-停在 main 开始处
continuec继续运行到下一个断点
nextn单步执行 C 语句,不进入函数
steps单步执行 C 语句,进入函数
nextini单步执行一条机器指令,不进入调用
stepisi单步执行一条机器指令,可进入调用
finish-执行到当前函数返回
quitq退出 GDB

看汇编时优先用 si / ni,看 C 源码时再用 s / n

13.3 断点

命令作用
break main在函数入口断点
break file.c:12在源码行断点
break *0x401136在机器地址断点
break *phase_1在函数入口地址断点
info breakpoints查看断点
delete 1删除编号为 1 的断点
disable 1 / enable 1禁用/启用断点
condition 1 x == 0给断点 1 加条件

Bomb Lab 常用:

break main
break phase_1
break explode_bomb
run

explode_bomb 下断点,可以在炸弹真正退出前停住,便于回看调用路径。

13.4 查看代码与反汇编

命令作用
list查看源码
disassemble main反汇编函数
disassemble /r main同时显示机器码字节
x/10i $pc从当前程序计数器开始显示 10 条指令
layout asmTUI 汇编窗口
layout regsTUI 寄存器窗口
set disassembly-flavor att使用 AT&T 语法
set disassembly-flavor intel使用 Intel 语法

当前执行位置:

x/i $pc

$pc 在 x86-64 上通常对应 %rip

13.5 查看寄存器

命令作用
info registers查看所有通用寄存器
info registers rax rdi rsi查看指定寄存器
print/x $rax以十六进制打印 %rax
print/d $rax以十进制打印 %rax
print/t $rax以二进制打印 %rax
set $rax = 0修改寄存器值

参数寄存器速查:

参数寄存器
第 1 个%rdi
第 2 个%rsi
第 3 个%rdx
第 4 个%rcx
第 5 个%r8
第 6 个%r9

整数和指针返回值通常看 %rax,浮点返回值通常看 %xmm0

13.6 查看内存

x 命令格式:

x/NFU 地址

含义:

字段含义常用值
N显示多少个单元1, 4, 16, 32
F显示格式x 十六进制, d 十进制, s 字符串, i 指令
U单元大小b 1 字节, h 2 字节, w 4 字节, g 8 字节

常用命令:

命令作用
x/16xb $rsp从栈顶看 16 个字节
x/8gx $rsp从栈顶看 8 个 8 字节值
x/s $rdi%rdi 指向的内存当字符串打印
x/10i $rip从当前指令开始看 10 条指令
x/wx 0x404040查看地址处 4 字节十六进制值

示例:检查函数第一个字符串参数。

info registers rdi
x/s $rdi

13.7 查看栈与调用链

命令作用
backtrace查看调用链
btbacktrace 简写
frame 1切换到调用链第 1 帧
info frame查看当前栈帧信息
info args查看当前函数参数
info locals查看局部变量
x/8gx $rsp查看栈顶附近内容
x/8gx $rbp-0x20查看当前栈帧局部区域

栈观察重点:

flowchart TB
    A["高地址"] --> B["调用者栈帧"]
    B --> C["返回地址"]
    C --> D["保存的 %rbp"]
    D --> E["局部变量/临时空间"]
    E --> F["低地址:%rsp"]

栈向低地址增长。call 会把返回地址压栈,ret 会从栈顶弹出返回地址。

13.8 条件码与分支调试

GDB 不直接把 ZF/SF/OF/CF 作为普通变量展示,但可以看 %eflags

命令作用
info registers eflags查看条件码寄存器
x/i $pc查看当前将执行的跳转指令
si执行一条指令,观察是否跳转

调试 cmp/test + jXX 的顺序:

  1. 停在 cmptest 前。
  2. 查看参与比较的寄存器或内存。
  3. 执行 si,让条件码被设置。
  4. 查看 eflags
  5. 再执行 si,观察 jXX 是否改变 %rip

13.9 Bomb Lab 基本流程

flowchart TD
    A["objdump/gdb 查看 phase 函数"] --> B["给 phase_i 下断点"]
    B --> C["run 输入文件"]
    C --> D["观察参数寄存器和字符串"]
    D --> E["单步执行分支"]
    E --> F["推出输入约束"]
    F --> G["写入答案文件"]

推荐启动方式:

gdb -q ./bomb

常用命令:

set disassembly-flavor att
break main
break explode_bomb
break phase_1
run answers.txt

查看当前 phase 的输入:

info registers rdi
x/s $rdi

查看比较字符串:

x/s 0x402400

具体地址要以当前反汇编结果为准。

13.10 常用自动显示

display 可以让 GDB 每次暂停时自动打印关心的信息。

命令作用
display/i $pc每次停下显示当前指令
display/x $rax每次停下显示 %rax
display/x $rsp每次停下显示栈顶地址
undisplay 1取消编号为 1 的自动显示

适合写入 tools/gdb/csapp.gdb

set disassembly-flavor att
set pagination off
display/i $pc

使用:

gdb -q -x tools/gdb/csapp.gdb ./bomb

14. 常见坑

正确理解
AT&T 操作数顺序看反movq S, DD = S
cmpq a, b 看反比较的是 b - a
以为 leaq 会访存leaq 只算地址,不读内存
混淆有符号/无符号跳转jl/jg 是有符号,jb/ja 是无符号
忘记栈向低地址增长push%rsp 减小
认为 -Og 会充分优化-Og 偏调试,-O1/-O2 才更激进
忽略 32 位写寄存器清零%eax 会清空 %rax 高 32 位
混淆 stepstepistep 走 C 语句,stepi 走机器指令
忘记查看参数寄存器函数入口先看 %rdi/%rsi/%rdx/%rcx/%r8/%r9
把浮点参数当成整数参数System V AMD64 下优先查看 %xmm0-%xmm7
混淆 ss/sdps/pd前者是标量单/双精度,后者是打包单/双精度
浮点相等只检查 ZFNaN 会产生无序结果,还要排除 PF = 1
cvtt 当作普通舍入cvtt 表示向零截断

15. 本章实验关联

内容对应练习/实验
汇编阅读、条件跳转、循环exercises/chapter-03/*.s
反汇编与控制流恢复labs/bomb-lab
栈、过程调用、缓冲区溢出labs/attack-lab
SSE/AVX 浮点指令、参数与比较exercises/chapter-03/*.s
GDB 调试机器代码bomb-labattack-lab