【函数栈帧的创建和销毁:一文看懂 C/C++ 函数调用的底层秘密】
本文适合:被“局部变量为什么是随机值”、“函数怎么传参”、“返回值怎么带回来”这些问题困扰过的初学者。
文末会解释:为什么返回局部变量的引用有时能打印出正确值,但依然是错的?
Hello,大家好呀,这里是小J,函数栈帧这部分内容可以说是语言学习的基石,能深刻的理解函数栈帧,很多C/C++里面的现象你都不会被绕晕,那就让我们一起学习,共同进步吧!
📚 在我们前期学习时,你可能有很多困惑
- 局部变量是怎么创建的?
- 为什么局部变量的值是随机值(甚至出现“烫烫烫”)?
- 函数是怎么传参的?传参的顺序是怎样的?
- 形参和实参是什么关系?
- 函数调用是怎么做的?
- 函数调用结束后怎么返回的?
- 为什么不能返回局部变量的指针/引用,但有时候居然能“正确”打印?
如果你对这些问题有一丝好奇,恭喜你 —— 你已经准备好揭开计算机最底层的浪漫:函数栈帧。
🧭 一、两个指针的舞台:ebp 和 esp
在 x86 架构下,CPU 用两个寄存器来管理函数调用时的内存:
ebp(栈底指针 / 帧指针):像一个锚点,固定当前函数的栈帧底部。esp(栈顶指针):始终指向当前栈的顶部(低地址方向)。
栈的特点:从高地址向低地址生长(压栈时 esp 减小,出栈时 esp 增大)。
你可以把栈想象成一叠盘子:ebp 标记最下面那个,esp 总是盯着最上面的空位。
每个函数在执行时都会在栈上“圈”一块属于自己的区域 —— 这就是栈帧,里面存放局部变量、参数、返回地址等。
🚀 二、main 函数也是被“人”调用的
你可能认为程序一启动就是 main,但 main 也是被启动例程调用的。
操作系统加载程序 → 执行启动例程(清理环境、获取参数) → call main → main 返回后再调用 exit。
所以 main 的栈帧创建和销毁,和普通函数一模一样。
🔧 三、从零开始:一个函数调用到底发生了什么?
我们用一个最简单的加法函数来模拟:
int add(int x, int y) {
int z = x + y;
return z;
}
int main() {
int a = 10;
int b = 20;
int c = add(a, b);
return 0;
}
第 1 步:压入参数(从右向左 🎯)
在 call add 之前,main 负责把参数压栈,顺序是从右向左:
push b ; 先压 b (20)
push a ; 再压 a (10)
call add
❓ 为什么从右向左?
为了支持可变参数函数(如 printf),这样栈顶总能拿到第一个参数的地址。
第 2 步:call 指令做了两件事
call add 执行时:
- 把下一条指令的地址压入栈(作为返回地址)—— 相当于埋了一张“回家的路标”。
- 跳转到 add 函数的代码处。
第 3 步:被调用函数建立自己的栈帧
进入 add 后,编译器自动生成序言代码:
push ebp ; 保存老的栈底指针(属于 main)
mov ebp, esp ; 让 ebp 指向当前栈顶(即新栈帧的底部)
sub esp, 4 ; 给局部变量 z 腾出空间(esp 下移)
此时栈的结构:
高地址
+-------------------------+
| main 的局部变量 a,b |
+-------------------------+
| 参数 b (20) | ← ebp + 12
+-------------------------+
| 参数 a (10) | ← ebp + 8
+-------------------------+
| 返回地址 (main内) | ← ebp + 4
+-------------------------+
| 旧的 ebp 值 (main的) | ← ebp (当前帧底部)
+-------------------------+
| 局部变量 z (未初始化) | ← ebp - 4 (esp 指向这里)
+-------------------------+
低地址
通过 ebp 可以很方便地访问参数(ebp+8、ebp+12)和局部变量(ebp-4、ebp-8……)。
第 4 步:函数体执行
int z = x + y;
对应汇编:
mov eax, [ebp+8] ; 取 a (10) → eax
add eax, [ebp+12] ; 加上 b (20) → eax = 30
mov [ebp-4], eax ; 存到局部变量 z
第 5 步:返回值与清理
mov eax, [ebp-4] ; 把 z 的值 (30) 放入 eax
mov esp, ebp ; 收回局部变量空间 (esp 指向 ebp)
pop ebp ; 恢复老的 ebp (main 的栈底)
ret ; 弹出返回地址并跳转回去
关键点:返回值通过 eax 寄存器传递,不依赖原栈内存。
第 6 步:调用者清理参数
在常见 cdecl 调用约定下,调用者负责弹出参数(如 add esp, 8),恢复栈平衡。
然后 main 从 eax 读取返回值给变量 c。
🧩 四、那些让你困惑的现象,现在全有了答案
1️⃣ 为什么未初始化的局部变量是“烫烫烫”?
在 Debug 模式下,编译器会用 0xCC 填充未初始化的栈内存。
0xCC 在 GB2312 汉字编码中对应 “烫”。当你打印一个未初始化的 int 时,它可能读到四个 0xCC,组合起来就是“烫烫烫”。
这是编译器在温柔地提醒你:你没给我赋值!
2️⃣ 为什么不能返回局部变量的指针或引用?
因为函数返回后,它的栈帧被销毁(esp 恢复,ebp 恢复),局部变量的内存不再属于你。
即使你拿到了地址,那块内存随时可能被下一个函数的栈帧覆盖 —— 这就是 悬空指针/引用。
3️⃣ 那为什么你的代码中 int b = adc(); 有时候能打印出正确的 1?
看下面这个例子:
int& adc() {
int c = 1;
return c; // 返回引用(地址)
}
int main() {
int b = adc(); // 从该地址读取值 → 拷贝给 b
cout << b; // 可能打印 1
}
栈帧时间线:
· adc 运行时,c 在栈上,值为 1。
· adc 返回前,把 c 的值放入 eax,但这里返回的是引用,所以返回的是地址。
· adc 栈帧销毁,但内存中的 1 暂时没被覆盖。
· main 中 int b = adc(); 去那个地址读值 → 读到了 1。
这完全是运气! 因为那块“已销毁”的内存还没有被其他函数占用。
一旦在 adc() 和 main 之间插入别的函数调用,新栈帧就可能覆盖它,结果就变成随机值或程序崩溃。
int& adc() { int c = 1; return c; }
void foo() { int x = 999; } // 会覆盖原 c 的位置
int main() {
int& ref = adc(); // 悬空引用
foo(); // 覆盖!
cout << ref; // ❗ 未定义行为,可能打印 999 或其他
}
一句话:能打印出正确值 ≠ 代码正确。未定义行为有时“碰巧”正确,但绝不能依赖。
📌 五、一张流程图总结全过程
main 调用 add 的完整生命周期:
+------------------------+
| main 压栈参数 (b, a) |
| (从右向左) |
+------------------------+
│
▼
+------------------------+
| call add: 压返回地址 |
| 跳转到 add 代码 |
+------------------------+
│
▼
+------------------------+
| add 保存 ebp,设立新 |
| 栈帧,分配局部变量空间 |
+------------------------+
│
▼
+------------------------+
| 执行加法,结果放入 eax |
+------------------------+
│
▼
+------------------------+
| add 恢复 esp、ebp, |
| ret 指令弹出返回地址 |
+------------------------+
│
▼
+------------------------+
| main 清理参数,从 |
| eax 读取返回值 |
+------------------------+
🛠️ 六、你可以亲自看一眼(调试实战)
打开 Visual Studio(或 Dev-C++ 带调试),设置断点,运行到 add 函数内部:
· 打开 寄存器 窗口(Ctrl+Alt+G)观察 ebp、esp 的变化。
· 打开 内存 窗口(Ctrl+Alt+M),输入 ebp+8 能看到参数,ebp-4 能看到局部变量(可能显示 0xCCCCCCCC 未初始化)。
你会亲眼看到那个“烫烫烫”的栈内存。
✅ 写在最后
函数栈帧是理解 C/C++ 底层执行模型的一把钥匙。初学者不必一次记住所有汇编细节,但抓住这几个核心:
- ebp 定位当前函数,esp 管理栈顶。
- 参数从右向左压栈,返回地址紧随其后。
- 局部变量在栈上分配,函数结束即失效。
- 返回值通过 eax 带回,不依赖栈内存。
- 未初始化的“烫烫烫”是调试版特有的保护填充。
- 返回局部变量的引用/指针是未定义行为,即使偶尔正确也绝不能写。
当你以后再看到函数调用时,脑子里应该浮现出那个精巧的栈帧结构 —— 它像一块块积木,垒起来,又拆掉,却保证了整个程序井然有序地运行。
🔥 这就是计算机的浪漫:看不见的栈,撑起了看得见的一切。希望这篇文章对你有所帮助,我们下篇文章见!
更多推荐
所有评论(0)