第 10 章 · 汇编与程序运行

前九章把零件都造好了:补码和 ALU(第 2、3 章)、指令集(第 4 章)、数据通路(第 5 章)、流水线(第 6 章)、存储层次(第 7 章)、虚拟内存(第 8 章)、中断(第 9 章)。这一章把它们装配起来,方法是看一段真实的 C 程序一步步变成指令、指令在栈上起舞的全过程。你会发现以前觉得玄学的很多事,突然都有了地址:gdb 里的 backtrace 是什么、函数调用到底发生了什么、为什么递归太深会爆栈、为什么返回局部变量地址会出事。这章也是 C 指针课程(尤其栈帧那章)的机器级续集,两边对照着读效果最好。

10.1 第一次把 C 翻译到汇编

从最小的函数开始:

int square(int x) { return x * x; }

gcc -O2 -S square.c 生成汇编(AT&T 语法,源操作数在前):

square:
    imull  %edi, %edi    ; 参数 x 已在 edi 里,自乘
    movl   %edi, %eax    ; 返回值约定放 eax
    ret                 ; 弹出返回地址,跳回调用者

值得停留的观察:

  • 参数 x 不在内存里,已经在 edi 寄存器里等着了。谁放进去的?调用者,按一套约定(下一节)。变量的「家」在寄存器和栈之间流动,C 源码完全看不出这件事。
  • 返回 = 塞进 eax 再 ret。返回值不在栈上,也是约定。
  • 源码一行,汇编可能一条也可能五条,还可能被优化到一条不剩。变量名在 -O1 之后彻底消失,取而代之的是寄存器名和 [rbp-8] 这种栈偏移。

Intel 语法(目的在前、无 % 前缀)和 AT&T 是同一种东西的两种方言,objdump -d -M intel 可切换。读哪家都行,关键是养成习惯:当你想不通一段代码为什么慢/为什么崩,直接看汇编,这是程序员视角看硬件的最后一关。

10.2 寻址方式的实战形态

第 4 章的寻址方式在 x86 汇编里的真实长相,对应你最熟的 C 写法:

mov  rax, [rbx]         ; rax = *p            (寄存器间接)
mov  rax, [rbx+8]       ; rax = p->next       (变址:基址+固定偏移)
mov  rax, [rbx+rcx*4]   ; rax = a[i]          (基址+变址×比例)
lea  rax, [rbx+rcx*4]   ; rax = rbx + rcx*4   (只算地址,不访存!)

最后一行是本章第一颗彩蛋:lea(load effective address)长得像访存,实际只做地址算术,不碰内存。编译器因此拿它当免费算术单元:一条 lea rax, [rdi+rdi*4] 完成 x*5(第 3 章说过乘法 3~4 周期,lea 只要 1 周期)。以后在优化过的汇编里看到大量 lea,不要疑惑,那是编译器在做算术搬运。

10.3 调用约定:函数怎么互相说话

两个函数之间没有协议就没办法传参,这套协议叫调用约定(calling convention),Linux x86-64 的事实标准是 System V ABI,核心条款:

  • 前 6 个整型参数依次放 rdi、rsi、rdx、rcx、r8、r9,第 7 个起压栈。
  • 返回值放 rax。
  • 寄存器分两派:rax/rcx/rdx/rsi/rdi/r8r11 是 caller-saved(调用者保存),rbx/rbp/r12r15 是 callee-saved(被调用者保存)。

最后一款最值得想清楚。如果所有寄存器都要求调用者保存,每次函数调用前都要压一整套寄存器,调用就太贵;如果全要求被调用者保存,被调函数哪怕只想用一次 rax 也要保存恢复全套。分两派是折中:一次性临时值放 caller-saved(死了就死了),跨调用还要活着的值放 callee-saved(被调者自觉保存恢复)。编译器分配寄存器时就按这个原则归类。

10.4 栈帧:函数的临时住房

每个函数在栈上占一段,叫栈帧。进函数时的三步序言和出函数的三步尾声,是汇编里出现频率最高的模板:

