28、ABI与调用约定:cdecl,stdcall,fastcall,x86-64 ABI

调用约定这东西,说白了就是「函数怎么传参、谁负责清理栈」的一套规矩。你写C代码时可能从来没管过它,但编译器、链接器、操作系统之间全靠它来对齐。我早年调试一个跨语言调用的崩溃,查了三天才发现是调用约定没对上——嗯,从那以后我再也不敢忽略这个细节了。

什么是ABI?什么是调用约定?

ABI(Application Binary Interface)是二进制层面的接口规范。它规定了函数怎么调用、数据怎么对齐、异常怎么处理等等。调用约定是ABI中最核心的一部分,专门管函数调用时的参数传递、栈平衡、返回值处理。

你想想看,你写了一个函数 int add(int a, int b),编译器把它编译成机器码。如果调用方和被调用方对「参数a放在哪里、参数b放在哪里、谁负责清理栈」的理解不一致,那程序跑起来就是灾难。

核心要点:调用约定是函数调用方和被调用方之间的「握手协议」。双方必须遵守同一套规则,否则程序行为未定义。

cdecl:C语言默认调用约定

cdecl是C语言最传统的调用约定,也是Linux、macOS等类Unix系统上x86-32位的默认约定。它的规则很简单:

  • 参数从右向左压栈
  • 调用方负责清理栈(即调用方在函数返回后调整ESP)
  • 返回值放在EAX寄存器中
  • 函数名前面加下划线(在Windows上)

为什么参数要从右向左?我个人习惯的理解是:这样最左边的参数(通常是this指针或第一个参数)在栈顶,方便变参函数(如printf)定位第一个固定参数。printf的第一个参数是格式字符串,它必须知道后面有多少个参数——从右向左压栈让第一个参数始终在已知位置。

// cdecl 调用示例
int __cdecl add(int a, int b) {
    return a + b;
}

// 调用方生成的汇编(伪代码)
push b          // 先压右参数
push a          // 再压左参数
call add
add esp, 8      // 调用方清理栈

避坑指南:我曾经在Windows上用MinGW编译一个库,默认是cdecl,但链接时用了stdcall的导入库,结果每次调用都栈不平衡,程序随机崩溃。后来我用dumpbin /exports检查了导出符号,才发现名字修饰不一样——cdecl是_add,stdcall是_add@8

stdcall:Windows API的标配

stdcall是Win32 API的标准调用约定。它和cdecl最大的区别是:被调用方负责清理栈。这意味着函数返回时用ret n指令,其中n是参数占用的字节数。

  • 参数从右向左压栈
  • 被调用方清理栈
  • 返回值放在EAX中
  • 函数名修饰:_function@n,n是参数总字节数
// stdcall 调用示例
int __stdcall add(int a, int b) {
    return a + b;
}

// 被调用方生成的汇编
push ebp
mov ebp, esp
mov eax, [ebp+8]    // a
add eax, [ebp+12]   // b
pop ebp
ret 8               // 被调用方清理8字节参数

为什么Windows要用stdcall?说白了是为了节省代码体积。每个调用点都少了一条add esp, n指令,积少成多。但代价是变参函数不能用stdcall——因为被调用方不知道参数个数,没法确定ret n中的n。

注意:如果你在Windows上写DLL导出函数,默认是stdcall。但如果你用cdecl导出,调用方必须显式声明__cdecl,否则链接时会因为符号名不匹配而失败。

fastcall:用寄存器传参

fastcall的核心理念是:前几个参数用寄存器传,减少栈操作,提升性能。但不同编译器对fastcall的实现有差异,这也是它最坑的地方。

在x86-32位下,常见的fastcall规则:

  • 前两个参数(或前三个,取决于编译器)用ECX、EDX传递
  • 剩余参数从右向左压栈
  • 被调用方清理栈
  • 函数名修饰:@function@n(MSVC)或_function@n(GCC)
