14、函数调用约定:cdecl、stdcall、fastcall,栈帧结构,参数传递与返回值

各位同学,今天我们来聊聊函数调用约定。这东西听起来有点学术,但说白了,就是「调用函数时,参数怎么传、栈怎么清、谁负责打扫战场」的一套规则。

我刚开始学C语言时,觉得函数调用就是压栈、跳转、返回,没什么大不了的。直到有一次我在嵌入式项目里调试一个诡异的崩溃——函数返回后栈指针居然不对了,程序直接飞掉。查了两天才发现,是调用方和被调用方对栈的清理方式不一致。嗯,从那以后,我再也不敢小看调用约定这回事了。

什么是调用约定?

调用约定,就是函数调用时,调用方和被调用方之间的一份「契约」。它规定了:

  • 参数从哪边开始压栈(从左到右?从右到左?)
  • 谁来清理栈上的参数(调用方?被调用方?)
  • 返回值放在哪里(寄存器?栈?)
  • 哪些寄存器需要保存(调用者保存?被调用者保存?)

不同的操作系统、不同的编译器、不同的硬件平台,默认的调用约定可能都不一样。但Windows x86平台上,最常见的三种是:cdeclstdcallfastcall

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 性能敏感、参数少的函数

调用约定的知识体系

下面这张图展示了三种调用约定的核心逻辑对比:

三种调用约定核心逻辑对比 cdecl 参数压栈:从右向左 清理栈:调用方 寄存器传参:无 可变参数:支持 调用方清理栈 add esp, 8 (每个调用点都有一条) 典型用途:C默认、printf stdcall 参数压栈:从右向左 清理栈:被调用方 寄存器传参:无 可变参数:不支持 被调用方清理栈 ret 8 (函数末尾只出现一次) 典型用途:Windows API fastcall 参数压栈:从右向左 清理栈:被调用方 寄存器传参:ECX/EDX 可变参数:不支持 寄存器传参(前两个) mov ecx, a mov edx, b 典型用途:性能敏感函数

实际开发中的选择

在实际项目中,怎么选?我个人的经验是:

  • 普通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。写跨平台代码时,可以用宏封装一下。

好了,关于函数调用约定,我们就聊到这里。记住:调用约定不是玄学,它是一套实实在在的规则。理解了它,你就能看懂反汇编代码,也能避免那些「莫名其妙」的栈崩溃问题。