C 语言底层|函数栈帧的创建与销毁(一):从汇编视角理解函数调用
本文深入解析了C语言函数栈帧的创建与销毁机制。通过一个简单的Add函数示例,详细演示了函数调用的完整过程:从main函数栈帧初始化、参数压栈(从右向左)、call指令保存返回地址,到Add函数栈帧创建、执行计算,再到返回值通过寄存器传递、栈帧销毁恢复现场。文章揭示了局部变量存储、未初始化变量随机值来源、形参与实参关系等核心问题的底层原理,并指出理解栈帧机制对掌握递归、函数调用约定等高级概念的基础性作用。基于VS2022 x86 Debug环境讲解,后续将补充汇编调试和内存布局等可视化内容。
写在前面
初学C语言的时候,我们心里总会有一连串“知其然不知其所以然”的疑问:
- 局部变量到底是怎么创建出来的?
- 为什么不初始化的局部变量是随机值?
- 函数传参的顺序是什么?形参和实参到底是什么关系?
- 函数调用到底是怎么执行的?调用结束后又是怎么准确回到原来的位置继续执行的?
提示:如需深入了解,请跳转(配有图片以及详细调试过程):<a href="/#post/2026-08-21-function-stack-frame-2">C语言底层:函数栈帧的创建与销毁(二)|深入理解函数栈帧</a>
这些问题看似零散,其实答案都指向同一个底层机制:函数栈帧的创建与销毁。 把栈帧的逻辑搞懂,相当于修炼了C语言的底层内功,后面理解递归、局部变量生命周期、函数调用约定,乃至二叉树的递归遍历,都会顺畅很多。
本篇是函数栈帧专题的第一版,先把核心原理和完整流程梳理清楚;后续会逐步补充VS2022下的汇编调试截图、内存栈布局以及更细致的逐行指令拆解。
环境说明
本文基于VS2022 + x86(32位)Debug 模式讲解,通过关闭额外编译优化,尽量还原最经典、最完整的栈帧创建流程,方便对照理解。
不同编译器、不同优化等级下,栈帧的具体排布、压栈细节会有细微差异,但核心逻辑是通用的。先把标准流程吃透,再去看不同环境的差异就会非常轻松。
一、预备知识:栈、寄存器与栈帧
1.1 栈是什么
栈是内存中一块先进后出(LIFO)的存储区域,它的生长方向是从高地址向低地址延伸。 我们常说的「压栈」就是往栈里放入数据,栈顶向低地址移动;「出栈」就是取出数据,栈顶向高地址回退。
1.2 两个核心寄存器
理解栈帧,首先要记住两个关键寄存器:
- ebp:栈底指针,指向当前函数栈帧的底部(高地址端)
- esp:栈顶指针,指向当前函数栈帧的顶部(低地址端)
ebp 和 esp 之间的内存空间,就是当前正在运行的函数的专属栈帧。每个函数被调用时,都会在栈上开辟自己的一块栈帧;函数执行结束后,这块栈帧会被销毁,空间归还。
1.3 函数栈帧里存了什么
简单说,函数栈帧就是函数调用过程中,在栈上为这个函数单独开辟的一块内存空间,主要用来存放:
- 调用者的栈底地址(旧 ebp)
- 函数的局部变量
- 函数的形参
- 函数调用的返回地址
- 部分寄存器的现场保护
二、完整示例:Add 函数调用全过程
我们用一个最简单的加法函数,完整走一遍栈帧的创建与销毁流程:
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;
}
整个过程可以分成五个阶段:main 栈帧初始化 → 参数压栈 → 调用函数 → Add 栈帧创建与执行 → 栈帧销毁与返回。
阶段1:main 函数栈帧的创建
程序从 main 函数开始执行,首先为 main 函数开辟栈帧:
- 将调用 main 函数的旧 ebp 压入栈中保存
- 把当前 esp 的值赋给 ebp,作为 main 栈帧的新栈底
- esp 向下移动一段距离,为 main 函数预留出栈帧空间
- 对预留的栈空间进行初始化填充(这也是未初始化局部变量随机值的来源)
- 在栈帧中为局部变量 a、b 分配空间,分别赋值 10、20
此时 ebp 是 main 栈帧的栈底,esp 是栈顶,main 的所有局部变量都存放在自己的栈帧范围内。
阶段2:函数传参——从右往左压栈
执行到Add(a,b)时,首先进行参数传递。 在 C 语言默认的调用约定下,传参顺序是从右往左,也就是先传 b,再传 a。
- 先把 b 的值(20)压入栈中,对应形参 y
- 再把 a 的值(10)压入栈中,对应形参 x
这里可以得到一个核心结论:
形参其实是实参的一份临时拷贝。压栈的过程就是把实参的值复制一份放到栈上,函数内部操作的是这份拷贝,所以修改形参不会影响外面的实参。
阶段3:call 指令——记录返回地址
参数压完之后,执行call指令调用 Add 函数。 call 指令会做两件事:
- 把 call 指令下一条指令的地址压入栈中保存——这就是函数执行完之后要回去的「返回地址」
- 跳转到 Add 函数的入口地址,开始执行 Add 函数
阶段4:Add 函数栈帧的创建与执行
进入 Add 函数后,和 main 一样,先搭建自己的栈帧:
- 把 main 的 ebp 压栈保存,方便返回时恢复 main 的栈底
- 将 esp 的值赋给 ebp,作为 Add 栈帧的新栈底
- esp 向下移动,为 Add 预留出栈帧空间
- 为局部变量 z 分配空间,计算
x + y的值并赋值给 z
此时可以看到:形参 x、y 并不在 Add 的栈帧内部,而是在 main 栈帧的上方;Add 是通过 ebp 地址偏移的方式去找到这两个形参的。
阶段5:返回值与栈帧销毁
函数执行到 return 时,开始销毁栈帧并返回:
- 把返回值(z 的值)放入 eax 寄存器中带回
- 释放 Add 的栈帧空间,esp 向上回退
- 弹出之前保存的旧 ebp,恢复 main 函数的栈底
- 执行 ret 指令,弹出之前保存的返回地址,跳回 main 函数中 call 的下一条指令
- main 继续执行,把 eax 寄存器中的返回值赋值给局部变量 c
至此,一次完整的函数调用就结束了。Add 的栈帧被销毁,空间归还给栈,但里面的旧数据并没有被真正清空,只是不再属于有效范围——这也是为什么「局部变量出了函数就不能访问」的根本原因。
三、回头解惑:开篇的问题都有了答案
1. 局部变量是怎么创建的?
函数栈帧开辟的时候,会在栈帧内部预留出空间,给每个局部变量分配对应的内存位置。本质就是在当前函数的栈帧里,通过 ebp 偏移找到对应的地址存放数据。
2. 为什么局部变量不初始化是随机值?
栈帧开辟时,编译器会把预留的栈空间填充一遍初始值。如果我们不主动赋值,读取到的就是这份填充值,也就是我们看到的「随机值」。
3. 函数是怎么传参的?传参顺序是怎样的?
传参本质就是把实参的值拷贝一份,从右往左依次压入栈中。形参并不是在被调函数栈帧里创建的,而是在调用方压栈,被调函数通过地址偏移去读取。
4. 形参和实参是什么关系?
形参是实参的一份临时拷贝,值相同但存储位置不同。函数内部修改形参,改的只是栈上的这份拷贝,不会影响外面的实参——这就是为什么传值调用无法修改实参的底层原因。
5. 函数调用是怎么做的?
通过 call 指令跳转到目标函数,同时把返回地址压栈保存;进入被调函数后,保存旧 ebp、搭建新栈帧,再开始执行函数体代码。
6. 函数调用结束后怎么返回的?
函数执行结束时,恢复调用者的 ebp 栈底,然后通过 ret 指令弹出之前保存的返回地址,跳回到调用方的下一条指令继续执行。返回值通过寄存器带回到调用方。
写在最后
这是函数栈帧专题的第一版内容,先把完整的标准流程和核心逻辑梳理清楚。 后续会逐步补充 VS2022 环境下的汇编调试截图、内存栈布局以及逐行指令的细节拆解,把整个过程更直观地呈现出来。
理解栈帧不只是为了搞懂 C 语言语法,更重要的是建立「函数调用本质是栈操作」的认知。后面学习递归、二叉树遍历的时候,你会发现每一层递归调用,本质就是在栈上不断开辟新栈帧的过程——底层逻辑是完全相通的。
如需深入了解,请跳转我的另一篇文章,十分感谢!<a href="/#post/2026-08-21-function-stack-frame-2">C语言底层:函数栈帧的创建与销毁(二)|深入理解函数栈帧</a>