// fastcall 示例(MSVC风格)
int __fastcall add(int a, int b, int c) {
    return a + b + c;
}

// 生成的汇编(伪代码)
// a -> ECX, b -> EDX, c -> 栈
push c
call @add@12

我记得有一次优化一个高频调用的数学库,把关键函数改成fastcall,性能提升了大约15%。但移植到GCC时发现参数传递顺序不一样——MSVC是ECX、EDX,GCC是EDX、ECX。嗯,跨编译器的fastcall基本就是雷区。

我的建议:除非你非常确定调用方和被调用方是同一个编译器编译的,否则别用fastcall。现代编译器在优化时已经会自行决定是否用寄存器传参,你手动指定反而可能干扰优化。

x86-64 ABI:统一的世界

到了64位时代,事情变得简单了。x86-64有两个主流ABI:

ABI 使用场景 参数传递 栈清理
System V AMD64 ABI Linux、macOS、BSD 前6个整数参数用RDI、RSI、RDX、RCX、R8、R9;前8个浮点参数用XMM0-XMM7 调用方清理
Microsoft x64 ABI Windows 前4个整数参数用RCX、RDX、R8、R9;浮点参数用XMM0-XMM3 调用方清理,但调用方预留32字节的「影子空间」

你看,64位下不再有cdecl、stdcall、fastcall这些乱七八糟的区分了。每个平台只有一个ABI,大家都遵守。这让我想起当年从32位迁移到64位时,最爽的就是不用再操心调用约定不匹配的问题。

// System V AMD64 ABI 示例
long add(long a, long b, long c, long d, long e, long f) {
    return a + b + c + d + e + f;
}

// 生成的汇编
// a -> RDI, b -> RSI, c -> RDX, d -> RCX, e -> R8, f -> R9
// 如果还有第7个参数,压栈
add rdi, rsi
add rdi, rdx
add rdi, rcx
add rdi, r8
add rdi, r9
mov rax, rdi
ret

一个细节:Windows x64 ABI要求调用方在栈上预留32字节的影子空间,即使前4个参数都用寄存器传了。这是为了简化异常处理和调试——被调用方可以把寄存器参数保存到影子空间,形成统一的栈帧布局。我刚开始写x64汇编时忘了预留这32字节,结果SEH异常处理直接崩了。

调用约定的选择策略

在实际项目中,你怎么选?我总结了几条经验:

  1. 跨平台代码:用默认约定。Linux用System V,Windows用Microsoft x64,别手动指定。
  2. DLL导出函数:Windows上明确指定__stdcall__cdecl,并在.def文件中匹配。我曾经因为忘记指定,导致VB6调用C++ DLL时栈不平衡。
  3. 性能敏感的内联函数:别碰fastcall。让编译器自己决定。现代编译器的寄存器分配比你手动指定强得多。
  4. 汇编和C混合编程:必须显式声明调用约定,并在汇编代码中严格遵守。我习惯在汇编文件开头用注释写明当前函数的调用约定。

知识体系图

调用约定知识体系 调用约定 cdecl 调用方清理栈 stdcall 被调用方清理栈 fastcall 寄存器传参 x86-64 ABI 统一规范 32位时代:多种约定并存 64位时代:统一为平台ABI 关键:调用方与被调用方必须使用相同约定 否则栈不平衡 → 程序崩溃

总结

调用约定是C语言和底层系统之间的桥梁。32位时代,cdecl、stdcall、fastcall各有用武之地,但也带来了兼容性噩梦。64位时代,System V和Microsoft x64 ABI统一了规则,让跨语言调用变得简单可靠。

我个人习惯是:写库代码时永远显式声明调用约定,并在文档中注明。写应用代码时用默认约定,别画蛇添足。如果你在调试莫名其妙的栈崩溃,第一件事就是检查调用方和被调用方的调用约定是否一致——这招帮我省了无数时间。

一句话记住:调用约定就是函数调用的「交通规则」。不遵守规则,就会出车祸。

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