第17章:汇编基础与调用约定
元数据卡
| 属性 | 值 |
|---|---|
| 难度 | Lv.2(需要 C 指针基础) |
| 核心架构 | x86-64(主)+ RISC-V(对比窗) |
| ABI | System V AMD64 |
| 核心工具 | gcc -S / objdump / readelf / gdb |
| 前置章节 | Vol 1 ch6(内存模型)、Vol 3 ch16(链接) |
你在哪
"C 和 C++ 的魔法最终归结为汇编语言和机器码。你站在 CPU 面前,看到它接受的最底层的指令。调用约定定义了函数怎么传参、怎么返回——这是编译器给你创造的幻觉。"
编译器把 a = b + c 变成三条机器指令就交差了。但 CPU 是单线程的流水线工人——它不认识变量名,只认识寄存器编号和内存地址。
问题来了:函数 foo() 调用 bar(a, b, c) 时,参数放哪?返回值怎么取回来?bar 用过的寄存器会不会把 foo 的数据踩烂?
世上没有"自动"这回事。调用约定(calling convention)就是人类和 CPU 之间签的契约——谁负责保存什么、参数走哪个通道、栈怎么收拾。嚼碎它,你才算真正"看见"你的程序怎么跑。
一句话定调:本章就是把你从"写 C 然后祈祷编译器搞定一切",变成"能读汇编、能看懂栈帧、能解释为什么
int和long混用会静默出 bug"的人。
你的任务
本章分层
- 必读:x86-64 寄存器分工(caller-saved vs callee-saved);System V AMD64 ABI 的参数传递规则;栈帧布局
- 选读:参数超过 6 个时的栈上布局;结构体返回值大于 16 字节的隐藏指针机制
- 深水区:符号表与重定位表的结构;内联汇编的模板语法与约束 本章不会要求你掌握
- 符号表(.symtab)和重定位表(.rela.text)的解析
- 内联汇编的完整约束列表
- Windows x64 调用约定(__vectorcall/__fastcall)
- 理解 x86-64 通用寄存器的分工(caller-saved vs callee-saved)
- 背下 System V AMD64 ABI 的参数传递规则(6 个寄存器 + 栈)
- 能手绘栈帧布局:返回地址 → 旧 rbp → 局部变量 → 调用参数
- 能用
gcc -S看自己的代码长什么样 - 能在 gdb 里用
info frame和x/8xg $rsp扒栈
遭遇战 → 获得技能
遭遇战 #1:寄存器政治
先扔一个最短的 C 函数:
int add(int a, int b) {
return a + b;
}用 gcc -O0 -S add.c 编译,你会看到类似这样的东西(x86-64):
add:
pushq %rbp # 保存调用者的帧指针
movq %rsp, %rbp # 设置当前帧指针
movl %edi, -4(%rbp) # 参数1(edi)→ 栈上局部变量
movl %esi, -8(%rbp) # 参数2(esi)→ 栈上局部变量
movl -4(%rbp), %eax
addl -8(%rbp), %eax # eax = a + b
popq %rbp # 恢复调用者的帧指针
ret # 返回值在 %eax 中关键观察:
| 寄存器 | 角色 | 谁负责保存 |
|---|---|---|
%rdi | 第1个整数参数 | 调用者(被调用者可以随意改) |
%rsi | 第2个整数参数 | 调用者 |
%rdx | 第3个整数参数 | 调用者 |
%rcx | 第4个整数参数 | 调用者 |
%r8 | 第5个整数参数 | 调用者 |
%r9 | 第6个整数参数 | 调用者 |
%rax | 返回值 | 调用者 |
%rbx | 通用 | 被调用者(用完必须恢复原值) |
%rbp | 帧指针 | 被调用者 |
%rsp | 栈指针 | 被调用者(函数返回时必须还原) |
%r12–%r15 | 通用 | 被调用者 |
Caller-saved 的意思是:调用 bar() 之前,如果你还想要 %rdi 里的值,你自己把它压栈。被调用者想怎么改就怎么改。
Callee-saved 的意思是:被调用者如果要用 %rbx,它必须在入口处 push 保存,返回前 pop 恢复。
💡 直觉:Caller-saved ≈ 你借给别人一本书,他可能在上面乱写——你还想要就自己复印一份。Callee-saved ≈ 你借给别人一本古籍,他用完必须擦干净还回来。
遭遇战 #2:参数超过6个怎么办?
int many_args(int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8) {
return a1 + a2 + a3 + a4 + a5 + a6 + a7 + a8;
}%rdi 到 %r9 装前 6 个,第 7 个(a7)开始压栈——从右向左依次入栈。调用者负责压,调用者负责清。
栈布局(调用者视角,入栈顺序从右到左):
高地址
+-------------------+
| a8 (第8个参数) | ← 调用者压入
| a7 (第7个参数) | ← 调用者压入
| 返回地址 | ← call 指令自动压入
| 旧 %rbp | ← pushq %rbp
| 局部变量 |
+-------------------+
低地址(%rsp 指向这里)RISC-V 对比窗:RISC-V 用 a0–a7(8 个寄存器)传参,x86-64 只有 6 个。这不是"更好",这是 RISC-V 寄存器更多(32 个 vs 16 个)。
遭遇战 #3:结构体返回值
typedef struct { long x, y; } Point;
Point make_point(long a, long b) {
Point p = {a, b};
return p;
}当返回值放不进 %rax(> 8 字节且不是 __int128),调用者会偷偷在栈上分配一个缓冲区,把地址通过 %rdi(作为隐藏的第一个参数)传给被调用者。是的,make_point(a, b) 在 ABI 层面是 make_point(&hidden, a, b)。
这解释了:为什么返回大结构体时性能更差——多了一次内存写入。
常见陷阱
实验:看你的代码怎么编译
编译/链接参考:
readelf -S和readelf -r查看符号表和重定位表的命令属于编译/链接章节的范畴。本卷主线并不需要理解符号表结构——知道这些命令存在即可,需要时查手册。
# 生成汇编
gcc -O2 -S foo.c -o foo.s
# 看符号表和节头
readelf -S foo.o
readelf -s foo.o
# 反汇编
objdump -d foo.o
# 看重定位条目(链接前)
readelf -r foo.o
# 运行时看栈
gdb ./a.out
(gdb) break main
(gdb) info frame
(gdb) x/16xg $rsp # 看栈上16个8字节
(gdb) info registers rbp rsp rip展示参考:以下内联汇编只为展示“汇编如何嵌入 C 代码”的全貌。内联汇编的完整约束语法是编译器特有的,本卷不要求掌握。
实验:手写内联汇编
static inline long atomic_add(long *ptr, long val) {
long prev;
__asm__ volatile(
"lock xaddq %0, %1"
: "=r"(prev), "+m"(*ptr)
: "0"(val)
: "memory", "cc"
);
return prev + val;
}内联汇编模板格式:指令 : 输出操作数 : 输入操作数 : 破坏列表
"=r"(prev)→ 输出,任意寄存器,赋值给prev"+m"(*ptr)→ 读+写,内存操作数"0"(val)→ 输入,绑定到与输出 %0 相同的寄存器"memory"→ 告诉编译器这段汇编可能改内存(防止重排)"cc"→ 改了条件码寄存器
通关挑战
- 写一个
swap(int *a, int *b),用gcc -O0 -S生成汇编,指出哪个寄存器传了哪个参数,栈帧多大 - 解释为什么
long和int混用时,%rdi的高 32 位可能残留垃圾数据(提示:x86-64 的隐式零扩展规则) - 用 gdb 在
add()入口处查看栈,画出完整的栈帧图(标注每个 8 字节的内容) - 写一个可变参数函数
sum(int count, ...),用gcc -S看它怎么用%al传递可变参数的浮点数量 - RISC-V 对比题:在 RISC-V 上写同样的
many_args(1..8),对比栈上参数偏移
验收标准
| 要求 | 自检 |
|---|---|
| 能说出 6 个参数寄存器的名字和序号 | %rdi→1, %rsi→2, ..., %r9→6 |
| 能画一幅完整的栈帧图(含返回地址、旧 rbp、局部变量、溢出参数) | 高地址在下,低地址在上 |
| 能解释 caller-saved vs callee-saved 的区别 | 谁 push 谁 pop |
能读懂 gcc -S 输出中的 movl/movq、pushq/popq、call/ret | 后缀 l=32bit, q=64bit |
能解释结构体返回值隐藏的 %rdi 传地址机制 | >16 字节走隐藏指针 |
| 能写出合法的基本内联汇编并说明各个段 | 模板:输出:输入:破坏 |
常见卡点
| 症状 | 病因 |
|---|---|
| 函数返回后栈指针不对 | 漏了 pop 或栈分配不平衡 |
| 局部变量被神奇覆盖 | 寄存器冲突——没保存 callee-saved 寄存器 |
int 参数在高位有垃圾 | 没做零扩展(x86-64 的 movl 到寄存器会自动零扩展高位,但 movzwl 等可能漏) |
汇编里 %0、%1 不知道对应谁 | 操作数编号从输出开始:%0=第一个输出,%1=第二个输出或第一个输入 |
volatile 不加导致循环被优化掉 | 编译器认为纯计算结果没用,直接删了你的汇编 |
| 结构体返回慢 | 隐藏指针导致额外内存写入(考虑用输出参数代替) |
现在不需要理解
- MMX/SSE/AVX 寄存器(浮点与 SIMD 在第 21 章展开)
- Windows x64 调用约定(
__vectorcall、__fastcall的差异——Vol 4 的"平台遗迹"会讲) - 动态栈分配(alloca) 的栈帧修复逻辑
- CFI 指令(
.cfi_startproc/.cfi_endproc)——DWARF 异常处理用的,调试器才关心 - TLS 寄存器(
%fs/%gs段寄存器,线程局部存储用)
旅人笔记
调用约定不是什么"高级黑魔法"。它是 CPU 时代留下的纸上契约:寄存器谁该保存、参数走哪条路、栈怎么收拾。同一套 ABI 之下,C 和 Rust 编译出来的目标文件可以互相链接,秘密就是它们都遵守了 System V AMD64 的约定。
你真正需要记住的就三条:
- 前 6 个整数参数走寄存器(
rdi→rsi→rdx→rcx→r8→r9) - 第 7 个开始走栈(从右向左入栈)
- %rax 装返回值(结构体大了走隐藏指针)
至于 caller-saved/callee-saved 的分工,你不需要背寄存器列表——编译器和 CPU 会搞定。你需要知道的是:当你在 gdb 里看到某个寄存器的值莫名其妙变了,八成是被调用函数踩了没有保存的 caller-saved 寄存器。
下一站预告(→ 第 18 章):当你终于能看懂汇编、读得懂栈帧了——下一步就是问:"我的代码快不快?慢在哪里?" 性能工程登场了。
"System V ABI 不是设计出来的,是在无数个编译器和操作系统的互相折磨中演化出来的。"——某 LLVM 开发者