目标代码与 ABI

本页目录

ABI 是二进制组件之间的约定,包含数据布局、调用约定、符号及对象格式等方面。函数边界两侧即使由不同编译器生成,也要对参数在哪里、哪些寄存器会被修改和返回值如何表示取得一致。

违反 ABI 可能立即崩溃,也可能只是悄悄读错参数或破坏一个以后才使用的值。本文以常见用户态 x86-64 调用为例;系统调用、内核代码和特殊调用约定需要单独看待。

参数先分类,再分配位置

System V AMD64 的前六个常见整数或指针参数使用 RDI、RSI、RDX、RCX、R8、R9;浮点参数使用另一套 XMM 寄存器规则。聚合类型需要按 ABI 分类,不能把所有参数都塞进同一个“前六个”列表。

Windows x64 的前四个参数位置按其类型使用 RCX/RDX/R8/R9 或对应 XMM0–3,调用者预留至少 32 字节的寄存器参数 home/shadow space。混合整数浮点参数并不是两套独立的无限前四名队列。Microsoft x64 调用约定

项目System V AMD64 常见规则Windows x64 常见规则
整数/指针寄存器RDI、RSI、RDX、RCX、R8、R9前四个位置用 RCX、RDX、R8、R9
标量浮点按规则分配 XMM0–7前四个位置对应 XMM0–3
shadow space没有 Windows 式强制四槽区调用者预留 32 字节
red zone用户态有 128 字节约定无相同保证
聚合、变参、向量查分类与特殊规则查独立规则

返回值同样按类型分类:标量整数常用 RAX,标量浮点常用 XMM0,某些聚合可拆到多个寄存器,另一些通过隐藏结果指针返回。“128 位结构体统一放 RDX:RAX”并不正确。AMD64 ABI

同一个混合参数声明,寄存器分配为什么不同

观察固定原型 double mix(int a, double b, int c, double d),不涉及变参或聚合。System V 分别消耗整数和浮点参数序列:a 使用 EDI,b 使用 XMM0,c 使用 ESI,d 使用 XMM1。Windows x64 按参数位置选择寄存器:a 使用 ECX,b 使用 XMM1,c 使用 R8D,d 使用 XMM3。

参数System V AMD64Windows x64
a:第 1 个参数,intEDIECX
b:第 2 个参数,doubleXMM0XMM1
c:第 3 个参数,intESIR8D
d:第 4 个参数,doubleXMM1XMM3

表中 int 只使用对应的 32 位值;不要从声明推断其寄存器其余位可用于传递其他参数。以下源文件不依赖平台头文件,可以分别生成两种目标的汇编:

/* mix.c */
double mix(int a, double b, int c, double d) {
    return a + b + c + d;
}
clang --target=x86_64-unknown-linux-gnu -O2 -S -masm=intel mix.c -o mix-sysv.s
clang --target=x86_64-pc-windows-msvc -O2 -S -masm=intel mix.c -o mix-win.s

从整数转 double 的指令可辨认两个整数来源,再沿加法链观察浮点输入。这里仅跨目标生成汇编,不需要运行 Windows 程序;返回值都是 XMM0,但输入布局不同,不能通过强制转换函数指针来消除差异。Microsoft 参数传递

参数位置与类型队列,为什么不能混用?

下面不只展示一份固定表,而是让参数类型改变时重新分配位置。System V 侧的 int/double 分别推进自己的序列;Windows 侧保留参数位置,因此第二个位置的 double 是 XMM1,即使 XMM0 尚未用于传参。超过前四个位置后,本例改用栈槽;这与是否还有某个空闲寄存器无关。

正在呈现知识画面
参数位置与类型队列,为什么不能混用?

System V 的本例分别消耗整数和浮点序列;Windows 前四个参数按位置选择 GPR 或 XMM,不能把第五个浮点参数重新塞进 XMM0。

保存约定:谁负责让值跨调用存活

