跳到内容

第 3 卷 / 第 17 章 / 第 1 课

17.1 汇编基础与调用约定 ​

C 与 C++ 程序经过编译和链接后,通常以机器指令交给 CPU 执行。函数调用必须遵守真实协议:参数放在哪里、由谁保存寄存器、返回值怎样交接,都由 ABI 与调用约定约束。

CPU 不认识 C 的参数名和返回类型。两个分别编译的函数之所以能互相调用,是因为它们遵守同一套 Application Binary Interface(ABI):参数放哪里、哪些寄存器必须保存、栈如何对齐、聚合类型怎样拆分、符号怎样命名。

本章以 Linux 常用的 System V AMD64 LP64 ABI 为主,long 和指针均为 64 位,不讨论 x32 ILP32。Windows x64、AArch64 和 RISC-V 规则不同,跨平台代码不能照搬寄存器表。

1. 先读懂最小函数 ​

c
int add(int left, int right) {
    return left + right;
}

add 的前提是数学和能用 int 表示;看到 leal 并不意味着 C 的 signed overflow 获得了回绕语义。在 x86-64 SysV 下,前两个整数参数通常在 %edi、%esi,int 返回值在 %eax。GCC 使用 AT&T 语法时,优化后的汇编可能接近:

asm
add:
    leal    (%rdi,%rsi), %eax
    ret

这不是 C 语句与汇编的一一翻译。编译器发现不需要栈槽、帧指针或保存寄存器,于是整个函数只剩运算和返回。

AT&T 语法通常写成“源,目的”,寄存器带 %,立即数带 $;Intel 语法常写成“目的,源”。看反汇编前先确认语法,避免把数据方向读反。

2. 整数参数与返回值 ​

SysV AMD64 对 integer class 的前六个参数依次使用:

text
1: rdi
2: rsi
3: rdx
4: rcx
5: r8
6: r9

int 等较窄值使用相应子寄存器,如 edi;指针与常见 64 位整数使用完整寄存器。整数类返回值通常从 rax 开始,必要时还可使用 rdx。

常见 float/double 参数使用 xmm0–xmm7;AVX 向量可用对应 ymm/zmm 寄存器,long double 等类型另有分类规则。一个函数同时含整数与浮点参数时,两组分配规则分别推进,不是所有参数共享“前六个”槽位。

聚合类型还要按 ABI 的 eightbyte classification 递归分类。记住六个整数寄存器只能解决常见标量函数,不能替代正式 ABI 规则。

3. Caller-saved 与 callee-saved ​

函数调用会把寄存器分成两类:

类别SysV AMD64 常见寄存器责任
caller-savedrax, rcx, rdx, rsi, rdi, r8–r11调用者若还要旧值,调用前保存
callee-savedrbx, rbp, r12–r15被调用者若修改,返回前恢复
stack pointerrsp返回时恢复到调用约定要求的位置

XMM 数据寄存器由调用者保留所需值。普通算术 flags 不能指望跨调用保持,但方向标志 DF 在函数入口和返回时必须清零;浮点控制状态也有独立保存规则。

“caller-saved 会被踩”不表示被调用者必须全部修改;它表示调用者不能依赖它们保持。编译器会根据活跃值与寄存器压力决定保存到其他寄存器还是栈。

callee-saved 也不一定在函数入口一律 push。只使用了 r12 的函数只需保存它;编译器也可以用 mov 保存,并把恢复安排进多个返回路径。

4. 栈对齐、返回地址与 frame pointer ​

x86-64 的 call 会把下一条指令地址压栈,再跳到目标。常见标量调用要求调用者在执行 call 前满足至少 16 字节栈对齐,因此普通函数刚进入时,rsp 因 8 字节返回地址而相对 16 字节边界偏移 8。

栈上传递 __m256、__m512 或更高对齐要求的对象时,参数区可能要求 32、64 字节或更强对齐,不能把 16 当作所有签名的唯一要求。

一个使用 frame pointer 的示意帧可能是:

text
higher addresses
  stack-passed arguments
  return address          <- call wrote this
  saved rbp               <- optional function prologue
  saved callee registers
  local spill slots
  outgoing call area / alignment
lower addresses           <- rsp after allocation

这不是每个函数都拥有的固定模板。优化器常省略 rbp frame pointer,局部变量可能在寄存器,栈槽也可复用。尾调用可能直接跳到另一个函数,不再创建新的返回地址。

Red zone ​

SysV AMD64 定义了 rsp 以下 128 字节 red zone,用户态函数可在其中暂存不需要跨函数调用存活的数据,leaf function 尤其常用,并约定 signal/interrupt handler 不破坏它。内核代码和使用不同 ABI/编译选项的环境不能盲目依赖这一区域。

