Skip to content

函数调用与栈帧

在 32 位 x86 架构中,函数调用过程对栈的操作是最经典的汇编学习案例。通过 EBP(基址指针) 形成的“栈帧(Stack Frame)”结构,是理解局部变量、参数传递和返回地址的核心。

我们通过一段简单的 C 代码,观察它转译为 32 位汇编后的样子,并配合栈图进行拆解。

1. 示例 C 代码

int add(int a, int b) {
    int sum = a + b;
    return sum;
}

int main() {
    add(1, 2);
    return 0;
}

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 后:

地址      内容            说明
-------|---------------|-------
0x2000 | 2             | 参数 b
0x1FFC | 1             | 参数 a  <-- ESP 停在这里

第二阶段:执行 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 现在都指向这
为什么 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。

地址      内容            说明
-------|---------------|-------
0x2004 | ...           | <-- 栈恢复到调用 add 之前的状态 (ESP)


4. 深度总结

  1. 参数位置:在 EBP 的“上方”(高地址),偏移量从 +8 开始(因为 +0 是旧 EBP,+4 是返回地址)。
  2. 局部变量位置:在 EBP 的“下方”(低地址),从 -4 开始。
  3. EBP 的作用:它像是一个锚点。在一个复杂的函数里,ESP 会频繁变动(比如循环里的临时 push),如果没有 EBP,程序员很难算清参数到底在 ESP+ 多少的地方。
  4. 对比 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 指向这里。随着临时操作动态跳动。

核心组件简述

  1. 参数区 (Function Arguments)

    • 在进入函数前,调用者把参数按顺序压栈。在被调函数内,通过 [ebp + 8]、[ebp + 12] 等向上寻址来读取。
  2. 返回地址 (Return Address)

    • 这是函数执行完后,CPU 回到主程序继续运行的“路标”。如果这里被黑客篡改,程序就会跳到恶意代码执行(即栈溢出攻击)。
  3. 栈帧指针 (Frame Pointer / EBP)

    • 它是栈帧的核心。 每个函数开头都会执行 push ebp; mov ebp, esp。
    • 通过 EBP,函数可以不顾 ESP 的变动,永远用固定的偏移量找到参数(正偏移)和局部变量(负偏移)。
  4. 局部变量区 (Local Variables)

    • 函数内声明的变量都在这里。它们在函数结束时,通过 mov esp, ebp 指令被瞬间销毁。
  5. 前驱栈帧链接 (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,那么:

  1. 通过 [ebp - offset] 访问的通常是局部变量。

  2. 在函数结尾看到成对的 pop edi, pop esi,说明它们是被保护的非易变寄存器。

既然你已经配置好了环境,想不想试着编译一下,然后在 x32dbg 里找找看 sub esp 之后,编译器到底把你的 local_var_1 放在了哪个 [ebp - ?] 的位置?