本节摘要:每次函数调用都在栈上搭起一座"栈帧":保存返回地址、备份寄存器、圈出局部变量。返回时整帧作废,内存沦为废墟但数据还在——这解释了值传递为什么改不了实参、返回局部变量地址为什么危险、未初始化局部变量为什么是垃圾值。本节逐条指令拆解栈帧的一生。

用一段真实代码走一遍全程:
#include <stdio.h> void scale(int v, int factor) { int doubled = v * factor; printf("%d\n", doubled); } int main(void) { int x = 3; scale(x, 10); return 0; }
汇编骨架(去掉优化,保留结构):
scale: push rbp ; 备份调用者的帧基准 mov rbp, rsp ; 建立自己的基准点 sub rsp, 16 ; 圈出 16 字节局部变量区 mov [rbp-20], edi ; 参数 v 存入本帧(寄存器参数落地) mov [rbp-24], esi ; 参数 factor 落地 mov eax, [rbp-20] imul eax, [rbp-24] mov [rbp-4], eax ; doubled = v * factor ... ; printf 调用 leave ; rsp = rbp; pop rbp 拆帧 ret ; 弹返回地址跳回 main
两个地址寄存器分工明确:rbp 是当前帧的"门牌号"(参数与局部变量都按它定位,如 [rbp-20]),rsp 是栈顶(随 sub/push 移动)。leave 一条指令拆帧,ret 跳回。帧拆掉后,那 16 字节里的数据还在,只是被标记为"可覆写"。
现象一:值传递改不了实参。 v 是 main 侧 x 的副本,v = 99 只改写本帧的 [rbp-20],x 在 main 的帧里纹丝不动。想在函数里改外面的变量,必须把变量的地址传进去——第 5 章的传址,本质是把废墟之外的一块合法内存的地址递给对方。
现象二:返回局部变量地址是定时炸弹。
int *make(void) { int local = 42; return &local; /* 返回废墟地址 */ } int main(void) { int *p = make(); printf("%d\n", *p); /* 可能 42 可能垃圾 可能崩 */ return 0; }
make 返回后帧已拆,*p 读的是废墟残留。运气好读到 42(下一次调用还没盖过来),运气差读到垃圾,更差直接段错误。编译器会警告(function returns address of local variable),别忽略它。
现象三:未初始化局部变量是垃圾值。 局部变量不清零(全局变量在 BSS 清零,这是两者的机器差异),初值就是废墟里上一个住户留下的字节。这也是为什么"开 -O0 测试碰巧对、上生产线 -O2 就崩"——不同编译选项下废墟内容不同。
gdb 是观察这一切的显微镜:
(gdb) break scale (gdb) run (gdb) bt # backtrace:打印调用链,每层就是一个栈帧 #0 scale (v=3, factor=10) at demo.c:5 #1 0x... in main () at demo.c:11 (gdb) info frame # 当前帧详情:rbp/rsp、返回地址、局部变量偏移 (gdb) info locals # 本帧局部变量与值 (gdb) x/8xw $rbp-24 # 按 32 位十六进制看 rbp 附近的内存
bt 输出的每一行对应一座栈帧;x 命令直接转储内存字节。排查崩溃的第一动作永远是 bt——调用链告诉你崩在哪条路径上。
| 需求 | 正确姿势 | 错误姿势 |
|---|---|---|
| 函数修改外部变量 | 传变量地址 | 返回局部变量地址 |
| 返回大块数据 | 调用者传缓冲区指针,或堆分配 | 返回栈数组 |
| 多个返回值 | 出参指针 / 小结构体按值返回 | 依赖寄存器残留 |
| 调试观察崩溃 | gdb 先 bt | 盯着源码空想 |
⚠️ 常见坑:忽略编译器对返回局部变量地址的警告。这类 bug 的可复现性与编译选项、调用深度、优化等级全部相关,上线后才爆发,现场极难排查。
💡 关键直觉:把每次函数调用想成"借一块地搭帐篷"——地是栈的,帐篷是帧,函数返回即拆帐篷。指针要么指向长住的房(全局/堆),要么指向还在住的帐篷(活跃帧内的变量),指向废墟的指针都是凶器。
下一节把帐篷摞起来——递归,以及摞得太高的后果:栈溢出。
问:为什么调试时看不到某些局部变量? 优化等级高时,编译器把变量请进寄存器或直接消掉,栈上根本没有它的位置,调试器自然显示不了。想看清栈帧请用 -O0 编译,观察生产二进制时则要接受信息残缺的现实。
问:函数参数在寄存器里还是栈上? 两者都有。按调用约定,前几个参数走寄存器,超出部分与被调函数需要落地保存的寄存器参数会复制到栈帧里。被优化掉落地的参数,栈帧里就找不到它的副本。
问:栈的大小可以调吗? 可以。链接时或线程创建时都能指定栈上限,嵌入式平台常把它调小省内存;但调大栈治标不治本,深递归的正解仍是改写为迭代加显式栈。