Skip to content

阅读汇编:从零开始建立直觉

面对满屏的 movaddjmp 搭配一堆看不懂的寄存器名,初学者的第一反应往往是关掉标签页。写模板报错的时候至少还能去 Stack Overflow 搜一下,但汇编输出就像天书一样,不知道该从哪里开始看。然而,只要借助 Compiler Explorer [1] Matt Godbolt, Compiler Explorer, godbolt.org, 2012 做一些有针对性的实验,就会发现汇编其实可以"半读半猜"地看懂,并不需要真的会写它。

先把环境说清楚

下面所有的实验都是在 Compiler Explorer(godbolt.org)上完成的。编译器方面,x86-64 用的是 GCC 16.1.1,ARM64 用的是 GCC 16.1.1 的 aarch64 版本,RISC-V 用的是 GCC 16.1.1 的 riscv64 版本。操作系统统一选 Linux,因为 Windows 下的调用约定不一样,汇编输出会有差异,这一点稍后会详细讨论。优化等级主要看 -O2,偶尔切到 -O0 做对比,原因后面会解释。

先从一个最简单的函数开始看

为了搞清楚不同架构下汇编到底长什么样,我们从最简单的 square 函数入手——把输入的整数乘以它自己然后返回。越是朴素的函数,越适合用来观察编译器的行为,因为逻辑简单、汇编短小,每一条指令的作用都一目了然。

cpp
int square(int x) {
    return x * x;
}

直觉上,不管什么 CPU 架构,既然做的是同一件事,编译出来的汇编应该大同小异。然而把三个架构在 Compiler Explorer 中并排放在一起时,会发现它们长得完全不一样——指令格式、寄存器命名、甚至连乘法的实现方式都不同。但仔细观察之后,一个关键模式浮出水面:虽然"长相"不同,骨架其实是一样的——都是把参数从某个地方取出来,做运算,然后把结果放到某个约定好的位置返回。理解了这个骨架之后,看汇编就不再令人畏缩了。

x86-64 的版本

先看 x86-64,大部分开发机跑的都是这个架构。在 -O2 优化下,GCC 生成的代码如下:

asm
square(int):
        imul    edi, edi
        mov     eax, edi
        ret

初次看到这段代码可能会疑惑:参数不是应该放在栈上吗?怎么直接就从 edi 里取了?这是 System V AMD64 ABI [2] System V Application Binary Interface, AMD64 Architecture, x86-64 psABI(Linux 下 x86-64 的调用约定)规定的——函数的前几个整数参数通过寄存器传递,第一个参数放 edi,返回值放 eax。所以这三条指令的意思就很清晰了:imul edi, edi 是 x86 的两操作数乘法形式——左边既是源也是目标,把 edi 里的值自己乘自己,结果还写回 edi,然后把它挪到 eax 作为返回值,最后 ret 返回。

一个自然的疑问是:为什么不让 imul 的结果直接落在 eax 里,非要再多一条 mov?实际上,imul 的两操作数形式会把结果写回第一个操作数(也就是 edi),而调用约定要求返回值必须在 eax 里,所以这条 mov 是绕不开的。如果让编译器用 imul eax, edi(把 edi 乘到 eax 里),倒是可以省掉 mov,但那样需要先把 edi 搬到 eax 里再做乘法,指令数一样,GCC 选了前一种策略。

另一个容易踩坑的地方:如果在 Windows 上编译同样的代码,参数会放在 ecx 里而不是 edi,返回值倒是同样在 eax。这是 Windows x64 [3] Microsoft, x64 Calling Convention, RCX/RDX/R8/R9 和 Linux x86-64 最大的区别之一——调用约定不同。在 Linux 上看懂了一段汇编,然后跑到 Windows 上用 MSVC 编译,会发现寄存器全变了,这并不是看错了,而是调用约定的差异。所以看汇编的时候,第一步先确认平台和调用约定,这能省掉很多困惑。

ARM64 的版本

接下来看 ARM64,也就是 AArch64 [4] ARM, AArch64 Architecture Reference Manual, ARMv8。同样的函数,GCC aarch64 在 -O2 下给出了这样的输出:

asm
square(int):
        mul     w0, w0, w0
        ret

这段代码只有两条指令,比 x86-64 还干净。w0 是 ARM64 里存放第一个整数参数和返回值的寄存器(32 位版本,64 位版本叫 x0)。因为参数是 int,32 位够用了,所以编译器用了 w 寄存器而不是 x 寄存器。mul 指令直接把 w0w0 的结果放回 w0,然后返回,没有多余的 mov——ARM64 的指令设计允许结果灵活地放在任意一个操作数的位置。

值得注意的是,ARM64 的寄存器命名比 x86-64 有规律得多。x86-64 那边 eaxedirsi 各不相同,需要死记硬背每个寄存器的特殊用途;而 ARM64 就是 x0x30 加一个栈指针 sp,32 位版本统一加个 w 前缀,非常整齐。这种规则的命名方式降低了阅读门槛——不需要记住一堆历史遗留的名字,只要知道 x0/w0 是参数和返回值就够了。

RISC-V 的版本

