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 的做法是:
- 调用者把所有参数(包括寄存器传递的)都在栈上留一份「寄存器保存区」,形成
va_list可遍历的连续区域; - 调用者把使用过的向量寄存器个数放进
al——被调用者据此知道xmm保存区里有几个是有效的; - 被调用者用
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 AMD64 | rdi rsi rdx rcx r8 r9 | xmm0~7 | rax:rdx | 16 字节栈对齐,红区 128 字节 |
| Windows x64 | rcx rdx r8 r9 | xmm0~3 | rax | 32 字节影子空间,无红区 |
| ARM64 (AAPCS64) | x0~x7 | v0~v7 | x0/v0 | 16 字节栈对齐 |
| 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 还有几条隐藏规则:
- 异常不能穿透 C 边界。C 没有异常语义,C++ 异常穿过
extern "C"函数是未定义行为;Rust 的panic穿过 FFI 边界默认直接 abort。所有跨边界调用都必须把错误编码成返回值。 - 结构体布局必须一致。C 的
struct布局由 ABI 规定,但 Rust 的#[repr(Rust)]允许重排字段,跨边界必须标#[repr(C)]。 - 布尔与枚举的宽度。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 时最需要的那一层确定性。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。