机器级程序:汇编与栈帧
递归层数稍多一点程序就 Segmentation fault;两个看起来一样的循环,改了一行判断顺序性能差一倍;多线程里 i++ 累加结果永远小于预期。这三件事在 C 源码层面都"看起来没问题"——因为决定它们的是编译器生成的那条机器指令序列,而不是你写的那行 C。CSAPP 第 3 章干的事,就是把 C 和机器之间那层黑盒掀开:读得懂汇编,你才有资格谈"这段代码快不快"。
1. 从 C 到可执行文件:四步流水线
一段 main.c 变成能跑的 a.out,中间经历四道工序,每一步产物都能落在磁盘上:
1 | gcc -E main.c -o main.i # 1 预处理:展开 #include / #define |
要看编译器干了什么,就用 -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 | long arith(long x, long y, long z) { |
1 | gcc -Og -c arith.c -o arith.o && objdump -d arith.o |
1 | 0000000000000000 <arith>: |
三个信息量:
- 参数
x,y,z分别在%rdi %rsi %rdx——没有任何一条"从内存读参数"的指令,参数走寄存器; - 局部变量
t1/t2也被优化掉了,直接落在%rax里。局部变量不必然占内存,寄存器够用时它们根本不出现在栈上; leaq(load effective address)不读内存,只算地址:lea (%rdi,%rsi,1), %rax就是rax = rdi + rsi*1。编译器拿它当"廉价加法器"用——因为地址计算单元独立于 ALU,还能顺带做a + 2*b + 8这种常数倍组合。
对照 mov 与 leaq 的语义差:
1 | movq 8(%rbp), %rax # 去内存地址 rbp+8 处【取值】放进 rax |
4. 控制流:条件码、跳转与 cmov
CPU 里有一组**条件码(condition codes)**单比特标志位:ZF(结果为零)、SF(结果为负)、CF(无符号进位/借位)、OF(有符号溢出)。算术指令顺带设置它们,cmp/test 则专门只设置它们而不改寄存器。
1 | long absdiff(long x, long y) { |
-Og 下生成的是最直白的"跳转版":
1 | absdiff: |
开到 -O1 以上会变成条件传送(cmov)版:
1 | absdiff: |
本质一句话:分支跳转依赖 CPU 的分支预测,猜错了要清空流水线罚十几个周期;cmov 把控制相关变成数据相关,两条路都算、最后挑一个,代价是多算一次减法。数据规律随机的分支,用 cmov(或位运算技巧)往往比 if 快;规律性强(比如几乎总是 true)的分支,if 更划算——这就是为什么"两个看起来一样的循环"性能会差一倍。
5. 栈帧:一次调用到底发生了什么
栈从高地址向低地址生长,%rsp 永远指向当前栈顶。一次 call 的代价是:把返回地址压栈、跳过去;ret 则弹回来。被调用者如果要开局部变量或保存寄存器,就把 %rsp 往下挪(减多少字节就是"申请多少栈空间")。
参数怎么传:前 6 个整型/指针参数走寄存器,第 7 个起才上栈;返回值放 %rax(128 位的整数或浮点用 %rax/%rdx 或 %xmm0)。
6. 亲手看一眼栈
读图不如跑一遍。下面这个程序逐层打印局部变量的地址:
1 |
|
1 | gcc -Og -fno-omit-frame-pointer probe.c -o probe && ./probe |
1 | depth=0 &local=0x7ffd3a1c8a2c |
地址递减,每深一层低 32 字节(这就是"栈向低地址生长")。32 字节 = 一个 8 字节对齐后的最小帧:返回地址 8 字节 + 旧 %rbp 8 字节 + local 及对齐填充 16 字节。
i++ 为什么不是原子的:它编译出来是三条指令——mov 取值到寄存器、add 加一、mov 写回内存。两个线程在这三步中间任意位置交错,就会丢更新:
1 | movq count(%rip), %rax # 1 读 |
C 源码里它是一个字符,机器眼里它是三步。"原子"是机器指令层面的概念,不是语言表达式层面的概念——std::atomic / AtomicInteger 的本质就是把它换成一条带 lock 前缀的指令(或 CAS 循环)。
7. 递归、栈溢出与帧指针
每递归一层就消耗一个栈帧,帧数 × 帧大小超过栈上限就爆:
1 |
|
1 | ulimit -s # 查看栈上限 |
1 | 8192 # ulimit -s:8192 KB = 8 MB |
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++ 不是原子的、递归深度不可控就爆栈——这两个"经典事故"的答案都在汇编里。下一篇我们顺着"局部变量缓冲区"那个槽位往下走,看越界写是怎么一路写到返回地址上去的。