最后是 RISC-V [5] RISC-V International, RISC-V ISA Specification, 2019(V 代表罗马数字五,所以读作"瑞斯克-五")。它的汇编长这样:

asm
square(int):
        mul     a0, a0, a0
        ret

等等,这跟 ARM64 几乎一模一样?确实如此。a0 在 RISC-V 里就是存放第一个参数和返回值的寄存器(a 代表 argument),mul 做乘法,结果放回 a0,然后返回。两条指令,干净利落。

RISC-V 作为最年轻的指令集架构,设计上吸取了前人的经验。它的整数寄存器就叫 x0x31,然后通过 ABI 约定给它们起了别名:a0-a7 是参数/返回值寄存器,t0-t6 是临时寄存器,s0-s11 是被调用者保存寄存器。汇编里看到的是别名,但本质上就是 x 编号。这种"底层统一编号 + 上层语义别名"的设计,比 x86-64 那种每个寄存器都有独特名字的方式要容易理解得多。

回头看:它们其实在说同一件事

把三个架构并排放在一起看,会发现一个有趣的现象:虽然指令名字不同、寄存器名字不同、指令数量也不同,但它们表达的"语义"完全一样——都是"取参数 → 乘法 → 放返回值 → 返回"。看汇编并不需要逐条指令都认识,只要抓住数据在哪个寄存器之间流动、做了什么运算,就能大致猜出它在干什么。

就像读一首用不太熟悉的语言写的诗,不需要查清每个词的意思,通过词的位置和重复模式就能感受到它的节奏和大意。汇编也是这样——看到 mul 或者 imul 就知道在做乘法,看到 ret 就知道函数要返回了,看到数据从一个寄存器搬到另一个寄存器就知道在传递什么。这种"半读半猜"的能力,比死记硬背每条指令的精确语义要实用得多。

一个关键的提醒:优化等级会彻底改变你看到的东西

上面展示的都是 -O2 下的输出。如果把优化关掉(-O0),会看到完全不同的画面——大量的 pushpop、内存读写,参数会被存到栈上再读回来,中间结果也会被反复写入内存。-O0 的汇编之所以如此冗长,是因为 -O0 的目的是让调试器能精确对应每一条 C++ 语句到汇编指令,所以它不做任何优化,所有变量都老老实实放在内存里。而 -O2 才是编译器"真正"想生成的代码。如果目标是理解编译器的优化行为和代码的实际性能,一定要看 -O2 或更高优化等级的输出,-O0 只会把方向带偏。

到这里,三种主流架构下最简单的函数汇编都过了一遍。虽然只是个 square 函数,但通过它建立了一个重要的认知框架:知道参数从哪来、结果到哪去、核心运算在哪条指令里完成。有了这个框架,后面看更复杂的函数汇编时就不会完全无从下手了。接下来带着这个基础,去看一些更真实的场景。


机器码和汇编到底什么关系

很多人把"机器码"和"汇编代码"混着叫,觉得反正都是那种看不懂的东西。但对着 objdump 的输出仔细看,左边那一列 0f af ff 和右边那一列 imul edi, edi 其实有着非常直白的一一映射关系,只是一般不会去认真想它。

先把概念理清楚:机器码是给机器的,汇编是给人的

左边那一堆十六进制数字——0fafff 之类的——就是机器码。它本质上是内存里的一串字节,CPU 直接读取这些字节,然后按照硬件设计好的规则去解释:读到 0f af 就知道这是一个乘法指令,后面的字节告诉它操作数在哪。CPU 不认识什么 imul,它只认数字。

右边那一列 imul edi, edi 就是汇编代码,是给人类看的版本。它和机器码之间基本上是一对一的映射关系——一条汇编指令对应一段固定格式的机器码字节。所以可以把汇编代码"汇编"成机器码(这就是汇编器干的事),也可以把机器码"反汇编"回汇编代码(这就是 objdump、IDA 这些工具干的事)。当然反汇编回去的时候,注释没了,变量名没了,原来 int x = n * n 这种语义信息也全丢了,能看到的就只剩下冰冷的指令。

但这个双向转换的通路是存在的,而且非常直接。汇编并不是一种"高级语言",不需要编译器做复杂的翻译——它几乎就是机器码的另一种写法。

动手写个最简单的平方函数,看看汇编长什么样

为了搞清楚寄存器那些事,从一个最朴素的平方函数开始:

cpp
// square.cpp
int square(int n) {
    return n * n;
}

然后用 gcc 编译成目标文件,不链接,只看汇编:

bash
# 我的环境:Arch Linux WSL, x86-64, gcc 16.1.1
g++ -c -O0 square.cpp -o square.o
objdump -d -M intel square.o

-M intel 是因为 AT&T 语法(操作数在后面、带 % 前缀那种)不太直观,Intel 语法至少操作数顺序和直觉一致。-O0 关掉所有优化,这样编译器不会对代码做任何改写,能看到最原始的翻译结果。

输出大概长这样(GCC 16, -O0):

asm
0000000000000000 <_Z6squarei>:
   0:   55                      push   rbp
   1:   48 89 e5                mov    rbp,rsp
   4:   89 7d fc                mov    DWORD PTR [rbp-0x4],edi
   7:   8b 45 fc                mov    eax,DWORD PTR [rbp-0x4]
   a:   0f af c0                imul   eax,eax
   d:   5d                      pop    rbp
   e:   c3                      ret