5. 第七个整数参数之后 ​

当 integer argument registers 不够时,剩余参数进入调用者准备的栈参数区域。不能把规则简单写成“从右向左逐个 push”:

  • 调用者要满足整体栈对齐;
  • 参数可能需要自己的对齐与分类;
  • 编译器常先一次调整 rsp,再用 mov 填入槽位;
  • 聚合参数可能被拆到寄存器与内存规则中;
  • 返回大对象的隐藏参数会占用一个参数寄存器;
  • 可变参数还有额外 ABI 元数据。

对八个 long 参数的普通函数,前六个使用寄存器,第七、第八个从 callee 入口处的返回地址上方读取。具体偏移要结合 prologue 是否移动 rsp/rbp 判断,不能只看源码序号硬背。

6. 小结构体不一定走内存返回 ​

旧规则“超过 8 字节就用隐藏指针”是错的。SysV AMD64 按 eightbyte 分类聚合返回值,常见非向量小结构体可用两个 eightbyte 的寄存器返回;向量及特殊分类要另查规则。例如:

c
typedef struct {
    long x;
    long y;
} Pair;

两个 long 通常属于两个 INTEGER eightbyte,所以 Pair 可以通过 rax 和 rdx 返回,不必写入调用者缓冲区。

更大的对象或被分类为 MEMORY 的对象通常使用 sret 协议:调用者提供返回缓冲区地址,并把隐藏指针作为隐式参数传给函数。它会改变后续显式参数所用寄存器,callee 还按 ABI 约定返回相应地址。

“用输出参数一定比返回结构体快”也不成立。小聚合寄存器返回很高效;即使使用隐藏缓冲区,编译器也可能通过返回值优化消除多余复制。先看目标 ABI 与生成代码。

7. 一个可反汇编的实验 ​

保存为 abi-demo.c。下面覆盖八个 long 参数、16 字节 Pair 与 24 字节 Triple 返回;实验输入的 signed 求和不会溢出。noinline 是 GCC/Clang 扩展,用来保持函数边界便于观察:

c
#include <stdio.h>
#include <stdlib.h>

#if defined(__GNUC__) || defined(__clang__)
#define NOINLINE __attribute__((noinline))
#else
#define NOINLINE
#endif

_Static_assert(sizeof(long) == 8 && sizeof(void *) == 8,
               "this experiment expects LP64");

typedef struct {
    long x;
    long y;
} pair_t;

typedef struct {
    long x;
    long y;
    long z;
} triple_t;

NOINLINE long sum_eight(
        long a, long b, long c, long d,
        long e, long f, long g, long h) {
    return a + b + c + d + e + f + g + h;
}

NOINLINE pair_t make_pair(long x, long y) {
    pair_t result = { x, y };
    return result;
}

NOINLINE triple_t make_triple(long x, long y, long z) {
    triple_t result = { x, y, z };
    return result;
}

int main(void) {
    volatile long input[8] = { 1, 2, 3, 4, 5, 6, 7, 8 };
    long total = sum_eight(
        input[0], input[1], input[2], input[3],
        input[4], input[5], input[6], input[7]
    );
    pair_t pair = make_pair(input[0], input[1]);
    triple_t triple = make_triple(
        input[0], input[1], input[2]
    );

    printf("%ld | %ld,%ld | %ld,%ld,%ld\n",
           total,
           pair.x, pair.y,
           triple.x, triple.y, triple.z);

    return total == 36
        && pair.x == 1 && pair.y == 2
        && triple.x == 1 && triple.y == 2 && triple.z == 3
        ? EXIT_SUCCESS : EXIT_FAILURE;
}
bash
cc -std=c17 -O2 -g -Wall -Wextra -Werror -fno-omit-frame-pointer abi-demo.c -o abi-demo
./abi-demo

cc -std=c17 -O2 -S -fverbose-asm abi-demo.c -o abi-demo.s
objdump -drwC abi-demo

观察点:

  1. sum_eight 从哪里读取第七、第八个参数?
  2. make_pair 是否使用 rax/rdx?
  3. make_triple 是否接收隐藏的结果缓冲区指针(它不是 call 压入的返回地址)?
  4. 开启和关闭 -fno-omit-frame-pointer 后,prologue 如何变化?
  5. 去掉 volatile input 后,编译器能否进一步常量传播调用?

这里的 volatile 保留输入访问,noinline 保留调用,但不禁止所有跨过程优化或函数克隆。示例函数使用外部链接;需要严格验证独立编译边界时,把函数与调用者分到两个编译单元并关闭 LTO。volatile 仍不是线程同步。

8. 工具分别回答什么 ​

让编译器输出汇编 ​

bash
cc -O2 -S source.c -o source.s
cc -O2 -S -masm=intel source.c -o source-intel.s

