一、函数调用约定:到底谁说了算?

各位同学,今天我们来聊一个看似基础、但实际坑特别多的话题——函数调用约定。说白了,就是函数调用时,参数怎么传、栈谁来清理、返回值放哪。这些规则如果搞不清楚,写出来的代码可能跑着跑着就崩了。

我个人习惯把调用约定理解为「函数之间的合同」。调用方和被调用方必须遵守同一份合同,否则程序就会出乱子。我在项目中遇到过好几次,就是因为不同模块用了不同的调用约定,结果栈指针乱飞,查了一整天才找到原因。

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,这就是调用方在清理栈。如果忘了这一步,栈指针就会偏移,后续函数调用全乱套。

⚠️ 我曾经踩过的坑: 在混合编程时,如果某个库是用 __stdcall 编译的,而你的代码默认用 __cdecl 去调用,那么栈清理就会重复或遗漏。结果就是——程序在 Debug 模式下跑得好好的,Release 模式下一调用就崩。因为优化开关改变了寄存器分配,把隐藏的问题暴露出来了。

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,相当于把两个参数弹出去了。这就是被调用方清理栈的实现方式。

💡 个人经验: 在写驱动或底层库时,我习惯用 __stdcall。因为这类代码通常被频繁调用,节省几条指令也是好的。但要注意,可变参数函数不能用 __stdcall,因为被调用方不知道参数个数,没法正确清理。

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 一样。但只清理栈上的参数,寄存器里的参数不需要清理。

🔧 避坑指南: 我曾经在优化一个音频编解码函数时,把内部循环调用的一个小函数改成了 __fastcall。结果性能提升了约 15%,因为减少了栈操作。但要注意,如果函数内部又调用了其他函数,ECX 和 EDX 可能会被覆盖,所以需要保存和恢复这些寄存器。这个开销有时候会抵消掉寄存器传参的好处。

1.5 核心对比:参数传递与栈清理责任

为了让你看得更清楚,我把最核心的区别单独拎出来:

  • 参数传递顺序: 三者都是从右到左。这是 C 语言的传统,好处是支持可变参数——最左边的参数(如 printf 的格式字符串)总是在栈顶,方便被调用方定位。
  • 参数存放位置: __cdecl 和 __stdcall 全走栈。__fastcall 前两个走寄存器,其余走栈。
  • 栈清理责任: 这是最关键的区分点。
    • __cdecl:调用方清理。适合可变参数函数。
    • __stdcall:被调用方清理。适合固定参数函数,代码更紧凑。
    • __fastcall:被调用方清理(只清理栈上的参数)。

为什么会这样设计?其实背后是权衡:调用方清理更灵活(支持可变参数),但代码体积大;被调用方清理更高效(代码紧凑),但不支持可变参数。

1.6 知识体系图

下面这张图帮你理清三种调用约定的核心逻辑:

函数调用约定核心对比 __cdecl __stdcall __fastcall 参数:全部栈 参数:全部栈 参数:寄存器(前2)+栈 清理方:调用方 清理方:被调用方 清理方:被调用方 适用:可变参数函数 适用:Windows API 适用:性能敏感短函数 核心区别:谁清理栈?__cdecl 调用方清理,__stdcall/__fastcall 被调用方清理

1.7 实际项目中的选择建议

说了这么多理论,到底该怎么选?我个人的经验是:

  • 普通应用程序: 用默认的 __cdecl 就行,省心。除非你要调用 Windows API,那必须用 __stdcall。
  • 库或驱动开发: 我建议用 __stdcall。因为库函数可能被各种语言调用,__stdcall 的栈清理规则更明确,不容易出错。
  • 性能热点函数: 如果函数体很小(比如几行代码),且被频繁调用,可以考虑 __fastcall。但一定要实测,不要凭感觉优化。
  • 跨平台代码: 尽量别用 __fastcall,因为不同编译器的实现有差异。我当年从 MSVC 移植到 GCC 时,就因为这个改了不少代码。
📌 一句话总结: 调用约定就是函数之间的「握手协议」。参数怎么传、栈谁来清,必须双方约定一致。否则,轻则栈不平衡,重则程序崩溃。记住:__cdecl 调用方清,__stdcall 被调用方清,__fastcall 寄存器加速传参。

好了,这一章的内容就到这里。下一章我们会深入函数栈帧的布局,看看局部变量、返回地址、参数到底是怎么在栈上排列的。到时候你会发现,理解了调用约定,栈帧分析就水到渠成了。


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