看到这里的第一反应可能是:等等,输入参数不是应该从某个地方"传进来"吗?C++ 函数有参数列表,但汇编里没有参数列表这个东西。参数到底去哪了?

寄存器就是 CPU 自带的"全局变量",但用法有规矩

CPU 里面有一小批速度极快的存储单元,叫寄存器。可以把它理解成一种"超高速全局变量"——直接在 CPU 内部,不用访存,读写几乎是零延迟的。但和全局变量不同的是,寄存器数量极其有限,x86-64 下通用寄存器也就十几个(RAX、RBX、RCX、RDX、RSI、RDI、R8-R15 这些),不可能把所有数据都塞进去。

关键问题是:谁规定哪个寄存器干什么活?如果编译器 A 觉得参数放 RAX 里,编译器 B 觉得参数放 RDI 里,那编译出来的代码根本没法互相调用。写了一个库,别人写了一个程序,结果因为寄存器用法不一致,调用就会出错。

所以必须有一套"交通规则",所有人都遵守,代码才能互操作。这套规则就是 ABI(Application Binary Interface,应用二进制接口)。ABI 规定了很多东西,其中最基础的一条就是:函数调用时,参数放哪个寄存器、返回值放哪个寄存器、哪些寄存器调用后可以随便改、哪些必须原样还回去。

Linux 下用的是 System V AMD64 ABI,Windows 下用的是微软自己的 x64 ABI,两套规则不一样。这也是 Linux 和 Windows 的二进制不能直接混用的原因之一(当然还有更多原因,但寄存器约定不同是最直接的一层)。

参数从 EDI 进来,结果必须从 EAX 出去

回到我们的平方函数。在 System V ABI 的规则里,第一个整数参数放在 RDI 寄存器里。注意我写的是 RDI(64 位),但我们的参数是 int,只有 32 位,所以实际上用的是 RDI 的低 32 位,也就是 EDI。RAX/EAX 同理,RAX 是 64 位版本,EAX 是 32 位版本。

所以函数一进来,n 的值就已经在 EDI 里了,不需要你从哪里"取",它已经在那了。

然后看指令序列:push rbp; mov rbp, rsp 是标准的栈帧建立过程,mov DWORD PTR [rbp-0x4], edi 把 EDI 里的参数存到栈上——这是 -O0 的典型行为,编译器不做任何优化,所有变量都老老实实放到内存里。接着 mov eax, DWORD PTR [rbp-0x4] 再从栈上读回来放进 EAX,imul eax, eax 做平方,pop rbp 恢复栈帧,最后 ret 返回。-O0 的冗长恰恰说明了为什么前面推荐看 -O2 的输出——多了三条栈帧操作指令,反而把核心逻辑淹没了。

接着 imul eax, eax 就是把 EAX 乘以 EAX,结果存回 EAX。这是 x86 一个很有特色的设计:大部分指令只接受两个操作数,而且左边的操作数既是源又是目标。这跟 C++ 里的 a *= a 是一个意思——读出左边的值,和右边的值做运算,再写回左边。它是一个"破坏性"操作,做完之后原来左边那个值就被覆盖了。如果后面还需要原来的值,得提前存好。

最后 ret 就是返回,控制权交回调用者。此时 EAX 里存的就是平方结果,调用者知道去 EAX 拿——因为 ABI 是这么规定的。

寄存器名字并不是随意的

初学者看到 RAX、EAX、AX、AL 这一堆名字,很容易以为它们是不同的寄存器。实际上,它们是同一个物理寄存器的不同"视图":RAX 是完整的 64 位,EAX 是低 32 位,AX 是低 16 位,AL 是最低 8 位。往 EAX 写数据会覆盖 RAX 的高 32 位(清零),往 AL 写只会改最低那个字节,其余位不受影响。

这个特性在调试时特别容易造成困惑。盯着寄存器窗口看时,可能会发现 RAX 的值和 EAX 对不上,还以为是调试器出了问题,实际上是因为某条指令只改了低 32 位,高 32 位是上一次操作留下的脏数据。所以看寄存器的时候一定要搞清楚当前看的是哪个"视图"。

到这里,一个最简单的 C++ 函数在 x86-64 下的汇编面貌就清楚了:参数通过寄存器传进来(不是栈,至少前几个参数不是),计算在寄存器之间完成,结果通过寄存器返回。整个过程没有访存,速度极快。当然这只是最简单的情况,参数多了、用了局部变量、开了优化之后,事情会复杂很多,但基础框架就是这套。


从一条 MOV 指令看懂寄存器传参:ARM 和 RISC-V 的调用约定

上一节说到那个求平方的函数,编译完之后核心就是一条乘法指令。函数返回时,控制权交回给调用者。调用者之前把参数塞进了 EDI 寄存器(x86-64 下的调用约定),现在它期望从 EAX 寄存器里拿到返回值——这就是 x86-64 的规矩,整型返回值走 EAX(或者 RAX)。所以那条 imul edi, edi 干的事情非常直白:把 EDI 里的值自己乘自己,结果写回 EDI,然后 mov 到 EAX,最后 ret。调用者从 EAX 一拿,完事。

