一、函数调用约定:到底谁说了算?
各位同学,今天我们来聊一个看似基础、但实际坑特别多的话题——函数调用约定。说白了,就是函数调用时,参数怎么传、栈谁来清理、返回值放哪。这些规则如果搞不清楚,写出来的代码可能跑着跑着就崩了。
我个人习惯把调用约定理解为「函数之间的合同」。调用方和被调用方必须遵守同一份合同,否则程序就会出乱子。我在项目中遇到过好几次,就是因为不同模块用了不同的调用约定,结果栈指针乱飞,查了一整天才找到原因。
1.1 三种主流调用约定概览
Windows平台上最常见的三种调用约定是:__cdecl、__stdcall 和 __fastcall。它们的主要区别在于:参数传递顺序、参数存放位置、以及最重要的——栈清理由谁负责。
先看一个对比表格,心里有个底:
| 调用约定 | 参数传递顺序 | 参数存放位置 | 栈清理责任方 | 典型用途 |
|---|---|---|---|---|
__cdecl |
从右到左压栈 | 栈 | 调用方(Caller) | C/C++ 默认约定,可变参数函数 |
__stdcall |
从右到左压栈 | 栈 | 被调用方(Callee) | Windows API 函数 |
__fastcall |
前两个参数放寄存器(ECX、EDX),其余从右到左压栈 | 寄存器 + 栈 | 被调用方(Callee) | 对性能敏感的短函数 |
嗯,这里要注意:__fastcall 的具体实现其实因编译器而异。我当年在移植代码时,就吃过这个亏——GCC 和 MSVC 对 fastcall 的处理不完全一样。
1.2 __cdecl:C语言的默认选择
__cdecl 是 C/C++ 的默认调用约定。如果你不显式指定,编译器就会用这个。它的特点是:参数从右向左压栈,调用方负责清理栈。
为什么调用方清理?因为只有调用方才知道自己传了多少个参数。你想想看,像 printf 这种可变参数函数,被调用方根本不知道你传了几个参数,它怎么清理?所以必须由调用方来收拾残局。
看个例子:
// 默认就是 __cdecl
int add(int a, int b) {
return a + b;
}
// 显式指定也可以
int __cdecl add_explicit(int a, int b) {
return a + b;
}
int main() {
int result = add(3, 5);
// 调用方 main() 负责清理栈上的参数
return 0;
}
对应的汇编伪代码大致是这样的:
; 调用方视角
push 5 ; 先压入 b(右参数)
push 3 ; 再压入 a(左参数)
call add
add esp, 8 ; 调用方清理栈:弹出两个 int(共8字节)
注意最后一行 add esp, 8,这就是调用方在清理栈。如果忘了这一步,栈指针就会偏移,后续函数调用全乱套。
1.3 __stdcall:Windows API 的标配
__stdcall 是 Windows API 的标准调用约定。参数同样从右向左压栈,但栈清理由被调用方负责。
为什么 Windows 要用 __stdcall?说白了,是为了节省代码体积。你想啊,每个调用点都要生成清理栈的指令,如果函数被调用 1000 次,那就要生成 1000 次 add esp, N。但如果由被调用方清理,只需要在函数返回时做一次清理就够了。
// Windows API 风格的函数
int __stdcall win_add(int a, int b) {
return a + b;
}
int main() {
int result = win_add(3, 5);
// 调用方不需要清理栈
return 0;
}
汇编伪代码:
; 调用方视角
push 5
push 3
call win_add
; 注意:这里没有 add esp, 8!因为被调用方会清理
; 被调用方视角(win_add 内部)
push ebp
mov ebp, esp
; ... 函数体 ...
mov esp, ebp
pop ebp
ret 8 ; ret 8 表示返回后自动弹出 8 字节参数
关键就在 ret 8 这条指令。普通的 ret 只返回,而 ret 8 会在返回的同时把栈指针加 8,相当于把两个参数弹出去了。这就是被调用方清理栈的实现方式。
1.4 __fastcall:用寄存器加速
__fastcall 的初衷是性能优化。它把前两个参数放到寄存器(ECX 和 EDX)中传递,剩下的参数才走栈。寄存器访问比内存快得多,所以短小精悍的函数用这个约定能明显提速。
int __fastcall fast_add(int a, int b, int c) {
// a 在 ECX,b 在 EDX,c 在栈上
return a + b + c;
}
int main() {
int result = fast_add(1, 2, 3);
return 0;
}
汇编伪代码:
; 调用方视角
push 3 ; 第三个参数压栈
mov ecx, 1 ; 第一个参数放 ECX
mov edx, 2 ; 第二个参数放 EDX
call fast_add
; 被调用方清理栈(如果有栈参数的话)
; 被调用方视角(fast_add 内部)
; ECX = a, EDX = b, 栈上有 c
; ... 函数体 ...
ret 4 ; 只清理栈上的一个参数(c)
这里有个细节:__fastcall 的栈清理责任方是被调用方,和 __stdcall 一样。但只清理栈上的参数,寄存器里的参数不需要清理。
1.5 核心对比:参数传递与栈清理责任
为了让你看得更清楚,我把最核心的区别单独拎出来:
- 参数传递顺序: 三者都是从右到左。这是 C 语言的传统,好处是支持可变参数——最左边的参数(如 printf 的格式字符串)总是在栈顶,方便被调用方定位。
- 参数存放位置: __cdecl 和 __stdcall 全走栈。__fastcall 前两个走寄存器,其余走栈。
- 栈清理责任: 这是最关键的区分点。
- __cdecl:调用方清理。适合可变参数函数。
- __stdcall:被调用方清理。适合固定参数函数,代码更紧凑。
- __fastcall:被调用方清理(只清理栈上的参数)。
为什么会这样设计?其实背后是权衡:调用方清理更灵活(支持可变参数),但代码体积大;被调用方清理更高效(代码紧凑),但不支持可变参数。
1.6 知识体系图
下面这张图帮你理清三种调用约定的核心逻辑:
1.7 实际项目中的选择建议
说了这么多理论,到底该怎么选?我个人的经验是:
- 普通应用程序: 用默认的 __cdecl 就行,省心。除非你要调用 Windows API,那必须用 __stdcall。
- 库或驱动开发: 我建议用 __stdcall。因为库函数可能被各种语言调用,__stdcall 的栈清理规则更明确,不容易出错。
- 性能热点函数: 如果函数体很小(比如几行代码),且被频繁调用,可以考虑 __fastcall。但一定要实测,不要凭感觉优化。
- 跨平台代码: 尽量别用 __fastcall,因为不同编译器的实现有差异。我当年从 MSVC 移植到 GCC 时,就因为这个改了不少代码。
好了,这一章的内容就到这里。下一章我们会深入函数栈帧的布局,看看局部变量、返回地址、参数到底是怎么在栈上排列的。到时候你会发现,理解了调用约定,栈帧分析就水到渠成了。
公众号:蓝海资料掘金营,微信 deep3321