14、函数调用约定:cdecl、stdcall、fastcall,栈帧结构,参数传递与返回值
各位同学,今天我们来聊聊函数调用约定。这东西听起来有点学术,但说白了,就是「调用函数时,参数怎么传、栈怎么清、谁负责打扫战场」的一套规则。
我刚开始学C语言时,觉得函数调用就是压栈、跳转、返回,没什么大不了的。直到有一次我在嵌入式项目里调试一个诡异的崩溃——函数返回后栈指针居然不对了,程序直接飞掉。查了两天才发现,是调用方和被调用方对栈的清理方式不一致。嗯,从那以后,我再也不敢小看调用约定这回事了。
什么是调用约定?
调用约定,就是函数调用时,调用方和被调用方之间的一份「契约」。它规定了:
- 参数从哪边开始压栈(从左到右?从右到左?)
- 谁来清理栈上的参数(调用方?被调用方?)
- 返回值放在哪里(寄存器?栈?)
- 哪些寄存器需要保存(调用者保存?被调用者保存?)
不同的操作系统、不同的编译器、不同的硬件平台,默认的调用约定可能都不一样。但Windows x86平台上,最常见的三种是:cdecl、stdcall、fastcall。
cdecl:C语言的默认约定
cdecl 是「C declaration」的缩写。它是C语言在x86平台上的默认调用约定。GCC和MSVC在x86下默认都用它。
它的核心规则很简单:
- 参数从右向左压栈。最后一个参数先入栈,第一个参数最后入栈。
- 调用方负责清理栈。函数返回后,调用方自己把栈指针加回去。
- 返回值放在EAX寄存器(如果是整数或指针)。
为什么从右向左?我个人理解,这是为了支持可变参数函数(比如printf)。如果从左向右压栈,第一个参数在栈顶,printf拿到格式字符串后,没法知道后面还有多少个参数。但从右向左压栈,第一个参数在栈底附近,格式字符串在栈顶,printf可以顺着栈往下找参数。你想想看,这个设计其实挺巧妙的。
// cdecl 示例
int __cdecl add(int a, int b) {
return a + b;
}
int main() {
int result = add(3, 5);
return 0;
}
对应的汇编大概长这样:
push 5 ; 先压 b(右)
push 3 ; 再压 a(左)
call add
add esp, 8 ; 调用方清理栈(8字节)
关键点:cdecl 的调用方清理机制,使得每个调用点都要生成清理代码。如果你的程序里调用了1000次同一个函数,那就有1000条 add esp, N 指令。代码体积会变大,但好处是——支持可变参数。
stdcall:Windows API 的标配
stdcall 是「standard call」的缩写。Windows API 函数几乎全部使用 stdcall。它的规则是:
- 参数从右向左压栈(和cdecl一样)。
- 被调用方负责清理栈。函数返回时,用
ret N指令把栈指针回退。 - 返回值放在EAX。
// stdcall 示例
int __stdcall add(int a, int b) {
return a + b;
}
int main() {
int result = add(3, 5);
return 0;
}
汇编代码:
push 5
push 3
call add
; 这里不需要 add esp, 8,因为 add 函数内部会清理
被调用方内部会生成类似这样的返回指令:
ret 8 ; 返回并弹出8字节参数
stdcall 的好处是代码体积更小——清理栈的指令只出现在函数末尾一次,而不是每个调用点。但代价是:不支持可变参数。因为被调用方在编译时就知道参数的总大小,如果参数个数可变,它没法确定要清理多少字节。
我曾经踩过的坑:在Windows上用MinGW编译一个动态库,导出函数忘了加 __stdcall,结果C#那边用DllImport默认是stdcall,两边对不上。调用一次崩一次。后来加了 __stdcall 才正常。记住:跨语言调用时,调用约定必须一致。
fastcall:用寄存器传参
fastcall 的初衷是「快」。它尽量用寄存器传参,减少栈操作。不同编译器对 fastcall 的实现略有差异,但MSVC和GCC的x86版本大致规则是:
- 前两个参数用ECX和EDX传递(32位整数或指针)。
- 剩余参数从右向左压栈。
- 被调用方清理栈(和stdcall一样)。
- 返回值放在EAX。
// fastcall 示例
int __fastcall add(int a, int b) {
return a + b;
}
int main() {
int result = add(3, 5);
return 0;
}
汇编代码:
mov ecx, 3 ; 第一个参数放 ECX
mov edx, 5 ; 第二个参数放 EDX
call add
; 不需要清理栈,因为没压栈
你看,两个参数都在寄存器里,连栈都不用碰。这比cdecl和stdcall都快。但寄存器数量有限,如果参数超过两个,剩下的还是得走栈。
我个人的习惯:在性能敏感的代码中,如果函数参数不超过两个,我会显式指定 fastcall。但要注意,fastcall 在不同编译器上行为不完全一致,跨平台代码慎用。
栈帧结构
不管用哪种调用约定,函数调用时都会在栈上形成一个「栈帧」。栈帧是函数执行时的局部上下文,包含:
- 返回地址(call指令自动压栈)
- 调用方的EBP(基址指针,用于恢复栈帧)
- 局部变量
- 保存的寄存器
- 参数(如果通过栈传递)
一个典型的栈帧布局(从高地址到低地址):
高地址
+------------------+
| 参数N | ← 调用方压入
| ... |
| 参数1 |
+------------------+
| 返回地址 | ← call指令压入
+------------------+
| 旧EBP | ← push ebp
+------------------+
| 局部变量1 |
| 局部变量2 |
| ... |
| 保存的寄存器 |
+------------------+ ← ESP指向这里
低地址
函数开头通常有两条指令:
push ebp
mov ebp, esp
这叫「建立栈帧」。之后,函数可以通过 [ebp+8] 访问第一个参数,通过 [ebp-4] 访问第一个局部变量。函数返回前,再执行:
mov esp, ebp
pop ebp
ret
这叫「销毁栈帧」。嗯,这里要注意:如果函数内部动态分配了栈空间(比如alloca),那 mov esp, ebp 这一步就特别重要,它能确保栈指针恢复到正确位置。
参数传递的细节
参数传递时,有几个细节容易忽略:
- 整数和指针:直接压栈或放寄存器,32位下占4字节。
- 结构体:如果结构体较小(比如4字节或8字节),有些编译器会直接压栈。如果结构体较大,通常压入一个指针,或者把结构体复制到栈上再传指针。
- 浮点数:x86 32位下,浮点数通常通过栈传递。但x86-64下,浮点数用XMM寄存器传。
- 参数对齐:有些编译器会按4字节或8字节对齐参数,即使参数本身只有1字节。
一个我遇到过的坑:在32位系统上,把一个很大的结构体按值传给函数。结果每次调用都要在栈上复制几百字节,性能惨不忍睹。后来改成传指针,速度直接翻倍。记住:大结构体尽量传指针,别传值。
返回值的传递
返回值的传递方式取决于返回值的大小和类型:
| 返回值类型 | 传递方式 |
|---|---|
| 整数/指针(≤4字节) | EAX |
| 64位整数 | EDX:EAX(高32位在EDX,低32位在EAX) |
| 浮点数(float/double) | ST(0)(x87浮点栈)或XMM0(SSE) |
| 大结构体(>8字节) | 调用方在栈上预留空间,把地址作为「隐藏参数」传给函数,函数把返回值写入该地址 |
对于大结构体,编译器会玩一个「小把戏」:调用方在栈上分配一块空间,然后把这块空间的地址作为第一个参数(在ECX或栈上)传给函数。函数把返回值写入这块空间,然后返回该地址。调用方再从这块空间读取返回值。
// 大结构体返回值示例
typedef struct {
int data[100];
} BigStruct;
BigStruct create_big() {
BigStruct s;
// 初始化...
return s;
}
// 编译器实际生成的代码类似:
// void create_big(BigStruct* result) {
// // 通过 result 指针写入返回值
// }
三种约定的对比
| 特性 | cdecl | stdcall | fastcall |
|---|---|---|---|
| 参数压栈顺序 | 从右向左 | 从右向左 | 从右向左(剩余参数) |
| 谁清理栈 | 调用方 | 被调用方 | 被调用方 |
| 寄存器传参 | 无 | 无 | 前两个参数用ECX/EDX |
| 支持可变参数 | 是 | 否 | 否 |
| 代码体积 | 较大 | 较小 | 最小 |
| 执行速度 | 一般 | 一般 | 较快 |
| 典型用途 | C语言默认、可变参数函数 | Windows API | 性能敏感、参数少的函数 |
调用约定的知识体系
下面这张图展示了三种调用约定的核心逻辑对比:
实际开发中的选择
在实际项目中,怎么选?我个人的经验是:
- 普通C代码:用默认的cdecl就行,别折腾。编译器会帮你处理好一切。
- Windows DLL导出函数:必须用stdcall,否则其他语言(C#、VB等)调用时会出问题。
- 性能热点函数:如果函数参数少(≤2个),调用频繁,可以考虑fastcall。但记得先profile,别盲目优化。
- 跨平台代码:尽量别依赖特定调用约定。x86-64上,调用约定已经统一了(Windows用Microsoft x64 calling convention,Linux用System V AMD64 ABI),不需要你手动指定。
一个小技巧:在GCC中,可以用 __attribute__((cdecl))、__attribute__((stdcall))、__attribute__((fastcall)) 来指定调用约定。MSVC则用 __cdecl、__stdcall、__fastcall。写跨平台代码时,可以用宏封装一下。
好了,关于函数调用约定,我们就聊到这里。记住:调用约定不是玄学,它是一套实实在在的规则。理解了它,你就能看懂反汇编代码,也能避免那些「莫名其妙」的栈崩溃问题。