那么问题来了:不同架构下,同一件事的"体感"差异到底有多大?把同一个函数分别在三种架构下编译,对着汇编一条一条对比,差异会非常明显。

ARM64 的简洁

先看 ARM64(AArch64)。有人可能以为 ARM 汇编和 x86 差不多,只是指令名不一样罢了。实际打开 objdump 一看,差异远超预期。

cpp
// square.cpp —— 就这么个简单函数
int square(int value) {
    return value * value;
}

用交叉编译工具链跑一下:

bash
# ARM64
aarch64-linux-gnu-g++ -O2 -c square.cpp -o square_arm64.o
aarch64-linux-gnu-objdump -d square_arm64.o

输出是这样的:

asm
square:
    mul w0, w0, w0
    ret

就这。两条指令,干干净净。一个特别舒服的地方是:W0 既是输入,又是输出。ARM 的调用约定里,W0(32 位)或者 X0(64 位)既是第一个参数的载体,也是返回值的载体。所以 mul w0, w0, w0 读起来就是"把 w0 乘以 w0,结果放回 w0",三个操作数全是同一个寄存器,视觉上极其统一。

接下来看一下这些指令的机器码,这能揭示一个重要的设计差异。

bash
aarch64-linux-gnu-objdump -d -j .text square_arm64.o | grep mul
# 0:   1b007c00    mul w0, w0, w0

1b007c00,四个字节。再看那条 ret

asm
# 4:   d65f03c0    ret

d65f03c0,也是四个字节。两条指令,都是精确的四个字节。这意味着指令解码器的工作特别简单,取指阶段每次固定取四个字节,不用做任何长度判断。这个设计之所以优雅,在对比 x86 之后会更加明显。

x86 的变长指令

同样的函数,x86-64 下编译:

bash
g++ -O2 -c square.cpp -o square_x64.o
objdump -d square_x64.o
asm
square(int):
    0:   0f af ff                imul   edi,edi
    3:   89 f8                   mov    eax,edi
    6:   c3                      ret

重点是指令的字节长度:

  • imul 指令:0f af ff,三个字节
  • mov 指令:89 f8,两个字节
  • ret 指令:c3,一个字节

三条指令,三种长度:3、2、1。换一个乘法写法,比如 imul eax, edi,它的机器码是 0f af c7,还是三个字节,但和上面那条 imul 的后缀不一样(ff vs c7),因为操作数编码不同。再换一个场景,如果乘数是立即数,指令长度又会变。

"变长指令"不只是教科书上的概念。对着 hex dump 去数字节就会发现:CPU 的前端每取一条指令,都得先读前几个字节来判断这条指令到底有多长,然后才能决定下一条指令从哪里开始。x86 的解码器是出了名的复杂,Intel 为了解决这个问题在 CPU 里塞了大量的预解码逻辑和微操缓存(micro-op cache),本质上就是在用硬件暴力弥补指令集设计的历史包袱。

RISC-V 的定长指令

再看一下 RISC-V(rv64gc):

bash
riscv64-linux-gnu-g++ -O2 -c square.cpp -o square_rv64.o
riscv64-linux-gnu-objdump -d square_rv64.o
asm
square:
    0:   02b50533    mul a0, a0, a0
    4:   8082        ret

和 ARM 一样,a0 既是第一个参数也是返回值,mul a0, a0, a0 语义完全一致。不过这里有个细节:mul 指令是四个字节(02b50533),但 ret 指令只有两个字节(8082)。RISC-V 的基础指令是定长四字节的,但它支持 16 位的压缩指令扩展(RVC),所以像 ret 这种常见指令会被压缩成两个字节。这算是在定长和变长之间找了个折中,比 x86 那种"完全不可预测"的变长还是要规矩得多。

操作数的个数:不是所有指令都那么规整

到这里可能会觉得,指令嘛,不就是"操作码 + 几个操作数",挺整齐的。但翻阅更多汇编之后会发现,现实远没有这么美好。

上面看到的 mulimul 都是典型的三操作数指令(目标 + 源1 + 源2),或者二操作数(目标同时也是源1)。但还有很多指令根本不按套路来。零操作数的指令最简单,比如 retnop,不需要任何额外信息。单操作数的也常见,比如各种跳转指令。双操作数和三操作数刚刚看过。

但真正让人困惑的是"隐式操作数"。比如 x86 里有一条 rep stosb,功能是"把 AL 寄存器的值重复写入 RDI(或 EDI)指向的内存,每次写完 RDI/EDI 自动递增,重复次数由 RCX(或 ECX)控制"。AL、RDI/EDI、RCX/ECX——这三个操作数在指令文本里一个都看不到,全是隐式的,硬编码在指令定义里。读汇编的人必须记住这条指令默认使用哪些寄存器。这种指令的"操作数个数"其实很难定义。

Intel 的历史包袱

