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 栈帧分析技巧——实战经验

说了这么多理论,咱们聊聊实战中怎么分析栈帧。我总结了几条经验:

  1. 先看函数开头push ebp; mov ebp, esp 是标准序言。如果没看到,可能是编译器优化了(FPO,Frame Pointer Omission)。
  2. 找返回地址:栈帧里,返回地址在 ebp+4(32位)或 rsp+8(x64,无 ebp 时)。找到返回地址,就能确定函数调用的来源。
  3. 识别局部变量sub esp, XX 分配的空间就是局部变量区。通过 [ebp-XX][rsp+XX] 的访问模式,可以推断变量类型和用途。
  4. 注意栈对齐:x64 下,如果看到 and rsp, -16sub 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 核心知识体系

下面这张图总结了本章的核心逻辑,帮你快速建立知识框架:

函数调用约定与栈帧知识体系 调用约定 参数放哪? 谁清理栈? 返回值在哪? cdecl 调用者清理 stdcall 被调用者清理 fastcall 寄存器传参 Win x64 影子空间 Linux x64 6个寄存器 栈帧结构(Stack Frame) 序言(Prologue) 局部变量区 参数区 尾声(Epilogue) 分析技巧:看序言 → 找返回地址 → 识别局部变量 → 注意栈对齐

8.10 小结

调用约定和栈帧是逆向工程的「基础设施」。你不需要背下所有细节,但一定要知道怎么识别它们。我的建议是:

  • 遇到 32 位程序,先看函数结尾是 retn 还是 retn XX,快速区分 cdecl 和 stdcall
  • 遇到 64 位程序,先看有没有影子空间(Windows)或 6 个寄存器传参(Linux)
  • 分析栈帧时,别只看工具给出的结果,手动跟踪一下栈指针的变化

记住一句话:调用约定是「契约」,栈帧是「现场」。理解了契约,你就能读懂现场;读懂了现场,你就能还原整个函数调用的过程。

公众号:蓝海资料掘金营,微信 deep3321