上一章学了栈和 push/pop,知道了数据怎么压进栈、怎么弹出来。但栈真正的大用途是支撑函数调用:参数传递、局部变量、返回值,全靠栈来组织。
这一章搞懂 call/ret、栈帧和调用约定,你就具备了追踪任何函数调用的能力。
本章涉及的所有指令(call、ret)都不影响任何标志位,它们只操作 ESP、EIP 和内存。
call:调用函数
call 的操作数是目标地址,可以是立即数、寄存器或内存:
call 0x00401100 ; 立即数(直接调用地址处的函数)
call eax ; 寄存器(调用 eax 里存的地址,常见于虚函数调用)
call dword ptr [esp+4] ; 内存(调用 [esp+4] 里存的地址)
不管操作数是什么,call 都做了两件事:
- 把下一条指令的地址(返回地址)压栈
- EIP 跳到目标地址
等价于:
push (下一条指令的地址)
jmp 目标地址
为什么要压返回地址?因为函数执行完后需要知道”回到哪里继续执行”。
跟踪示例
假设当前 EIP = 0x00401020,ESP = 0x012FF310:



执行 call 0x00401100 后,栈顶现在存着 0x00401025,这就是 call 之后那条指令的地址。函数结束后会用到它。
ret:从函数返回
ret ; 从栈顶弹出返回地址,跳回去
ret 做了两件事:
- 从栈顶弹出返回地址到 EIP
- ESP 加 4
等价于:
pop eip (概念上,实际不能直接这么写)
跟踪示例
函数执行到最后,ESP = 0x012FF30C,栈顶是 0x00401025(之前 call 压入的返回地址):