隐式操作数的问题,x86 可以说是"重灾区"。原因并不复杂:x86 这个指令集从 1978 年的 8086 开始,一路演化到今天的 x86-64,中间经历了 40 多年。每一代新 CPU 都要在老指令集基础上加新东西,而且必须保持向后兼容——1985 年写的 8086 机器码,放到 2026 年的 CPU 上照样能跑。这个约束听起来很美好,但代价就是指令集变得越来越臃肿、越来越不规整。新指令的编码空间被老指令占满了,就只能用各种前缀字节来扩展,导致解码逻辑越来越复杂。

这个处境是不是有点耳熟?C++ 的向后兼容性问题和这个简直一模一样——今天写 C++26 的代码,编译器还得能处理 C89 风格的声明、C 风格的转型、各种历史遗留特性。每次有人提议"把某某旧特性删了吧",回答永远是"不行,会破坏现有代码"。于是背着这个包袱一路往前走。

相比之下,ARM 和 RISC-V 就清爽多了。ARM64 是 2011 年左右设计的(AArch64),算是"净室实现"——不背着 32 位 ARM 的历史包袱,重新设计了一套指令编码。RISC-V 更是 2010 年从零开始的学术项目,指令的正交性做得非常好:同样的操作码格式,换一下寄存器编号就能用,不存在"这条指令隐式用 EAX、那条指令隐式用 EDX"这种让人抓狂的规则。

寄存器的命名:A 寄存器的由来

前面一直在说 EAX、W0、a0 这些名字,但你有没有想过,为什么 x86 的寄存器叫这些奇怪的名字?这些名字背后是有历史含义的。

x86 里有一个寄存器叫 A(Accumulator,累加器)。在 8080 甚至更早的 8008 时代,A 寄存器就是"默认的那个寄存器"——很多运算默认就对着 A 做,不需要在指令里额外指定。比如加法,"把某个值加到 A 上"的指令编码,比"把某个值加到 B 上"的指令编码要短,因为 A 是"默认目标",省掉了指定目标寄存器的几个 bit。

这个设计思路一直延续到了 x86。今天写 imul edi, edi,如果换成 imul ebx, ebx,机器码可能会更长(取决于具体编码),因为 EAX(或者说 RAX)在很多指令里仍然是"特权寄存器"——它是隐式指令的默认目标,也是某些特殊操作(比如 mul 的双精度结果高位放在 EDX 里)的固定参与者。

很多教程里总说"尽量用 EAX",这并不是玄学优化技巧,而是指令集编码层面就给出的"优待"——用 A 寄存器,指令可能更短,解码可能更快。当然在现代 CPU 上这个差异已经被各种微架构优化抹平了很多,但了解这个背景之后,再看那些隐式操作数的指令,就不会觉得莫名其妙了。

到这里,"一条简单的函数调用在汇编层面到底长什么样"这件事算是从头到尾捋了一遍:从参数怎么传、返回值怎么放,到不同架构的指令编码差异,再到寄存器命名的历史渊源。每一步都不复杂,但拼在一起看的时候,整个体系就串起来了。



搞懂函数调用时参数到底去了哪——从寄存器命名到 ABI

看 Compiler Explorer 生成的汇编代码时,最大的心理障碍往往不是指令本身,而是那些乱七八糟的寄存器名字。RAX、EAX、AX、AL、AH——这到底是一个东西还是四个东西?搞清楚 x86 的寄存器布局之后,这个问题就迎刃而解了。

先搞清楚 RAX、EAX、AX 到底是什么关系

回到最根本的问题:寄存器是什么?可以把它理解为 CPU 内部的一小排超高速存储格子,数量非常有限。在 8 位机时代,最核心的那个寄存器叫 A 寄存器,也就是 Accumulator(累加器),大部分算术运算都围着它转。后来 CPU 从 8 位进化到 16 位、32 位、64 位,这个寄存器的宽度跟着变大了,但它的"地位"一直没变——始终是那个承担主要计算任务的通用寄存器。

关键在于:当你看到 RAX 的时候,你看到的是一个 64 位的值。但当你看到 EAX 的时候,你看到的并不是另一个寄存器,而是同一个寄存器的低 32 位。同理,AX 是低 16 位,AL 是最低的 8 位,AH 是倒数第二个 8 位(也就是 bits 8-15)。它们全部指向同一块物理存储,只是用不同的名字去"切"它。

用一个简单的示意图来说明:

text
63                              31        15  7    0
+--------------------------------+----------+----+----+
|              RAX               |   EAX    | AX      |
|                                |          +----+----+
|                                |          | AH | AL |
+--------------------------------+----------+----+----+

所以在汇编里看到这样的代码时,不必慌张:

asm
mov rax, rdi      ; 把 64 位参数放进 rax 做计算
shr rax, 32       ; 右移 32 位
mov eax, eax      ; 只保留低 32 位作为返回值

这里从 rax 切到 eax,不是数据在两个寄存器之间搬来搬去,而是编译器在说"算完了,现在只关心低 32 位了"。C++ 源码里的类型信息(比如参数是 int64_t 但返回值是 int32_t)会直接反映到汇编里对同一个寄存器不同名称的使用上。类型信息消失之后,就是以这种方式"残留"在汇编里的。

那些名字奇怪的寄存器,以及好记的新朋友们

