递归层数稍多一点程序就 Segmentation fault;两个看起来一样的循环,改了一行判断顺序性能差一倍;多线程里 i++ 累加结果永远小于预期。这三件事在 C 源码层面都"看起来没问题"——因为决定它们的是编译器生成的那条机器指令序列,而不是你写的那行 C。CSAPP 第 3 章干的事,就是把 C 和机器之间那层黑盒掀开:读得懂汇编,你才有资格谈"这段代码快不快"。

1. 从 C 到可执行文件:四步流水线

一段 main.c 变成能跑的 a.out,中间经历四道工序,每一步产物都能落在磁盘上:

1
2
3
4
5
gcc -E main.c -o main.i      # 1 预处理:展开 #include / #define
gcc -Og -S main.i -o main.s # 2 编译:C -> 汇编文本(人能读的最后一层)
gcc -c main.s -o main.o # 3 汇编:汇编 -> 可重定位机器码
gcc main.o -o a.out # 4 链接:多个 .o + 库 -> 可执行文件
objdump -d main.o # 反汇编:把机器码翻译回汇编
C 源码到可执行文件的四步流水线 main.c main.i main.s main.o a.out 预处理 cpp 编译 cc1 汇编 as 链接 ld ./a.out

要看编译器干了什么,就用 -Og——它是"为调试而生"的优化级别:保留源码结构、不做激进重排,比 -O0 更接近真实优化、比 -O2 好读得多。objdump -d 则是对已编译的二进制(包括系统库、第三方 .so)逆向的唯一手段。

2. 寄存器:CPU 的工作台

x86-64 有 16 个 64 位通用寄存器。它们不是"16 个一样的变量",每个都有约定俗成的角色,且谁负责保护它是调用约定的一部分:

寄存器 典型角色 保存责任
%rax 返回值、累加器、乘除隐含操作数 调用者保存
%rdi %rsi %rdx %rcx %r8 %r9 第 1~6 个整型参数 调用者保存
%rsp 栈顶指针 被调用者保存(必须还原)
%rbp 帧指针(可省略,见 -fomit-frame-pointer) 被调用者保存
%rbx %r12 %r13 %r14 %r15 通用、跨调用存活 被调用者保存
%r10 %r11 临时、系统调用用 调用者保存

坑:调用者保存 vs 被调用者保存,不是"重不重要",而是**"谁在函数甲调函数乙时负责备份"**。甲需要 %rdi 里的值活过这次调用,甲自己存;乙想用 %rbx,乙必须开场压栈、退场弹出还原。写内联汇编或做协程切换时踩错这条,会得到一个"偶尔才错"的幽灵 bug。

3. mov、leaq 与算术指令

下面这个函数做两件事:两个参数相加、再加上第三个:

1
2
3
4
5
long arith(long x, long y, long z) {
long t1 = x + y;
long t2 = z + t1;
return t2;
}
1
gcc -Og -c arith.c -o arith.o && objdump -d arith.o
1
2
3
4
0000000000000000 <arith>:
0: 48 8d 04 37 lea (%rdi,%rsi,1),%rax # rax = x + y
4: 48 01 d0 add %rdx,%rax # rax = rax + z
7: c3 retq

三个信息量:

  1. 参数 x,y,z 分别在 %rdi %rsi %rdx——没有任何一条"从内存读参数"的指令,参数走寄存器;
  2. 局部变量 t1/t2 也被优化掉了,直接落在 %rax 里。局部变量不必然占内存,寄存器够用时它们根本不出现在栈上;
  3. leaq(load effective address)不读内存,只算地址:lea (%rdi,%rsi,1), %rax 就是 rax = rdi + rsi*1。编译器拿它当"廉价加法器"用——因为地址计算单元独立于 ALU,还能顺带做 a + 2*b + 8 这种常数倍组合。

对照 mov 与 leaq 的语义差:

1
2
movq  8(%rbp), %rax      # 去内存地址 rbp+8 处【取值】放进 rax
leaq 8(%rbp), %rax # 把 rbp+8 这个【地址本身】放进 rax

4. 控制流:条件码、跳转与 cmov

CPU 里有一组**条件码(condition codes)**单比特标志位:ZF(结果为零)、SF(结果为负)、CF(无符号进位/借位)、OF(有符号溢出)。算术指令顺带设置它们,cmp/test 则专门只设置它们而不改寄存器。

