ABI 与调用约定

ABI 是编译器与操作系统之间的契约。本文讲解 System V AMD64 的寄存器与栈传递规则、参数分类算法与结构体拆分、返回值与变参约定,对比 Windows x64 与 ARM64 的差异,并给出跨语言 FFI、栈对齐、名字修饰与常见踩坑的排查手段。

1. ABI 是编译器之间的契约

一句话总结: ABI 规定函数如何调用、数据如何布局、符号如何命名,它是「由不同编译器、不同语言编译出来的代码能互相调用」的前提。

编译器可以把一个函数优化得面目全非,但有一件事它绝不能自作主张:函数之间的接口。当 main.o 里的代码调用 libfoo.so 里的函数时,双方必须就「参数放哪、返回值怎么取、谁负责保存寄存器、结构体怎么传」达成完全一致的约定。这套约定就是 ABI(Application Binary Interface)。

ABI 覆盖的内容比「调用约定」更广,大致分四块:

组成部分内容不一致的后果
调用约定参数与返回值的传递位置参数错位、栈失衡
数据布局结构体对齐、填充、字节序字段读错、跨语言结构体不匹配
名字修饰符号在目标文件里的拼写链接期找不到符号
运行时约定栈展开、异常、TLS、初始化顺序异常穿透失败、静态构造不执行

调用约定(calling convention)是其中最核心、也最容易踩坑的部分。它回答一个具体问题:调用一个函数时,参数和返回值到底放在哪里。

2. 寄存器与栈的传递规则

一句话总结: System V AMD64 用 rdi/rsi/rdx/rcx/r8/r9 传整数与指针参数,用 xmm0~xmm7 传浮点参数,超出的部分压栈;返回值用 rax(以及 rdx)与 xmm0(以及 xmm1)。

以最常见的 System V AMD64 ABI(Linux、macOS、BSD 的 x86-64 约定)为例,参数分配遵循两条独立的流水线:

整数/指针参数:  rdi, rsi, rdx, rcx, r8, r9   (依次分配)
浮点/向量参数:  xmm0, xmm1, xmm2, xmm3, xmm4, xmm5, xmm6, xmm7
返回值:         rax (整数), rdx (第二返回值), xmm0/xmm1 (浮点)

两条流水线互相独立:第 7 个整数参数会溢出到栈上,但这不影响浮点参数继续使用 xmm 寄存器。

long f(long a, long b, long c, long d, long e, long f, long g);
// a→rdi  b→rsi  c→rdx  d→rcx  e→r8  f→r9  g→栈 [rsp+8]

double g(int a, double x, int b, double y);
// a→rdi  x→xmm0  b→rsi  y→xmm1   (两条流水线并行)

寄存器的「保存责任」也必须明确,否则调用者的临时值会被被调用者破坏:

寄存器类别具体寄存器谁负责保存
调用者保存rax, rcx, rdx, rsi, rdi, r8~r11调用者(跨调用需自己压栈)
被调用者保存rbx, rbp, r12~r15被调用者(用之前先保存、返回前恢复)
浮点调用者保存xmm0~xmm15调用者
栈指针rsp双方,调用点须 16 字节对齐
# 一个符合 System V ABI 的函数序言与尾声
my_func:
    push   %rbp                # 保存被调用者保存的 rbp
    mov    %rsp, %rbp
    push   %rbx                # 用到 rbx, 先保存
    ...
    pop    %rbx                # 恢复
    pop    %rbp
    ret

还有两条容易忽略的规则。其一是栈对齐:在 call 指令执行的瞬间,rsp 必须是 16 的倍数;函数序言 push %rbp 后 rsp 变成 16 的倍数减 8,所以局部变量区的大小通常要取 16 的倍数来补回来。其二是红区(red zone):叶子函数可以放心使用 rsp 下方 128 字节而不调整栈指针,因为信号处理之外没有东西会覆盖它——但这个优化在编写信号处理程序时必须显式关闭。

3. 参数分类算法

一句话总结: 结构体按 8 字节切成若干「eightbyte」,每个 eightbyte 归类为 INTEGER、SSE 或 MEMORY;任一为 MEMORY 则整个结构体走栈,否则按类别分派到对应寄存器流水线。

