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. 先读懂最小函数
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 语法时,优化后的汇编可能接近:
add:
leal (%rdi,%rsi), %eax
ret这不是 C 语句与汇编的一一翻译。编译器发现不需要栈槽、帧指针或保存寄存器,于是整个函数只剩运算和返回。
AT&T 语法通常写成“源,目的”,寄存器带 %,立即数带 $;Intel 语法常写成“目的,源”。看反汇编前先确认语法,避免把数据方向读反。
2. 整数参数与返回值
SysV AMD64 对 integer class 的前六个参数依次使用:
1: rdi
2: rsi
3: rdx
4: rcx
5: r8
6: r9int 等较窄值使用相应子寄存器,如 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-saved | rax, rcx, rdx, rsi, rdi, r8–r11 | 调用者若还要旧值,调用前保存 |
| callee-saved | rbx, rbp, r12–r15 | 被调用者若修改,返回前恢复 |
| stack pointer | rsp | 返回时恢复到调用约定要求的位置 |
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 的示意帧可能是:
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 的寄存器返回;向量及特殊分类要另查规则。例如:
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 扩展,用来保持函数边界便于观察:
#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;
}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观察点:
sum_eight从哪里读取第七、第八个参数?make_pair是否使用rax/rdx?- make_triple 是否接收隐藏的结果缓冲区指针(它不是 call 压入的返回地址)?
- 开启和关闭
-fno-omit-frame-pointer后,prologue 如何变化? - 去掉
volatile input后,编译器能否进一步常量传播调用?
这里的 volatile 保留输入访问,noinline 保留调用,但不禁止所有跨过程优化或函数克隆。示例函数使用外部链接;需要严格验证独立编译边界时,把函数与调用者分到两个编译单元并关闭 LTO。volatile 仍不是线程同步。
8. 工具分别回答什么
让编译器输出汇编
cc -O2 -S source.c -o source.s
cc -O2 -S -masm=intel source.c -o source-intel.s源码级汇编容易看函数边界和编译器注释,但还没有最终链接地址。
看 object file
cc -O2 -c source.c -o source.o
objdump -drwC source.o
readelf -s source.o
readelf -r source.oobjdump -dr 把反汇编与 relocation 放在一起。readelf -s 看 symbol,readelf -r 看链接器以后要修补的位置。object 中的 call 位移可能仍是占位值。
看运行时栈
(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 的格式是:
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 的成本放回真实工作负载。