第 4 章 · 指令系统

把同一个函数分别编译到 x86-64 和 RISC-V,对比着看,ISA 的所有核心分歧一次全露馅。函数朴素到不能再朴素:

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

x86-64 的输出(gcc -O2,反汇编标注了每条指令的字节):

8d 04 37          lea    eax,[rdi+rsi]     ; 3 字节
01 d0             add    eax,edx           ; 2 字节
c3                ret                      ; 1 字节

RISC-V 的输出:

00b50533          add    a0,a0,a1          ; 4 字节
00c50533          add    a0,a0,a2          ; 4 字节
00008067          ret                      ; 4 字节

三件事直接可读:x86 指令长短不一(1 到 15 字节都可能),RISC-V 条条 4 字节;x86 用了两条完全不同的指令(一条带内存计算,一条纯加法),RISC-V 两条长得一模一样;x86 代码 6 字节,RISC-V 12 字节,前者省一半空间。变长还是定长、指令强还是弱,这就是 ISA 合同的两大条款。第 1 章把 ISA 叫「软硬件的分界线」,本章展开它的具体条文。

4.1 一条指令里有什么

每条指令至少包含两块信息:干什么(操作码),对谁干(操作数在哪)。按操作数个数分类其实是段历史:

  • 三地址(add a, b, c,含义 a = b + c):最直观,RISC-V 的 add 就是。
  • 二地址(add a, b,含义 a = a + b):结果覆盖一个源,x86 的主流写法,所以上面 x86 代码里 eax 反复出现。
  • 一地址(隐含累加器)和零地址(隐含栈顶,比如 JVM 字节码):上古和虚拟机时代的遗产,知道存在即可。

指令长度是两种哲学的直接体现。定长(32 位)解码器闭着眼睛都知道下一条指令在哪,第 6 章流水线取指会感谢这个决定;变长省内存,但解码器得逐字节判断「这条指令到哪结束」,x86 的解码器是业界公认的复杂度黑洞,Intel 干脆在芯片里把变长指令预先切成定长的微操作再执行,等于在里面藏了个 RISC 内核。

4.2 寻址方式:操作数的六种藏身处

同样一条「加」,操作数可以来自很多地方。以「把某个数加进 r1」为例:

寻址方式写法示例操作数实际在哪对应的 C
立即addi r1, r1, 5就在指令里x + 5
寄存器add r1, r1, r2寄存器最普通的变量运算
寄存器间接ld r1, 0(r2)r2 指向的内存*p
变址ld r1, 8(r2)r2 + 8 指向的内存p->field、a[i]
基址变址ld r1, (r2,r3)r2 + r3 指向的内存二维数组元素
PC 相对beq r1, r0, +16相对当前指令偏移if/循环跳转

这张表值得盯一分钟:C 的每个语法构造几乎一比一对应一种寻址。指针是寄存器间接,结构体字段是「基址加固定偏移」的变址,数组 a[i] 是「基址加变量偏移」。你以后看任何汇编,先认寻址方式,基本就知道这行汇编对应源码里的哪种东西。

不同 ISA 的寻址能力差别很大:x86 有十几种(还包括「基址 + 变址 × 比例」这种一步算出 a[i*8] 的豪华写法),RISC-V 砍到只剩四五种。RISC 的态度是:寻址方式越多,单条指令越强,但解码越复杂、流水线越难做;少而规整,编译器多干点活就是了。

4.3 RISC-V 的零号寄存器:一个小设计的大聪明

RISC-V 有 32 个通用寄存器,x0 到 x31,其中 x0 永远是 0:写它等于扔进黑洞,读它恒得 0。第一眼觉得浪费,一个寄存器的位置就为了存个 0?实际上这个设计相当值钱:

  • 清零不需要指令,add x5, x0, x0 就把 x5 清零(不过真要清零,编译器更常用 mv x5, x0,同一件事的别名)。
  • 「比较」「置零」「丢弃写入」这些高频操作全部有了着落。beq x1, x0 就是「x1 等于 0 就跳」,不用专门的「与零比较」指令。
  • 4.2 表里的寄存器间接寻址 ld r1, 0(r2),把 r2 换成 x0 就变成直接寻址 ld r1, 100(x0):一种寻址机制,两种用法。

ARM 的 xzr 是同一个思路。这类小设计透露 RISC 的整体口味:不堆指令,用少量机制组合出大量效果。

4.4 CISC 和 RISC:一场已经打完的战争

教科书喜欢把这两派写成正面对决,列一张长长的对比表。我更愿意这样总结:分歧的本质是「复杂度放硬件还是放编译器」,而这场战争在现代 CPU 里已经以「内部合流」的方式结束了。

RISC 阵营(ARM、RISC-V):指令少而规整、定长、只有 load/store 能碰内存(所以叫 load-store 架构),复杂操作交给编译器拼装。换来的是解码简单、流水线好做、功耗低,移动端和嵌入式全面获胜。

CISC 阵营(x86):一条指令能干一串事(上面 lea eax,[rdi+rsi] 一条指令做了取寄存器、相加、送回三件事),省指令数省内存。代价是解码复杂。

而现实是:现代 x86 处理器在前端把每条复杂指令翻译成类 RISC 的微操作(µop),后端执行的其实是 RISC;现代 ARM/RISC-V 为了省内存带宽,也搞出了压缩指令扩展(RISC-V 的 C 扩展把常用指令压到 16 位)。两边在架构层面互相抄作业,真正的性能分野发生在微架构(第 5、6 章的流水线、分支预测、Cache 设计)。「CISC 还是 RISC」作为选型问题基本失去了意义,Apple 用 ARM(精简指令)做 M 系列打桌面,Intel 用 x86 守服务器,赢的都不是指令集本身。

维度x86-64ARM (AArch64)RISC-V
指令长度1~15 字节变长定长 32 位(另有 16 位压缩)定长 32 位(可扩展 16 位)
通用寄存器1631 + 零寄存器32 + 零寄存器
访存大量指令可直接访存仅 load/store仅 load/store
授权模式封闭授权收费开源免费
主战场桌面、服务器手机、(如今)笔记本嵌入式、新兴市场

一个值得记住的观察:RISC-V 基础整数指令集只有四十来条指令,一张 A4 纸印得下。对照 x86 上千条指令、两千多页手册,你就明白为什么学界和新兴公司都爱 RISC-V:教得完,学得完,造得完。

练习

  1. 对照阅读:上面 add3 的两个版本里,x86 的 lea eax,[rdi+rsi] 一条指令干了三件事。把它拆成三条最朴素的 RISC 风格指令(加载、加、存),再想想为什么编译器宁可用 lea。
  2. 手算:RISC-V 一条 32 位指令,操作码 7 位,两个源寄存器号各 5 位,目的寄存器 5 位,功能码 7 位,剩下多少位给立即数或别的字段?(这就是为什么寄存器不能超过 32 个:寄存器号每加 1 位,所有指令都变长。)
  3. 动手:随便写个几十行的 C 文件,分别用 gcc -O2 -S 生成 x86 汇编和 riscv64-linux-gnu-gcc -O2 -S(没有交叉编译器就找在线 Compiler Explorer,编译完对比指令条数和字节数。
  4. 思考:为什么 JVM 字节码选栈式(零地址)而不是寄存器式?提示:字节码要在千差万别的真实机器上解释执行,栈式解释器写起来有多简单?后来 Android 的 Dalvik 改成寄存器式,图什么?