Caller-saved 表示被调用者可以修改,调用者若还需要旧值,应先保存或换位置。Callee-saved 表示被调用者使用该寄存器时,需要在返回前恢复调用者的值。

System V 中 RBX、RBP、R12–R15 属于常见被调用者保存寄存器,RSP 必须按调用返回的约定恢复。RAX、RCX、RDX、RSI、RDI、R8–R11 可被调用破坏。向量寄存器和状态寄存器也有各自规则,不能只检查通用寄存器。

这直接影响寄存器分配:跨调用存活的值不能无保护地放在会被调用破坏的位置。

栈布局与对齐

System V 示例:使用 rbp 的一种栈帧布局 栈上传递的第 7 个整数参数 rbp + 16 返回地址 rbp + 8 调用者的 rbp rbp 局部变量 / spill / 保存区 负偏移,布局由编译器决定 当前已分配区域的低地址边界 rsp 高地址 低地址 仅为一种布局;帧指针可省略,保存区顺序和局部变量位置并不由这张图固定。

对常见 System V 调用,call 之前 RSP 满足 16 字节对齐,call 压入 8 字节返回地址后,函数入口的 (RSP+8) 满足该对齐。某些栈上传递向量等情况有更强要求,应以对应 ABI 规则为准。

因此展示七个整数参数时,不能只写 push 第七参数; call 却完全忽略当前栈状态和填充。调用者可以预留整块参数区再写入,不必按源代码参数顺序逐个 push。

局部变量不一定在栈上,优化后可能在寄存器中、被常量替代或完全消失。帧指针也可省略;展开信息描述如何恢复调用者状态,不只是保存一张从 RSP 找旧 RBP 的表。ELF 中运行时展开常涉及 .eh_frame 等信息。

序言与尾声:满足约定的代码,不是固定模板

序言可能保存被调用者保存寄存器、分配栈空间、设置帧指针;尾声恢复相应状态。叶函数可能没有这些动作,优化还可以将保存推迟到真正需要的路径。

下面是一个 Linux System V 下调用外部 twice(long)、再加 1 的 GNU 汇编示例。使用 Intel 语法,入口 RSP 模 16 为 8,减 8 后为后续 call 建立对齐。

.intel_syntax noprefix
.text
.globl plus_one_twice
.type plus_one_twice, @function
plus_one_twice:
    .cfi_startproc
    sub rsp, 8
    .cfi_def_cfa_offset 16
    call twice@PLT
    add rsp, 8
    .cfi_def_cfa_offset 8
    add rax, 1
    ret
    .cfi_endproc
.size plus_one_twice, .-plus_one_twice
.section .note.GNU-stack,"",@progbits

首个 long 参数已经位于 RDI,返回值位于 RAX;这个函数没有使用需要额外保存的 RBX 等寄存器。CFI 随栈调整描述 CFA,以便展开器恢复调用状态。

要在调用后继续使用原参数,就必须保留它

把计算改成 twice(x) + x。x 起初位于 RDI,而 twice 可以合法改写 RDI,因此返回后再直接加 RDI 是错误的。下面先把调用者的 RBX 保存到栈,再用 RBX 保留 x;twice 必须恢复它收到的 RBX,最后本函数再恢复上层调用者的旧值。

# keep_x.s:GNU 汇编,Linux System V AMD64
.intel_syntax noprefix
.text
.globl keep_x
.type keep_x, @function
keep_x:
    .cfi_startproc
    push rbx
    .cfi_def_cfa_offset 16
    .cfi_offset rbx, -16
    mov rbx, rdi
    call twice@PLT
    add rax, rbx
    pop rbx
    .cfi_def_cfa_offset 8
    .cfi_restore rbx
    ret
    .cfi_endproc
.size keep_x, .-keep_x
.section .note.GNU-stack,"",@progbits

入口 RSP 模 16 为 8,push 的 8 字节既保存 RBX,也使 call 前 RSP 模 16 为 0。这里不应照抄上一段的 sub rsp,8 再减一次,否则又会破坏这一调用点的对齐。