1
2
3
4
5
6
7
8
long absdiff(long x, long y) {
long result;
if (x < y)
result = y - x;
else
result = x - y;
return result;
}

-Og 下生成的是最直白的"跳转版":

1
2
3
4
5
6
7
8
9
10
absdiff:
cmpq %rsi, %rdi # 比较 x 和 y(做 x - y,只留标志位)
jge .L2 # x >= y 就跳到 .L2
movq %rsi, %rax # rax = y
subq %rdi, %rax # rax = y - x
ret
.L2:
movq %rdi, %rax # rax = x
subq %rsi, %rax # rax = x - y
ret

开到 -O1 以上会变成条件传送(cmov)版:

1
2
3
4
5
6
7
8
absdiff:
movq %rsi, %rax # rax = y - x(先算出来)
subq %rdi, %rax
movq %rdi, %rdx # rdx = x - y(再算出来)
subq %rsi, %rdx
cmpq %rsi, %rdi
cmovge %rdx, %rax # x >= y 时才把 rdx 搬到 rax
ret

本质一句话:分支跳转依赖 CPU 的分支预测,猜错了要清空流水线罚十几个周期;cmov 把控制相关变成数据相关,两条路都算、最后挑一个,代价是多算一次减法。数据规律随机的分支,用 cmov(或位运算技巧)往往比 if 快;规律性强(比如几乎总是 true)的分支,if 更划算——这就是为什么"两个看起来一样的循环"性能会差一倍。

5. 栈帧:一次调用到底发生了什么

栈从高地址向低地址生长,%rsp 永远指向当前栈顶。一次 call 的代价是:把返回地址压栈、跳过去;ret 则弹回来。被调用者如果要开局部变量或保存寄存器,就把 %rsp 往下挪(减多少字节就是"申请多少栈空间")。

x86-64 栈帧布局(栈向低地址生长) 高地址 → 低地址 调用者(caller)的栈帧 上一层函数的地盘 参数 7 … n(超过 6 个的部分) 逆序压入,8 字节对齐 返回地址(call 指令自动压入) ret 就靠它找回去 被保存的 %rbp(旧帧指针) 串成链表,回溯栈就靠它 被保存的 callee-saved 寄存器 rbx / r12-r15 局部变量 / 数组 / 缓冲区 溢出攻击的入口在这里 为下一次调用准备的参数区 第 7+ 个参数放这儿 %rsp 指向最底部的已用位置;sub $N, %rsp 就是"申请 N 字节" %rbp 固定指向"旧 rbp"那个槽位,局部变量靠 rbp 的负偏移寻址

参数怎么传:前 6 个整型/指针参数走寄存器,第 7 个起才上栈;返回值放 %rax(128 位的整数或浮点用 %rax/%rdx 或 %xmm0)。

x86-64 System V 调用约定:整型参数传递 %rdi %rsi %rdx %rcx %r8 %r9 参数 1 参数 2 参数 3 参数 4 参数 5 参数 6 参数 7 … n:压入栈帧,每个按 8 字节对齐(见上一图的"参数 7…n"槽) 返回值:整数/指针 → %rax(>8 字节用 %rax + %rdx,浮点 → %xmm0)

6. 亲手看一眼栈

读图不如跑一遍。下面这个程序逐层打印局部变量的地址:

1
2
3
4
5
6
7
8
9
#include <stdio.h>

void probe(int depth) {
int local = depth;
printf("depth=%d &local=%p\n", depth, (void *)&local);
if (depth < 3) probe(depth + 1);
}

int main(void) { probe(0); return 0; }
1
gcc -Og -fno-omit-frame-pointer probe.c -o probe && ./probe
1
2
3
4
depth=0  &local=0x7ffd3a1c8a2c
depth=1 &local=0x7ffd3a1c8a0c
depth=2 &local=0x7ffd3a1c89ec
depth=3 &local=0x7ffd3a1c89cc

地址递减,每深一层低 32 字节(这就是"栈向低地址生长")。32 字节 = 一个 8 字节对齐后的最小帧:返回地址 8 字节 + 旧 %rbp 8 字节 + local 及对齐填充 16 字节。

i++ 为什么不是原子的:它编译出来是三条指令——mov 取值到寄存器、add 加一、mov 写回内存。两个线程在这三步中间任意位置交错,就会丢更新:

1
2
3
movq  count(%rip), %rax   # 1 读
addq $1, %rax # 2 改
movq %rax, count(%rip) # 3 写回

C 源码里它是一个字符,机器眼里它是三步。"原子"是机器指令层面的概念,不是语言表达式层面的概念——std::atomic / AtomicInteger 的本质就是把它换成一条带 lock 前缀的指令(或 CAS 循环)。

7. 递归、栈溢出与帧指针

每递归一层就消耗一个栈帧,帧数 × 帧大小超过栈上限就爆:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>
#include <string.h>

static long depth = 0;

void boom(void) {
char buf[1024]; /* 每层占 1 KB */
memset(buf, (int)(++depth & 0xff), sizeof buf);
if (depth % 1000 == 0)
printf("depth=%ld buf=%p\n", depth, (void *)buf);
boom();
}

int main(void) { boom(); return 0; }
1
2
ulimit -s          # 查看栈上限
gcc -O0 boom.c -o boom && ./boom
1
2
3
4
5
6
7
8192                      # ulimit -s:8192 KB = 8 MB
depth=1000 buf=0x7ffc9d5ef8c0
depth=2000 buf=0x7ffc9d5eb8c0
depth=3000 buf=0x7ffc9d5e78c0
...
depth=8000 buf=0x7ffc9d5a48c0
[1] 12345 segmentation fault (core dumped) ./boom

8 MB ÷ 1 KB ≈ 8192 层,和实测对得上(略少是因为还有 main 的帧和环境变量区)。

坑:栈大小是进程级固定的(Linux 默认 8 MB,线程栈常更小,比如 pthread 默认也是 8 MB 但可用 pthread_attr_setstacksize 调);堆则可以动态增长。所以:

  • 递归深度不可控(比如解析用户输入的嵌套 JSON)→ 改迭代或显式栈;
  • 大数组别放栈上(char buf[8*1024*1024] 是经典自杀写法)→ 用 malloc 或 static;
  • Java 同理,只是它把崩溃包装成了 StackOverflowError——JVM 每个线程有独立的虚拟机栈,语义和这里完全对应。

另外,%rbp 不是必需的:-O1 以上默认开 -fomit-frame-pointer,把 %rbp 也当通用寄存器用,局部变量改用 %rsp 相对寻址,省一个寄存器 + 两条指令,代价是栈回溯(backtrace)变慢且需要额外的 .eh_frame 调试信息。生产环境调试 core dump 时,建议临时加 -fno-omit-frame-pointer。

8. 三语言对照与小结

维度 C / C++(x86-64) Java Python
中间表示 汇编文本 → 机器码 字节码(栈式,JVM 解释/JIT) 字节码(CPython 逐条解释)
参数传递 前 6 个走寄存器 局部变量表 + 操作数栈 对象 + 参数元组
栈帧可见性 可 objdump、可算偏移 不可见,由 JVM 管理 不可见
爆栈表现 Segmentation fault StackOverflowError(可捕获) RecursionError(可捕获)
栈大小 进程/线程固定(默认 8 MB) -Xss 可调(默认约 1 MB) sys.setrecursionlimit()
局部变量是否占内存 未必(可能被优化进寄存器) 未必(JIT 同样做寄存器分配) 一定(都在 frame 对象里)
原子性 需 std::atomic / lock 前缀 AtomicInteger / synchronized GIL 下字节码级,但仍非逻辑原子

🐾 小结:这一章就四句话——① C 语句和机器指令不是一一对应,局部变量可能只在寄存器里活过一生,想知道真相就 gcc -Og -S 或 objdump -d;② leaq 是廉价加法器、cmp/test 只设标志位,条件码 + jmp/cmov 构成全部控制流,cmov 用"两条路都算"换掉分支预测惩罚;③ 栈向低地址生长,帧 = 返回地址 + 旧 rbp + 保存寄存器 + 局部变量,前 6 个参数走 %rdi…%r9,返回值在 %rax,caller-saved / callee-saved 的分工是内联汇编和协程的必考点;④ 栈空间是固定且有限的,i++ 不是原子的、递归深度不可控就爆栈——这两个"经典事故"的答案都在汇编里。下一篇我们顺着"局部变量缓冲区"那个槽位往下走,看越界写是怎么一路写到返回地址上去的。