函数调用与栈帧
在 32 位 x86 架构中,函数调用过程对栈的操作是最经典的汇编学习案例。通过 EBP(基址指针) 形成的“栈帧(Stack Frame)”结构,是理解局部变量、参数传递和返回地址的核心。
我们通过一段简单的 C 代码,观察它转译为 32 位汇编后的样子,并配合栈图进行拆解。
1. 示例 C 代码
2. 对应的 32 位汇编(简化版)
在 32 位系统中,通常使用 cdecl 调用约定:参数通过栈传递,由调用者清理栈。
; --- main 函数执行部分 ---
push 2 ; 压入参数 b
push 1 ; 压入参数 a
call add ; 调用函数 (隐含 PUSH EIP)
add esp, 8 ; 调用结束后,清理栈上的两个参数 (4+4=8字节)
; --- add 函数内部 ---
add:
push ebp ; 【序言】保存调用者的 EBP
mov ebp, esp ; 【序言】设置当前函数的基址 (EBP = ESP)
sub esp, 4 ; 【序言】为局部变量 sum 分配 4 字节空间
mov eax, [ebp+8] ; 取出参数 a (1)
mov edx, [ebp+12] ; 取出参数 b (2)
add eax, edx ; eax = a + b
mov [ebp-4], eax ; 将结果存入局部变量 sum
mov eax, [ebp-4] ; 将返回值存入 EAX
mov esp, ebp ; 【尾声】恢复 ESP,销毁局部变量空间
pop ebp ; 【尾声】恢复调用者的 EBP
ret ; 【尾声】弹出返回地址到 EIP,跳回 main
3. 栈图变化全程拆解(重点)
我们将栈想象成一个从高地址向低地址增长的容器。
第一阶段:主程序准备 (Call 之前)
当 main 执行 push 2 和 push 1 后:
第二阶段:执行 CALL (进入函数之前)
call add 指令执行时,CPU 自动将 call 的下一条指令地址压入栈:
地址 内容 说明
-------|---------------|-------
0x2000 | 2 | 参数 b
0x1FFC | 1 | 参数 a
0x1FF8 | Return Addr | 返回地址 (main 函数中 add esp, 8 的地址) <-- ESP
第三阶段:函数序言 (Prologue)
进入 add 后,执行 push ebp 和 mov ebp, esp。这是建立栈帧的关键:
地址 内容 说明
-------|---------------|-------
0x2000 | 2 | [ebp + 12] -> 参数 b
0x1FFC | 1 | [ebp + 8 ] -> 参数 a
0x1FF8 | Return Addr | [ebp + 4 ] -> 返回地址
0x1FF4 | Old EBP | [ebp + 0 ] -> main 的 EBP <-- ESP 和 EBP 现在都指向这
ESP 怎么动(比如之后又 push 了别的东西),只要 EBP 不变,我们就可以通过固定的偏移量找到一切:
* [ebp + 8] 永远是第一个参数。
* [ebp + 12] 永远是第二个参数。
* [ebp - 4] 永远是第一个局部变量。
第四阶段:分配局部变量
执行 sub esp, 4 为 sum 留出位置:
地址 内容 说明
-------|---------------|-------
0x2000 | 2 | 参数 b
0x1FFC | 1 | 参数 a
0x1FF8 | Return Addr | 返回地址
0x1FF4 | Old EBP | EBP 指向这里
0x1FF0 | (undefined) | [ebp - 4] -> 局部变量 sum <-- ESP 指向这里
第五阶段:函数尾声 (Epilogue)
计算完毕,准备离开。
1. mov esp, ebp:ESP 直接跳回 EBP 的位置,瞬间销毁所有局部变量。
2. pop ebp:栈顶的 Old EBP 弹回给 EBP 寄存器。此时栈顶变成了 Return Addr。
3. ret:弹出 Return Addr 给 EIP。
第六阶段:回到主程序清理
回到 main 后,栈上还残留着参数 1 和 2。
执行 add esp, 8。
4. 深度总结
- 参数位置:在
EBP的“上方”(高地址),偏移量从+8开始(因为+0是旧 EBP,+4是返回地址)。 - 局部变量位置:在
EBP的“下方”(低地址),从-4开始。 - EBP 的作用:它像是一个锚点。在一个复杂的函数里,
ESP会频繁变动(比如循环里的临时 push),如果没有EBP,程序员很难算清参数到底在ESP+多少的地方。 - 对比 64 位:
- 32 位:主要靠栈传参,效率较低,但栈帧结构非常清晰。
- 64 位:前 6 个参数进寄存器(RDI, RSI...),通常不再强制使用
RBP作为基址(由RSP配合偏移直接访问),这样可以多省出一个通用寄存器。
理解了这个栈图,你就理解了缓冲区溢出攻击(Stack Overflow)的原理: 如果局部变量 sum 是一个数组,而我们写入了超长的数据,数据就会向下覆盖掉 Old EBP 甚至 Return Addr。这样当函数 ret 时,我们就能控制程序跳到我们想要的任何地方。
一个完整的栈帧(Stack Frame),本质上是栈内存中属于单个函数调用的一块独立“工作区”。
在经典的 x86 架构中,一个栈帧被限定在 EBP(栈底指针) 和 ESP(栈顶指针) 之间。
以下是一个标准栈帧从高地址到低地址的完整构成:
栈帧结构图解
| 地址方向 | 内容 | 说明 |
|---|---|---|
| 高地址 | 参数 (Arguments) | 由调用者(Caller)压入,供被调函数使用。 |
| ↑ | 返回地址 (Return Addr) | 执行 CALL 时自动压入,记录函数结束后回哪去。 |
| ↑ | 旧的 EBP (Old EBP) | 函数“序言”压入,记录上一个函数的栈底地址。 |
| EBP 锚点 | 当前栈帧基址 | EBP 指向这里。它是访问参数和变量的参照点。 |
| ↓ | 局部变量 (Locals) | 函数内部定义的变量,通过 sub esp, n 开辟。 |
| ↓ | 被保存的寄存器 | 函数若改动了 EBX/ESI 等,需在此备份以便恢复。 |
| 低地址 | 栈顶 (Stack Top) | ESP 指向这里。随着临时操作动态跳动。 |
核心组件简述
-
参数区 (Function Arguments)
- 在进入函数前,调用者把参数按顺序压栈。在被调函数内,通过
[ebp + 8]、[ebp + 12]等向上寻址来读取。
- 在进入函数前,调用者把参数按顺序压栈。在被调函数内,通过
-
返回地址 (Return Address)
- 这是函数执行完后,CPU 回到主程序继续运行的“路标”。如果这里被黑客篡改,程序就会跳到恶意代码执行(即栈溢出攻击)。
-
栈帧指针 (Frame Pointer / EBP)
- 它是栈帧的核心。 每个函数开头都会执行
push ebp; mov ebp, esp。 - 通过 EBP,函数可以不顾 ESP 的变动,永远用固定的偏移量找到参数(正偏移)和局部变量(负偏移)。
- 它是栈帧的核心。 每个函数开头都会执行
-
局部变量区 (Local Variables)
- 函数内声明的变量都在这里。它们在函数结束时,通过
mov esp, ebp指令被瞬间销毁。
- 函数内声明的变量都在这里。它们在函数结束时,通过
-
前驱栈帧链接 (Saved EBP)
- 栈帧中保存了上一个函数的 EBP,这形成了一个“链表”。调试器(Debugger)就是顺着这个链条实现“回溯(Backtrace)”,让你看到函数的调用层级。
总结
一个完整的栈帧就是 “上找参数、下存变量、中间记地址”。
- 序言(Prologue):负责把上个函数的栈帧挂起,建立当前栈帧。
- 主体:干活,读写局部变量和参数。
- 尾声(Epilogue):拆除当前栈帧,找回旧栈帧,顺着返回地址跳回去。
简单来说:是的,易变寄存器和非易变寄存器在物理上是分开压栈的,且各自承担不同的角色。
在 32 位逆向中,你会发现栈帧的构建像盖楼一样,每一层都有特定的用途。为了帮你理清思路,我们可以把它们分为 “为了维护环境” 的压栈和 “为了存放数据” 的压栈。
1. 非易变寄存器(Callee-saved)的压栈
非易变寄存器(如 EBX, ESI, EDI)的压栈通常发生在函数的最开始(序言部分)。
-
目的:保护现场。因为调用者(Caller)可能也在用这些寄存器,如果你在函数里要修改它们,必须先
push保存,等函数结束前再pop还原。 -
位置:通常紧跟在
push ebp和sub esp, XX(分配局部变量)之后。 -
特征:你在反汇编中看到函数头有一连串的
push esi,push edi,那就是在保护非易变寄存器。
2. 局部变量的“压栈”
虽然我们常说“局部变量压栈”,但现代编译器(包括你正在用的 MinGW GCC)其实很少用 push 来存放局部变量。
-
操作方式:编译器会一次性通过
sub esp, XX指令在栈上“挖一个大坑”。 -
存放位置:局部变量就存在这个“坑”里。比如你的
int local_1 = 0x1111;,在汇编里对应的是mov [ebp-4], 0x1111,而不是push 0x1111。 -
与寄存器的关系:这个“坑”位于 返回地址/旧EBP 和 被保存的非易变寄存器 之间(或之后)。
3. 易变寄存器(Caller-saved)的压栈
易变寄存器(如 EAX, ECX, EDX)的压栈通常是临时性的。
-
目的:传参或备份。比如你要调用下一个函数,需要用
EAX传一个值,或者你怕接下来的CALL会弄脏EAX里的重要数据,你会临时push eax。 -
位置:随机分布在函数体(Body)中。
总结:栈帧内的“三权分立”
在一个标准的 32 位栈帧中,从高地址到低地址的分布通常是:
| 顺序 | 内容类型 | 典型汇编指令 |
|---|---|---|
| 1 | 函数参数 | push eax (在调用者中执行) |
| 2 | 返回地址 | CALL 指令自动完成 |
| 3 | 旧的 EBP | push ebp |
| 4 | 局部变量区 | sub esp, 0x20 |
| 5 | 非易变寄存器 | push esi / push edi |
| 6 | 临时数据/参数 | push 0xAAAA (准备调用下一个函数) |
逆向小技巧
当你在 x32dbg 中看到 sub esp, 0x40 之后紧跟着几个 push,那么:
-
通过
[ebp - offset]访问的通常是局部变量。 -
在函数结尾看到成对的
pop edi,pop esi,说明它们是被保护的非易变寄存器。
既然你已经配置好了环境,想不想试着编译一下,然后在 x32dbg 里找找看 sub esp 之后,编译器到底把你的 local_var_1 放在了哪个 [ebp - ?] 的位置?