本节摘要:栈是汇编世界里组织最严密的内存区:call 压返回地址、开场两条指令建栈帧、局部变量挂在 rbp 之下、收尾三条指令精确拆帧、ret 弹返回地址——一场调用就是一套完整的内存协议。本节逐指令追踪一次调用的栈上全程,讲清 rbp 与 rsp 的分工、rbp 链如何支撑调用栈回溯,并解释叶子函数与尾调用为什么可以免掉整套礼仪。读完你能徒手画出任意函数调用前后的栈布局。
栈的物理事实只有一句话:rsp 寄存器指着栈顶,push 使指针向低地址移动,pop 反向移回。栈帧的协议则是围绕这个事实的一套社交礼仪——每个函数在栈上圈一块临时地盘(栈帧),地盘里放它的局部变量、保存的寄存器与收到的返回地址,函数返回时地盘必须恢复原样。礼仪的完整动作清单如下,先看全貌再逐条拆:
; 调用方(caller) call func ; 压返回地址,跳转 ; 被调方(callee)开场三件套的前两件 func: push rbp ; 保存调用方的帧基址 mov rbp, rsp ; 立起自己的帧基址 sub rsp, 16 ; 给局部变量划地盘 ; ...函数体:[rbp-8] 等访问局部变量... mov rsp, rbp ; 拆地盘 pop rbp ; 归还调用方的帧基址 ret ; 弹返回地址,回去
七个动作,一步不多一步不少。下面把它拆成三个阶段,每阶段配栈图。
执行 call 时硬件自动做两件事:把下一条指令的地址(返回地址)压入栈、跳到函数入口。x86 的栈向低地址生长:假设调用前 rsp 指向 0x7ffc00,压入八字节返回地址后 rsp 减为 0x7ffbf8,恰好指着刚压进去的返回地址。
紧接着的两条开场指令完成了"交接班":push rbp 把调用者的帧基址存进栈,mov rbp, rsp 让 rbp 指向这个刚存进去的位置。从这一刻起,rbp 成了当前帧的锚点——它不动,所有局部变量都以它为参照寻址(4.1 节的"基址加位移"拼法)。为什么需要两个栈指针?分工使然:rsp 随 push/pop 频繁移动,不适合做稳定参照;rbp 在整个函数体内纹丝不动,是局部变量的"零点"。sub rsp, 16 则是在两个锚点之间为局部变量划出的地盘。

栈帧协议还有一个容易被忽视的副产品。注意到每个帧的开头都存着"上一帧的 rbp"(call 后紧跟 push rbp),而当前 rbp 又正指向这个槽位——于是所有帧的 rbp 值串成一条链:当前 rbp 指向的槽里放着上一帧的 rbp,跳过去又是一个槽、又放着上上帧的 rbp……这条 rbp 链就是调试器 backtrace 命令的实现基础:沿着链逐级解引用,每一级再把"槽位上方"的返回地址报出来,就得到了完整的调用栈。
这也解释了一个调试现象:函数被优化成"帧指针省略"(-O2 下的常见行为,rbp 被挪作通用寄存器)后,rbp 链断了,backtrace 有时缺帧或错帧——不是调试器坏了,是协议的可选件被省了。C 代码里 assert 失败时打印的调用栈之所以可信,靠的就是这条链没断(或调试信息补全了断链)。
开场三件套不是法律,是约定。两种情形下编译器会合法地省掉它。其一,叶子函数(不再调用任何别人的函数):既然不会有人在我下面再压东西,rbp 锚点与划地动作都可以免,直接用 rsp 加位移访问局部变量甚至只用寄存器。其二,尾调用(函数最后一件事是跳去另一个函数):编译器把它变成 jmp,当前帧当场作废,新函数的返回地址直接对接到调用方——栈上不留中间帧,backtrace 里也就看不到它。读反汇编时看到"函数末尾是 jmp 而不是 call",先想到尾调用再怀疑控制流异常,能少走弯路。
礼仪一旦被破坏,排错信号极其鲜明。push 与 pop 数量不配平、或函数体里对 rsp 做了加减却没恢复,ret 就会弹出错误的地址——程序"返回"到莫名其妙的内存,段错误的崩溃地址离谱地不在任何代码段内。4.3 节的案例一就是这类失衡的完整复盘。
趁热再补一块当代产物里的高频拼图:参数与栈帧的关系。5.2 节会细讲调用约定,这里先看它与栈帧的交界——前六个参数走寄存器,不占栈帧;第七个起的参数在 call 之前由调用方压进栈,位置恰好在返回地址的"上方"(更高地址处)。于是被调函数里访问栈参数的寻址是 [rbp + 16] 起步(加 16 是跳过"保存的 rbp"与"返回地址"两个槽位)——这就是为什么反汇编里 rbp 加正偏移读到的是参数、rbp 减偏移读到的是局部变量:正负偏移的分水岭就是 rbp 本身。记住这条分界线,读任何函数的栈访问都能立刻分清"这是在碰参数还是在碰局部量"。
栈帧还有一个容易被忽略的时间维度:帧的寿命。从 push rbp 到 pop rbp 之间,帧内数据稳定有效;ret 之后这块地盘立即回到"自由内存"状态——下一个被调用的函数会在同一片地址上重建自己的帧。所以调试器里"函数返回后查看旧帧变量"看到的值,只是尚未被覆盖的残影,可作线索不可作证据。这个"寿命"视角在 4.3 案例一里还会用到:栈失衡的本质就是让某个帧提前或推迟了退租时间。
⚠️ 常见坑:以为栈帧里的局部变量在函数返回后仍然有效。ret 之后帧的空间并没有被抹除(只是 rsp 越过了它),下一个函数的帧会原样复用这块地盘——"指向已返回函数局部变量的指针"能读到旧值纯属时间差,这不是合法语义而是悬空指针的经典陷阱,C 代码审查时的高危项。
💡 关键直觉:栈帧协议本质是一份"内存租约"——call 签约(压返回地址)、函数体租用、ret 退租(栈针复位)。租约期内你的地盘随用随改,退租后一切归零重租。理解了租约,"为什么局部变量不初始化时值是随机的"这类问题自动解开:那块内存刚被上一个租客用过。
栈帧协议在手,下一节进入全册最过瘾的部分:三个真实 Bug 的错位排错复盘,把本章与 4.1 的知识全部押上战场。