当你在C代码中写下一行简单的函数调用result = add(a, b, c),编译器需要回答一系列问题:参数a、b、c应该放在哪里?是寄存器还是栈?如果是寄存器,用哪些?返回值result又该如何传递?调用前后谁负责保存寄存器?栈指针需要调整多少?
这些问题的答案,构成了计算机科学中一个看似琐碎却影响深远的主题——调用约定(Calling Convention)。它定义了函数调用时调用者与被调用者之间的"契约",规定了参数传递、返回值处理、栈帧管理和寄存器保存的全部规则。
2004年,Raymond Chen在他的博客"The Old New Thing"上连续发表了五篇关于调用约定历史的文章,详细追溯了从16位Windows到AMD64的演进过程。他开篇便调侃道:“x86平台上调用约定最美妙的事情就是——你有这么多选择!“这句看似轻松的评论背后,是四十年计算机架构演进中无数次设计权衡的历史积淀。
寄存器的先天约束
调用约定的核心矛盾,源于寄存器资源的稀缺性。
在16位x86时代,CPU只有8个16位通用寄存器:AX、BX、CX、DX、SI、DI、BP、SP。其中SP是栈指针,必须专用;BP寄存器则有一个特殊属性——它默认使用SS(栈段)选择器,而其他寄存器默认使用DS(数据段)选择器。在分段内存模型主导的16位时代,这意味着BP是唯一可以安全访问栈上数据的寄存器。因此,BP自然而然地成为了帧指针(Frame Pointer)的标准选择。
这个架构层面的约束深刻影响了早期的调用约定设计。函数序言(Prologue)必须保存旧的BP值,设置新的BP指向当前栈帧,然后才能安全地访问局部变量和参数。经典的序言代码是:
push bp ; 保存调用者的帧指针
mov bp, sp ; 设置新的帧指针
sub sp, N ; 分配局部变量空间
这个模式如此根深蒂固,以至于即使到了32位保护模式分段内存已经不再重要,BP(后扩展为EBP)仍然被保留为帧指针的角色。
32位时代:分裂的起源
进入32位时代,寄存器数量增加不多(EAX、EBX、ECX、EDX、ESI、EDI、EBP、ESP),但地址空间和内存成本的降低使得栈操作的开销相对上升。编译器厂商开始探索不同的优化策略,导致了调用约定的第一次大分裂。
cdecl:C语言的默认选择
cdecl(C Declaration)是C语言最经典的调用约定。它的规则简单直接:
- 参数从右向左压栈
- 调用者清理栈
- 返回值存入EAX(或EDX:EAX用于64位值)
从右向左压栈看似奇怪,实则是为了支持变长参数函数。考虑printf("%d %d", a, b):编译器不知道printf会读取多少个参数,但无论多少个,第一个参数"%d %d"总是在栈顶——它被最后压栈。这样printf可以通过第一个参数计算出其他参数的位置。
调用者清理栈的设计也是为了配合变长参数。被调用者不知道自己被传了多少参数,因此无法知道需要清理多少栈空间。只有调用者知道这个信息。
stdcall:Windows API的标准
Windows API采用了stdcall(Standard Call)约定,主要区别在于:
- 参数从右向左压栈(同cdecl)
- 被调用者清理栈
- 函数名修饰:
_func@N(N是参数字节数)
被调用者清理栈的好处是代码体积更小:每次调用省去一条add esp, N指令。对于频繁调用的系统API,这个优化是可观的。代价是不支持变长参数——Windows API恰好很少需要。
fastcall:寄存器传参的尝试
fastcall是早期对"寄存器传参"的探索。Microsoft的fastcall用ECX和EDX传递前两个参数,Borland的fastcall用EAX、EDX、ECX传递前三个参数。这种分裂本身就说明了问题:在只有8个通用寄存器的架构上,拿出2-3个传参,剩下的寄存器就捉襟见肘了。
fastcall的问题还不止于此。ECX在循环中常用作计数器(Loop Counter,“C"来自Count),EDX常用于乘除法的扩展结果。占用这些寄存器可能反而降低性能。
thiscall:C++的隐形成本
C++带来了新的挑战:成员函数需要一个隐式的this指针。在32位Windows上,thiscall约定将this指针放入ECX,其他参数通过栈传递。这个设计避免了this占用栈空间,但也意味着成员函数与自由函数有不同的调用约定——这是C++ ABI复杂性的开端。
64位革命:两套ABI的诞生
AMD在1999至2000年间发布的AMD64架构(Intel后来称为Intel 64或x86-64)彻底改变了游戏规则。2003年4月,第一款AMD64处理器Opteron正式上市。64位模式下,通用寄存器从8个扩展到16个:RAX、RBX、RCX、RDX、RSI、RDI、RBP、RSP以及全新的R8-R15。寄存器数量的翻倍,使得"寄存器传参"成为可行的默认选择。
然而,正是这个历史机遇,导致了Unix/Linux和Windows两条完全不同的ABI道路。
System V AMD64 ABI:Unix的选择
Unix系统的AMD64 ABI(通常称为System V AMD64 ABI)规定:
- 前6个整数/指针参数通过RDI、RSI、RDX、RCX、R8、R9传递
- 前8个浮点参数通过XMM0-XMM7传递
- 返回值放入RAX(或XMM0用于浮点)
- 栈必须16字节对齐
- 有128字节的"红区”(Red Zone)
为什么选择RDI和RSI作为前两个参数寄存器?这个选择有着精妙的理由。在x86架构中,RSI和RDI是字符串操作指令(如MOVS、CMPS)的源和目的操作数寄存器。将它们作为参数寄存器,使得最简单的字符串拷贝函数可以精简到:
strcpy:
mov al, byte [rdi] ; RDI已经指向目标
mov byte [rsi], al ; RSI已经指向源
; ...
ret
这是一个深思熟虑的设计:在底层系统代码中,内存拷贝操作极其频繁,能省下两条MOV指令是有价值的。
Windows x64 ABI:微软的路径
Windows x64 ABI做出了不同的选择:
- 前4个整数/指针参数通过RCX、RDX、R8、R9传递
- 前4个浮点参数通过XMM0-XMM3传递
- 返回值放入RAX(或XMM0用于浮点)
- 调用者必须在栈上预留32字节的"影子空间”(Shadow Space)
- 栈必须16字节对齐
为什么只传4个参数而不是6个?这个选择与Windows的历史有关。Raymond Chen解释道,RCX、RDX、R8、R9的选择源于对x86寄存器编号的考量:在指令编码的MOD R/M字节中,寄存器编号0-7依次对应AX、CX、DX、BX、SP、BP、SI、DI。选择AX(返回值)、CX、DX作为前三个寄存器,加上"新"的R8、R9,形成了一个逻辑上连贯的序列。
而Unix选择6个参数,是因为传统RISC架构(MIPS、SPARC、PowerPC)都使用6个寄存器传参。System V ABI的设计者希望与这些架构保持一致性。
影子空间:争议的设计
Windows x64最独特的设计是影子空间(也称为Home Space)。无论函数有多少参数,调用者都必须在栈上预留32字节空间,即使函数只有0-3个参数。这个设计引发了大量争议:这不是浪费栈空间吗?
Raymond Chen给出了详细解释。影子空间的设计初衷包括:
支持变长参数函数:变长参数函数(如printf)需要知道所有参数的起始位置。如果参数只在寄存器中,函数必须在序言中将它们全部溢出到栈上。预留32字节意味着前4个参数总是可以在确定的位置找到。
支持无原型调用:经典的C允许调用未声明的函数。如果编译器不知道函数的参数数量,它必须按最坏情况处理——预留足够的空间。
简化调试:调试器可以在影子空间中找到参数的值,即使寄存器已经被重用。
Raymond Chen特别指出:“预留影子空间不会浪费任何东西。被调用函数可以将这32字节用作临时存储空间——保存RBX、RSI等寄存器。”
红区:Unix的优化
System V AMD64 ABI引入了"红区"概念:栈指针以下128字节是保留给叶子函数(不调用其他函数的函数)使用的。信号处理程序和中断处理程序不会触及这片区域。
这意味着叶子函数可以使用这128字节存储局部变量,而无需调整栈指针:
; 传统方式
sub rsp, 64 ; 分配栈空间
; 使用局部变量
add rsp, 64 ; 恢复栈指针
ret
; 红区方式(叶子函数)
; 直接使用 [rsp-8], [rsp-16] 等位置
ret ; 无需恢复栈指针
省去两条指令的代价,在高频调用的叶子函数上可以累积成可观的性能提升。
Windows x64 ABI没有红区概念。微软认为红区增加了内核的复杂性(内核必须知道红区的存在并避开它),而收益有限。
栈帧的复杂性
调用约定不仅规定参数传递,还规定栈帧的完整布局。一个典型的x86-64函数栈帧包含:
高地址
┌─────────────────┐
│ 参数N...7 │ (仅当参数超过6/4个时)
├─────────────────┤
│ 返回地址 │ ← call指令压入
├─────────────────┤
│ 旧RBP值 │ ← push rbp(可选)
├─────────────────┤
│ 局部变量 │
│ callee-saved │ ← 需要保存的寄存器
│ 临时空间 │
├─────────────────┤
│ 影子空间 │ (仅Windows,32字节)
│ (或红区) │ (仅Unix叶子函数,128字节)
└─────────────────┘ ← RSP
低地址
栈对齐:不可忽视的规则
x86-64 ABI要求在执行call指令时,栈指针必须16字节对齐(RSP % 16 == 0)。这个要求源于SSE/AVX指令:许多向量指令要求操作数16字节或32字节对齐,未对齐访问会导致性能下降甚至异常(对于某些MOV指令)。
考虑一个简单的函数调用序列:
; 假设进入函数时 RSP 已16字节对齐
sub rsp, 40 ; 分配空间(包括32字节影子空间+8字节对齐)
; 现在 RSP % 16 == 0 吗?取决于原值
call other_func ; call压入8字节返回地址,对齐被破坏
在函数内部,如果需要调用其他函数或使用对齐的向量操作,通常需要在序言中重新对齐:
push rbp
mov rbp, rsp
and rsp, -16 ; 强制16字节对齐
这个对齐要求是调用约定中最容易被忽视却最容易导致难以调试问题的规则之一。
寄存器保存:经济学原理
调用约定将寄存器分为两类:caller-saved(调用者保存)和callee-saved(被调用者保存)。
Caller-saved寄存器(在x86-64 System V中:RAX、RCX、RDX、RSI、RDI、R8-R11):
- 调用函数前,如果这些寄存器中的值需要在调用后使用,调用者负责保存
- 被调用者可以自由修改这些寄存器
Callee-saved寄存器(在x86-64 System V中:RBX、RBP、R12-R15):
- 被调用者如果修改这些寄存器,必须在返回前恢复原值
- 调用者可以假设这些寄存器在调用后值不变
这个设计的经济学原理是什么?
假设一个寄存器在调用前存活(live),即调用后仍需要使用。如果由调用者保存,每次调用都要保存,无论被调用者是否真的修改了这个寄存器。如果由被调用者保存,只有被调用者实际修改时才需要保存。
然而,如果被调用者根本不需要这个寄存器(没有修改它),那么caller-saved策略就浪费了一次保存操作。
理想的情况是:频繁使用的寄存器应该是caller-saved(因为多数调用者不需要跨调用保存它们),而用作长期存储的寄存器应该是callee-saved(因为如果需要修改,代价是值得的)。
实际的设计反映了这个权衡。RAX是返回值寄存器,自然需要caller-saved;RCX、RDX是参数寄存器,调用后通常不再需要。RBX传统上用作"通用保存寄存器”,因为它没有特殊用途(不像RCX是循环计数器,RDX是乘除法扩展),因此设为callee-saved。
变长参数:x86-64上最复杂的角落
变长参数函数(如printf、scanf)是调用约定设计中最棘手的挑战。在32位x86上,实现非常简单:所有参数都在栈上,va_list就是一个指针:
// i386上的va_arg实现(简化)
#define va_arg(list, mode) ((mode *)(list = (char *)list + sizeof(mode)))[-1]
x86-64彻底打破了这个简单模型。参数可能在寄存器中,也可能在栈上。va_list不再是一个指针,而是一个复杂的结构体:
typedef struct {
unsigned int gp_offset; // 下一个GP寄存器参数的偏移
unsigned int fp_offset; // 下一个FP寄存器参数的偏移
void *overflow_arg_area; // 栈上参数的起始位置
void *reg_save_area; // 寄存器保存区的起始位置
} va_list[1];
变长参数函数必须在序言中将所有可能包含参数的寄存器保存到栈上,形成"寄存器保存区"。va_arg宏需要执行多达11个步骤来判断参数是在寄存器还是栈上,然后从正确的位置获取值。
Nelson Elhage在他的博客中详细分析了这个复杂性,他写道:“我原本以为可以在一个晚上写一个补丁来实现LLVM的va_arg支持。读了半小时ABI规范后,我惊恐地放弃了。”
更复杂的是,小型结构体(16字节或更小)可能被拆分并分别放入寄存器。va_arg需要知道这个结构体是如何传递的,并在必要时将其重新组装。
结构体传递:当sizeof超过寄存器宽度
当结构体作为参数传递或作为返回值时,调用约定变得更加复杂。
参数传递
在System V AMD64 ABI中:
- 16字节及以下的结构体:如果可以放入寄存器,则拆分放入(例如两个8字节成员可能分别放入RDI和RSI)
- 超过16字节的结构体:通过指针传递,调用者负责分配空间
Windows x64 ABI的处理类似,但阈值不同:
- 8字节及以下:尝试放入寄存器
- 超过8字节:通过指针传递
返回值
当函数返回大型结构体时,问题更加复杂。调用者不知道结构体会在哪里被构造,但需要为其分配空间。
解决方案是"sret"(struct return)约定:调用者在栈上分配空间,将地址作为隐式的第一个参数传递给被调用者。被调用者将结果直接写入这个地址。
; struct Point create_point(int x, int y);
; 调用方式:
sub rsp, 16 ; 分配Point结构体空间
mov rcx, rsp ; 隐式第一个参数:返回值地址
mov edx, [x] ; 第二个参数
mov r8d, [y] ; 第三个参数
call create_point
; 结果现在在 [rsp]
这个机制也解释了为什么C++17引入了"强制省略拷贝"(mandatory copy elision):当函数返回一个临时对象时,编译器可以直接在被调用者的栈帧中构造它,无需任何拷贝。这本质上是将sret约定固化到了语言层面。
尾调用优化:当调用变成跳转
尾调用优化(Tail Call Optimization)是一种将函数调用转换为跳转的优化技术。如果函数的最后一步是调用另一个函数,并且不需要保留当前栈帧,那么可以重用当前栈帧,将call指令替换为jmp。
但尾调用优化需要满足严格的调用约定条件:
- 调用者和被调用者的参数数量相同或更少
- 调用者和被调用者的返回类型兼容
- 被调用者不会访问调用者的栈帧
在Windows x64上,尾调用优化更加受限,因为影子空间的存在意味着每次调用都需要32字节的空间,即使参数完全通过寄存器传递。
其他架构的视角
调用约定的设计差异不仅存在于x86-64的不同操作系统之间,不同CPU架构之间差异更大。
ARM64:AAPCS64
ARM64架构遵循AAPCS64(Procedure Call Standard for the ARM 64-bit Architecture)。它的设计体现了RISC哲学的简洁性:
- 前8个整数参数通过X0-X7传递
- 前8个浮点参数通过V0-V7传递
- 返回值放入X0(或X0:X1用于128位值)
- X19-X28是callee-saved寄存器
- 栈必须16字节对齐
ARM64拥有更多的寄存器(31个通用寄存器),因此可以传递更多参数。更重要的是,ARM64的设计从头开始就是64位的,没有历史包袱。
RISC-V:简洁的极致
RISC-V的调用约定进一步简化:
- 前8个整数参数通过a0-a7传递
- 前8个浮点参数通过fa0-fa7传递
- a0-a1同时用于返回值(支持两个寄存器的返回值)
- callee-saved寄存器:s0-s11
RISC-V的设计哲学是"简洁至上"。调用约定中没有影子空间、没有红区——这些优化被认为应该在更高层次(编译器或运行时)实现,而不是硬编码到ABI中。
性能的真实影响
调用约定对性能的影响有多大?
最直接的指标是函数调用的开销。在现代x86-64处理器上,一个"空"函数调用(参数全部在寄存器中,函数体只有ret)大约需要:
- call指令:1-2个周期
- ret指令:1-2个周期
- 总计:约2-4个周期
如果需要保存和恢复寄存器,每个寄存器增加约1个周期。如果参数在栈上,每个参数增加约1个周期的内存访问延迟(假设缓存命中)。
相比之下,一次函数调用错过L1缓存的代价是数十个周期。因此,在现代处理器上,调用约定本身的直接开销通常可以忽略不计。
真正的性能影响来自间接因素:
代码体积:cdecl每次调用后需要add esp, N,比stdcall多一条指令。在代码密度敏感的场景(嵌入式系统、指令缓存受限),这是有意义的。
寄存器压力:使用寄存器传参会增加寄存器压力。如果被调用者需要这些寄存器进行计算,就必须先保存它们。在寄存器数量有限的架构上(如32位x86),fastcall并不总是带来性能提升。
内联优化:调用约定的影响在内联后完全消失。现代编译器大量使用内联,使得调用约定的性能差异更加不重要。
ABI稳定性:不能承受之变
调用约定是ABI(Application Binary Interface)的核心组成部分。一旦发布,就不能轻易改变——否则所有已编译的库和程序都需要重新编译。
这导致了历史的锁定效应。Windows保留了stdcall用于大多数API,即使现代编译器更倾向于fastcall风格的寄存器传参。Linux内核系统调用使用特定的调用约定,即使这与应用程序的调用约定不同。
ABI稳定性也解释了为什么C++的ABI如此复杂。成员函数指针在MSVC和GCC中的实现完全不同,因为两家公司在1990年代做出了不同的选择,而后无法更改。C++标准至今没有定义ABI,使得不同编译器编译的C++代码无法互相调用。
一个统一的梦想
在64位时代,Windows和Unix选择了不同的ABI,导致开发者需要处理两套不同的规则。跨平台代码必须使用条件编译或抽象层来隐藏这些差异。FFI(Foreign Function Interface)库必须实现复杂的参数转换逻辑。
有朝一日会有统一的调用约定吗?不太可能。每个选择都有其历史原因和现有生态的支撑。Windows的影子空间简化了变长参数和调试;Unix的红区优化了叶子函数;两者都反映了对不同优先级的选择。
或许,这正是计算机科学的常态:没有完美的解决方案,只有在特定约束下的权衡。函数调用约定四十年演进的历史,正是这一命题的最佳注脚。
参考资料
- Raymond Chen, “The history of calling conventions, part 1-5”, The Old New Thing, 2004
- System V Application Binary Interface, AMD64 Architecture Processor Supplement, Version 1.0
- Microsoft Learn, “x64 Calling Convention”
- Eli Bendersky, “Stack frame layout on x86-64”, 2011
- Nelson Elhage, “amd64 and va_arg”, 2010
- Procedure Call Standard for the ARM 64-bit Architecture (AAPCS64)
- RISC-V Calling Conventions, GitHub
- Agner Fog, “Calling conventions for different C++ compilers and operating systems”
- Wikipedia, “X86 calling conventions”
- Stack Overflow, “Why does Windows64 use a different calling convention from all other OSes on x86-64?”
- CMU CS 411 Lecture Notes on Calling Conventions
- Stanford CS107 Guide to x86-64
- Intel 64 and IA-32 Architectures Software Developer’s Manual
- AMD64 Architecture Programmer’s Manual Volume 3: General-Purpose and System Instructions
- “Writing A Simple Operating System From Scratch”, Nick Blundell
- OSDev Wiki, “Calling Conventions”
- Cornell CS3410 Lecture Notes on Calling Conventions
- “Reliable and Fast DWARF-Based Stack Unwinding”, HAL-Inria, 2019
- GCC Internals, “Function Entry”
- LLVM Documentation, “Exception Handling”