Skip to content

第17章:汇编基础与调用约定


元数据卡

属性
难度Lv.2(需要 C 指针基础)
核心架构x86-64(主)+ RISC-V(对比窗)
ABISystem 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 然后祈祷编译器搞定一切",变成"能读汇编、能看懂栈帧、能解释为什么 intlong 混用会静默出 bug"的人。


你的任务

本章分层

  • 必读:x86-64 寄存器分工(caller-saved vs callee-saved);System V AMD64 ABI 的参数传递规则;栈帧布局
  • 选读:参数超过 6 个时的栈上布局;结构体返回值大于 16 字节的隐藏指针机制
  • 深水区:符号表与重定位表的结构;内联汇编的模板语法与约束 本章不会要求你掌握
  • 符号表(.symtab)和重定位表(.rela.text)的解析
  • 内联汇编的完整约束列表
  • Windows x64 调用约定(__vectorcall/__fastcall)
  1. 理解 x86-64 通用寄存器的分工(caller-saved vs callee-saved)
  2. 背下 System V AMD64 ABI 的参数传递规则(6 个寄存器 + 栈)
  3. 能手绘栈帧布局:返回地址 → 旧 rbp → 局部变量 → 调用参数
  4. 能用 gcc -S 看自己的代码长什么样
  5. 能在 gdb 里用 info framex/8xg $rsp 扒栈

遭遇战 → 获得技能

遭遇战 #1:寄存器政治

先扔一个最短的 C 函数:

c
int add(int a, int b) {
    return a + b;
}

gcc -O0 -S add.c 编译,你会看到类似这样的东西(x86-64):

asm
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个怎么办?

c
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:结构体返回值

c
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 -Sreadelf -r 查看符号表和重定位表的命令属于编译/链接章节的范畴。本卷主线并不需要理解符号表结构——知道这些命令存在即可,需要时查手册。

bash
# 生成汇编
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 代码”的全貌。内联汇编的完整约束语法是编译器特有的,本卷不要求掌握。

实验:手写内联汇编

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" → 改了条件码寄存器

通关挑战

  1. 写一个 swap(int *a, int *b),用 gcc -O0 -S 生成汇编,指出哪个寄存器传了哪个参数,栈帧多大
  2. 解释为什么 longint 混用时,%rdi 的高 32 位可能残留垃圾数据(提示:x86-64 的隐式零扩展规则)
  3. 用 gdb 在 add() 入口处查看栈,画出完整的栈帧图(标注每个 8 字节的内容)
  4. 写一个可变参数函数 sum(int count, ...),用 gcc -S 看它怎么用 %al 传递可变参数的浮点数量
  5. 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/movqpushq/popqcall/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 的约定。

你真正需要记住的就三条:

  1. 前 6 个整数参数走寄存器rdirsirdxrcxr8r9
  2. 第 7 个开始走栈(从右向左入栈)
  3. %rax 装返回值(结构体大了走隐藏指针)

至于 caller-saved/callee-saved 的分工,你不需要背寄存器列表——编译器和 CPU 会搞定。你需要知道的是:当你在 gdb 里看到某个寄存器的值莫名其妙变了,八成是被调用函数踩了没有保存的 caller-saved 寄存器

下一站预告(→ 第 18 章):当你终于能看懂汇编、读得懂栈帧了——下一步就是问:"我的代码快不快?慢在哪里?" 性能工程登场了。


"System V ABI 不是设计出来的,是在无数个编译器和操作系统的互相折磨中演化出来的。"——某 LLVM 开发者

Built with VitePress | Software Systems Atlas