源码级汇编容易看函数边界和编译器注释,但还没有最终链接地址。

看 object file ​

bash
cc -O2 -c source.c -o source.o
objdump -drwC source.o
readelf -s source.o
readelf -r source.o

objdump -dr 把反汇编与 relocation 放在一起。readelf -s 看 symbol,readelf -r 看链接器以后要修补的位置。object 中的 call 位移可能仍是占位值。

看运行时栈 ​

text
(gdb) break make_triple
(gdb) run
(gdb) info registers rdi rsi rdx rcx r8 r9 rsp rbp
(gdb) x/16gx $rsp
(gdb) info frame
(gdb) disassemble /m make_triple

调试器结果受优化影响。变量可能被消除,函数可能内联,尾调用可能让 backtrace 少一层。-O0 便于逐行,但会制造大量教学型栈槽;-O2 -g 更接近真实代码。

9. 可变参数为什么更复杂 ​

printf 一类 variadic function 不知道未命名参数的类型,调用者和 callee 必须遵循额外 ABI 规则。SysV AMD64 用 %al 告诉可变参数调用点,使用了多少个 vector register 参数的上界;callee 还要建立或使用 register save area,让 va_arg 能依次取得寄存器与栈参数。

这不是“%al 精确保存所有浮点参数数量”的通用规则,也不适用于非可变参数函数。声明与定义若对是否 variadic、参数类型或 prototype 理解不一致,就会从错误位置解释比特。

C 的 default argument promotions 也会在 ... 中把 float 提升为 double,把小整数按整数提升规则变成 int 或 unsigned int。printf format 与实参类型不匹配是未定义行为,不是一次安全的动态转换。

10. 内联汇编的真正风险 ​

GCC extended asm 的格式是:

text
asm qualifiers (
    template
    : output operands
    : input operands
    : clobbers
)

约束告诉优化器输入放哪里、哪些值被修改、是否触碰未列出的内存。模板里的机器指令写对了,约束写错仍会得到错误程序,因为编译器可能把两个本应分离的操作数分配到同一寄存器,或把内存访问移动到 asm 周围。

asm volatile 阻止某些删除与循环外提优化,但编译器仍可能移动、复制或合并特定 asm,不自动成为 C 线程内存屏障。"memory" clobber 约束编译器对内存访问的移动,也不会单独发出目标 CPU 所需的硬件 fence。

原子操作应优先使用 <stdatomic.h> 或编译器原子 built-in,系统调用使用已有 wrapper。内联汇编适合实现编译器/内核边界或确实没有 intrinsic 的指令,并需要目标架构专家审查。

11. ABI 不匹配怎样出错 ​

链接器主要按 symbol 找地址,不会替 C 函数做完整运行时类型校验。以下不一致都可能成功链接后静默破坏状态:

  • 调用者认为参数是 int,callee 按 double 从 XMM 寄存器读取;
  • 一边使用不同结构体 packing;
  • 一边启用了不兼容 calling convention;
  • variadic format 与实际类型不符;
  • C 与其他语言的 FFI 对字符串、所有权或异常展开约定不同;
  • 编译单元对同一函数使用不兼容声明。

公共头文件、FFI 生成器和 ABI 测试帮助各方共享契约;extern "C" 指定 C 语言链接,不自动转换数据布局、所有权或 C++ 异常。跨语言边界还应明确内存由谁分配和释放。

12. 架构对比 ​

AArch64 常用 x0–x7 传前八个整数参数,RISC-V 常用 a0–a7。它们的 callee-saved 集合、浮点寄存器、栈对齐和聚合分类各自不同。

“RISC-V 参数寄存器更多,所以 ABI 更好”没有意义。寄存器数量、编码、调用密度、编译器策略和工作负载共同决定性能。切换目标架构时,应阅读对应 psABI,而不是机械替换寄存器名。

13. 小结 ​

calling convention 是分别编译的代码共享的二进制协议:

  • 常见整数参数先走 rdi, rsi, rdx, rcx, r8, r9;
  • caller-saved 值跨调用不受保护,callee-saved 值由使用它的 callee 恢复;
  • 栈参数区域还受对齐、类型分类和隐藏参数影响;
  • frame pointer 可以省略,栈帧没有唯一固定图;
  • 两个 integer eightbyte 的小结构体可以寄存器返回;
  • 大型或 MEMORY class 返回值通常使用隐藏缓冲区;
  • variadic、内联汇编和跨语言 FFI 都要求更严格的 ABI 契约。

下一章进入性能工程:从测量开始,把汇编、cache、分支、锁和 I/O 的成本放回真实工作负载。

对照 ABI 与编译器约束 ​

Built with VitePress | Software Systems Atlas