【函数栈帧的创建和销毁:一文看懂 C/C++ 函数调用的底层秘密】
本文适合被“局部变量为什么是随机值”、“函数怎么传参”、“返回值怎么带回来”这些问题困扰过的初学者。文末会解释为什么返回局部变量的引用有时能打印出正确值但依然是错的Hello,大家好呀这里是小J,函数栈帧这部分内容可以说是语言学习的基石能深刻的理解函数栈帧很多C/C里面的现象你都不会被绕晕那就让我们一起学习共同进步吧 在我们前期学习时你可能有很多困惑局部变量是怎么创建的为什么局部变量的值是随机值甚至出现“烫烫烫”函数是怎么传参的传参的顺序是怎样的形参和实参是什么关系函数调用是怎么做的函数调用结束后怎么返回的为什么不能返回局部变量的指针/引用但有时候居然能“正确”打印如果你对这些问题有一丝好奇恭喜你 ——你已经准备好揭开计算机最底层的浪漫函数栈帧。 一、两个指针的舞台ebp 和 esp在 x86 架构下CPU 用两个寄存器来管理函数调用时的内存ebp栈底指针 / 帧指针像一个锚点固定当前函数的栈帧底部。esp栈顶指针始终指向当前栈的顶部低地址方向。栈的特点从高地址向低地址生长压栈时esp减小出栈时esp增大。你可以把栈想象成一叠盘子ebp标记最下面那个esp总是盯着最上面的空位。每个函数在执行时都会在栈上“圈”一块属于自己的区域 ——这就是栈帧里面存放局部变量、参数、返回地址等。 二、main 函数也是被“人”调用的你可能认为程序一启动就是main但main也是被启动例程调用的。操作系统加载程序 → 执行启动例程清理环境、获取参数 →call main→ main 返回后再调用exit。所以main的栈帧创建和销毁和普通函数一模一样。 三、从零开始一个函数调用到底发生了什么我们用一个最简单的加法函数来模拟intadd(intx,inty){intzxy;returnz;}intmain(){inta10;intb20;intcadd(a,b);return0;}第 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 可以很方便地访问参数ebp8、ebp12和局部变量ebp-4、ebp-8……。第 4 步函数体执行intzxy;对应汇编mov eax, [ebp8] ; 取 a (10) → eax add eax, [ebp12] ; 加上 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看下面这个例子intadc(){intc1;returnc;// 返回引用地址}intmain(){intbadc();// 从该地址读取值 → 拷贝给 bcoutb;// 可能打印 1}栈帧时间线· adc 运行时c 在栈上值为 1。· adc 返回前把 c 的值放入 eax但这里返回的是引用所以返回的是地址。· adc 栈帧销毁但内存中的 1 暂时没被覆盖。· main 中 int b adc(); 去那个地址读值 → 读到了 1。这完全是运气 因为那块“已销毁”的内存还没有被其他函数占用。一旦在 adc() 和 main 之间插入别的函数调用新栈帧就可能覆盖它结果就变成随机值或程序崩溃。intadc(){intc1;returnc;}voidfoo(){intx999;}// 会覆盖原 c 的位置intmain(){intrefadc();// 悬空引用foo();// 覆盖coutref;// ❗ 未定义行为可能打印 999 或其他}一句话能打印出正确值 ≠ 代码正确。未定义行为有时“碰巧”正确但绝不能依赖。 五、一张流程图总结全过程main 调用 add 的完整生命周期 ------------------------ | main 压栈参数 (b, a) | | (从右向左) | ------------------------ │ ▼ ------------------------ | call add: 压返回地址 | | 跳转到 add 代码 | ------------------------ │ ▼ ------------------------ | add 保存 ebp设立新 | | 栈帧分配局部变量空间 | ------------------------ │ ▼ ------------------------ | 执行加法结果放入 eax | ------------------------ │ ▼ ------------------------ | add 恢复 esp、ebp | | ret 指令弹出返回地址 | ------------------------ │ ▼ ------------------------ | main 清理参数从 | | eax 读取返回值 | ------------------------️ 六、你可以亲自看一眼调试实战打开 Visual Studio或 Dev-C 带调试设置断点运行到 add 函数内部· 打开 寄存器 窗口CtrlAltG观察 ebp、esp 的变化。· 打开 内存 窗口CtrlAltM输入 ebp8 能看到参数ebp-4 能看到局部变量可能显示 0xCCCCCCCC 未初始化。你会亲眼看到那个“烫烫烫”的栈内存。✅ 写在最后函数栈帧是理解 C/C 底层执行模型的一把钥匙。初学者不必一次记住所有汇编细节但抓住这几个核心ebp 定位当前函数esp 管理栈顶。参数从右向左压栈返回地址紧随其后。局部变量在栈上分配函数结束即失效。返回值通过 eax 带回不依赖栈内存。未初始化的“烫烫烫”是调试版特有的保护填充。返回局部变量的引用/指针是未定义行为即使偶尔正确也绝不能写。当你以后再看到函数调用时脑子里应该浮现出那个精巧的栈帧结构 —— 它像一块块积木垒起来又拆掉却保证了整个程序井然有序地运行。 这就是计算机的浪漫看不见的栈撑起了看得见的一切。希望这篇文章对你有所帮助我们下篇文章见
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2636072.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!