函数调用约定
函数调用约定就是一套规则,规定:
- 函数参数放哪里?
- 返回值放哪里?
- 谁负责清理栈?
- 哪些寄存器调用前后必须保持不变?
- 函数调用时栈怎么摆?
你逆向时看到一个 call,想还原出"它调用了什么函数、传了什么参数、返回了什么",就必须懂调用约定。
从函数调用本身讲
你写 C/C++:
int add(int a, int b) {
return a + b;
}
int main() {
int r = add(3, 5);
return r;
}
源码里看起来很简单:
main 调用 add
- 传入
3 和 5
add 返回 8
main 返回 8
但 CPU 不认识"函数""参数""返回值"这些高级概念。
CPU 只认识:
所以编译器必须把函数调用翻译成底层动作:
- 把参数放到规定位置
- 执行
call 跳到函数
- 函数执行计算
- 把返回值放到规定位置
- 执行
ret 回到调用者
这个"规定位置"就是调用约定的一部分。
什么是调用约定
调用约定,英文叫:
Calling Convention
它规定函数调用时双方怎么配合。
比如:
int r = add(3, 5);
这里:
main 是 caller
add 是 callee
调用约定规定 caller 和 callee 之间的协议,主要包括:
- 参数怎么传
- 返回值怎么传
- 栈怎么维护
- 哪些寄存器可以随便改
- 哪些寄存器必须恢复
- 函数名如何修饰
逆向时最常见的是前四个。
Windows x64 调用约定
你现在学 Windows 逆向,第一阶段重点先掌握:
Windows x64 calling convention
这是 64 位 Windows 上最常见的调用约定。
核心规则
| 参数位置 |
存放位置 |
| 第 1 个整数/指针参数 |
RCX |
| 第 2 个整数/指针参数 |
RDX |
| 第 3 个整数/指针参数 |
R8 |
| 第 4 个整数/指针参数 |
R9 |
| 第 5 个及以后参数 |
放到栈上 |
| 整数/指针返回值 |
RAX |
如果是 32 位 int 参数,则看低 32 位寄存器:
RCX 的低 32 位 = ECX
RDX 的低 32 位 = EDX
R8 的低 32 位 = R8D
R9 的低 32 位 = R9D
RAX 的低 32 位 = EAX
所以你的 add(int a, int b) 里:
add(3, 5)
调用时大概是:
mov ecx, 3
mov edx, 5
call add
进入 add 时:
返回时:
结合你的 add 反汇编看
你 IDA 里真正的 add 是:
mov [rsp+arg_8], edx
mov [rsp+arg_0], ecx
mov eax, [rsp+arg_8]
mov ecx, [rsp+arg_0]
add ecx, eax
mov eax, ecx
retn
对应源码:
int add(int a, int b) {
return a + b;
}
逐行理解
mov [rsp+arg_8], edx
把第二个参数 b 保存到栈上。因为:EDX = 第 2 个 int 参数
mov [rsp+arg_0], ecx
把第一个参数 a 保存到栈上。因为:ECX = 第 1 个 int 参数
mov eax, [rsp+arg_8]
把 b 读到 EAX。
mov ecx, [rsp+arg_0]
把 a 读到 ECX。
add ecx, eax
执行:a + b,结果在 ECX。
mov eax, ecx
把结果放进 EAX。因为调用约定规定:返回值放 RAX/EAX。
retn
返回到调用者。
为什么参数明明在 ECX/EDX,又要存到栈上?
因为你现在看的大概率是 Debug 或低优化版本。
Debug 版本为了方便调试,经常这样做:
- 先把寄存器参数保存到栈上
- 之后用栈上的变量来模拟源码里的
a、b
这样调试器更容易显示:
如果是 Release 优化版本,可能会很短:
lea eax, [rcx+rdx]
ret
或者:
mov eax, ecx
add eax, edx
ret
这时编译器觉得:
所以逆向时要知道:
- Debug 代码更啰嗦,更像源码。
- Release 代码更短,但更难看。
call 指令到底做了什么
你看到:
call add
它不只是"跳过去"。
call 做两件事:
- 把下一条指令的地址压入栈,作为返回地址。
- 跳转到目标函数执行。
比如:
mov ecx, 3
mov edx, 5
call add
mov [rsp+20h], eax
执行 call add 时,CPU 会把 mov [rsp+20h], eax 这条指令的地址压到栈里。这个地址叫返回地址,然后跳到 add。
等 add 执行 ret 时,CPU 会从栈里取出返回地址,跳回去。
所以:
call = 保存返回地址 + 跳到函数
ret = 取出返回地址 + 跳回调用者
栈是什么
栈是一块内存区域,用来保存:
- 返回地址
- 局部变量
- 临时数据
- 部分函数参数
- 保存的寄存器
x64 里栈顶由 RSP 指向。
栈的特点
- 向低地址增长
- 压栈时:
RSP 变小
- 出栈时:
RSP 变大
比如:
push rax
大概等价于:
sub rsp, 8
mov [rsp], rax
而:
pop rax
大概等价于:
mov rax, [rsp]
add rsp, 8
函数调用时栈里有什么
假设:
int r = add(3, 5);
在进入 add 后,栈上至少会有返回地址(因为 call 自动把返回地址压入栈)。
概念图:
高地址
|
| 调用者自己的栈内容
|
| 返回地址 <- call 自动压入
|
低地址 <- RSP 附近
如果函数自己需要局部变量,它可能继续:
sub rsp, 20h
给自己开一块栈空间。
函数序言和函数尾声
很多函数开头会看到:
push rbp
mov rbp, rsp
sub rsp, 30h
这叫函数序言(prologue),作用:
- 保存旧的栈帧基准
- 建立新的栈帧
- 给局部变量分配栈空间
函数结尾可能看到:
add rsp, 30h
pop rbp
ret
这叫函数尾声(epilogue),作用:
但注意:x64 优化代码里不一定用 rbp 当栈帧。Release 代码可能没有典型的 push rbp; mov rbp, rsp,所以不要死记"函数一定长这样"。
Shadow Space 是什么
Windows x64 有一个很重要的规则:调用者在调用函数前,必须在栈上预留 32 字节空间。
这叫 shadow space(也叫 home space)。
为什么是 32 字节?
因为前 4 个参数用寄存器传:RCX、RDX、R8、R9。每个 8 字节,4 个就是 4 * 8 = 32 字节。
这块空间可以让被调用函数把寄存器参数"存回栈上"。
所以你看到 add 里:
mov [rsp+arg_8], edx
mov [rsp+arg_0], ecx
本质上就是把寄存器参数保存到栈上的某些位置。这在 Debug 代码里很常见。
为什么有 shadow space
因为编译器和调试器有时需要参数在内存里也有一份,比如:
- 方便调试器显示参数
- 方便可变参数函数
- 方便某些 ABI 规则
- 方便被调用函数保存寄存器参数
所以 Windows x64 规定 caller 必须提前准备好这块空间。
第 5 个参数怎么办
前 4 个参数走寄存器,第 5 个开始走栈。
例如:
int f(int a, int b, int c, int d, int e, int f) {
return a + b + c + d + e + f;
}
调用时大概:
a -> ECX
b -> EDX
c -> R8D
d -> R9D
e -> 栈上
f -> 栈上
逆向时看到一个函数使用 mov eax, [rsp+28h] 之类的位置,就要意识到:这可能是第 5 个或更后面的参数,具体偏移要结合函数栈布局分析。
返回值怎么返回
整数、指针返回值通常放 RAX。如果是 32 位 int:EAX。
例如:
int add(int a, int b) {
return a + b;
}
返回前一定会把结果放到 EAX:
mov eax, ecx
ret
如果返回指针:
char* get_ptr();
返回值在 RAX。
如果返回浮点数,常见会用 XMM0。初学阶段先重点掌握:整数/指针返回值看 RAX/EAX。
哪些寄存器可以被函数随便改
调用约定还规定寄存器保存规则。
Windows x64 下大概分两类:
- volatile registers(易失寄存器):函数可以随便改,调用者如果想保留,自己保存。
- non-volatile registers(非易失寄存器):函数如果要改,必须改完恢复。
常见易失寄存器
RAX, RCX, RDX, R8, R9, R10, R11
XMM0-XMM5
常见非易失寄存器
RBX, RBP, RDI, RSI, RSP
R12, R13, R14, R15
XMM6-XMM15
这对逆向有什么用?
如果你看到一个函数开头:
push rbx
push rsi
sub rsp, 20h
说明它准备使用 RBX、RSI,但因为这些是非易失寄存器,所以先保存。
函数结束前会恢复:
pop rsi
pop rbx
ret
你就知道:这些 push/pop 是函数保存现场,不一定是业务逻辑。
Caller 和 Callee 谁负责清理栈
在 Windows x64 下,通常由 caller(调用者) 负责维护调用前后的栈平衡。
你经常看到调用者代码类似:
sub rsp, 28h
mov ecx, 3
mov edx, 5
call add
add rsp, 28h
这里:
sub rsp, 28h 是在调用前准备栈空间和对齐。
add rsp, 28h 是在调用后恢复栈。
但具体数字不一定固定,要看函数需要和对齐。
栈对齐是什么
Windows x64 通常要求函数调用时栈保持一定对齐,常见是 16 字节对齐。
为什么要对齐?主要为了:
所以你会看到一些看起来奇怪的 sub rsp, 28h。为什么不是正好 32 字节?因为还要考虑:
call 压入的 8 字节返回地址
shadow space 32 字节
- 16 字节对齐
这类栈调整对初学者看起来很乱。第一阶段你只要记住:
sub rsp, xxx 通常是在给函数调用或局部变量准备栈空间。
add rsp, xxx 通常是在恢复栈。
- 不要把每一个栈调整都当作业务逻辑。
main 为什么不是程序真正入口
你之前看到 IDA 里很复杂,就是因为程序不是直接从 main 开始。
真正启动流程
Windows Loader 加载 exe
↓
进入 CRT start
↓
初始化 C/C++ 运行库
↓
初始化全局变量
↓
准备 argc / argv / envp
↓
调用你的 main
↓
接收 main 返回值
↓
清理运行库
↓
ExitProcess
所以 IDA 里入口点通常是 start,而不是你的 main。
你真正关心的是从 CRT 里找到 main,或者 IDA 已经识别出来的 main_0。
为什么 IDA 里会有 main_0
因为 MSVC 编译的程序里可能有很多启动包装函数。IDA 可能把你的 main 命名为:
不一定总是漂亮地叫 main。
你这次的情况是:
int __fastcall main_0(int argc, const char **argv, const char **envp)
{
return sub_140003508(3, 5, envp);
}
这个 main_0 就是你的 main。
然后 sub_140003508 是一个跳板:
jmp sub_140007220
真正的 add 是 sub_140007220。
为什么会有跳板函数
你看到:
sub_140003508:
jmp sub_140007220
这叫跳板(thunk)。它可能来自:
- 编译器生成的包装
- 链接器优化
- 函数级链接
- 增量链接
- 调试配置
- 运行库/符号处理
初学阶段你不用纠结它为什么生成,只要知道:只有一条 jmp 的函数通常不是核心业务逻辑,它只是把控制流转到另一个函数。
所以遇到这种 jmp sub_xxx,就继续跟过去看目标函数。
函数调用在逆向中怎么看
当你看到:
mov ecx, 3
mov edx, 5
call sub_140007220
你应该立刻翻译成:
sub_140007220(3, 5);
如果后面看到:
mov [rsp+20h], eax
说明:
int result = sub_140007220(3, 5);
因为返回值在 EAX。
如果你看到:
lea rcx, [rsp+40h]
call sub_140001000
你要理解:第一个参数是某个栈上变量的地址,可能对应:
sub_140001000(&local_var);
如果你看到:
mov rcx, rax
call sub_140001000
说明上一个函数的返回值被当成这个函数的第一个参数,可能对应:
p = func1();
func2(p);
如何从汇编还原函数参数
看 call 前面的几条指令。
例如:
mov ecx, 10
mov edx, 20
mov r8d, 30
call sub_140001000
大概率是:
sub_140001000(10, 20, 30);
如果是:
lea rcx, [rsp+50h]
mov edx, 100
call sub_140001000
大概率是:
sub_140001000(&local_buffer, 100);
如果是:
mov rcx, [rbx+20h]
call sub_140001000
大概率是:
sub_140001000(obj->field_20);
或者:
sub_140001000(some_struct.member);
具体要看上下文。
如何判断返回值有没有被使用
函数调用后看 RAX/EAX 是否被用。
例如:
call sub_140001000
test eax, eax
jz short fail
对应:
if (sub_140001000() == 0) {
fail;
}
如果:
call sub_140001000
mov [rsp+30h], eax
对应:
int x = sub_140001000();
如果:
call sub_140001000
mov rcx, rax
call sub_140002000
对应:
sub_140002000(sub_140001000());
或者:
auto p = sub_140001000();
sub_140002000(p);
如果:
call sub_140001000
后面完全不用 RAX,可能是:
sub_140001000();
返回值被忽略。
32 位调用约定简单了解
你现在主要看 x64,但逆向里也会遇到 x86 32 位。32 位 Windows 常见调用约定有:
cdecl
stdcall
fastcall
thiscall
它们和 x64 最大区别是:32 位大量参数通过栈传递。
cdecl
常见于 C 函数。
特点:
例如:
int add(int a, int b);
add(3, 5);
可能是:
push 5
push 3
call add
add esp, 8
这个 add esp, 8 说明调用者清理了 2 个参数,每个 4 字节。
stdcall
常见于 WinAPI 32 位。
特点:
- 参数从右到左压栈
- 返回值在
EAX
- 被调用者清理栈
可能看到:
push 5
push 3
call add
函数结尾:
ret 8
这里 ret 8 表示返回时顺便清理 8 字节参数。
fastcall
32 位 fastcall 常把前几个参数放寄存器,比如 ECX、EDX,然后剩下的放栈。不同编译器略有差异。
thiscall
C++ 成员函数常见。
特点:
在 x64 Windows 下,成员函数也遵守统一 x64 调用约定,所以 this 指针通常在 RCX。
C++ 成员函数和 this 指针
比如:
class User {
public:
int age;
int get_age() {
return age;
}
};
int main() {
User u;
return u.get_age();
}
成员函数 u.get_age() 底层其实类似 get_age(&u)。也就是说,成员函数会隐式多一个参数:this。
Windows x64 下第一个参数在 RCX,所以 RCX = this 指针。
成员函数内部访问字段 return age;,汇编可能是:
mov eax, [rcx]
ret
意思是从 this 指向的对象开头读取 age。
如果字段偏移是 0x10,可能是:
mov eax, [rcx+10h]
虚函数调用为什么是间接 call
普通函数调用:
call add
编译器知道目标地址。
虚函数:
animal->speak();
运行时才知道 animal 实际是 Dog 还是 Cat,所以它通常要:
- 从对象里取 vtable 指针
- 从 vtable 里取函数地址
call 那个函数地址
汇编可能类似:
mov rax, [rcx]
call qword ptr [rax]
这里:
RCX = this
[RCX] = vtable 指针
[RAX] = 第一个虚函数地址
这就是为什么函数指针和虚函数在逆向里会出现:
call rax
call qword ptr [rax+10h]
call qword ptr [rcx]
这种间接调用。
逆向时怎样判断一个函数有几个参数
没有符号时,IDA/Ghidra 不一定准确。你要自己看。在 Windows x64 下,重点看函数入口和调用点。
从调用点看
mov ecx, 1
mov edx, 2
mov r8d, 3
call sub_xxx
大概率至少 3 个参数。
从函数内部看
mov [rsp+8], rcx
mov [rsp+10h], rdx
mov [rsp+18h], r8
说明函数使用了前三个参数。
如果函数内部只用:
mov eax, ecx
那它可能只有 1 个参数。
从栈参数看
如果看到函数使用类似:
mov eax, [rsp+28h]
mov ecx, [rsp+30h]
可能有第 5、第 6 个参数。但栈偏移要结合函数有没有 sub rsp, xxx、push rbx 这类指令。
为什么 IDA 显示 __fastcall
你看到:
__int64 __fastcall sub_140007220(int a1, int a2)
在 x64 IDA 里,__fastcall 基本可以理解成:使用寄存器传参的 x64 调用约定。不要把它和 32 位 fastcall 完全混为一谈。
在 Windows x64 下,大多数普通函数都遵守统一的 x64 调用约定,所以 IDA 经常显示 __fastcall。
为什么 IDA 把返回值显示成 __int64
你的源码可能是 int add(int a, int b),但 IDA 可能显示:
__int64 __fastcall sub_140007220(int a1, int a2)
原因是:
- IDA 没有源码类型信息
- x64 下返回寄存器是
RAX,IDA 有时保守地认为是 64 位
但汇编里如果看到 mov eax, ...,通常说明它实际返回的是 32 位 int。在 x64 里写 EAX 会自动清零 RAX 高 32 位,所以 IDA 可能显示成 __int64 也不奇怪。
逆向时要结合:
来判断真实类型。
看函数调用时的固定套路
每次看到一个 call,按这个顺序分析:
call 的目标是谁?
call 前 RCX/RDX/R8/R9 被设置成什么?
call 前有没有往栈上传更多参数?
call 后 RAX/EAX 有没有被使用?
- 这个函数有没有改变关键内存?
- 这个函数是否只是跳板(thunk)?
- IDA 的函数签名是否可信?
例如:
lea rcx, [rsp+40h]
mov edx, 20h
call sub_140001000
test eax, eax
jz fail
你可以先还原成:
if (sub_140001000(&local_40, 0x20) == 0) {
fail;
}
不一定完全正确,但方向对了。
常见函数形态总结
无参数无返回
void f();
可能:
call f
调用前不明显设置 RCX/RDX/R8/R9。
两个 int 参数,一个 int 返回
int f(int a, int b);
可能:
mov ecx, a
mov edx, b
call f
; eax 是返回值
一个指针参数
int f(char* s);
可能:
lea rcx, [rsp+buffer]
call f
或者:
mov rcx, rax
call f
C++ 成员函数
obj->method(x);
可能:
mov rcx, obj
mov edx, x
call method
其中:
RCX = this
EDX = 第一个显式参数 x
注意:this 占了第一个参数位置。
虚函数
obj->virtual_method();
可能:
mov rcx, obj
mov rax, [rcx]
call qword ptr [rax+offset]
函数指针
fp(a, b);
可能:
mov ecx, a
mov edx, b
call rax
或者:
call qword ptr [rsp+30h]
调用约定对 CTF 逆向的价值
学调用约定后,你做题时能更快判断:
- 哪个函数是校验函数
- 用户输入作为第几个参数传入
strcmp/memcmp 返回值怎么判断
- 加密函数的
input/output/key 分别在哪里
- 函数返回 0 是成功还是失败
- 对象方法里的
this 是谁
- 虚函数调用真正跳到哪里
比如你看到:
lea rcx, [rsp+input]
lea rdx, [rsp+encoded]
call check
test eax, eax
jz fail
你就能还原:
if (check(input, encoded) == 0) {
fail;
}
这就是逆向的核心能力。
你现在这个 add 实验应该怎么做
你当前 add 实验,建议你在 notes 里记录这些:
main_0 是我的 main。
sub_140003508 是跳板函数。
sub_140007220 是真正的 add。
main_0 调用 add 时传入 3 和 5。
- Windows x64 下第一个 int 参数在
ECX,第二个 int 参数在 EDX。
add 返回值在 EAX。
- Debug 版本中
add 把 ECX/EDX 保存到了栈上,所以看起来比源码复杂。
然后在 x64dbg 里做:
- 在
sub_140007220 下断点。
- 运行到断点。
- 观察
ECX 是否为 3。
- 观察
EDX 是否为 5。
- 单步执行
add ecx, eax。
- 观察
EAX 最终是否为 8。
- 执行
ret 后,观察返回到 main_0 后 EAX 是否仍然是 8。
如果你能做到这一步,说明你已经真正理解了:
这就是函数与调用约定的第一关。
最短总结
函数调用约定就是 caller 和 callee 的协议。
Windows x64 最重要规则
| 项目 |
规则 |
| 参数 1 |
RCX |
| 参数 2 |
RDX |
| 参数 3 |
R8 |
| 参数 4 |
R9 |
| 更多参数 |
栈上 |
| 返回值 |
RAX |
| 调用者预留 |
32 字节 shadow space |
call |
压入返回地址 |
ret |
跳回返回地址 |
逆向时看到
mov ecx, 3
mov edx, 5
call sub_xxx
你应该翻译成:
sub_xxx(3, 5);
看到:
call sub_xxx
test eax, eax
jz fail
你应该翻译成:
if (sub_xxx() == 0) {
fail;
}
这就是从汇编读回 C/C++ 逻辑的基础
注:这篇文章是我的原创文章,同时也发布于CSDN等其他平台。