阅读汇编:从零开始建立直觉
面对满屏的 mov、add、jmp 搭配一堆看不懂的寄存器名,初学者的第一反应往往是关掉标签页。写模板报错的时候至少还能去 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 函数入手——把输入的整数乘以它自己然后返回。越是朴素的函数,越适合用来观察编译器的行为,因为逻辑简单、汇编短小,每一条指令的作用都一目了然。
int square(int x) {
return x * x;
}直觉上,不管什么 CPU 架构,既然做的是同一件事,编译出来的汇编应该大同小异。然而把三个架构在 Compiler Explorer 中并排放在一起时,会发现它们长得完全不一样——指令格式、寄存器命名、甚至连乘法的实现方式都不同。但仔细观察之后,一个关键模式浮出水面:虽然"长相"不同,骨架其实是一样的——都是把参数从某个地方取出来,做运算,然后把结果放到某个约定好的位置返回。理解了这个骨架之后,看汇编就不再令人畏缩了。
x86-64 的版本
先看 x86-64,大部分开发机跑的都是这个架构。在 -O2 优化下,GCC 生成的代码如下:
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 下给出了这样的输出:
square(int):
mul w0, w0, w0
ret这段代码只有两条指令,比 x86-64 还干净。w0 是 ARM64 里存放第一个整数参数和返回值的寄存器(32 位版本,64 位版本叫 x0)。因为参数是 int,32 位够用了,所以编译器用了 w 寄存器而不是 x 寄存器。mul 指令直接把 w0 乘 w0 的结果放回 w0,然后返回,没有多余的 mov——ARM64 的指令设计允许结果灵活地放在任意一个操作数的位置。
值得注意的是,ARM64 的寄存器命名比 x86-64 有规律得多。x86-64 那边 eax、edi、rsi 各不相同,需要死记硬背每个寄存器的特殊用途;而 ARM64 就是 x0 到 x30 加一个栈指针 sp,32 位版本统一加个 w 前缀,非常整齐。这种规则的命名方式降低了阅读门槛——不需要记住一堆历史遗留的名字,只要知道 x0/w0 是参数和返回值就够了。
RISC-V 的版本
最后是 RISC-V [5] RISC-V International, RISC-V ISA Specification, 2019(V 代表罗马数字五,所以读作"瑞斯克-五")。它的汇编长这样:
square(int):
mul a0, a0, a0
ret等等,这跟 ARM64 几乎一模一样?确实如此。a0 在 RISC-V 里就是存放第一个参数和返回值的寄存器(a 代表 argument),mul 做乘法,结果放回 a0,然后返回。两条指令,干净利落。
RISC-V 作为最年轻的指令集架构,设计上吸取了前人的经验。它的整数寄存器就叫 x0 到 x31,然后通过 ABI 约定给它们起了别名:a0-a7 是参数/返回值寄存器,t0-t6 是临时寄存器,s0-s11 是被调用者保存寄存器。汇编里看到的是别名,但本质上就是 x 编号。这种"底层统一编号 + 上层语义别名"的设计,比 x86-64 那种每个寄存器都有独特名字的方式要容易理解得多。
回头看:它们其实在说同一件事
把三个架构并排放在一起看,会发现一个有趣的现象:虽然指令名字不同、寄存器名字不同、指令数量也不同,但它们表达的"语义"完全一样——都是"取参数 → 乘法 → 放返回值 → 返回"。看汇编并不需要逐条指令都认识,只要抓住数据在哪个寄存器之间流动、做了什么运算,就能大致猜出它在干什么。
就像读一首用不太熟悉的语言写的诗,不需要查清每个词的意思,通过词的位置和重复模式就能感受到它的节奏和大意。汇编也是这样——看到 mul 或者 imul 就知道在做乘法,看到 ret 就知道函数要返回了,看到数据从一个寄存器搬到另一个寄存器就知道在传递什么。这种"半读半猜"的能力,比死记硬背每条指令的精确语义要实用得多。
一个关键的提醒:优化等级会彻底改变你看到的东西
上面展示的都是 -O2 下的输出。如果把优化关掉(-O0),会看到完全不同的画面——大量的 push、pop、内存读写,参数会被存到栈上再读回来,中间结果也会被反复写入内存。-O0 的汇编之所以如此冗长,是因为 -O0 的目的是让调试器能精确对应每一条 C++ 语句到汇编指令,所以它不做任何优化,所有变量都老老实实放在内存里。而 -O2 才是编译器"真正"想生成的代码。如果目标是理解编译器的优化行为和代码的实际性能,一定要看 -O2 或更高优化等级的输出,-O0 只会把方向带偏。
到这里,三种主流架构下最简单的函数汇编都过了一遍。虽然只是个 square 函数,但通过它建立了一个重要的认知框架:知道参数从哪来、结果到哪去、核心运算在哪条指令里完成。有了这个框架,后面看更复杂的函数汇编时就不会完全无从下手了。接下来带着这个基础,去看一些更真实的场景。
机器码和汇编到底什么关系
很多人把"机器码"和"汇编代码"混着叫,觉得反正都是那种看不懂的东西。但对着 objdump 的输出仔细看,左边那一列 0f af ff 和右边那一列 imul edi, edi 其实有着非常直白的一一映射关系,只是一般不会去认真想它。
先把概念理清楚:机器码是给机器的,汇编是给人的
左边那一堆十六进制数字——0f、af、ff 之类的——就是机器码。它本质上是内存里的一串字节,CPU 直接读取这些字节,然后按照硬件设计好的规则去解释:读到 0f af 就知道这是一个乘法指令,后面的字节告诉它操作数在哪。CPU 不认识什么 imul,它只认数字。
右边那一列 imul edi, edi 就是汇编代码,是给人类看的版本。它和机器码之间基本上是一对一的映射关系——一条汇编指令对应一段固定格式的机器码字节。所以可以把汇编代码"汇编"成机器码(这就是汇编器干的事),也可以把机器码"反汇编"回汇编代码(这就是 objdump、IDA 这些工具干的事)。当然反汇编回去的时候,注释没了,变量名没了,原来 int x = n * n 这种语义信息也全丢了,能看到的就只剩下冰冷的指令。
但这个双向转换的通路是存在的,而且非常直接。汇编并不是一种"高级语言",不需要编译器做复杂的翻译——它几乎就是机器码的另一种写法。
动手写个最简单的平方函数,看看汇编长什么样
为了搞清楚寄存器那些事,从一个最朴素的平方函数开始:
// square.cpp
int square(int n) {
return n * n;
}然后用 gcc 编译成目标文件,不链接,只看汇编:
# 我的环境: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):
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 一看,差异远超预期。
// square.cpp —— 就这么个简单函数
int square(int value) {
return value * value;
}用交叉编译工具链跑一下:
# ARM64
aarch64-linux-gnu-g++ -O2 -c square.cpp -o square_arm64.o
aarch64-linux-gnu-objdump -d square_arm64.o输出是这样的:
square:
mul w0, w0, w0
ret就这。两条指令,干干净净。一个特别舒服的地方是:W0 既是输入,又是输出。ARM 的调用约定里,W0(32 位)或者 X0(64 位)既是第一个参数的载体,也是返回值的载体。所以 mul w0, w0, w0 读起来就是"把 w0 乘以 w0,结果放回 w0",三个操作数全是同一个寄存器,视觉上极其统一。
接下来看一下这些指令的机器码,这能揭示一个重要的设计差异。
aarch64-linux-gnu-objdump -d -j .text square_arm64.o | grep mul
# 0: 1b007c00 mul w0, w0, w01b007c00,四个字节。再看那条 ret:
# 4: d65f03c0 retd65f03c0,也是四个字节。两条指令,都是精确的四个字节。这意味着指令解码器的工作特别简单,取指阶段每次固定取四个字节,不用做任何长度判断。这个设计之所以优雅,在对比 x86 之后会更加明显。
x86 的变长指令
同样的函数,x86-64 下编译:
g++ -O2 -c square.cpp -o square_x64.o
objdump -d square_x64.osquare(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):
riscv64-linux-gnu-g++ -O2 -c square.cpp -o square_rv64.o
riscv64-linux-gnu-objdump -d square_rv64.osquare:
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 那种"完全不可预测"的变长还是要规矩得多。
操作数的个数:不是所有指令都那么规整
到这里可能会觉得,指令嘛,不就是"操作码 + 几个操作数",挺整齐的。但翻阅更多汇编之后会发现,现实远没有这么美好。
上面看到的 mul、imul 都是典型的三操作数指令(目标 + 源1 + 源2),或者二操作数(目标同时也是源1)。但还有很多指令根本不按套路来。零操作数的指令最简单,比如 ret、nop,不需要任何额外信息。单操作数的也常见,比如各种跳转指令。双操作数和三操作数刚刚看过。
但真正让人困惑的是"隐式操作数"。比如 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)。它们全部指向同一块物理存储,只是用不同的名字去"切"它。
用一个简单的示意图来说明:
63 31 15 7 0
+--------------------------------+----------+----+----+
| RAX | EAX | AX |
| | +----+----+
| | | AH | AL |
+--------------------------------+----------+----+----+所以在汇编里看到这样的代码时,不必慌张:
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)为例。前六个整数参数(包括指针)依次放在这些寄存器里:
第 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 只给了四个寄存器来传参数:
第 1 个参数 → RCX
第 2 个参数 → RDX
第 3 个参数 → R8
第 4 个参数 → R9注意顺序和名字都跟 Linux 不一样。这意味着同样一个函数,在 Linux 上前六个参数全走寄存器,在 Windows 上第五个和第六个就已经要压栈了。跨平台调试性能问题时,同样的 C++ 代码在两边的汇编长得完全不一样,这往往就是 ABI 差异导致的。
这个差异其实会对 API 设计产生微妙的影响。如果知道 Windows 上只有四个寄存器可用,在设计高频调用的接口时就会更倾向于控制参数数量。不过这个话题后面遇到具体场景再展开。
动手验证一下
光说不练假把式,写个最简单的函数丢到 Compiler Explorer 里看看:
// 编译选项:-O1 -m64
// 平台:x86-64 Linux (GCC)
long add_three(long a, long b, long c) {
return a + b + c;
}对应的汇编大概长这样(GCC 16, -O1):
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。清爽。
再试一个超过六个参数的:
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;
}汇编会变成这样:
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 汇编的时候,打开反汇编窗口,满屏的 t0、a7、s1、ra,跟 x86 那套 rax、rbx、rcx 看起来一样,似乎都是一堆需要死记硬背的字母缩写。但真正搞明白之后会发现,RISC-V 的寄存器命名根本不是随意的缩写——它直接告诉你这个寄存器该干什么。理解了命名背后的调用约定语义,这些名字自己就能推导出来。
先从最基础的编号说起
RISC-V 一共有 32 个通用寄存器,编号从 x0 到 x31。注意,是 32 个,不是 31 个——x0 确实是一个存在的寄存器,只不过它硬连线为 0,往里写什么都是 0,读出来永远是 0。这个设计初看似乎多此一举,但在写内联汇编的时候会发现,常数零能直接当操作数用,省去了不少 mov 指令。
然后是位宽的问题。RISC-V 的寄存器是 64 位的(RV64G 标准),编号 x0 到 x31 对应完整的 64 位值。如果只需要操作低 16 位,直接用 0xFFFF 做掩码与一下就行,不需要像某些架构那样有单独的 16 位寄存器别名。这一点相当清爽,不需要在不同位宽的寄存器名之间来回切换。
前面说的是 x0 到 x30,但其实 x0 到 x31 一共 32 个寄存器全都要说。其中 x1 比较特殊,它是 ra(Return Address),后面会详细说。不管怎样,32 个寄存器摆在那里,比 x86-64 那种历史包袱沉重的命名方式直观得多——x86 的通用寄存器名字是从 16 位时代一路继承过来的,rax 是 a 扩展来的,r8 到 r15 是后来硬加的,整个体系毫无规律可言。
那些别名到底是什么
关键来了。实际写汇编或者看反汇编输出的时候,几乎永远不会看到 x0 到 x31 这种纯数字编号。编译器和反汇编器把每个寄存器都重命名了,换成了有语义的名字。看到一堆 t、s、a 开头的东西会觉得是一套需要死记的约定,但只要理解了调用约定(Calling Convention),这些名字自己就能推导出来。
看一个简单的例子,用 GCC 16.1.1 编译的 RISC-V 64 位目标:
// 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;
}编译命令:
riscv64-linux-gnu-g++ -O1 -S test.cpp -o test.s看输出的汇编:
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 个参数分别放在 a0 到 a7 里,返回值也放在 a0 里。a 就是 Argument(参数)的意思,a0 到 a7 就是参数寄存器,同时 a0 兼任返回值寄存器。这比 x86 那套 "RDI 放第一个参数、RSI 放第二个参数、RDX 放第三个参数" 的散装命名好记得多。
T 寄存器和 S 寄存器——调用约定的核心
搞懂了 a 寄存器,剩下的就顺理成章了。t 开头的是 Temporary(临时)寄存器,从 t0 到 t6 一共 7 个(具体映射后面会列表说明)。s 开头的是 Saved(被调用者保存)寄存器,从 s0 到 s11 一共 12 个。
这两个概念很容易搞混。一个常见的坑是:在 t0 里存了一个中间值,然后调用了另一个函数,回来之后 t0 的值变了,程序直接跑飞。这是因为 t 寄存器是调用者保存的——如果在 t0 里存了东西,然后调用别的函数,必须自己提前把它存到栈上,因为被调用的函数可以随意使用 t0,不保证它的值不变。
而 s 寄存器正好反过来,是被调用者保存的。如果在一个函数里用了 s1,那么必须在函数返回前把 s1 恢复成调用者期望的值。换句话说,调用者可以放心地在 s1 里存东西然后调用别的函数,回来之后 s1 的值一定还在。
下面用一个直观的代码示例来验证:
// 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;
}// 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 名 | 含义 | 调用约定 |
|---|---|---|---|
| x0 | zero | 硬连线为零 | — |
| x1 | ra | 返回地址 | 调用者保存 |
| x2 | sp | 栈指针 | 被调用者保存 |
| x3 | gp | 全局指针 | — |
| x4 | tp | 线程指针 | — |
| x5-x7 | t0-t2 | 临时寄存器 | 调用者保存 |
| x8 | s0/fp | 被保存寄存器/帧指针 | 被调用者保存 |
| x9 | s1 | 被保存寄存器 | 被调用者保存 |
| x10-x17 | a0-a7 | 参数/返回值 | 调用者保存 |
| x18-x27 | s2-s11 | 被保存寄存器 | 被调用者保存 |
| x28-x31 | t3-t6 | 临时寄存器 | 调用者保存 |
t 就是临时用、用完即弃的,s 就是要好好保管的,a 就是传参数的,ra 就是记住从哪来的,sp 就是管栈的。每个名字都在告诉你它的职责。
顺便一提,如果之前用过 32 位 ARM,会发现 ARM 只有 16 个通用寄存器(R0-R15),参数只能放 R0-R3 四个,多出来的全走栈。RISC-V 有 32 个寄存器,光参数寄存器就有 8 个,临时寄存器有 7 个,被调用者保存的寄存器有 12 个——寄存器多了,函数调用时压栈出栈的次数就少了,性能上的好处是实打实的。
隐式参数——this 指针和返回值优化
到这里可能觉得,参数传递不就是 a0 到 a7 嘛,很简单。但有一个容易被忽略的问题:C++ 的成员函数,this 指针放哪?
this 指针就是一个隐式的第一个参数。在 RISC-V Linux 上,它放在 a0 里,然后声明的第一个"真正的"参数放在 a1 里,以此类推。这跟 x86-64 Linux 上的约定是一样的(x86 上 this 放 RDI,第一个参数放 RSI)。
一个简单的验证代码:
struct Foo {
long x;
long bar(long y) { return x + y; }
};
// 编译后看汇编,bar 的签名等价于:
// long Foo_bar(Foo* this, long y)
// a0 = this, a1 = y_ZN3Foo3barEl:
ld a0, 0(a0) # 从 this->x 加载值到 a0
add a0, a0, a1 # a0 += y
ret清清楚楚,a0 一开始装的是 this 指针,然后直接被覆盖成了 this->x 的值,最后加上 a1 里的 y 返回。
但还有更复杂的情况。如果你写了这样的代码:
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,第二个参数 b 在 a2。
_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调用处的汇编大概长这样:
# 调用者在栈上预留 32 字节
addi sp, sp, -32
mv a0, sp # 把缓冲区地址作为第一个参数
mv a1, ... # 真正的参数 a
mv a2, ... # 真正的参数 b
call _Z9make_bigll
# 现在 sp 指向的位置就是构造好的 Big 对象第一次看到这个的时候很容易困惑——为什么参数位置全错了一位?原因是有一个隐式的指针参数插在了最前面。这种事情不去看汇编是永远不会注意到的,但一旦遇到了,不了解的话能调试一整天。
到这里,RISC-V 的寄存器命名体系就彻底搞通了。回头看看其实没那么难,关键是要理解每个名字背后的调用约定语义,而不是把它们当成无意义的符号去死记。