push rbp            ; ① 保存调用者的帧底
mov  rbp, rsp       ; ② 立自己的帧底,之后一切偏移以 rbp 为锚
sub  rsp, 16        ; ③ 给局部变量腾 16 字节

;   函数体:局部变量在 [rbp-8],第一个参数在 [rbp+16]
;   (rbp+8 是 call 压进来的返回地址)

mov  rsp, rbp       ; ④ 一次性丢弃整个栈帧
pop  rbp            ; ⑤ 还给调用者帧底
ret                 ; ⑥ 弹出返回地址,跳回去

画成内存图(栈向下生长,地址从高到低):

高地址
┌──────────────┐
│  调用者的帧   │
├──────────────┤ ◄─ 调用者的 rbp
│   返回地址    │  ◄─ call 指令自动压入
├──────────────┤
│  旧的 rbp    │  ◄─ 当前 rbp(帧底)
├──────────────┤
│  局部变量     │  ◄─ [rbp-8] 们
├──────────────┤ ◄─ 当前 rsp
      ↓ 继续向下生长
低地址

递归就是同一张图摞 n 份:每层调用一个新帧,没有出口条件就一直摞,把栈段(通常 8MB,ulimit -s 可查)啃穿,再往低地址碰就是越界,MMU 查无映射,第 8 章那套流程启动,你收到段错误。「爆栈」从来不是一个抽象说法,是真把 8MB 地址空间写穿了。

这也给 C 指针课的老话题补上机器级解释:为什么不能返回局部变量的地址。函数 ret 之后 rsp 弹回、栈帧作废,那块内存马上会被下一个函数的帧覆盖。地址值还在(指针不知道自己过期了),数据已经是别人的了。悬空指针的机器级真相,不过是一次 rsp 的移动。

10.5 一行代码的完整旅程

收个尾:执行 a[i] += 1 这一行,前面九章的机器逐一登场。取指(第 5 章 IF)从指令 Cache(第 7 章)拉来这条 load;MMU 查 TLB(第 8 章)把虚拟地址翻成物理地址;数据 Cache(第 7 章)命中最好,未命中去主存(第 8 章 DRAM);数据进 ALU(第 3 章)加一,流水线(第 6 章)同时已经在跑后面十几条指令;中间任何时刻网卡来一个包,中断(第 9 章)会把这一切暂停几微秒。你以为的一行代码,是上百个硬件部件在纳秒尺度上的合奏。这门课给不了你所有细节,但给了你每一个细节的问路石:以后遇到任何「程序为什么这样跑」的问题,你知道该去哪一章找答案。

常见错误

  • 读优化汇编时试图和源码逐行对齐。-O2 会重排、合并、内联、删除指令,对不齐是常态。先找函数入口和 ret,再看数据流,比逐行对照有效得多。
  • 忘了 ret 隐含弹出返回地址。手写汇编时平衡 push/pop,忘了平衡的栈,ret 会跳到垃圾地址,症状是随机崩溃且 backtrace 全花。
  • 把 rbp 当通用寄存器使。rbp 是 callee-saved 的帧锚,调试器和栈回溯(第 10 章 gdb bt)都依赖它链回上一层;乱用后 core 文件没法看。

练习

  1. 动手:写 int add(int a, int b) { return a + b; },gcc -O2 -S 看汇编;再写 8 个参数的版本,观察第 7、8 个参数在调用者侧 push 的样子,验证 10.3 的说法。
  2. 动手:写一个无限递归函数 int f(void){ return f(); },开 ulimit -c unlimited 跑到段错误,gdb 打 core 后 bt,数一数栈回溯的深度,再乘上每帧大小,和 ulimit -s 的栈上限对个量级。
  3. 手算:一个函数有 3 个 8 字节局部变量、调用约定要求 rsp 16 字节对齐,序言里 sub rsp 应该减多少?
  4. 思考:gcc 开 -O2 后很多小函数直接内联,栈帧消失了。这对「函数调用有开销」的传统认知意味着什么?什么情况下内联反而让程序变慢(提示:第 6 章的指令 Cache)?