现在 EIP 回到了 0x00401025(mov ebx, eax),函数调用完成。
栈帧:函数的”工作台”
每个函数被调用时,都会在栈上开辟一块属于自己的空间,叫做栈帧(Stack Frame)。栈帧里存着:
- 函数的参数
- 返回地址
- 旧 EBP(调用者的栈帧底部)
- 局部变量
函数序言(Prologue)
几乎每个函数开头都有这两条指令:
push ebp ; 保存调用者的 EBP
mov ebp, esp ; 设置当前函数的 EBP = 当前 ESP
执行后,EBP 指向刚 push 进去的”旧 EBP”的位置。从这一刻起,[ebp+X] 用来访问参数,[ebp-X] 用来访问局部变量。
分配栈帧空间
sub esp, 0x0C ; 在栈上分配 12 字节
ESP 往低地址移 12 字节,腾出空间。这块空间用途很多:
- 局部变量(最主要的用途)
- 临时存储:编译器做中间计算时需要临时空间
- 子函数参数:调用其他函数前,先把参数放到栈上
- 对齐填充:编译器可能多分配几个字节来保持栈对齐
所以 sub esp, N 分配的字节数经常比局部变量加起来还多,多出来的就是上面这些用途。你不需要精确计算每一字节归谁,只要知道 [ebp-X] 访问的是这块空间里的某个位置就行。
函数尾声(Epilogue)
函数结束时,恢复栈并返回:
mov esp, ebp ; 恢复 ESP(释放局部变量空间)
pop ebp ; 恢复调用者的 EBP
ret ; 返回到调用点
有的编译器用 leave 指令代替 mov esp, ebp + pop ebp,效果一样,下一节详细讲。
常见错误:往没分配的栈空间写数据
新手容易写出这样的代码:
push ebp
mov ebp, esp
mov dword ptr [esp-4], 0x123456 ; ← esp-4 在 ESP 之下,没分配过
pop ebp
ret
错误分析
[esp-4] 是 ESP 之下的内存,没有用 sub esp, 4 分配过。在 Windows x86 上,中断或异常一旦触发就会往这片区域写数据,覆盖掉你的值。而且这个值写完就丢了,pop ebp 读的是 [esp] 不是 [esp-4]。
正确做法是先 sub esp, 4 腾出空间,或者建立栈帧后用 [ebp-4]:
push ebp
mov ebp, esp
sub esp, 4 ; 分配 4 字节局部变量空间
mov dword ptr [ebp-4], 0x123456 ; 现在合法了
add esp, 4 ; 释放空间(或用 mov esp, ebp)
pop ebp
ret完整函数调用过程
让我们跟踪一个完整的函数调用。C 代码:
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <string.h>
int add(int a, int b) {
int result = a + b;
return result;
}
int main() {
int sum = add(3, 5);
return sum;
}
MSVC Debug 模式编译后的真实输出:
调用方(main)
00401086 push 5 ; 参数 2(从右往左压栈)
00401088 push 3 ; 参数 1
0040108A call add (0401000h) ; 调用 add,压入返回地址 0040108F
0040108F add esp,8 ; 清理参数(调用方负责,cdecl 约定)
00401092 mov dword ptr [ebp-8],eax ; sum = 返回值
注意 call 之后紧跟的地址是 0040108F,这就是被压入栈的返回地址。
被调用方(add 函数)
最终到达的函数体:
00401000 push ebp ; 保存旧 EBP
00401001 mov ebp,esp ; 设置新 EBP
00401003 sub esp,0CCh ; 分配栈帧空间(远大于实际需要)
00401009 push ebx ; 保存 callee-saved 寄存器
0040100A push esi
0040100B push edi
0040100C lea edi,[ebp-0Ch] ; 用 0xCC 填充 [ebp-0Ch] ~ [ebp-1]
0040100F mov ecx,3
00401014 mov eax,0CCCCCCCCh
00401019 rep stos dword ptr es:[edi] ; 3 个 dword = 12 字节
0040101B mov ecx,offset ... ; MSVC 调试辅助
00401020 call __CheckForDebuggerJustMyCode
00401025 nop
00401026 mov eax,dword ptr [a] ; eax = 参数 a(值是 3)
00401029 add eax,dword ptr [b] ; eax += 参数 b(值是 5)
0040102C mov dword ptr [result],eax ; result = 8
0040102F mov eax,dword ptr [result] ; 返回值 = 8
00401032 pop edi ; 恢复 edi
00401033 pop esi ; 恢复 esi
00401034 pop ebx ; 恢复 ebx
00401035 add esp,0CCh ; 释放栈帧空间
0040103B cmp ebp,esp ; 检查栈是否平衡
0040103D call __RTC_CheckEsp ; 不平衡就报错
00401042 mov esp,ebp ; 恢复 ESP
00401044 pop ebp ; 恢复旧 EBP
00401045 ret ; 返回到 0040108F
Debug 模式下编译器塞了很多额外代码。逐个拆解:
序言(Prologue):
push ebp+mov ebp, esp— 标准栈帧建立,所有函数都这样开头sub esp, 0xCC— 分配 204 字节栈帧空间。明明只有 1 个 int 局部变量(4 字节),为什么分 204?Debug 模式宁可多分,方便调试时观察内存push ebx/esi/edi— 保存这三个寄存器(调用约定要求 callee-saved,后面讲)lea edi, [ebp-0Ch]+rep stos— 把[ebp-0Ch]到[ebp-1]填满0xCCCCCCCC。为什么只填这 12 字节(而不是整块 204 字节)?因为编译器只为紧邻 EBP 的局部变量区做填充,这 12 字节正好覆盖了[ebp-4]、[ebp-8](result 存在这里)、[ebp-0Ch]这些可能被引用的位置;更低的地址段留给后续 push 的寄存器和临时空间,没必要全填。这是 MSVC Debug 的安全措施,未初始化的局部变量会被读成0xCCCCCCCC(而不是随机值),方便发现问题__CheckForDebuggerJustMyCode— MSVC 的”Just My Code”调试辅助,Release 模式不会出现
函数体:
[a]就是[ebp+8](第一个参数,值 3),[b]就是[ebp+0Ch](第二个参数,值 5)mov [result], eax—[result]就是[ebp-8](局部变量)mov eax, [result]— 返回值通过 EAX 传回调用方
尾声(Epilogue):
pop edi/esi/ebx— 恢复之前保存的寄存器add esp, 0xCC— 释放栈帧空间(和序言的sub esp, 0xCC对应)cmp ebp, esp+__RTC_CheckEsp— 检查栈是否平衡,不平衡说明有 bugmov esp, ebp+pop ebp— 最终恢复 ESP 和 EBPret— 弹出返回地址,EIP 跳回 main 的0040108F
Release 模式下,上面整个 add 函数可能被优化成一条 lea eax, [ecx+edx] 甚至直接内联到 main 里。但逆向入门先学 Debug 形态,结构清晰,Release 的优化以后会讲。
栈帧布局图
进入 add 函数体后(mov ebp, esp 之后),栈帧是这样的,低地址在上,高地址在下,和栈的生长方向一致:


