8. 函数调用约定与栈帧:cdecl、stdcall、fastcall、x64调用约定、栈帧结构与分析技巧
函数调用约定,说白了就是「调用者」和「被调用者」之间的一纸契约。它规定了参数怎么传、谁来清理栈、返回值放哪。我刚开始逆向的时候,经常被各种调用约定搞晕——明明看着像函数调用,结果栈指针乱跳,分析半天才发现是调用约定搞错了。
今天咱们就把这块硬骨头啃下来。我会结合自己踩过的坑,把 cdecl、stdcall、fastcall 和 x64 调用约定讲透,再聊聊栈帧结构怎么分析。
8.1 为什么需要调用约定?
你想想看,如果每个编译器都按自己的方式传参,那不同模块编译出来的代码就没法互相调用了。调用约定就是为了解决这个「沟通问题」。
核心要回答三个问题:
- 参数放哪? 栈上?寄存器里?
- 谁清理栈? 调用者?被调用者?
- 返回值怎么传? eax/rax?还是通过栈?
不同的调用约定,答案不一样。咱们一个一个看。
8.2 cdecl——C语言默认约定
cdecl 是 C 语言默认的调用约定,也是我最早接触的。它的规则很简单:
- 参数从右向左压栈
- 调用者负责清理栈
- 返回值放在 eax
举个例子,调用 func(1, 2, 3) 时,汇编大概是这样的:
push 3 ; 最后一个参数先入栈
push 2
push 1 ; 第一个参数最后入栈
call func
add esp, 12 ; 调用者清理栈(3个参数,每个4字节)
这里有个关键点:add esp, 12 是调用者做的。为什么?因为 cdecl 支持可变参数(比如 printf),只有调用者才知道自己传了多少个参数。
重要特征: 在 cdecl 中,函数返回后,栈指针 esp 会恢复到调用前的值。如果看到 add esp, XX 紧跟在 call 后面,基本可以判断是 cdecl。
我在逆向一个老旧 DLL 时遇到过坑:函数明明是 cdecl,但反汇编里没看到 add esp。后来发现是编译器优化了——参数少于 4 个时,有些编译器会用寄存器传参。嗯,这里要注意,不要死板地只看清理方式。
8.3 stdcall——Windows API 的标配
stdcall 是 Windows API 的标准调用约定。它的规则和 cdecl 很像,但有一个关键区别:
- 参数从右向左压栈
- 被调用者负责清理栈
- 返回值放在 eax
看代码:
push 3
push 2
push 1
call func ; func 内部会执行 ret 12,而不是 ret
在函数内部,你会看到这样的结尾:
ret 12 ; 返回并清理 12 字节(3个参数)
为什么 Windows 要用 stdcall?因为节省代码空间。每个调用点都省掉了 add esp, XX,对于频繁调用的系统 API,积少成多。
分析技巧: 在 IDA 或 x64dbg 中,如果看到函数结尾是 retn XX(XX 不为 0),那基本就是 stdcall。cdecl 的结尾是 retn(不带数字)。
我曾经逆向一个恶意软件时,发现它大量使用 stdcall 调用系统 API。但有个函数很奇怪——它用 stdcall 却传了可变参数。结果栈被搞乱了,程序直接崩溃。这其实是个反调试技巧,故意用错调用约定来干扰分析。
8.4 fastcall——用寄存器加速
fastcall 的设计初衷是「快」。它用寄存器传前两个参数,减少栈操作。
- 前两个参数(32位)用 ecx 和 edx 传递
- 剩余参数从右向左压栈
- 被调用者清理栈
调用 func(1, 2, 3) 时:
mov ecx, 1 ; 第一个参数放 ecx
mov edx, 2 ; 第二个参数放 edx
push 3 ; 第三个参数压栈
call func ; func 内部 ret 4(只清理栈上的1个参数)
不同编译器对 fastcall 的实现有细微差别。比如,Borland 编译器用 eax、edx、ecx 传三个参数,而 MSVC 只用 ecx 和 edx。逆向时要注意区分。
注意: fastcall 在 x64 下已经「进化」成了新的调用约定。x64 下不再叫 fastcall,而是有自己的一套规则,咱们后面讲。
8.5 x64 调用约定——统一与规范
到了 64 位时代,调用约定变得统一了。Windows 和 Linux 各有一套标准,但都比 32 位时代清晰很多。
Windows x64(Microsoft x64 Calling Convention)
- 前 4 个参数用 rcx、rdx、r8、r9 传递
- 剩余参数从右向左压栈
- 调用者负责清理栈(但栈上要预留「影子空间」)
- 返回值放在 rax
影子空间(Shadow Space)是 Windows x64 的特色。调用者必须在栈上预留 32 字节(4个参数的位置),即使参数全用寄存器传。为什么?因为被调用者可能想把这些参数存到栈上,或者自己又调用了别的函数。
sub rsp, 40 ; 预留影子空间(32字节)+ 对齐(8字节)
mov rcx, 1 ; 参数1
mov rdx, 2 ; 参数2
mov r8, 3 ; 参数3
mov r9, 4 ; 参数4
call func
add rsp, 40 ; 恢复栈
Linux x64(System V AMD64 ABI)
- 前 6 个参数用 rdi、rsi、rdx、rcx、r8、r9 传递
- 剩余参数从右向左压栈
- 调用者负责清理栈
- 返回值放在 rax
Linux 的调用约定更「大方」,给了 6 个寄存器。而且没有影子空间的概念,栈上只放多余的参数。
关键区别: Windows x64 有影子空间,Linux x64 没有。如果你在逆向一个跨平台程序,看到函数开头 sub rsp, XX 后紧跟着 mov [rsp+XX], rcx,那大概率是 Windows 的代码。
8.6 栈帧结构——函数调用的「骨架」
栈帧(Stack Frame)是函数调用时在栈上分配的一块区域,用来存局部变量、参数、返回地址等。理解栈帧结构,是逆向分析的基本功。
一个典型的栈帧长这样(32位,cdecl):
高地址
+------------------+
| 调用者的局部变量 |
+------------------+
| 参数3 | <- 调用者压栈
+------------------+
| 参数2 |
+------------------+
| 参数1 |
+------------------+
| 返回地址 | <- call 指令压入
+------------------+
| 保存的 ebp | <- push ebp
+------------------+
| 局部变量1 | <- sub esp, XX
+------------------+
| 局部变量2 |
+------------------+
| ... |
+------------------+ <- esp 指向这里
低地址
函数开头通常有「序言」(Prologue):
push ebp ; 保存旧的 ebp
mov ebp, esp ; 设置新的 ebp
sub esp, 20h ; 分配局部变量空间
函数结尾有「尾声」(Epilogue):
mov esp, ebp ; 恢复 esp
pop ebp ; 恢复 ebp
ret ; 返回
在 x64 下,栈帧结构类似,但多了影子空间和对齐要求。Windows x64 要求栈在 call 指令执行前必须 16 字节对齐。
8.7 栈帧分析技巧——实战经验
说了这么多理论,咱们聊聊实战中怎么分析栈帧。我总结了几条经验:
- 先看函数开头:
push ebp; mov ebp, esp是标准序言。如果没看到,可能是编译器优化了(FPO,Frame Pointer Omission)。 - 找返回地址:栈帧里,返回地址在 ebp+4(32位)或 rsp+8(x64,无 ebp 时)。找到返回地址,就能确定函数调用的来源。
- 识别局部变量:
sub esp, XX分配的空间就是局部变量区。通过[ebp-XX]或[rsp+XX]的访问模式,可以推断变量类型和用途。 - 注意栈对齐:x64 下,如果看到
and rsp, -16或sub rsp, XX后栈指针不是 16 的倍数,那肯定有对齐操作。
我的习惯: 在 x64dbg 中分析栈帧时,我会先看栈窗口,找到返回地址(通常高亮显示)。然后往上翻,看参数和局部变量的布局。如果函数有多个调用点,我会对比不同调用点的栈布局,确认调用约定是否一致。
我曾经逆向一个加壳程序,它的函数序言被篡改了——push ebp 被替换成了 push 0,导致栈帧分析完全失效。后来我通过跟踪栈指针的变化,手动重建了栈帧结构。嗯,这种时候,理解栈帧的原理比依赖工具更重要。
8.8 调用约定识别速查表
| 调用约定 | 参数传递 | 栈清理者 | 典型结尾 | 常见场景 |
|---|---|---|---|---|
| cdecl | 栈(从右向左) | 调用者 | retn |
C 语言默认、printf |
| stdcall | 栈(从右向左) | 被调用者 | retn XX |
Windows API |
| fastcall | ecx/edx + 栈 | 被调用者 | retn XX |
编译器优化、部分系统函数 |
| Windows x64 | rcx/rdx/r8/r9 + 栈 | 调用者 | retn |
Windows 64位程序 |
| Linux x64 | rdi/rsi/rdx/rcx/r8/r9 + 栈 | 调用者 | retn |
Linux 64位程序 |
8.9 核心知识体系
下面这张图总结了本章的核心逻辑,帮你快速建立知识框架:
8.10 小结
调用约定和栈帧是逆向工程的「基础设施」。你不需要背下所有细节,但一定要知道怎么识别它们。我的建议是:
- 遇到 32 位程序,先看函数结尾是
retn还是retn XX,快速区分 cdecl 和 stdcall - 遇到 64 位程序,先看有没有影子空间(Windows)或 6 个寄存器传参(Linux)
- 分析栈帧时,别只看工具给出的结果,手动跟踪一下栈指针的变化
记住一句话:调用约定是「契约」,栈帧是「现场」。理解了契约,你就能读懂现场;读懂了现场,你就能还原整个函数调用的过程。