本文适合:被“局部变量为什么是随机值”、“函数怎么传参”、“返回值怎么带回来”这些问题困扰过的初学者。
文末会解释:为什么返回局部变量的引用有时能打印出正确值,但依然是错的?

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 执行时:

  1. 把下一条指令的地址压入栈(作为返回地址)—— 相当于埋了一张“回家的路标”。
  2. 跳转到 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++ 底层执行模型的一把钥匙。初学者不必一次记住所有汇编细节,但抓住这几个核心:

  1. ebp 定位当前函数,esp 管理栈顶。
  2. 参数从右向左压栈,返回地址紧随其后。
  3. 局部变量在栈上分配,函数结束即失效。
  4. 返回值通过 eax 带回,不依赖栈内存。
  5. 未初始化的“烫烫烫”是调试版特有的保护填充。
  6. 返回局部变量的引用/指针是未定义行为,即使偶尔正确也绝不能写。

当你以后再看到函数调用时,脑子里应该浮现出那个精巧的栈帧结构 —— 它像一块块积木,垒起来,又拆掉,却保证了整个程序井然有序地运行。

🔥 这就是计算机的浪漫:看不见的栈,撑起了看得见的一切。希望这篇文章对你有所帮助,我们下篇文章见!

更多推荐