018、堆栈—CALL/RET 与函数调用
返回:网络安全知识库 · 上一节:线程栈与 PUSH/POP · 下一节:调试回溯与函数分析
CALL 做了什么
CALL 的核心动作不是单纯跳转,而是:
- 保存下一条指令的地址,也就是返回地址。
- 把执行流转移到目标函数。
可以近似理解为:
1 | push 下一条指令地址 |
识别函数调用时,返回地址是串联调用者与被调用函数的关键证据。
直接调用与间接调用
E8 相对调用
常见的 E8 rel32 使用有符号相对位移:
1 | 目标地址 = CALL 下一条指令地址 + 有符号 rel32 |
例如调试器把某条指令显示为:
1 | 00401000 E8 2B000000 call 00401030 |
CALL 长度为 5 字节,下一条指令地址是 00401005,再加相对位移得到目标地址。
FF 间接调用
FF /2 形式从寄存器或内存中取得目标地址:
1 | call eax |
逆向时,这类调用常见于:
- 函数指针。
- 导入地址表调用。
- 虚函数调用。
- 回调函数和跳转表。
RET 如何返回
RET 从栈顶取出返回地址,并继续执行:
1 | pop eip ; 仅用于理解,不能直接这样编码 |
ret 0Ch 除了取出返回地址,还会额外调整栈顶:
1 | 弹出返回地址,再让 ESP 增加 0x0C |
这通常意味着被调用函数还负责清理一部分栈参数。
如果栈顶不是正确的返回地址,RET 就会把错误数据当成下一条指令地址。
经典 x86 栈帧
未优化的 32 位函数经常使用下面的入口:
1 | push ebp |
含义依次是:
- 保存调用者的
EBP。 - 建立当前函数的固定基准。
- 为局部变量预留空间。
对应的退出过程可能是:
1 | mov esp, ebp |
也可能直接写成:
1 | leave |
LEAVE 基本等价于 mov esp, ebp 加 pop ebp。
参数和局部变量定位
对典型 32 位栈帧而言:
| 地址样式 | 常见含义 |
|---|---|
[ebp] |
调用者保存的 EBP |
[ebp+4] |
返回地址 |
[ebp+8] |
第一个栈参数 |
[ebp+0Ch] |
第二个栈参数 |
[ebp-4] |
第一个局部变量或临时空间 |
因此看到:
1 | mov eax, [ebp+8] |
可以先推测函数接收两个整数参数并返回它们的和,再结合调用点和返回值验证。
MOV、REP 与 STOS 在函数中的作用
这一阶段的视频还会遇到 MOV、REP、STOS 等指令,它们经常出现在函数初始化和内存处理中。
1 | xor eax, eax |
这段代码可能是在把一段局部缓冲区清零:
EAX = 0提供填充值。EDI指向目标内存。ECX提供重复次数。REP STOSD连续写入双字。
逆向时应把连续指令还原成“初始化缓冲区”这样的整体语义,而不是逐条翻译。
嵌套调用与回溯
假设调用关系是:
1 | main → A → B → C |
每次 CALL 都会留下一个返回地址。传统栈帧还会把上一层 EBP 串成链,因此调试器可以沿栈帧逐层回溯。
1 | C 的栈帧 |
不过,编译器启用优化后可能省略帧指针、内联函数或重排代码。此时不能只靠 EBP/RBP 链判断调用关系,还需要结合符号、栈展开信息和调用点。
栈由谁清理
32 位调用约定中,栈参数可能由调用者或被调用者清理:
1 | call sub_401000 |
或者:
1 | ret 8 ; 被调用者清理 |
这不仅影响栈平衡,也能帮助推断函数的调用约定和参数总宽度。
调试练习
选择一个简单函数:
1 | int add(int a, int b) |
在关闭优化的 32 位 Debug 构建中观察:
- 调用前参数如何准备。
CALL执行前后的ESP和栈顶。- 函数入口如何建立栈帧。
- 参数位于哪个偏移。
- 返回值写入哪个寄存器。
RET后是否回到原调用点。
学习检查清单
| 检查项 | 状态 |
|---|---|
| 能说明 CALL 保存返回地址和转移控制流的过程 | 待复盘 |
| 能计算 E8 相对调用的目标地址 | 待复盘 |
| 能区分直接调用和间接调用 | 待复盘 |
| 能解释 RET 与 RET n 的差别 | 待复盘 |
| 能画出经典 x86 函数栈帧 | 待复盘 |
| 能从 EBP 正负偏移区分参数和局部变量 | 待复盘 |
| 能在调试器中完成一次函数调用回溯 | 待复盘 |