/* abi-main.c:与 keep_x.s 一起编译 */
extern long keep_x(long);
long twice(long x) { return x * 2; }
int main(void) {
    return keep_x(7) != 21 || keep_x(-3) != -9;
}

执行 cc -O2 abi-main.c keep_x.s -o abi-demo 后运行 abi-demo,正常退出状态为 0。只用这里的有限小值,避免让 long 乘法或加法的有符号溢出掩盖 ABI 问题。

一个编译器碰巧没有修改 RDI,不能证明错误写法正确。检查调用边界要依据被调方允许破坏哪些状态,而不是只观察某次机器码;实现变化、插桩或链接到另一个兼容定义都可能让潜伏错误显现。

RBX 的两份值属于不同调用层 进入 keep_x RBX = 上层旧值 push 后借用 RBX 栈保存旧值;RBX = x twice 返回后 用 x 求和,再 pop 恢复 入口 rsp % 16 = 8 → push 后 = 0 → call 压入返回地址 被调用者保存寄存器可以使用,但返回时必须把调用者看到的值恢复。

跨调用的 x,保存在哪儿才可靠?

值是否保住、上层寄存器是否恢复、调用点是否对齐,是三项独立检查。下面允许构造“算出 21,却破坏 RBX”或“值都正确,却额外减栈导致失去对齐”的情况。数值结果是对示例被调方行为的符号推演;一旦违反 ABI,就不能把它当作真实机器执行仍保证返回该数值的承诺。

正在呈现知识画面
跨调用的 x,保存在哪儿才可靠?

RDI 允许被被调方改写。用 RBX 保留 x 时必须先保存上层的旧 RBX;这次 push 已经完成对齐,再照抄 sub rsp,8 会破坏它。

Red zone:不能让临时值无保护地跨调用

System V 用户态在 RSP 以下提供 128 字节 red zone,信号处理等需遵守相关约定。函数可用它保存临时数据而不调整 RSP,叶函数尤其常见。

但嵌套调用会使用更低地址的栈空间,不能指望 red zone 中的值安全跨过普通函数调用。非叶函数也可以在适当区间使用不需跨调用存活的临时数据,因此“只有叶函数在任何意义上能用”过于绝对。内核、中断和其他平台不能直接照搬此保证。

变参:调用方知道实际参数,被调方按约定取出

printf 等函数的声明没有列出全部实参类型,但调用点的编译器知道实际表达式,并应用默认参数提升,例如 float 提升为 double。格式字符串和实际实参是否匹配仍属于语言与接口检查问题。

System V 对变参调用使用 AL 表示向量寄存器参数使用数量的上界,取值范围按 ABI 规定。被调方按需要建立寄存器保存区,va_list 跟踪寄存器区与栈参数区。不是调用方普遍把所有 XMM 参数预先写进被调方的 va_list。

Windows 的变参规则不同,浮点实参在相关位置可能需要同时放入通用寄存器。跨 ABI 手写桥接时必须专门处理,而不能只替换参数寄存器名字。

PIC 与 PIE:减少绝对地址依赖

位置无关代码常利用 PC 相对寻址访问局部数据与代码,对可被动态绑定的符号使用 GOT、PLT 或其他约定路径。不是所有 PIC 符号访问都必然多一次 GOT 间接:绑定、可见性和链接器放松可能允许直接的相对访问。

PIE 是可执行文件的位置无关形式之一,配合加载地址随机化使用。是否需要某种重定位以及重定位发生在何处,要结合对象格式和符号规则。见链接与加载。

跨语言边界速查

核对函数原型、数据大小与对齐、聚合分类、调用约定、异常是否能跨边界、内存由谁释放,以及符号名称。extern "C" 一类声明能指定部分链接与调用规则,但不会自动让所有语言对象都具有兼容布局和生命周期。

参考

Keywords: ABI, calling convention, stack alignment, caller-saved, callee-saved, shadow space, red zone, variadic, CFI, PIC, PIE