搞懂了 RAX 的命名规律之后,你可能会想,那其他的呢?RAX、RCX、RDX、RSP、RBP、RSI、RDI……这些名字完全没有任何规律可言。它们都是从上古时代继承下来的历史遗留名称:A 是累加器,C 是计数器,D 是数据,SP 是栈指针,BP 是基址指针,SI 和 DI 分别是源索引和目的索引。知道历史背景之后稍微好记一点,但很大程度上还是靠反复使用形成的肌肉记忆。

不过有一个好消息:当 AMD 把架构从 32 位扩展到 64 位的时候,新增的 8 个通用寄存器直接命名为 R8 到 R15。干净利落。所以现在 x86-64 一共有 16 个通用寄存器,其中 8 个是历史遗留的奇怪名字,8 个是清爽的数字编号。

当然还有 SIMD/多媒体寄存器(XMM/YMM/ZMM 之类),不过那些是另一块大内容了,今天我们先聚焦在通用寄存器和函数调用上。

函数参数到底在哪个寄存器里

看汇编最大的困惑之一是:写了一个函数,传了三个参数进去,汇编里怎么就变成一堆 mov 指令在寄存器之间倒腾了?参数到底从哪来的?这就涉及到 ABI(Application Binary Interface)了。

ABI 规定的东西很多,但从读汇编的角度来说,最关心的就一件事:函数的前几个参数分别放在哪个寄存器里。只要知道这个,就能在汇编里追踪 C++ 变量到底变成了什么。

以 Linux(System V AMD64 ABI)为例。前六个整数参数(包括指针)依次放在这些寄存器里:

text
第 1 个参数 → RDI
第 2 个参数 → RSI
第 3 个参数 → RDX
第 4 个参数 → RCX
第 5 个参数 → R8
第 6 个参数 → R9

超过六个的参数就只能压到栈上了,通过栈指针偏移来访问。使用 std::forward 做完美转发的时候,如果参数特别多,在汇编里会看到大量的栈操作,因为转发可能把参数"展开"了,数量一下子就超了六个寄存器的容量。

返回值更简单,统一放在 RAX 里(如果是 128 位返回值会用 RDX:RAX 两个拼起来)。

浮点数参数稍微复杂一点,走的是另一套寄存器(XMM0 到 XMM7),不过基本的思路是一样的——前几个走寄存器,多了走栈。

Windows 的规则不一样

如果在 Windows 上用 MSVC,情况就不一样了。Windows x64 ABI 只给了四个寄存器来传参数:

text
第 1 个参数 → RCX
第 2 个参数 → RDX
第 3 个参数 → R8
第 4 个参数 → R9

注意顺序和名字都跟 Linux 不一样。这意味着同样一个函数,在 Linux 上前六个参数全走寄存器,在 Windows 上第五个和第六个就已经要压栈了。跨平台调试性能问题时,同样的 C++ 代码在两边的汇编长得完全不一样,这往往就是 ABI 差异导致的。

这个差异其实会对 API 设计产生微妙的影响。如果知道 Windows 上只有四个寄存器可用,在设计高频调用的接口时就会更倾向于控制参数数量。不过这个话题后面遇到具体场景再展开。

动手验证一下

光说不练假把式,写个最简单的函数丢到 Compiler Explorer 里看看:

cpp
// 编译选项:-O1 -m64
// 平台:x86-64 Linux (GCC)

long add_three(long a, long b, long c) {
    return a + b + c;
}

对应的汇编大概长这样(GCC 16, -O1):

asm
add_three(long, long, long):
    add rdi, rsi           ; rdi(a) += rsi(b)
    lea rax, [rdi + rdx*1] ; rax = rdi + rdx(c)
    ret

你看,a 在 RDI,b 在 RSI,c 在 RDX,完全符合我们说的规则。返回值在 RAX。清爽。

再试一个超过六个参数的:

cpp
long sum_seven(long a, long b, long c, long d,
               long e, long f, long g) {
    return a + b + c + d + e + f + g;
}

汇编会变成这样:

asm
sum_seven(long, long, long, long, long, long, long):
    lea rax, [rdi + rsi]       ; a + b
    add rax, rdx               ; + c
    add rax, rcx               ; + d
    add rax, r8                ; + e
    add rax, r9                ; + f
    add rax, QWORD PTR [rsp+8] ; + g,从栈上取!注意偏移 +8,因为 [rsp] 是 call 压入的返回地址
    ret

前六个参数分别在 RDI、RSI、RDX、RCX、R8、R9,第七个参数 g 跑到栈上去了,通过 [rsp+8] 来访问(call 指令将返回地址压入了 [rsp],所以第一个栈参数要偏移 8 字节)。知道 ABI 规则之后,读汇编就像有了地图一样,不再是满屏天书了。

顺便提一下 ARM64

如果接触过 ARM64(比如 Apple Silicon 或者嵌入式开发),那边就清爽多了。通用寄存器直接叫 X0 到 X30,没有历史包袱。函数参数就是 X0、X1、X2……一路排下去,返回值在 X0。如果想看 32 位版本,就把 X 换成 W,比如 W0 就是 X0 的低 32 位,命名逻辑跟 x86 的 RAX/EAX 是一样的思路,但名字好记得多。

