在 C/C++ 编程中,“函数调用约定” 是一套 “隐形规则”—— 它规定了函数调用时参数怎么压栈、谁来清理栈、函数名怎么修饰。如果不理解这套规则,可能会遇到 “编译通过但链接报错”“程序崩溃” 等奇怪问题,尤其是在调用 Windows API 或第三方库时。
本文聚焦最常用的两种调用约定:__cdecl 和 __stdcall,用通俗的语言和代码示例讲清楚它们的区别和用法。
一、先搞懂:什么是 “函数调用约定”?
简单来说,函数调用的过程就像 “寄快递”:
- 调用者(比如
main函数)是 “寄件人”,要把 “包裹”(参数)按规则交给快递员(栈); - 被调用者(比如
add函数)是 “收件人”,取到包裹后处理; - 最后要有人负责 “清理快递盒”(栈空间)—— 这就是调用约定要解决的核心问题。
所有调用约定都会明确 3 件事:
- 参数压栈顺序:先压第一个参数,还是最后一个参数?
- 栈清理责任:调用者清理栈,还是被调用者清理栈?
- 函数名修饰规则:编译器给函数起的 “内部名字” 是什么样的(影响链接)?
二、__cdecl:C 语言默认的调用约定
__cdecl 是 “C Declaration” 的缩写,是 C/C++ 程序的默认调用约定(比如 printf 就用它)。
1. __cdecl 的核心规则
| 规则 | 具体说明 |
|---|---|
| 参数压栈顺序 | 从右到左(比如调用 add(1,2),先压 2,再压 1) |
| 栈清理责任 | 调用者清理(寄件人自己收拾快递盒) |
| 函数名修饰(VC 编译器) | 函数名前加下划线 _(比如 add 会变成 _add) |
2. 为什么是 “调用者清理栈”?
最大的好处是支持可变参数函数(比如 printf(const char* format, ...))。因为被调用者(比如 printf)不知道调用者会传几个参数(可能是 printf("%d", a),也可能是 printf("%d,%s", a, b)),无法确定要清理多少栈空间,所以只能让调用者(知道自己传了几个参数)来清理。
3. 代码示例 + 汇编解析(直观理解)
我们用一个简单的加法函数,看 __cdecl 是怎么工作的(基于 32 位环境,栈单位为 4 字节)。
C 代码:
#include <stdio.h>
// 显式声明 __cdecl 调用约定(默认也可以不写)
int __cdecl add_cdecl(int a, int b) {
return a + b; // 返回值存在 EAX 寄存器
}
int main() {
int result = add_cdecl(10, 20); // 调用函数
printf("结果:%d\n", result);
return 0;
}
对应的关键汇编代码(简化版):
; ------------------- main 函数(调用者)的操作 -------------------
push 20 ; 1. 先压右参数 b=20(栈地址:低地址→高地址)
push 10 ; 2. 再压左参数 a=10(此时栈中:[10, 20])
call add_cdecl ; 3. 调用函数:把下一条指令地址压栈,跳转到 add_cdecl
add esp, 8 ; 4. 调用者清理栈!esp+8 表示清除 2 个参数(每个 4 字节)
; ------------------- add_cdecl 函数(被调用者)的操作 -------------------
add_cdecl:
mov eax, [esp+4] ; 从栈中取 a=10(esp+4 是第一个参数的位置)
mov edx, [esp+8] ; 从栈中取 b=20(esp+8 是第二个参数的位置)
add eax, edx ; 计算 a+b,结果存在 eax 中
ret ; 5. 仅返回:把栈中保存的指令地址弹出,跳回 main
关键观察:
- 压栈顺序:
20先压,10后压(右→左); - 栈清理:
add esp, 8是在main中执行的(调用者清理); - 返回值:通过
eax寄存器带回main。
三、__stdcall:Windows API 常用的调用约定
__stdcall 是 “Standard Call” 的缩写,不是默认调用约定,但在 Windows API 中被广泛使用(比如 MessageBox、CreateWindow 等)。
1. __stdcall 的核心规则
| 规则 | 具体说明 |
|---|---|
| 参数压栈顺序 | 和 __cdecl 一样:从右到左 |
| 栈清理责任 | 被调用者清理(收件人收拾快递盒) |
| 函数名修饰(VC 编译器) | 函数名前加 _,后加 @+ 参数总字节数(比如 add 会变成 _add@8,8 是 2 个 int 的字节数) |
2. 为什么是 “被调用者清理栈”?
好处是效率更高:调用者不需要写清理栈的代码,尤其在多次调用同一个函数时,能减少代码量。但缺点也明显:不支持可变参数—— 因为被调用者必须知道参数的总字节数,才能确定要清理多少栈空间(比如 ret 8 表示清理 8 字节)。
3. 代码示例 + 汇编解析
还是用加法函数,看 __stdcall 和 __cdecl 的区别。
C 代码:
#include <stdio.h>
// 显式声明 __stdcall 调用约定
int __stdcall add_stdcall(int a, int b) {
return a + b;
}
int main() {
int result = add_stdcall(10, 20); // 调用函数
printf("结果:%d\n", result);
return 0;
}
对应的关键汇编代码(简化版):
; ------------------- main 函数(调用者)的操作 -------------------
push 20 ; 1. 先压右参数 b=20(和 __cdecl 一样)
push 10 ; 2. 再压左参数 a=10
call add_stdcall ; 3. 调用函数:压指令地址,跳转
; 4. 没有栈清理代码!因为被调用者会清理
; ------------------- add_stdcall 函数(被调用者)的操作 -------------------
add_stdcall:
mov eax, [esp+4] ; 取 a=10(和 __cdecl 一样)
mov edx, [esp+8] ; 取 b=20
add eax, edx ; 计算 a+b
ret 8 ; 5. 返回 + 清理栈!ret 8 表示:弹出指令地址后,esp+8(清理 8 字节)
关键观察:
- 压栈顺序和
__cdecl完全相同; - 栈清理:
ret 8是在add_stdcall中执行的(被调用者清理); - 函数名修饰:
add_stdcall会变成_add_stdcall@8(比__cdecl多了@8)。
四、__cdecl vs __stdcall:核心区别总结
| 对比维度 | __cdecl(C 默认) | __stdcall(Windows API 常用) |
|---|---|---|
| 栈清理责任 | 调用者(寄件人) | 被调用者(收件人) |
| 可变参数支持 | ✅ 支持(如 printf) | ❌ 不支持 |
| 函数名修饰(VC) | 前加 _(如 _add) | 前加 _,后加 @字节数(如 _add@8) |
| 适用场景 | 通用 C/C++ 代码、可变参数函数 | Windows API、固定参数的库函数 |
| 效率 | 略低(调用者要写清理代码) | 略高(被调用者统一清理) |
五、实际开发中的常见问题与注意事项
1. 问题 1:调用约定不匹配导致链接错误
比如你在代码中调用 Windows API MessageBox(它用 __stdcall),但如果你误把函数声明为 __cdecl,编译器会生成错误的函数名(比如 _MessageBoxA vs _MessageBoxA@16),链接时就会报 “无法解析的外部符号”。
解决办法:Windows API 已经通过宏定义好了调用约定,直接包含 windows.h 即可(比如 #define WINAPI __stdcall),不要自己改。
2. 问题 2:调用约定不匹配导致栈溢出
如果调用者按 __cdecl 写(不清理栈),但被调用者按 __stdcall 写(也清理栈),会导致栈被 “重复清理”;反之,如果调用者清理了,被调用者也清理了,会导致栈指针错误,最终程序崩溃。
解决办法:调用函数时,确保声明的调用约定和函数实际的调用约定一致(比如看库文档说明)。
3. 如何显式指定调用约定?
在函数声明时加关键字即可,示例:
// __cdecl 调用约定(可省略,默认)
int __cdecl add(int a, int b);
// __stdcall 调用约定(Windows API 常用)
int __stdcall MessageBoxA(void* hWnd, const char* text, const char* caption, unsigned int uType);
六、一句话总结(方便记忆)
__cdecl:C 家默认,调用者清栈,支持可变参数(如printf);__stdcall:Windows 爱用,被调用者清栈,固定参数更高效。
记住这两点,就能应对大多数开发场景啦!
1万+

被折叠的 条评论
为什么被折叠?



