ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

深入理解函数栈帧的创建与销毁——从汇编视角彻底搞懂函数调用

深入理解函数栈帧的创建与销毁——从汇编视角彻底搞懂函数调用 目录为什么值得花时间理解函数栈帧预备知识内存布局、栈、寄存器、汇编指令什么是函数栈帧实验代码与观察工具函数栈帧的创建逐行分析反汇编函数栈帧的销毁逐行分析反汇编全流程图解回答开头的五个经典问题拓展调用约定、x64 差异、Debug 与 Release总结自测题1. 为什么值得花时间理解函数栈帧函数栈帧stack frame是函数调用机制的底层实现核心。理解它之后下面这些玄学问题都会迎刃而解局部变量是如何创建的为什么它出了函数就没了为什么局部变量不初始化时内容是随机值Debug 下为什么是烫烫烫函数调用时参数是如何传递的传参顺序是怎样的形参和实参分别是怎样实例化的为什么修改形参不影响实参函数的返回值是如何带回的递归为什么会栈溢出为什么不能返回局部变量的地址这些问题全部指向同一个底层机制。下面我们从零开始。2. 预备知识2.1 进程的内存布局一个运行中的进程其虚拟地址空间通常划分为以下几块高地址 0xFFFFFFFF ┌────────────────────────────┐ │ 内核空间 │ ├────────────────────────────┤ │ ← 栈区 (Stack) │ ← 向下增长高地址 → 低地址 │ ↓ │ 由 esp 寄存器指向栈顶 │ 局部变量、函数参数、 │ │ 返回地址等 │ ├────────────────────────────┤ │ │ │ 空闲内存区域 │ │ │ ├────────────────────────────┤ │ 堆区 (Heap) → │ ← 向上增长低地址 → 高地址 │ malloc/new 分配 │ 由程序员手动管理 ├────────────────────────────┤ │ 静态区.data / .bss │ ← 全局变量、static 变量 ├────────────────────────────┤ │ 代码段.text │ ← 程序的机器指令 └────────────────────────────┘ 0x00000000要点栈从高地址向低地址生长堆从低地址向高地址生长两者相向而行。2.2 栈的基本特性栈是一种后进先出LIFOLast In First Out的数据结构。生活中的类比是叠盘子最后放上去的盘子最先被取出来。程序中的栈是一块具有 LIFO 属性的动态内存区域压栈push数据写入栈顶栈顶指针向低地址移动 → 栈增大出栈pop从栈顶读出数据栈顶指针向高地址移动 → 栈减小在 x86 平台上栈顶由esp寄存器定位栈帧的底部由ebp寄存器定位。ebp 和 esp 之间的区域就是当前函数的栈帧空间。2.3 相关寄存器寄存器名称作用eax累加寄存器通用寄存器常用于存放函数返回值、暂存中间计算结果ebx基址寄存器通用寄存器常用于存放数据指针ecx计数寄存器通用寄存器常用于循环计数edx数据寄存器通用寄存器常配合 eax 做乘除法esi源变址寄存器通用寄存器字符串/内存操作的源地址edi目的变址寄存器通用寄存器字符串/内存操作的目的地址esp栈顶指针指向当前栈顶最低地址处ebp栈底指针帧指针指向当前栈帧的底部最高地址处用于定位参数和局部变量eip指令指针指向下一条要执行的指令地址函数跳转的本质就是修改 eip32 位下是 esp/ebp/eip64 位下对应 rsp/rbp/rip同样有 rax、rbx 等。2.4 相关汇编指令指令含义对栈的影响mov dst, src数据传送复制src → dst无push x把 x 压入栈顶esp 减小32 位下 -4pop x把栈顶值弹出存入 xesp 增大32 位下 4sub esp, nesp esp - n开辟栈空间esp 减小add esp, nesp esp n回收栈空间esp 增大lea reg, [addr]计算地址并存入 reg不访问内存无call 目标① 把下一条指令地址压栈保存返回地址② 跳转到目标函数改写 eipesp 减小ret① 从栈顶弹出返回地址存入 eip恢复执行位置等价于pop eipesp 增大jmp 目标无条件跳转直接改写 eip不压栈无rep stos dword ptr [edi]重复执行 stos把 eax 的值写入 [edi]ecx 次 edi 递增无xor eax, eax异或清零常用于return 0时把 eax 置 0无关键理解call和ret是一对。call压入回家的路ret弹出回家的路这就是函数能去能回的根本原因。2.5 一个演示用的小知识0xCC 与烫烫烫在 Debug 模式下VS 通常会用0xCCx86 的断点指令 int 3的机器码来填充函数栈帧中未初始化的内存。填充的范围与方式随 VS 版本、编译设置而不同有的把整个栈帧填满有的只填局部变量区本文 5.1 节就是这种有的逐个变量填甚至有的完全不填——所以看到有的地方有 0xCC、有的地方是垃圾值都属正常关键记住它是调试期的标记字节。0xCC填充有两个作用如果程序不小心执行到了未初始化的栈内存会立刻触发断点方便调试器捕获调试器可以把未初始化的内存直观地显示出来。而两个连续的0xCC字节0xCCCC恰好是 GBK 编码中的汉字“烫”——这就是 Debug 下打印未初始化字符串看到烫烫烫烫的原因。同理Debug 下堆内存malloc未初始化填充的是0xCD0xCDCD对应汉字“屯”所以看到屯屯屯说明问题出在堆上。3. 什么是函数栈帧函数栈帧stack frame是函数调用过程中在调用栈call stack上开辟的一块连续空间。每次函数调用都会创建自己的栈帧函数返回时销毁。这块空间用来存放函数参数调用方压入的实参副本返回地址call 指令压入告诉函数调用结束后回到哪里上一层函数的 ebp用于恢复调用方的栈帧临时数据非静态局部变量、编译器生成的临时变量被保存的寄存器函数执行期间可能用到 ebx/esi/edi 等需先保存原值以便返回时恢复一句话总结一个函数的栈帧 从它的 ebp 到它的 esp 之间的那块内存。4. 实验代码与观察工具4.1 演示代码#includestdio.hintAdd(intx,inty){intz0;zxy;returnz;}intmain(){inta3;intb5;intret0;retAdd(a,b);printf(%d\n,ret);return0;}4.2 观察工具VS 调试调用堆栈窗口调试 → 窗口 → 调用堆栈或Alt7。调试进入Add后可以看到清晰的调用链Add ← main ← invoke_main ← ...。这说明main之前是由 CRT 的invoke_main调用的它、main、Add各有自己的栈帧。反汇编窗口断点停在main第一行右键 →转到反汇编或AltF12。寄存器窗口调试 → 窗口 → 寄存器Alt5实时观察 esp/ebp 变化。内存窗口调试 → 窗口 → 内存输入 esp 或 ebp 的值直接观察栈内存内容。减少噪音工具 → 选项 → 调试 → 常规关闭仅我的代码并在反汇编窗口右键取消显示符号代码以外的干扰项可让反汇编更干净。注意VS 每次调试会重新分配内存反汇编中的地址如00BE1820每次运行都不同但指令序列和偏移量是稳定的分析时以指令为准。以下反汇编基于x86 Debug/Od禁用优化环境。x64 或 Release 环境会有显著差异见第 9 节。5. 函数栈帧的创建逐行分析反汇编5.1 main 函数栈帧的创建序言/prologueint main() { // 函数栈帧的创建 00BE1820 push ebp ; ① 把调用方(invoke_main)的 ebp 压栈保存esp-4 00BE1821 mov ebp,esp ; ② 把 esp 的值赋给 ebp → 产生 main 自己的 ebp 00BE1823 sub esp,0E4h ; ③ esp-0xE4为 main 开辟栈帧空间 00BE1829 push ebx ; ④ 保存 ebxesp-4 00BE182A push esi ; ⑤ 保存 esiesp-4 00BE182B push edi ; ⑥ 保存 ediesp-4 00BE182C lea edi,[ebp-24h] ; ⑦ edi ebp-0x24初始化区域的起始地址 00BE182F mov ecx,9 ; ⑧ ecx 9重复次数 00BE1834 mov eax,0CCCCCCCCh ; ⑨ eax 0xCC填充值 00BE1839 rep stos dword ptr es:[edi] ; ⑩ 重复 9 次写 4 字节edi4ecx--逐条解读①push ebp—— 把 invoke_main 的 ebp 压栈保存。此刻 esp 减 4栈顶存放的是调用方的栈底地址。这是将来返回时恢复 invoke_main 栈帧的依据。②mov ebp, esp—— 把 esp 的值复制给 ebp。此后ebp 就是 main 函数的栈底指针它恰好指向刚才压入的旧 ebp。这一句是整个序言的核心新栈帧的底 旧栈帧的顶两个栈帧由此首尾相连。③sub esp, 0E4h—— esp 向下移动 0xE4228字节。此时ebp 与 esp 之间围出了 0xE4 字节的空间这就是 main 函数的栈帧后续局部变量、临时数据、调试信息都放在这里。④⑤⑥ push ebx/esi/edi—— 保存三个会被函数使用的寄存器的原值被调用者保存约定以便返回时恢复。⑦~⑩ rep stos—— 这四条合起来等价于下面的伪代码作用是把[ebp-0x24]到[ebp]这 0x2436字节的区域全部填成 0xCCediebp-0x24;ecx9;eax0xCCCCCCCC;for(;ecx!0;--ecx,edi4)*(int*)edieax;// 每次写 4 字节9 次共 36 字节这就是烫烫烫的来源。创建完成后的 main 栈帧结构高地址 ┌───────────────────────────┐ │ invoke_main 的栈帧 │ ├───────────────────────────┤ │ 返回地址 (call main压入) │ ← main 的 ebp4 ├───────────────────────────┤ │ 旧 ebp (invoke_main的) │ ← 【main 的 ebp 指向这里】 ├───────────────────────────┤ │ a (ebp-0x08) │ │ b (ebp-0x14) │ │ ret(ebp-0x20) │ │ 0xCC 填充到 ebp-0x24 │ │ 未初始化空间…… │ ├───────────────────────────┤ │ 保存的 edi │ │ 保存的 esi │ │ 保存的 ebx │ └───────────────────────────┘ ← 【main 的 esp 指向这里】 低地址5.2 局部变量的创建与初始化int a 3; 00BE183B mov dword ptr [ebp-8],3 ; 3 → ebp-8 处这就是变量 a int b 5; 00BE1842 mov dword ptr [ebp-14h],5 ; 5 → ebp-0x14 处这就是变量 b int ret 0; 00BE1849 mov dword ptr [ebp-20h],0 ; 0 → ebp-0x20 处这就是变量 ret结论局部变量就是在当前函数自己的栈帧空间里、以ebp 为基准按偏移分配出来的。所谓局部变量的生命周期从进入函数开始、到函数返回结束物理本质就是进入函数时开辟栈帧变量随之存在返回时销毁栈帧变量所在内存被回收随时会被后续的函数调用覆盖。三个变量的偏移也解释了为什么未初始化的局部变量是随机值[ebp-8]这块内存上一次可能被别的函数用过或者被 0xCC 填充过总之里面残留着上一任住户的数据编译器不会替你清零。同时可以看到同一函数内先定义的变量在更高地址后定义的在更低地址a 在 ebp-8b 在 ebp-0x14ret 在 ebp-0x20。但这只是 Debug 下的常见布局不是语言标准保证的行为Release 下更不成立。5.3 传参与 call 指令ret Add(a, b); // ---- 实参压栈注意顺序先 b 后 a即从右往左---- 00BE1850 mov eax,dword ptr [ebp-14h] ; eax b 5 00BE1853 push eax ; 压入 besp-4 00BE1854 mov ecx,dword ptr [ebp-8] ; ecx a 3 00BE1857 push ecx ; 压入 aesp-4 // ---- 调用 ---- 00BE1858 call 00BE10B4 ; ① 压入返回地址 00BE185D ② 跳到 Add 00BE185D add esp,8 ; 【返回后执行】清理压入的两个参数 00BE1860 mov dword ptr [ebp-20h],eax ; eax → ret 变量三个关键点传参顺序从右往左。先压 b 再压 a所以 a’ 位于更低的地址栈顶方向。这是 cdecl 调用约定的规则也是printf这类可变参数函数能工作的前提——靠固定的压栈顺序格式串才能找到各个参数。形参是实参的一份拷贝。压进栈的是从[ebp-14h]、[ebp-8]读出来的值5 和 3与 main 里的 a、b 已无关联。所以Add内部怎么改 x、y都不会影响 main 的 a、b——这就是值传递的底层含义。call做了两件事把下一条指令的地址00BE185D压栈然后跳转到Add。压返回地址是为了函数执行完能回到 call 的下一条指令继续执行。压参 call 之后、进入 Add 之前的栈高地址 ┌──────────────────────────┐ │ main 的栈帧 │ ├──────────────────────────┤ ← main 的 esp压参前 │ b 5 │ ← 将成为 Add 的 [ebp0Ch]y ├──────────────────────────┤ │ a 3 │ ← 将成为 Add 的 [ebp8]x ├──────────────────────────┤ │ 返回地址 00BE185D │ ← call 压入将成为 Add 的 [ebp4] └──────────────────────────┘ ← 【当前 esp】 低地址5.4 Add 函数栈帧的创建int Add(int x, int y) { 00BE1760 push ebp ; 保存 main 的 ebpesp-4 00BE1761 mov ebp,esp ; 产生 Add 自己的 ebp ← 核心 00BE1763 sub esp,0CCh ; esp-0xCC开辟 Add 的栈帧 00BE1769 push ebx ; 保存 ebx 00BE176A push esi ; 保存 esi 00BE176B push edi ; 保存 edi int z 0; 00BE176C mov dword ptr [ebp-8],0 ; 创建局部变量 zebp-8 处填 0 z x y; 00BE1773 mov eax,dword ptr [ebp8] ; eax x栈上第一个参数 00BE1776 add eax,dword ptr [ebp0Ch] ; eax y栈上第二个参数 00BE1779 mov dword ptr [ebp-8],eax ; 结果写回 z return z; 00BE177C mov eax,dword ptr [ebp-8] ; z → eax准备带回返回值 }版本差异说明本文所依据的编译环境里Add 的序言没有出现lea edi / rep stos那段 0xCC 初始化只有sub esp,0CCh开辟空间而 main 的序言有见 5.1——同一份反汇编里出现这种不一致很常见不同 VS 版本、不同变量的填充方式都可能不同不影响开辟栈帧这一主线。另外注意Add的sub esp,0CCh与main的sub esp,0E4h大小不同栈帧大小由编译器按该函数所需的局部空间逐个计算不是固定的。Add 栈帧建立后的完整布局——这张图是全文的核心务必看懂高地址 ┌──────────────────────────┐ │ main 的栈帧 │ ├──────────────────────────┤ │ b 5 │ ← [ebp0Ch] 即形参 y ├──────────────────────────┤ │ a 3 │ ← [ebp8] 即形参 x ├──────────────────────────┤ │ 返回地址 00BE185D │ ← [ebp4] ├──────────────────────────┤ │ main 的 ebp旧ebp │ ← 【Add 的 ebp 指向这里】 ├──────────────────────────┤ │ z 8 │ ← [ebp-8] │ 0xCC 填充区 │ ├──────────────────────────┤ │ 保存的 edi │ │ 保存的 esi │ │ 保存的 ebx │ └──────────────────────────┘ ← 【Add 的 esp 指向这里】 低地址形参的定位规律x86 Debug cdecl[ebp 4] 返回地址 [ebp 8] 第 1 个参数x [ebp 0Ch] 第 2 个参数y [ebp 10h] 第 3 个参数若有…… 每个参数占 4 字节 [ebp - 8] 第 1 个局部变量z可以看到形参并不在 Add 自己开辟的栈帧ebp 以下里而在 ebp 以上的返回地址与参数区——那块空间是 main 在调用前压进去的。严格说参数区属于调用方 main 的栈帧被调用方只是顺着 ebp 往上读。这也解释了为什么 x64 下参数改走寄存器后形参的寻址方式会完全变样。5.5 返回值的传递00BE177C mov eax,dword ptr [ebp-8] ; 把 z 的值放进 eax返回值是通过寄存器eax带回的不是通过栈拷贝的。return z只是把 z 的值复制到 eax然后函数就可以退出了。回到 main 后00BE1860 mov dword ptr [ebp-20h],eax ; 把 eax 的值存进 main 的 ret 变量main 从eax中读取返回值——这就是返回值通过寄存器带回的完整证据。也是为什么返回值有大小限制32 位下 eax 装 4 字节没问题C 返回大对象时编译器会改用隐藏指针策略见第 9.2 节。6. 函数栈帧的销毁逐行分析反汇编Add 执行完毕开始退房00BE177F pop edi ; 恢复 ediesp4 00BE1780 pop esi ; 恢复 esiesp4 00BE1781 pop ebx ; 恢复 ebxesp4 00BE1782 mov esp,ebp ; ★ esp ebp直接把栈顶收回到栈底整块栈帧被逻辑抹除 00BE1784 pop ebp ; 弹出栈顶的旧ebp存入 ebpesp4→ 恢复了 main 的 ebp 00BE1785 ret ; 弹出栈顶的返回地址存入 eip → 跳回 00BE185D逐条解读pop edi/esi/ebx—— 与序言中的三个 push 相逆恢复被保存的寄存器。mov esp, ebp—— 销毁栈帧的关键一步。把 esp 直接拉回到 ebp 的位置ebp~esp 之间的整块空间z、0xCC 区、保存的寄存器瞬间全部回到未分配状态。注意内存里的数据并没有被清零只是 esp 上移后这些数据被视为空闲会被下一次 push/调用覆盖——这就是局部变量出了函数就不可靠的真正原因。pop ebp—— 此刻栈顶恰好是序言第一步压入的main 的 ebp弹出它之后ebp 恢复成了 main 的栈底指针main 的栈帧重新被认领。ret—— 等价于pop eip把栈顶的返回地址00BE185D弹出装进 eipCPU 随即回到 call 的下一条指令继续执行。至此去和回闭环。回到 main 之后00BE185D add esp,8 ; esp8跳过栈上的 a 和 b清理参数 00BE1860 mov dword ptr [ebp-20h],eax ; eax → ret 变量add esp, 8调用方负责清理压进去的两个参数2 × 4 8 字节。由调用方清理参数 cdecl 调用约定第 9.1 节详述。对被调用方 Add 来说它根本没碰过参数区直接mov esp,ebp整体回收非常干净。销毁过程的栈变化ret 执行前Add 的栈帧还存在 高地址 ┌─────────────────────────────┐ │ main 的栈帧 │ ├─────────────────────────────┤ │ b 5 (y, ebp0Ch) │ │ a 3 (x, ebp8) │ ├─────────────────────────────┤ │ 返回地址 00BE185D │ ← [ebp4] ├─────────────────────────────┤ │ 旧 ebpmain 的 │ ← Add 的 ebp 指向这里 ├─────────────────────────────┤ │ z 8 │ ← [ebp-8] │ 0xCC 填充区 │ ├─────────────────────────────┤ │ 保存的 edi/esi/ebx │ └─────────────────────────────┘ ← Add 的 esp 低地址 ret 执行后栈帧销毁esp/ebp 恢复到 main 高地址 ┌─────────────────────────────┐ │ main 的栈帧 │ ├─────────────────────────────┤ │ a / b / ret ... │ ├─────────────────────────────┤ │ 参数区、返回地址、 │ │ Add 的整块栈帧已被 │ │ 回收数据暂存待覆盖 │ └─────────────────────────────┘ ← esp ebpmain 的 低地址 eax 8返回值销毁的四步pop edi/esi/ebx恢复寄存器 →mov esp,ebp整块回收栈帧 →pop ebp恢复 main 的 ebp →ret弹出返回地址跳回 main。7. 全流程图解把ret Add(a, b)这一次调用拆成五个阶段观察 esp/ebp 的移动阶段① main 序言完成准备压参 ───────────────────────────────────────── 高地址 ┌────────────────────────────┐ │ 返回地址call main 压入│ ← main ebp4 ├────────────────────────────┤ │ 保存的 invoke_main ebp │ ← 【main ebp 指向这里】 ├────────────────────────────┤ │ a / b / ret │ ← main ebp-8 一带局部变量在 ebp 下方 │ 0xCC / 未初始化空间 │ ├────────────────────────────┤ │ 保存的 edi/esi/ebx │ ← main esp压参前 └────────────────────────────┘ 低地址 阶段② push b、push a从右往左 ───────────────────────────────────────── 高地址 ┌────────────────────────────┐ │ 返回地址call main 压入│ ← main ebp4 ├────────────────────────────┤ │ 保存的 invoke_main ebp │ ← 【main ebp 指向这里】 ├────────────────────────────┤ │ a / b / ret │ │ 0xCC / 未初始化空间 │ ├────────────────────────────┤ │ 保存的 edi/esi/ebx │ ├────────────────────────────┤ │ b 5 │ ← 将成为 Add 的 [ebp0Ch]y ├────────────────────────────┤ │ a 3 │ ← 将成为 Add 的 [ebp8]x └────────────────────────────┘ ← esp 低地址 阶段③ call Add压入返回地址跳转 ───────────────────────────────────────── 高地址 ┌────────────────────────────┐ │ 返回地址call main 压入│ ← main ebp4 ├────────────────────────────┤ │ 保存的 invoke_main ebp │ ← 【main ebp 指向这里】 ├────────────────────────────┤ │ a / b / ret │ │ 0xCC / 未初始化空间 │ ├────────────────────────────┤ │ 保存的 edi/esi/ebx │ ├────────────────────────────┤ │ b 5 │ ← 将成为 Add 的 [ebp0Ch]y ├────────────────────────────┤ │ a 3 │ ← 将成为 Add 的 [ebp8]x ├────────────────────────────┤ │ 返回地址 00BE185D │ ← 将成为 Add 的 [ebp4] └────────────────────────────┘ ← esp 低地址 阶段④ Add 序言完成push ebp / mov ebp,esp / sub esp,0CCh ───────────────────────────────────────── 高地址 ┌────────────────────────────┐ │ 返回地址call main 压入│ ← main ebp4 ├────────────────────────────┤ │ 保存的 invoke_main ebp │ ← 【main ebp 指向这里】 ├────────────────────────────┤ │ a / b / ret │ │ 0xCC / 未初始化空间 │ ├────────────────────────────┤ │ 保存的 edi/esi/ebx │ ← main esp ├────────────────────────────┤ │ b 5 │ ← Add 的 [ebp0Ch]y ├────────────────────────────┤ │ a 3 │ ← Add 的 [ebp8]x ├────────────────────────────┤ │ 返回地址 00BE185D │ ← Add 的 [ebp4] ├────────────────────────────┤ │ 保存的 main 的 ebp │ ← 【Add 的 ebp 指向这里】 ├────────────────────────────┤ │ z 0 / 0xCC 填充区 │ ← Add 的 [ebp-8] 一带 ├────────────────────────────┤ │ 保存的 edi/esi/ebx │ ← Add esp └────────────────────────────┘ 低地址 阶段⑤ Add 销毁pop×3 / mov esp,ebp / pop ebp / ret 后 add esp,8 清理参数 → 回到阶段①的状态 仅 eax 中带回了返回值 8 ───────────────────────────────────────── 高地址 ┌────────────────────────────┐ │ 返回地址call main 压入│ ← main ebp4 ├────────────────────────────┤ │ 保存的 invoke_main ebp │ ← 【main ebp 已恢复指向这里】 ├────────────────────────────┤ │ a / b / ret │ │ 0xCC / 未初始化空间 │ ├────────────────────────────┤ │ 保存的 edi/esi/ebx │ ← main espadd esp,8 之后已恢复 └────────────────────────────┘ 低地址 eax 8返回值通过寄存器带回不占用栈一次完整的调用 esp/ebp 一路向下开疆拓土再一路向上原路返回。ebp 像书签一样标记了从哪来ret 顺着书签找回到哪去。8. 回答开头的五个经典问题现在可以逐条回答了Q1局部变量是如何创建的在进入函数时序言push ebp; mov ebp,esp; sub esp,N一次性开辟出栈帧空间局部变量就分布在这块空间里以[ebp-偏移]的方式访问。局部变量的创建没有任何系统调用只是 esp 往下挪了一点。Q2为什么局部变量不初始化内容是随机的栈帧内存不会被自动清零。Debug 下它要么残留上一次函数调用的数据要么被填成 0xCC所以是烫Release 下编译器干脆不生成填充代码rep stos那段是 Debug 专属里面是什么就是什么。所以未初始化的局部变量读到的是历史残留值不是随机数只是对你而言不可预测。Q3函数调用时参数如何传递顺序是怎样的x86 Debug/cdecl 下实参先被复制再从右往左依次push到栈上本例先 push b 再 push a。被调用方通过[ebp8]、[ebp0Ch]、[ebp10h]… 依次读取第 1、2、3 个参数。注意栈上的 a’、b’ 是 main 压入的形参 x、y 只是指着这块内存的名字。Q4形参和实参分别怎样实例化的为什么修改形参不影响实参实参在 main 的栈帧里a 在 [ebp-8]b 在 [ebp-14h]调用时把实参的值拷贝压栈形成参数区的 a’、b’——这就是形参的实例化。形参 x、y 直接就是这块拷贝[ebp8]/[ebp0Ch]与 main 的 a、b 是两块不同的内存所以改形参永远不影响实参。这就是值传递。想让它影响就得传地址指针让形参指向实参所在的内存——那是址传递。Q5函数返回值是如何返回的内置类型int 等通过eax 寄存器带回被调用方mov eax, [ebp-8]调用方返回后mov [ebp-20h], eax。栈帧虽然销毁了但寄存器不受影响所以返回值能安全跨越栈帧销毁这道坎。9. 拓展调用约定、x64 差异、Debug 与 Release9.1 调用约定calling convention前面看到的从右往左压栈 add esp,8由调用方清理就是一种调用约定。常见几种调用约定参数传递顺序参数清理责任典型用途cdeclC 默认从右往左压栈调用方清理add esp,n标准 C 函数、可变参数函数printfstdcallWinAPI从右往左压栈被调用方清理ret n弹出返回地址后再 espnWindows APIMessageBox 等fastcall前 2 个参数走寄存器ecx/edx其余压栈被调用方清理高频调用的函数thiscallCthis 走 ecx其余压栈被调用方清理C 非虚成员函数为什么 cdecl 必须由调用方清理因为可变参数函数printf(%d%d, a, b)只有调用方知道到底压了几个参数被调用方无从得知自然没法清理。这就是printf必须用 cdecl 的根本原因。如何一眼识别 cdecl反汇编里 call 返回后紧跟add esp, N→ 调用方清理 → cdecl。9.2 x6464 位下有什么不同本文的逐行分析基于 x86。x64 的差异非常大主要几点前 4 个整数/指针参数走寄存器RCX、RDX、R8、R9浮点走XMM0~XMM3不再全部压栈。第 5 个参数起才压栈。影子空间shadow space / home space即使参数走寄存器调用方也必须在栈上给被调用方预留 32 字节4 × 8的备份区被调用方可以把这 4 个寄存器参数存进来统一用栈访问——所以[ebp8]那种定位形参的思路在 x64 下变了但预留空间的逻辑还在。寄存器更宽rsp/rbp/rax… 8 字节push一次移动 8 字节栈要求16 字节对齐call前 rsp ≡ 0 mod 16否则部分 SSE 指令会崩溃。返回值整数/指针走rax浮点走xmm0超出寄存器能装下的较大结构体则由调用方在栈上预留空间、通过隐藏指针返回。寄存器命名esp/ebp/eip → rsp/rbp/rip。x64 下函数序言通常是push rbp mov rbp, rsp sub rsp, 20h ; 影子空间 局部变量9.3 Debug 与 Release 的差异非常重要的认知维度Debug/Od本文环境Release/O2优化开启标准序言完整的push ebp; mov ebp,esp; sub esp,N可能省略帧指针用 rsp 直接寻址甚至整个序言消失栈初始化rep stos填 0xCC烫的来源不填充未初始化变量是真正的垃圾值局部变量全部分配在栈上地址固定ebp-N可能直接放进寄存器甚至根本不分配内存内联不内联函数可能被内联展开根本看不到 call/栈帧参数访问一律压栈、[ebp8]定位仍走栈但帧指针省略后改用espN寻址或直接缓存进寄存器x86 cdecl 的参数在栈上这点不变反汇编可读性与源码逐行对应与源码严重错位重排、合并、删除给学习者的建议学基础机制 → 在Debug x86下观察本文逐行对得上分析真实性能/安全问题 → 必须看Release因为那才是生产环境的真实形态千万不要把 Debug 下观察到的结论比如变量一定在 ebp-8当作语言标准——那是编译器实现细节换个编译器/优化等级就不成立。9.4 栈帧知识的实际应用① 为什么不能返回局部变量的地址int*f(){intx10;returnx;// 危险}return之后执行mov esp,ebp; pop ebp; retx 所在的栈帧已经回收虽然那 4 字节还没被覆盖但下一次任何函数调用都可能立刻覆盖它——这就是悬垂指针dangling pointer。② 递归为什么会栈溢出Stack Overflow每层递归都要走一遍序言push ebpsub esp, N栈不断向低地址生长。递归没有出口或深度太大时esp 一路下探越过栈的边界触发访问违规——这就是递归爆栈。系统给每个线程的栈通常只有 1 MBWindows 默认按每层 100 字节算大约 1 万层就到底了。③ 缓冲区溢出与栈攻击voidvulnerable(){charbuf[16];gets(buf);// 不检查长度}buf在栈帧里其上方高地址依次是保存的 ebp、返回地址。恶意输入过长时会覆盖返回地址函数ret时就会跳转到攻击者指定的地址——这就是经典的栈溢出攻击。现代防御手段栈金丝雀/cookie/GSVS 默认开启销毁前校验 canary 值、ASLR地址随机化、DEP/NX禁止栈上执行代码。④ 大对象的返回eax只有 4 字节x64 的 rax 8 字节返回struct大对象时怎么办编译器会用隐藏参数调用方在自己的栈帧里开一块空间把地址偷偷作为第 1 个参数传给被调用方被调用方把结果写进该地址——相当于void f(Obj* hidden_ret, ...)。这就是《程序员的自我修养》第 10 章讲的内容。这也是返回值为什么要放寄存器/为什么有大小限制的完整答案。⑤ 调试技巧程序崩溃时看调用堆栈窗口能直接还原谁调用了谁本质就是顺着栈上的返回地址往回爬怀疑踩内存时对照[ebp-N]看哪个变量被写花了esp/ebp之差就是当前栈帧大小可以快速发现异常的栈消耗。10. 总结一次完整的函数调用栈帧的生命周期可以浓缩成下面这段对称的汇编; 创建prologue push ebp ; 保存调用方栈底 mov ebp, esp ; 新栈底 旧栈顶链起来 sub esp, N ; 开辟自己的栈帧 push ebx/esi/edi ; 保存要用的寄存器 ; Debug 下还有 rep stos 填 0xCC ; 销毁epilogue pop edi/esi/ebx ; 恢复寄存器 mov esp, ebp ; 回收整块栈帧 pop ebp ; 恢复调用方栈底 ret ; 弹出返回地址 → 回到 call 的下一条指令核心记忆点ebp~esp 之间就是当前栈帧序言把两者连起来尾声把它们拆回去。参数从右往左压栈形参是实参的拷贝通过[ebp8]起步的正偏移访问。局部变量在 ebp 以下通过[ebp-N]负偏移访问生命周期 栈帧生命周期。返回值走 eax栈帧销毁也不怕因为寄存器不归栈管。call 压返回地址ret 弹返回地址一去一回构成闭环。Debug 下的填充0xCC→烫、固定偏移都是编译器实现细节不是语言标准。
返回列表