吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 4|回复: 0
收起左侧

[学习记录] 【逆向基础】C/C++反汇编之函数调用约定

[复制链接]
Eternall 发表于 2026-8-25 09:35

函数调用约定

函数调用约定就是一套规则,规定:

  • 函数参数放哪里?
  • 返回值放哪里?
  • 谁负责清理栈?
  • 哪些寄存器调用前后必须保持不变?
  • 函数调用时栈怎么摆?

你逆向时看到一个 call,想还原出"它调用了什么函数、传了什么参数、返回了什么",就必须懂调用约定。


从函数调用本身讲

你写 C/C++:

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

int main() {
    int r = add(3, 5);
    return r;
}

源码里看起来很简单:

  • main 调用 add
  • 传入 35
  • add 返回 8
  • main 返回 8

但 CPU 不认识"函数""参数""返回值"这些高级概念。

CPU 只认识:

  • 寄存器
  • 内存
  • 跳转
  • call
  • ret

所以编译器必须把函数调用翻译成底层动作:

  1. 把参数放到规定位置
  2. 执行 call 跳到函数
  3. 函数执行计算
  4. 把返回值放到规定位置
  5. 执行 ret 回到调用者

这个"规定位置"就是调用约定的一部分。


什么是调用约定

调用约定,英文叫:

Calling Convention

它规定函数调用时双方怎么配合。

  • 调用者caller
  • 被调用者callee

比如:

int r = add(3, 5);

这里:

  • maincaller
  • addcallee

调用约定规定 callercallee 之间的协议,主要包括:

  • 参数怎么传
  • 返回值怎么传
  • 栈怎么维护
  • 哪些寄存器可以随便改
  • 哪些寄存器必须恢复
  • 函数名如何修饰

逆向时最常见的是前四个。


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

  • ECX = 3
  • EDX = 5

返回时:

  • EAX = 8

结合你的 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 版本为了方便调试,经常这样做:

  1. 先把寄存器参数保存到栈上
  2. 之后用栈上的变量来模拟源码里的 ab

这样调试器更容易显示:

  • a = 3
  • b = 5

如果是 Release 优化版本,可能会很短:

lea eax, [rcx+rdx]
ret

或者:

mov eax, ecx
add eax, edx
ret

这时编译器觉得:

  • 没必要把参数存到栈上
  • 直接用寄存器算就行

所以逆向时要知道:

  • Debug 代码更啰嗦,更像源码。
  • Release 代码更短,但更难看。

call 指令到底做了什么

你看到:

call add

它不只是"跳过去"。

call 做两件事:

  1. 把下一条指令的地址压入栈,作为返回地址
  2. 跳转到目标函数执行。

比如:

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),作用:

  • 释放局部变量栈空间
  • 恢复旧 rbp
  • 返回调用者

但注意:x64 优化代码里不一定用 rbp 当栈帧。Release 代码可能没有典型的 push rbp; mov rbp, rsp,所以不要死记"函数一定长这样"。


Shadow Space 是什么

Windows x64 有一个很重要的规则:调用者在调用函数前,必须在栈上预留 32 字节空间。

这叫 shadow space(也叫 home space)。

为什么是 32 字节?

因为前 4 个参数用寄存器传:RCXRDXR8R9。每个 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 位 intEAX

例如:

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

说明它准备使用 RBXRSI,但因为这些是非易失寄存器,所以先保存。

函数结束前会恢复:

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 字节对齐

为什么要对齐?主要为了:

  • 性能
  • SSE/AVX 指令要求
  • ABI 规范

所以你会看到一些看起来奇怪的 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
  • main_0
  • sub_xxxxx

不一定总是漂亮地叫 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

真正的 addsub_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 函数。

特点:

  • 参数从右到左压栈
  • 返回值在 EAX
  • 调用者清理栈

例如:

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 常把前几个参数放寄存器,比如 ECXEDX,然后剩下的放栈。不同编译器略有差异。

thiscall

C++ 成员函数常见。

特点:

  • this 指针通常放 ECX
  • 其他参数走栈

在 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,所以它通常要:

  1. 从对象里取 vtable 指针
  2. 从 vtable 里取函数地址
  3. 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, xxxpush 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,按这个顺序分析:

  1. call 的目标是谁?
  2. callRCX/RDX/R8/R9 被设置成什么?
  3. call 前有没有往栈上传更多参数?
  4. callRAX/EAX 有没有被使用?
  5. 这个函数有没有改变关键内存?
  6. 这个函数是否只是跳板(thunk)?
  7. 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 里记录这些:

  1. main_0 是我的 main
  2. sub_140003508 是跳板函数。
  3. sub_140007220 是真正的 add
  4. main_0 调用 add 时传入 35
  5. Windows x64 下第一个 int 参数在 ECX,第二个 int 参数在 EDX
  6. add 返回值在 EAX
  7. Debug 版本中 addECX/EDX 保存到了栈上,所以看起来比源码复杂。

然后在 x64dbg 里做:

  1. sub_140007220 下断点。
  2. 运行到断点。
  3. 观察 ECX 是否为 3
  4. 观察 EDX 是否为 5
  5. 单步执行 add ecx, eax
  6. 观察 EAX 最终是否为 8
  7. 执行 ret 后,观察返回到 main_0EAX 是否仍然是 8

如果你能做到这一步,说明你已经真正理解了:

  • 参数传递
  • 返回值
  • call
  • ret
  • 栈保存参数

这就是函数与调用约定的第一关。


最短总结

函数调用约定就是 callercallee 的协议。

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等其他平台。

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-8-26 10:59

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表