规律:
[ebp+8]— 第一个参数[ebp+0Ch]— 第二个参数(+8 + 4 = +12 = 0x0C)[ebp-4]— 第一个局部变量[ebp-8]— 第二个局部变量[ebp]— 保存的旧 EBP[ebp+4]— 返回地址
参数在 EBP 上方(高地址),局部变量在 EBP 下方(低地址)。记住这个布局,逆向时看到 [ebp+X] 就知道是参数,[ebp-X] 就是局部变量。
[ebp-8] 而不是 [ebp-4]?很多教程把”第一个局部变量在 [ebp-4]”当定律,但这只是理想化的讲解。编译器有权根据以下因素自由调整局部变量的位置:
- 安全检查:MSVC Debug 模式开启
/RTC运行时错误检查,可能在[ebp-4]放置安全 Cookie 或填充0xCCCCCCCC保护带,把变量挤到更低的地址 - 内存对齐:编译器为了让变量 4 字节或 8 字节对齐,可能跳过
[ebp-4] - 变量排序:C/C++ 源码中变量的声明顺序不等于栈中的内存顺序,编译器会根据使用频率和大小重新排列
规律里写的 [ebp-4] 是”第一个局部变量的典型位置”,但实际要看反汇编里的 mov 指令操作数才能确定。
调用方清理参数
函数返回后,main 继续执行:
0040108F add esp,8 ; ESP += 8,把之前 push 的两个参数"扔掉"
00401092 mov dword ptr [ebp-8],eax ; sum = eax(值是 8)
add esp, 8 把 ESP 恢复到 push 参数之前的位置。这种”调用方清理参数”的约定叫做 cdecl(C declaration),是 C/C++ 程序最常用的调用约定。
调用约定
上面的例子中,main 调用 add 后用 add esp, 8 清理了参数。这不是唯一的做法。谁来清理参数就是”调用约定”要回答的核心问题。
调用约定规定了三件事:
- 参数怎么传递(压栈顺序、寄存器传参)
- 谁来清理栈上的参数
- 返回值放在哪里(EAX)
32 位 x86 最常用的三种约定是 cdecl、stdcall 和 fastcall。
cdecl:C 语言的默认约定
cdecl 是 C/C++ 程序中最常见的调用约定:
- 参数从右往左压栈
- 调用方(caller)负责清理参数,call 之后用
add esp, N恢复栈 - 支持可变参数(如
printf),因为只有调用方知道自己压了几个参数
; 调用 foo(3, 5) — cdecl
push 5 ; 参数 2(右边的先压)
push 3 ; 参数 1(左边的后压)
call foo
add esp, 8 ; ← 调用方清理,ESP 恢复
特点:call 之后紧跟 add esp, N。每次调用同一个函数,清理代码都在调用方出现一次,代码稍大但很灵活。
stdcall:Windows API 的标准约定
stdcall 是 Win32 API 使用的调用约定:
- 参数同样从右往左压栈
- 被调用方(callee)负责清理参数,函数末尾用
ret N代替ret - 调用方不需要额外的
add esp,代码更紧凑
; 调用 MessageBoxA(..., "title", "text", 0) — stdcall
push 0 ; MB_OK
push offset title
push offset text
push 0 ; hWnd = NULL
call MessageBoxA
; ← 没有 add esp!被调用方已经用 ret 16 清理了
MessageBoxA 函数内部,结尾不是普通的 ret,而是:
ret 16 ; 弹出返回地址后,ESP 再加 16(4 个参数 × 4 字节)
ret N 做了三件事:弹出返回地址到 EIP、ESP + 4、然后再 ESP += N。一步完成返回和清理。
fastcall:寄存器传参
fastcall 是三种约定中最快的,前两个参数直接走寄存器,不用压栈:
- 前两个参数通过 ECX 和 EDX 传递(不用 push,省了内存操作)
- 剩余参数从右往左压栈
- 被调用方清理栈上参数(和 stdcall 一样用
ret N,但 N 只算栈上的参数,不算走寄存器的)
; 调用 foo(3, 5, 10) — fastcall
mov ecx, 3 ; 第一个参数 -> ECX
mov edx, 5 ; 第二个参数 -> EDX
push 0xA ; 第三个参数(压栈)
call foo
; ← 没有 add esp,被调用方用 ret 4 清理
注意 ret 4 只清理 1 个栈上参数(4 字节),不是 3 个,前两个走寄存器了。
fastcall 在逆向中常见于编译器内部函数(编译器自动生成的辅助代码)和性能敏感的回调。不过用的比 cdecl 和 stdcall 少。
三种约定对比
; ===== cdecl:调用方清理 =====
; 调用方代码
push 5
push 3
call foo_cdecl
add esp, 8 ; ← 调用方加这句
; foo_cdecl 函数末尾
ret ; ← 普通 ret,不管清理
; ===== stdcall:被调用方清理 =====
; 调用方代码
push 5
push 3
call foo_stdcall
; ← 没有 add esp
; foo_stdcall 函数末尾
ret 8 ; ← ret N,被调用方清理
; ===== fastcall:寄存器 + 被调用方清理 =====
; 调用方代码
mov ecx, 3 ; 第一个参数 -> ECX
mov edx, 5 ; 第二个参数 -> EDX
call foo_fastcall
; ← 没有 add esp
; foo_fastcall 函数末尾
ret ; ← 没有 ret N(两个参数都走寄存器,栈上没参数要清理)
逆向时如何判断调用约定
看到函数调用后,看两处就能判断:
| 观察位置 | cdecl | stdcall | fastcall |
|---|---|---|---|
| call 之前 | 全部 push | 全部 push | 有 mov ecx/edx |
| call 之后 | 有 add esp, N | 什么都没有 | 什么都没有 |
| 函数末尾 | 普通 ret | ret N | ret 或 ret N |
实战中,Windows API 函数全是 stdcall(MessageBox、CreateFile、ReadFile、WriteFile……),而你自己写的 C/C++ 函数默认是 cdecl。fastcall 偶尔出现在编译器生成的代码中。如果你在 x64dbg 里看到 ret N(N > 0),那几乎一定是 stdcall 的 API 函数。
注意:64 位程序用统一的 x64 调用约定(前几个参数走寄存器 RCX、RDX、R8、R9),不再区分这三种约定。这些只在 32 位程序中出现。
leave 指令
前面在函数尾声看到过 mov esp, ebp + pop ebp 这两条指令。有些编译器会把它们合并成一条:
leave
leave 等价于:
mov esp, ebp ; ESP 指回栈帧底部(释放所有局部变量)
pop ebp ; 恢复调用者的 EBP
一条指令做了两件事。原理是:EBP 指向栈帧底部(旧 EBP 的位置),mov esp, ebp 把 ESP 拉回来,等于一次性释放了所有局部变量空间。然后 pop ebp 从栈上恢复旧 EBP。
不过现代 MSVC 很少用 leave,你看到的 Debug 和 Release 输出都是分开写的 mov esp, ebp + pop ebp。leave 更多出现在 GCC/MinGW 编译的程序,或者手写汇编里。逆向时如果看到 leave,知道它是这两条指令的缩写就行。
它们完全等价,只是编译器的优化选择。你两个都要认识。
全局变量 vs 局部变量
到目前为止,我们看到的局部变量都通过 [ebp-X] 访问,它们住在栈上,函数返回后就没了。但程序还有一种变量:全局变量,它们住在固定的内存地址,整个程序运行期间一直存在。
局部变量
mov dword ptr [ebp-4], 5 ; 局部变量
- 地址形式:
[ebp-X](相对于栈帧的偏移) - 位置:栈(Stack)
- 生命周期:函数执行期间。函数返回后,这块栈空间会被其他函数覆盖
- 每次调用函数,局部变量的地址可能不同
全局变量
mov dword ptr ds:[0x0040A000], 5 ; 全局变量
- 地址形式:
ds:[固定地址](绝对地址,写死在程序里) - 位置:
.data段(有初始值)或.bss段(未初始化,默认 0) - 生命周期:整个程序运行期间
- 每次访问都是同一个地址
对比表
| 局部变量 | 全局变量 | |
|---|---|---|
| 汇编形式 | [ebp-X] | ds:[固定地址] |
| 存储位置 | 栈 | .data / .bss 段 |
| 生命周期 | 函数执行期间 | 整个程序 |
| 地址特征 | 相对地址,每次可能不同 | 绝对地址,永远不变 |
| 示例 | mov dword ptr [ebp-4], 5 | mov dword ptr ds:[0x0040A000], 5 |
回忆第四章的内存布局图,局部变量在栈上,全局变量在 .data/.bss 段。逆向时看到内存访问,一眼判断:
[ebp-X]-> 局部变量(栈上)[ebp+X](X > 4)-> 参数(栈上,调用者压入的)ds:[0x00XXXXXX]-> 全局变量(固定地址段)ds:[0x00XXXXXX]地址在0x00400000附近 -> 程序自身的全局变量ds:[0x00XXXXXX]地址很大如0x7XXXXXXX-> 系统DLL 里的变量或 API 地址
举个例子:
; C 代码
int g_count = 0; ; 全局变量
void foo(int x) { ; x 是参数
int sum = x + g_count; ; sum 是局部变量
g_count = sum; ; 写回全局变量
}
; 编译后的汇编
push ebp
mov ebp, esp
sub esp, 4 ; 分配 sum
mov eax, dword ptr [ebp+8] ; eax = x(参数)
add eax, dword ptr ds:[0x0040A000] ; eax += g_count(全局变量)
mov dword ptr [ebp-4], eax ; sum = eax(局部变量)
mov eax, dword ptr [ebp-4] ; eax = sum
mov dword ptr ds:[0x0040A000], eax ; g_count = eax(写回全局变量)
mov esp, ebp
pop ebp
ret
同一个函数里,局部变量用 [ebp-X] 访问,全局变量用 ds:[固定地址] 访问,两种风格共存,非常好认。
回到 x64dbg 实操
打开 x64dbg,加载上一章的 CrackMe 程序。这次我们追踪一个完整的函数调用:
任务一:追踪一个完整的函数调用
- 搜索字符串找到
push <..."reverse2026"...> - 在上面几行找到
call指令(调用strcmp) - 在
call那行按 F2 设断点 - 按 F9 运行,程序会在断点停下
- 按 F7 步入 call,进入函数内部
- 观察寄存器窗口:ESP 变了(返回地址压栈),EIP 跳到了函数入口
- 继续按 F7,观察
push ebp->mov ebp, esp-> 函数体 ->pop ebp->ret - ret 之后 EIP 回到 call 的下一条指令
每一步都看清楚 ESP、EBP、EAX 的变化。这就是函数调用的完整流程。
任务二:找到 stdcall 的 Windows API 调用
Windows 程序启动时会调用大量 API。我们来找一个 stdcall 调用,观察 ret N:
- 在 x64dbg 的符号(Symbols) 标签页,找到程序导入的 DLL 列表
- 双击某个 DLL(比如
user32.dll),找到MessageBoxA或EnableWindow之类的函数 - 双击函数名跳到它的代码
- 按 Ctrl+G 输入函数名,在调用处设断点
- 运行到断点后,F7 步入
- 滚动到函数末尾,找到
ret N指令(N 可能是 4、8、16 等) - 对比:你自己的函数末尾是普通
ret(cdecl),API 函数末尾是ret N(stdcall)
试着找 2-3 个不同的 API 函数,看看它们的 ret N 中 N 分别是多少,算算它们各有几个参数(N ÷ 4 = 参数个数)。
任务三:区分局部变量和全局变量
- 在任意函数内部单步执行(F8)
- 观察反汇编窗口中的内存访问指令
- 看到
[ebp-X]形式的 -> 这是局部变量 - 看到
ds:[0x00XXXXXX]形式的 -> 这是全局变量 - 试着在内存窗口中跳到全局变量的地址(Ctrl+G 输入地址),看看里面存着什么值
练习
-
函数
foo的栈帧中,[ebp+8]是第一个参数,[ebp+0Ch]是第二个参数。第三个参数在哪个位置?参考答案[ebp+10h]。每个参数 4 字节,第一个 +8,第二个 +8+4=+0xC,第三个 +8+4+4=+0x10。 -
为什么
call要把返回地址压栈?如果不压会怎样?参考答案因为函数执行完后必须知道”回到哪里继续执行”。如果不压返回地址,
ret就不知道该跳回哪里,程序会崩溃。call相当于push (下一条指令地址)+jmp 目标。ret相当于pop eip。两者配合,形成完整的调用-返回机制。 -
以下汇编中,
[ebp-4]和[ebp+8]分别是什么?push ebp mov ebp, esp sub esp, 8 mov dword ptr [ebp-4], 0 mov eax, dword ptr [ebp+8] add eax, dword ptr [ebp-4] mov dword ptr [ebp-8], eax mov eax, dword ptr [ebp-8] mov esp, ebp pop ebp ret参考答案[ebp-4]— 第一个局部变量(初始化为 0)[ebp-8]— 第二个局部变量(存eax + [ebp-4]的结果)[ebp+8]— 函数的第一个参数
对应 C 大致是:
int foo(int a) { int temp = 0; int result = a + temp; return result; } -
在反汇编中,如何区分一个函数用的是 cdecl 还是 stdcall 调用约定?
参考答案看两处:
- call 之后:如果有
add esp, N-> cdecl(调用方清理);如果没有 -> 可能是 stdcall - 函数末尾:如果是普通
ret-> cdecl;如果是ret N(N > 0)-> stdcall(被调用方清理)
只要看到
ret N,就是 stdcall。只要看到add esp, N,就是 cdecl。 - call 之后:如果有
-
leave指令做了什么?写出与它等价的两条指令。参考答案leave做了两件事:mov esp, ebp— 把 ESP 恢复到栈帧底部,一次性释放所有局部变量空间pop ebp— 恢复调用者的 EBP
等价于:
mov esp, ebp pop ebp它用在函数尾声(Epilogue),和
ret配合:leave->ret。 -
以下两个内存访问,哪个是局部变量,哪个是全局变量?
mov eax, dword ptr [0x0040B000] mov ecx, dword ptr [ebp-0Ch]参考答案[0x0040B000]— 全局变量。绝对地址,固定不变,位于程序的.data或.bss段[ebp-0Ch]— 局部变量。相对于 EBP 的偏移,位于栈上,函数返回后失效
识别规则:
[ebp-X]-> 局部;ds:[固定地址]或[固定地址]-> 全局。 -
x64dbg 实操题。加载一个程序,完成以下步骤并记录观察结果:
- 找到任意一个
call指令,F2 设断点 - F9 运行到断点,记录当前 ESP 和 EIP 的值
- F7 步入 call,再次记录 ESP 和 EIP,ESP 变了多少?为什么?栈顶现在存着什么?
- 继续按 F7,找到函数序言(
push ebp+mov ebp, esp),观察 EBP 的变化 - 找到函数尾声(
pop ebp+ret或leave+ret),观察 ESP、EBP 恢复的过程 - ret 之后,EIP 跳到了哪里?和栈顶之前存的值有关系吗?
参考答案参考答案(具体数值因程序而异):
- 找到 call,比如
call 0x00401200,在 0x00401050 处。F2 设断点 - F9 停下,假设 ESP =
0x0019F700,EIP =0x00401050 - F7 步入后:ESP =
0x0019F6FC(减了 4,因为返回地址压栈),EIP =0x00401200(跳到函数入口)。栈顶0x0019F6FC存着0x00401055(call 下一条指令的地址) push ebp:ESP 再减 4,EBP 的旧值存入栈。mov ebp, esp:EBP 现在等于当前 ESPpop ebp/leave:EBP 恢复为调用者的值,ESP 回到返回地址处。ret:弹出返回地址到 EIP- ret 之后 EIP =
0x00401055,就是步骤 3 中栈顶存的那个返回地址。完全吻合
- 找到任意一个