写在前面初学C语言的时候我们心里总会有一连串“知其然不知其所以然”的疑问局部变量到底是怎么创建出来的为什么不初始化的局部变量是随机值函数传参的顺序是什么形参和实参到底是什么关系函数调用到底是怎么执行的调用结束后又是怎么准确回到原来的位置继续执行的这些问题看似零散其实答案都指向同一个底层机制函数栈帧的创建与销毁。 把栈帧的逻辑搞懂相当于修炼了C语言的底层内功后面理解递归、局部变量生命周期、函数调用约定乃至二叉树的递归遍历都会顺畅很多。本篇是函数栈帧专题的第一版先把核心原理和完整流程梳理清楚后续会逐步补充VS2022下的汇编调试截图、内存栈布局以及更细致的逐行指令拆解。环境说明本文基于VS2022 x8632位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 栈帧创建与执行 → 栈帧销毁与返回。阶段1main 函数栈帧的创建程序从 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这里可以得到一个核心结论形参其实是实参的一份临时拷贝。压栈的过程就是把实参的值复制一份放到栈上函数内部操作的是这份拷贝所以修改形参不会影响外面的实参。阶段3call 指令——记录返回地址参数压完之后执行call指令调用 Add 函数。 call 指令会做两件事把 call 指令下一条指令的地址压入栈中保存——这就是函数执行完之后要回去的「返回地址」跳转到 Add 函数的入口地址开始执行 Add 函数阶段4Add 函数栈帧的创建与执行进入 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 语言语法更重要的是建立「函数调用本质是栈操作」的认知。后面学习递归、二叉树遍历的时候你会发现每一层递归调用本质就是在栈上不断开辟新栈帧的过程——底层逻辑是完全相通的。