参数不只是标量,结构体才是真正考验 ABI 的地方。System V AMD64 的规则是:把结构体按 8 字节切成若干块(eightbyte),逐块分类,再统一分派。

INTEGER, SSE, MEMORY = "INTEGER", "SSE", "MEMORY"

def merge(a, b):
    """分类合并: 同类保持, 混合取 INTEGER, 含 MEMORY 则整体 MEMORY"""
    if a is None: return b
    if b is None: return a
    if a == MEMORY or b == MEMORY: return MEMORY
    if a == b: return a
    return INTEGER               # INTEGER 与 SSE 混合 -> INTEGER

def classify_field(field):
    if field.kind in ("float", "double"):
        return SSE
    if field.kind in ("int", "ptr", "bool"):
        return INTEGER
    if field.kind in ("struct", "array"):
        return classify_struct(field)   # 递归分类
    return MEMORY

def classify_struct(struct):
    """把结构体切成 eightbyte 并逐块分类"""
    if struct.size > 16:                 # 超过两个 eightbyte
        return [MEMORY, MEMORY]
    n = max(1, (struct.size + 7) // 8)
    classes = [None] * n
    for field in struct.fields:
        lo = field.offset // 8
        hi = (field.offset + field.size - 1) // 8
        c = classify_field(field)
        for i in range(lo, hi + 1):
            classes[i] = merge(classes[i], c)
    return [c or INTEGER for c in classes]

分类结果决定参数放哪:INTEGER 走整数寄存器,SSE 走 xmm 寄存器,MEMORY 走栈。寄存器不足时该参数整体溢出到栈。

def assign_args(classes, int_regs, sse_regs):
    int_it, sse_it = iter(int_regs), iter(sse_regs)
    locs, stack_off = [], 8          # 栈上第一个参数在 [rsp+8] (返回地址占 8)
    for cls in classes:
        reg = None
        if cls == INTEGER:
            reg = next(int_it, None)
        elif cls == SSE:
            reg = next(sse_it, None)
        if reg is not None and cls != MEMORY:
            locs.append(("reg", reg))
        else:
            locs.append(("stack", stack_off))
            stack_off += 8
    return locs

几个具体例子能立刻看出这套规则的效果:

struct Pair { long a; long b; };       // 16 字节, 两个 INTEGER -> rdi:rsi
struct Two  { double x; double y; };   // 16 字节, 两个 SSE     -> xmm0:xmm1
struct Mix  { double d; long i; };     // INTEGER+SSE -> xmm0:rdi
struct Big  { long a[3]; };            // 24 字节 > 16 -> 整个走栈
struct Cpx  { float a, b, c, d; };     // 16 字节, 全 SSE -> xmm0 (打包)

4. 返回值与结构体返回

一句话总结: 小于等于 16 字节的结构体用 rax:rdx 或 xmm0:xmm1 返回;更大的结构体由调用者提供缓冲区,被调用者通过隐藏指针写入,该指针占用 rdi,真实参数顺延。

返回值的规则与参数对称但更微妙:结构体 ≤ 16 字节时按分类放进 rax:rdx(INTEGER)或 xmm0:xmm1(SSE);超过 16 字节则改为「调用者分配、被调用者填充」:

struct Big make_big(void);
// 实际等价于:
//   void make_big(struct Big *ret_slot);   // ret_slot 通过 rdi 传入
//   函数把结果写进 *ret_slot, 同时把 ret_slot 放进 rax 返回
# 调用 struct Big make_big(void)
    sub    $32, %rsp            # 调用者为返回值预留空间
    mov    %rsp, %rdi           # 隐藏指针占 rdi
    call   make_big
    # 结果在 [rsp .. rsp+24), rax 也指向它

这个「隐藏指针占 rdi」的细节是跨语言 FFI 最容易出错的地方:一个 C 函数 void f(struct Big b, int x),x 在 C 里是第二个参数,但实际会落到 rsi,因为 b 占用了 rdi(作为隐藏指针)。写汇编胶水或手写 JIT 时,如果漏掉这条规则,参数会整体错位一格。

def lower_call(func, args):
    """生成调用序列: 处理隐藏返回值指针导致的参数偏移"""
    hidden = func.ret_type.size > 16
    regs = ["rdi", "rsi", "rdx", "rcx", "r8", "r9"]
    if hidden:
        emit("sub", ret_slot_size(func.ret_type), "rsp")
        emit("mov", "rsp", regs[0])       # 隐藏指针占掉第一个寄存器
        regs = regs[1:]                    # 真实参数从 rsi 开始
    for arg, reg in zip(args, regs):
        emit("mov", arg, reg)
    emit("call", func.name)

5. 变参函数与栈帧

一句话总结: 变参函数必须把参数全部压栈或用寄存器保存区,System V 还用 al 记录使用过的向量寄存器个数,va_arg 靠这套约定遍历参数。

变参函数(printf 那类)打破了「参数个数编译期已知」的前提,ABI 必须额外提供遍历手段。

System V AMD64 的做法是:

  1. 调用者把所有参数(包括寄存器传递的)都在栈上留一份「寄存器保存区」,形成 va_list 可遍历的连续区域;
  2. 调用者把使用过的向量寄存器个数放进 al——被调用者据此知道 xmm 保存区里有几个是有效的;
  3. 被调用者用 va_start / va_arg 按「寄存器保存区 → 栈溢出区」的顺序取参。
// printf 的调用约定: al = 使用的向量寄存器个数
// printf("%f %d", 1.0, 2);
    movsd  .LC0(%rip), %xmm0
    mov    $1, %eax              # 用了 1 个向量寄存器
    mov    $2, %esi
    lea    .LC1(%rip), %rdi
    call   printf@PLT
def lower_varargs_call(func, fixed_args, var_args):
    """变参调用: 全部参数走保存区, 并设置 al"""
    n_vector = sum(1 for a in var_args if a.is_float)
    for a in fixed_args + var_args:
        emit_spill_to_save_area(a)      # 无论走寄存器还是栈, 都在栈上留一份
    emit("mov", n_vector, "eax")        # al = 向量寄存器个数
    emit("call", func.name)

这也解释了为什么变参函数的性能通常更差:即便参数本来能走寄存器,也必须额外写一遍保存区;同时被调用者无法对参数个数做静态检查,这也是 printf 格式串漏洞的根源。

6. 跨语言与平台差异

一句话总结: Windows x64 用 rcx/rdx/r8/r9 且要求 32 字节影子空间,ARM64 用 x0~x7 与 v0~v7,C++ 名字修饰因编译器而异——跨边界调用必须显式约定 ABI。

平台整数参数寄存器浮点参数寄存器返回值特殊要求
System V AMD64rdi rsi rdx rcx r8 r9xmm0~7rax:rdx16 字节栈对齐,红区 128 字节
Windows x64rcx rdx r8 r9xmm0~3rax32 字节影子空间,无红区
ARM64 (AAPCS64)x0~x7v0~v7x0/v016 字节栈对齐
32 位 x86 (cdecl)无(全走栈)无(x87 栈)eax调用者清栈
32 位 x86 (stdcall)无(全走栈)无eax被调用者清栈

差异带来的直接后果是跨平台二进制不通用,也意味着任何手写汇编或 JIT 代码都必须按目标平台生成不同的调用序列。C++ 的名字修饰(name mangling)更麻烦:void f(int) 在 Itanium ABI 下是 _Z1fi,在 MSVC 下是 ?f@@YAXH@Z,两者完全不兼容——这也是为什么跨编译器分发 C++ 库如此困难。

// 跨语言边界必须用 C ABI 抹平名字修饰与调用约定差异
extern "C" int process(const char *data, size_t len);

// 显式指定 ABI (GCC/Clang)
__attribute__((ms_abi))   void win_style(void);    // 按 Windows x64 调用
__attribute__((sysv_abi)) void lin_style(void);    // 按 System V 调用

// C++ 类的成员函数: this 指针占第一个参数位
struct Widget { int get() const; };
// 实际等价于: int Widget_get(const Widget *this);

跨语言 FFI 还有几条隐藏规则:

  1. 异常不能穿透 C 边界。C 没有异常语义,C++ 异常穿过 extern "C" 函数是未定义行为;Rust 的 panic 穿过 FFI 边界默认直接 abort。所有跨边界调用都必须把错误编码成返回值。
  2. 结构体布局必须一致。C 的 struct 布局由 ABI 规定,但 Rust 的 #[repr(Rust)] 允许重排字段,跨边界必须标 #[repr(C)]。
  3. 布尔与枚举的宽度。C 的 _Bool 是 1 字节,C++ 的 bool 也是 1 字节,但某些语言(如老式 C 的 int 作布尔)宽度不同,跨边界传递会读到垃圾值。

7. 工程实践与排错

一句话总结: 参数错位、栈失衡、对齐崩溃是三大高频症状;用反汇编、-fverbose-asm 与 ABI 检查工具逐层定位。

# 1. 观察编译器实际生成的调用序列
gcc -S -O2 -fverbose-asm foo.c -o -  | grep -A5 'call'

# 2. 用 objdump 反汇编, 确认参数寄存器
objdump -d --no-show-raw-insn a.out | grep -B8 'call.*process'

# 3. 检查跨模块 ABI 不一致 (LTO 会暴露)
gcc -flto -Wl,--no-allow-shlib-undefined foo.o libbar.a

三类典型症状与原因:

  • 参数错位:最常见于手写汇编或 JIT 时漏算隐藏返回值指针(占 rdi),或混淆了 Windows 与 System V 的寄存器顺序。特征是「前几个参数正常、后面的全是垃圾」。
  • 栈失衡:调用者与被调用者对「谁清栈」的理解不一致(cdecl vs stdcall),或者 push/pop 数量不匹配。表现为函数返回后跳飞、或栈指针漂移。
  • 对齐崩溃:movaps 这类 SSE 指令要求 16 字节对齐操作数,栈没对齐就触发 SIGSEGV。特征是「只在 -O2 下崩溃、-O0 正常」——因为优化器才用上对齐指令。修复方式是确保调用点 rsp 是 16 的倍数。
// 典型对齐崩溃: 手写汇编忘了保持 16 字节对齐
void call_it(void (*fn)(void)) {
    asm volatile(
        "sub $8, %%rsp\n"     // 错误: 破坏 16 字节对齐
        "call *%0\n"
        "add $8, %%rsp\n"
        :: "r"(fn) : "memory");
}
// 正确做法: sub $16 或者干脆不调整 (叶子函数)

8. 总结

概念要点
ABI 组成调用约定、数据布局、名字修饰、运行时约定
参数寄存器SysV: rdi rsi rdx rcx r8 r9 + xmm0~7
保存责任调用者保存 rax/rcx/rdx/rsi/rdi/r8~r11,其余被调用者保存
栈对齐调用点 rsp 须 16 字节对齐;红区 128 字节仅叶子函数可用
参数分类按 eightbyte 分类 INTEGER/SSE/MEMORY,含 MEMORY 整体走栈
大结构体返回调用者分配缓冲区,隐藏指针占 rdi,参数顺延
变参约定全部参数留栈上保存区,al 记录向量寄存器个数
平台差异Windows x64 用 rcx 且需 32 字节影子空间;ARM64 用 x0~x7
跨语言用 C ABI 抹平名字修饰;异常不得穿透边界;结构体须 repr(C)
排错手段-S -fverbose-asm、objdump -d、对齐检查

ABI 是编译器世界里少有的「不许优化」的领域:它把函数接口钉死成二进制契约,代价是牺牲了跨模块的寄存器分配自由度,换来的是不同编译器、不同语言、不同年代的代码能够互操作。理解调用约定的价值在于:它让你在写汇编、写 JIT、做 FFI、甚至只是读反汇编时,能准确说出「这个参数现在到底在哪个寄存器里」,而这恰恰是定位底层 bug 时最需要的那一层确定性。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「compiler」更多文章

  1. 浮点语义与快速数学优化
  2. 查询式编译器与增量类型检查:Salsa 架构
  3. Sanitizer 与编译期安全加固:ASan、TSan 与 CFI