前九章把零件都造好了:补码和 ALU(第 2、3 章)、指令集(第 4 章)、数据通路(第 5 章)、流水线(第 6 章)、存储层次(第 7 章)、虚拟内存(第 8 章)、中断(第 9 章)。这一章把它们装配起来,方法是看一段真实的 C 程序一步步变成指令、指令在栈上起舞的全过程。你会发现以前觉得玄学的很多事,突然都有了地址:gdb 里的 backtrace 是什么、函数调用到底发生了什么、为什么递归太深会爆栈、为什么返回局部变量地址会出事。这章也是 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 ; 弹出返回地址,跳回调用者
值得停留的观察:
[rbp-8] 这种栈偏移。Intel 语法(目的在前、无 % 前缀)和 AT&T 是同一种东西的两种方言,objdump -d -M intel 可切换。读哪家都行,关键是养成习惯:当你想不通一段代码为什么慢/为什么崩,直接看汇编,这是程序员视角看硬件的最后一关。
第 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,不要疑惑,那是编译器在做算术搬运。
两个函数之间没有协议就没办法传参,这套协议叫调用约定(calling convention),Linux x86-64 的事实标准是 System V ABI,核心条款:
最后一款最值得想清楚。如果所有寄存器都要求调用者保存,每次函数调用前都要压一整套寄存器,调用就太贵;如果全要求被调用者保存,被调函数哪怕只想用一次 rax 也要保存恢复全套。分两派是折中:一次性临时值放 caller-saved(死了就死了),跨调用还要活着的值放 callee-saved(被调者自觉保存恢复)。编译器分配寄存器时就按这个原则归类。
每个函数在栈上占一段,叫栈帧。进函数时的三步序言和出函数的三步尾声,是汇编里出现频率最高的模板:
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 的移动。
收个尾:执行 a[i] += 1 这一行,前面九章的机器逐一登场。取指(第 5 章 IF)从指令 Cache(第 7 章)拉来这条 load;MMU 查 TLB(第 8 章)把虚拟地址翻成物理地址;数据 Cache(第 7 章)命中最好,未命中去主存(第 8 章 DRAM);数据进 ALU(第 3 章)加一,流水线(第 6 章)同时已经在跑后面十几条指令;中间任何时刻网卡来一个包,中断(第 9 章)会把这一切暂停几微秒。你以为的一行代码,是上百个硬件部件在纳秒尺度上的合奏。这门课给不了你所有细节,但给了你每一个细节的问路石:以后遇到任何「程序为什么这样跑」的问题,你知道该去哪一章找答案。
int add(int a, int b) { return a + b; },gcc -O2 -S 看汇编;再写 8 个参数的版本,观察第 7、8 个参数在调用者侧 push 的样子,验证 10.3 的说法。int f(void){ return f(); },开 ulimit -c unlimited 跑到段错误,gdb 打 core 后 bt,数一数栈回溯的深度,再乘上每帧大小,和 ulimit -s 的栈上限对个量级。sub rsp 应该减多少?