yuqi-zheng

编译器优化:调用约定与参数传递


每一次函数调用,调用方和被调用方之间都存在一份”合同”:参数放在哪里、返回值应当从哪里取回。这份合同就是调用约定(calling convention),在每个平台的应用二进制接口(ABI)中被正式定义。理解参数是如何传递的 —— 特别是哪些值留在寄存器里、哪些会”溢出”到栈上 —— 直接关系到性能,也会影响我们对 API 设计的取舍。

本文是 Matt Godbolt 的《Advent of Compiler Optimisations 2025》系列中的一篇。

System V AMD64 ABI

在 Linux x86-64 上,System V ABI 规定了函数调用方式。前六个整型或指针参数通过寄存器传递:

参数位置寄存器
1rdi
2rsi
3rdx
4rcx
5r8
6r9

超过第六个的参数走栈。浮点参数使用一组单独的 XMM 寄存器。返回值放在 rax 中(128 位返回值还会用到 rdx)。

关键的洞察是:通过寄存器传参基本是零成本的 —— 值已经在 CPU 中,不需要内存访问。栈上的参数要在调用点做一次写,在被调用方再做一次读,即便命中了缓存也比寄存器慢。

结构体并不”昂贵”

一个常见的误解是:按值传递结构体是昂贵的,因为要复制。在很多情况下,这是错的。

看两个”长得一样”的函数签名:

struct Args { long x, y; };
void foo(long a, long b);
void bar(Args args);

调用 foo(a, b) 时,编译器把 a 放到 rdib 放到 rsi。调用 bar(args) 时,编译器把 args.x 放到 rdiargs.y 放到 rsi。两者生成的汇编完全一样。没有涉及内存,也没有复制。结构体只是被打包进了两个寄存器。

只要结构体满足 ABI 关于寄存器分类的规则 —— 大致来说,总大小不超过 16 字节,且所有字段都是整型、指针或小型浮点类型 —— 它就能像一个个独立参数一样通过寄存器传递。

更小的字段

当结构体包含像 int 这样更小的类型时,ABI 会把多个字段打包进同一个寄存器。一个含两个 int 成员的结构体会作为一个 rdi 值传递:第一个字段在低 32 位,第二个字段在高 32 位。被调用方用一次 move 和一次移位把它们分别取出:

mov rax, rdi
shr rax, 32    ; rax = y(高 32 位)
add eax, edi   ; result = y + x(低 32 位仍在 edi 中)

更细碎的字段

同样的打包规则也适用于 charshort 字段。一个含有 8 个 char 的结构体总大小为 8 字节,能装进一个寄存器。它作为一个 rdi 值传递,八个字节被打包在一起。被调用方用移位和掩码提取各个字段。

多参数 vs. 结构体

有意思的是,当参数数量超过六个时,情况出现了反转。比较一下:传递八个独立的 long 值,与把它们打包为一个结构体。

使用八个独立参数时,前六个走寄存器,最后两个走栈。被调用方必须用显式的内存访问来加载栈上的参数:

add rax, QWORD PTR [rsp+8]
add rax, QWORD PTR [rsp+16]

如果改成一个含八个 long 成员的结构体,总大小是 64 字节。超过了 16 字节,ABI 要求整个结构体按引用传递(传递一个指向栈上拷贝的指针)。这看起来效率低下,但对于天然就该这样组织的数据聚合体而言,实际中往往并不会明显更慢。

反直觉的情况是:一个由大量小字段组成、总大小仍在 16 字节以内的结构体。例如含八个 char 字段的结构体总共 8 字节 —— 它们全部通过一个寄存器传递,而八个独立的 char 参数却会让其中两个走栈。

对设计的启示

std::string_view

std::string_view 包含一个指针和一个 size —— 两个 64 位值,总共 16 字节。按照 System V ABI,它恰好用两个寄存器传递(rdirsi)。按值传递 string_view 是完全免费的:不涉及任何内存。这也是 string_view 被设计为值类型的原因之一。

std::optional<T>

std::optional<T>T 的基础上加了一个布尔标志。如果 T 本身能装进一个寄存器、且 optional 没有引入填充,那么组合后的类型可能仍能装进两个寄存器、无需内存访问地传递。如果填充或对齐要求使得大小超过 16 字节,optional 就必须按引用传递。

Windows ABI 的差异

Microsoft x64 ABI 只用四个寄存器传递整型参数(rcxrdxr8r9)。一个 string_view(两个 64 位值)就占了两个”参数槽位”,因此需要其中一个值落到栈上。在 Linux 上性能最优的类型和 API,在 Windows 上可能表现出不同的性能特征。

实用准则

按值传递小对象。 16 字节以内的对象通常完全走寄存器。老式 C++ 里”按 const 引用传”的直觉,对小类型并不总是最好的选择。

把相关参数聚合到结构体里。 如果一个函数接收很多类型相关的小参数,把它们分组到结构体中可以减少栈溢出,并改善数据局部性。

避免冗长的标量参数列表。 整型参数超过六个就保证了会有栈参与。把参数打包到一个紧凑的结构体中可能彻底消除这部分开销。

注意 ABI 跨平台的差异。 在 Linux 上寄存器最优的代码,在 Windows 上可能行为不同。如果跨平台性能至关重要,请在两个平台上都验证一下。

使用 Compiler Explorer 查看你关心的任何函数签名的调用序列,汇编会直接告诉你寄存器是如何分配的。