到这里,寄存器命名和参数传递规则这块算是彻底理清了。看到汇编里一会 rax 一会 eax 就发懵,是因为不知道它就是在切同一个寄存器的不同宽度。理解了这一点,心里就踏实多了。接下来带着这个基础,去看更复杂的汇编模式。


RISC-V 的寄存器命名——从编号到语义

看 RISC-V 汇编的时候,打开反汇编窗口,满屏的 t0a7s1ra,跟 x86 那套 raxrbxrcx 看起来一样,似乎都是一堆需要死记硬背的字母缩写。但真正搞明白之后会发现,RISC-V 的寄存器命名根本不是随意的缩写——它直接告诉你这个寄存器该干什么。理解了命名背后的调用约定语义,这些名字自己就能推导出来。

先从最基础的编号说起

RISC-V 一共有 32 个通用寄存器,编号从 x0x31。注意,是 32 个,不是 31 个——x0 确实是一个存在的寄存器,只不过它硬连线为 0,往里写什么都是 0,读出来永远是 0。这个设计初看似乎多此一举,但在写内联汇编的时候会发现,常数零能直接当操作数用,省去了不少 mov 指令。

然后是位宽的问题。RISC-V 的寄存器是 64 位的(RV64G 标准),编号 x0x31 对应完整的 64 位值。如果只需要操作低 16 位,直接用 0xFFFF 做掩码与一下就行,不需要像某些架构那样有单独的 16 位寄存器别名。这一点相当清爽,不需要在不同位宽的寄存器名之间来回切换。

前面说的是 x0x30,但其实 x0x31 一共 32 个寄存器全都要说。其中 x1 比较特殊,它是 ra(Return Address),后面会详细说。不管怎样,32 个寄存器摆在那里,比 x86-64 那种历史包袱沉重的命名方式直观得多——x86 的通用寄存器名字是从 16 位时代一路继承过来的,raxa 扩展来的,r8r15 是后来硬加的,整个体系毫无规律可言。

那些别名到底是什么

关键来了。实际写汇编或者看反汇编输出的时候,几乎永远不会看到 x0x31 这种纯数字编号。编译器和反汇编器把每个寄存器都重命名了,换成了有语义的名字。看到一堆 tsa 开头的东西会觉得是一套需要死记的约定,但只要理解了调用约定(Calling Convention),这些名字自己就能推导出来。

看一个简单的例子,用 GCC 16.1.1 编译的 RISC-V 64 位目标:

cpp
// test.cpp
long add(long a, long b, long c, long d,
         long e, long f, long g, long h, long i) {
    return a + b + c + d + e + f + g + h + i;
}

编译命令:

bash
riscv64-linux-gnu-g++ -O1 -S test.cpp -o test.s

看输出的汇编:

asm
add:
    add a0, a0, a1    # a0 += a1
    add a0, a0, a2    # a0 += a2
    add a0, a0, a3    # a0 += a3
    add a0, a0, a4    # a0 += a4
    add a0, a0, a5    # a0 += a5
    add a0, a0, a6    # a0 += a6
    add a0, a0, a7    # a0 += a7
    ld  a1, 0(sp)     # 第9个参数在栈上,加载到 a1
    add a0, a0, a1    # a0 += 栈上的参数
    ret

看到没?前 8 个参数分别放在 a0a7 里,返回值也放在 a0 里。a 就是 Argument(参数)的意思,a0a7 就是参数寄存器,同时 a0 兼任返回值寄存器。这比 x86 那套 "RDI 放第一个参数、RSI 放第二个参数、RDX 放第三个参数" 的散装命名好记得多。

T 寄存器和 S 寄存器——调用约定的核心

搞懂了 a 寄存器,剩下的就顺理成章了。t 开头的是 Temporary(临时)寄存器,从 t0t6 一共 7 个(具体映射后面会列表说明)。s 开头的是 Saved(被调用者保存)寄存器,从 s0s11 一共 12 个。

这两个概念很容易搞混。一个常见的坑是:在 t0 里存了一个中间值,然后调用了另一个函数,回来之后 t0 的值变了,程序直接跑飞。这是因为 t 寄存器是调用者保存的——如果在 t0 里存了东西,然后调用别的函数,必须自己提前把它存到栈上,因为被调用的函数可以随意使用 t0,不保证它的值不变。

s 寄存器正好反过来,是被调用者保存的。如果在一个函数里用了 s1,那么必须在函数返回前把 s1 恢复成调用者期望的值。换句话说,调用者可以放心地在 s1 里存东西然后调用别的函数,回来之后 s1 的值一定还在。

下面用一个直观的代码示例来验证:

cpp
// caller.cpp
extern "C" long callee();

long caller() {
    register long temp __asm__("t0") = 42;
    register long saved __asm__("s1") = 99;
    long result = callee();
    // temp 可能已经被 callee 破坏了
    // saved 一定还是 99
    return temp + saved + result;
}
cpp
// callee.cpp
extern "C" long callee() {
    // 故意写 t0,这是合法的
    register long t0_val __asm__("t0") = 0;
    // 故意写 s1,但必须恢复
    register long s1_val __asm__("s1") = 0;
    __asm__ volatile("" : "=r"(t0_val) : "0"(t0_val));
    __asm__ volatile("" : "=r"(s1_val) : "0"(s1_val));
    return 1;
}

编译运行之后你会发现,caller 里回来的时候 temp 的值确实变了,而 saved 还是 99。这就是调用约定的力量。

完整映射表

演讲者说他会在显示器左下角贴便利贴,很多人也是这么干的。但搞明白命名逻辑之后,这张表其实不需要背——理解了就能推出来。不过为了方便,下面列出完整的映射作为速查表:

编号ABI 名含义调用约定
x0zero硬连线为零
x1ra返回地址调用者保存
x2sp栈指针被调用者保存
x3gp全局指针
x4tp线程指针
x5-x7t0-t2临时寄存器调用者保存
x8s0/fp被保存寄存器/帧指针被调用者保存
x9s1被保存寄存器被调用者保存
x10-x17a0-a7参数/返回值调用者保存
x18-x27s2-s11被保存寄存器被调用者保存
x28-x31t3-t6临时寄存器调用者保存

t 就是临时用、用完即弃的,s 就是要好好保管的,a 就是传参数的,ra 就是记住从哪来的,sp 就是管栈的。每个名字都在告诉你它的职责。

顺便一提,如果之前用过 32 位 ARM,会发现 ARM 只有 16 个通用寄存器(R0-R15),参数只能放 R0-R3 四个,多出来的全走栈。RISC-V 有 32 个寄存器,光参数寄存器就有 8 个,临时寄存器有 7 个,被调用者保存的寄存器有 12 个——寄存器多了,函数调用时压栈出栈的次数就少了,性能上的好处是实打实的。

隐式参数——this 指针和返回值优化

到这里可能觉得,参数传递不就是 a0a7 嘛,很简单。但有一个容易被忽略的问题:C++ 的成员函数,this 指针放哪?

this 指针就是一个隐式的第一个参数。在 RISC-V Linux 上,它放在 a0 里,然后声明的第一个"真正的"参数放在 a1 里,以此类推。这跟 x86-64 Linux 上的约定是一样的(x86 上 this 放 RDI,第一个参数放 RSI)。

一个简单的验证代码:

cpp
struct Foo {
    long x;
    long bar(long y) { return x + y; }
};

// 编译后看汇编,bar 的签名等价于:
// long Foo_bar(Foo* this, long y)
// a0 = this, a1 = y
asm
_ZN3Foo3barEl:
    ld    a0, 0(a0)     # 从 this->x 加载值到 a0
    add   a0, a0, a1    # a0 += y
    ret

清清楚楚,a0 一开始装的是 this 指针,然后直接被覆盖成了 this->x 的值,最后加上 a1 里的 y 返回。

但还有更复杂的情况。如果你写了这样的代码:

cpp
struct Big {
    long data[4];
};

Big make_big(long a, long b) {
    Big result{};
    result.data[0] = a;
    result.data[1] = b;
    return result;
}

Big 有 32 字节,放不进一个寄存器。编译器做返回值优化(RVO/NRVO)的时候,不会真的在函数里构造一个 Big 然后拷贝出去,而是会在调用者的栈帧里预留好空间,然后把这个空间的地址作为隐式参数传给被调用函数。在 RISC-V 上,这个隐式参数就放在 a0 里,声明的第一个参数 a 反而被挤到了 a1,第二个参数 ba2

asm
_Z9make_bigll:
    # a0 = 隐式的返回值缓冲区地址
    # a1 = a, a2 = b
    sd    a1, 0(a0)     # result.data[0] = a
    sd    a2, 8(a0)     # result.data[1] = b
    sd    zero, 16(a0)  # result.data[2] = 0
    sd    zero, 24(a0)  # result.data[3] = 0
    ret

调用处的汇编大概长这样:

asm
    # 调用者在栈上预留 32 字节
    addi  sp, sp, -32
    mv    a0, sp        # 把缓冲区地址作为第一个参数
    mv    a1, ...       # 真正的参数 a
    mv    a2, ...       # 真正的参数 b
    call  _Z9make_bigll
    # 现在 sp 指向的位置就是构造好的 Big 对象

第一次看到这个的时候很容易困惑——为什么参数位置全错了一位?原因是有一个隐式的指针参数插在了最前面。这种事情不去看汇编是永远不会注意到的,但一旦遇到了,不了解的话能调试一整天。

到这里,RISC-V 的寄存器命名体系就彻底搞通了。回头看看其实没那么难,关键是要理解每个名字背后的调用约定语义,而不是把它们当成无意义的符号去死记。


参考文献
1
Matt GodboltCompiler Explorergodbolt.org, 2012
2
AMD / System VSystem V Application Binary Interface, AMD64 Architecturex86-64 psABI, 2018, calling convention: RDI, RSI, RDX, RCX, R8, R9 for integer args
3
Microsoftx64 Calling ConventionMicrosoft Learn, 2024, integer args in RCX, RDX, R8, R9
4
ARMARM Architecture Reference Manual, ARMv8 (AArch64)ARM Ltd, 2020, X0-X30 registers, procedure call standard
5
RISC-V InternationalRISC-V Instruction Set ManualRISC-V International, 2019, RV64G, integer registers x0-x31, ABI names

基于 VitePress 构建