RISC-V编码

RISC-V指令编码设计、模块化扩展与过程调用机制

Posted by CloudingYu on March 18, 2026

一、中断与流水线的回顾

1.1 中断在流水线中的复杂性

在单周期CPU中,中断处理很简单——确定把当前这一条指令执行完即可。但进入流水线后,事情变得复杂。

当中断到来时,PC已经指向了fetch阶段,流水线中可能有多条指令在不同的阶段(fetch、decode、execute、memory、writeback)。如果直接清空所有流水级,PC已经算出了新值,前面的指令还在继续跑着。

中断的设计就要知道——发出的中断不是说马上来,必须得把手头上的事情干完。一旦到了流水线以后就变得很复杂。


二、RISC-V 指令系统——从批评到理解

2.1 RISC-V 指令集的整体印象

一开始看 RISC-V 的指令编码表格,觉得和 MIPS 一样就是画个二维表格,横坐标是 opcode,纵坐标是 funct,往里填指令。但仔细分析后发现,设计一个指令集远没有批评那么容易。

批评总是很容易的。真想自己实践干一下还是挺有挑战的。试图对 RISC-V 做些改进,但想来想去,最多也就是能把 OP 从最后挪到前面跟 MIPS 一样——做一些表面 polish,实际也没什么用。

2.2 为什么指令字段要对齐

RISC-V 指令格式的核心设计原则之一是字段对齐。不同类型的指令虽然格式不同,但关键字段(如 rs1、rs2、rd)总是出现在相同的位置。

对齐的好处在于硬件的指令预处理

  • 在流水线很深的情况下,硬件希望尽早开始工作。比如一拿到指令,不用等完全译码就能去取寄存器操作数。
  • 如果 rs1、rs2 字段位置固定,硬件可以直接”拉着数据就去取”,取出来以后最多用不到扔掉——组合逻辑判断一下就扔了。
  • 但如果字段不对齐,硬件必须等到 OP code 译码完毕才能判断指令类型,然后才能去取数,这样就没办法把流水线分得更细。

对齐的好处是——管你的,先去取了以后大概率至少有六分之四都是能用的,每一个环节就全力以赴。

2.3 RISC-V 六个基本指令格式

RISC-V 基础整数指令集(RV32I)有六种指令格式:

格式 用途 关键特征
R-type 寄存器-寄存器运算(add, sub, and, or 等) 三个寄存器操作数,7位funct7 + 3位funct3
I-type 带立即数的运算、load指令、JALR 12位立即数,rs1/rd/funct3字段与R型对齐
S-type store指令 立即数拆成两部分([11:5]和[4:0]),rs2替代rd位置
B-type 条件分支 立即数重排(无[0]位),与S型字段对齐
U-type LUI、AUIPC 20位高位立即数,无rs1/rs2
J-type JAL 20位立即数,特殊排列

2.4 寄存器字段的扩展空间

RISC-V 在 R-type 指令中给 funct7 留了 7 位,funct3 留了 3 位,加上 7 位的 opcode,这意味着任何一条指令后面还跟着约 1024 个功能的扩展空间(2^10 = 1024)。

MIPS 只给了 16 个 I-type 指令编码位,而 RISC-V 给了 12 个——虽然看起来少了,但 RISC-V 的设计哲学是:指令格式种类多(R/I/S/B/U/J 共六种 vs MIPS 的三种),每种内部有更多的扩展空间。

2.5 “师出有名”——指令编码的约束

RISC-V 明确规定了哪些 opcode 空间给哪种指令类型。比如某一段是 R-type,某一段是 I-type。这样做的好处是:

  • 以后扩展时,你只能在对应类型的空间里填新指令。
  • 如果你要在 I-type 的空间里塞一个奇怪的指令,就必须向别人解释”为什么一个以前是 I-type 指令的东西,现在是一个其他的指令了”。

名不正则言不顺——这就把自己给框住了。但反过来想,这种”自缚手脚”恰恰是设计智慧——明确了分类原则后,每个类型内部都有预留空间(reserved),随着应用发展,这些预留空间可以逐步启用。

2.6 指令集是”活的”

指令集不是一个死的东西,而是非常具有生命力的东西。当年有计算机的时候,谁知道会天天看电影、听音乐?后来就把向量和 media 相关指令加上去了。后来谁知道要天天做神经元计算?神经元计算需要浮点,浮点指令就要跟上。

面对这些变化,必须把指令集做成一个可扩展的。这就是 RISC-V 设计的核心优势——分类以后给每个类型留下了很大的发展空间。

2.7 核心指令集 I 与嵌入式

RV32I(或 RV64I)是最基本的核心指令集。有了 I 就能做嵌入式 CPU,嵌入式 CPU 就可以卖很多量。

RISC-V 已经卖了几十亿片了——大量的嵌入式设备。一个激光笔的遥控器里面可能都有一个小处理器。嵌入式处理器的要求是便宜,4 比特的处理器在洗衣机里也存在——洗衣机咕噜咕噜转,它算上一分钟也给你算过来了,不需要很快。


三、RISC-V 的模块化扩展

3.1 模块化设计理念

RISC-V 采用模块化的指令集扩展架构:

字母 含义 说明
I 基本整数指令集 必须支持,嵌入式 CPU 的最小配置
M 乘除法指令 MUL, DIV 等
A 原子指令 Atomic operations
F 单精度浮点 Float
D 双精度浮点 Double
C 压缩指令 16位指令扩展
G 通用组合 G = IMAFD

每个模块可以独立支持。你只要在卖芯片时把支持的字母标上去,大家就知道你能做什么。编译器也可以通过编译选项(如 -march=rv32imc)知道目标芯片的能力。

这个想法确实很巧妙——把它分开。因为有的时候需要支持操作系统,有的时候只是个嵌入式,不一定要那么多操作。

3.2 运行操作系统的需要

如果要在 RISC-V 上跑操作系统(如 Linux),一般需要 G = IMAFD,再加上特权架构

  • M 模式(Machine Mode):最高权限,可以控制所有资源
  • S 模式(Supervisor Mode):监管者模式,相当于操作系统核心,管理虚拟内存、页表、系统调用、中断
  • U 模式(User Mode):用户模式,普通应用程序,不能操作页表等特权资源

如果只跑嵌入式 Linux,不一定需要全部三种模式。

3.3 扩展指令集的内容

M 扩展增加了:12 条整数乘除法指令、6 条移位指令、3 条加减运算指令。

后续扩展还包括向量/多媒体指令。多媒体指令本质上都是在做向量运算(矩阵运算是向量运算的高维扩展)。现在神经网络大量使用向量运算,未来还会冒出什么新指令不好说。


四、大立即数的加载——LUI + ADDI 技巧

4.1 问题:12 位不够怎么办

RISC-V 的 I-type 指令只有 12 位立即数。如果需要在寄存器中放入一个 32 位的值怎么办?

解决方案:LUI(Load Upper Immediate)+ ADDI 组合。

  • LUI 将 20 位立即数加载到寄存器的高 20 位,低 12 位清零
  • ADDI 再加上低 12 位的立即数

这样两条指令就能构造出任意的 32 位立即数。

4.2 举例:加载 x = 8191

对于 int x = 8191(正的 8191),直接使用:

LUI  x5, 1       # x5 = 0x00001000 (高20位放了1,即 2^12)
ADDI x5, x5, -1  # x5 = 4096 + (-1) = 4095, 但这就是8191吗?

实际上 8191 = 0x1FFF。LUI 加载高位需要计算:如果 LUI x5, 0 然后 ADDI x5, x5, 8191,但这会触发符号扩展的问题。

4.3 ADDI 的符号扩展陷阱

这里有一个关键的设计细节:ADDI 指令在执行加法前,会先对 12 位立即数做符号扩展(sign-extend)到 32 位。

int x = -8191 为例:

  • -8191 的 32 位表示为 0xFFFFE001
  • 如果用 LUI 加载高位 0xFFFFE(即高 20 位全 1),再 ADDI 加 0x001
  • 但 ADDI 的 12 位立即数是 0x001,符号扩展后变成 0x00000001
  • LUI 结果 + 0x00000001 会产生进位,导致高位溢出变成 0x00000000

ADDI 实际上做了一个符号扩展。最高位上那个 1 加进去以后,给进位了——不行。

4.4 编译器需要判断

编译器的解决办法:在生成代码时判断立即数的范围。

  • 如果低 12 位的最高位(第 11 位)是 1,说明符号扩展后会是负数(高位全 1),此时需要先 LUI 加 1,再 ADDI 减来补偿。

以 8191 为例(0x00001FFF),正确的做法是:

LUI  x5, 2       # x5 = 0x00002000 = 8192
ADDI x5, x5, -1  # x5 = 8192 + (-1) = 8191

RISC-V 的设计选择是”尽量简单点,有事你们找软件的同学”。好消息是,对软件工程师来说,责任越大,价值也越高。


五、压缩指令(C 扩展)

5.1 16 位压缩指令的本质

压缩指令每一条 16 位,但每一条 16 位指令都对应一条功能完整的 32 位指令。执行时由硬件先转换为 32 位指令再执行。

没省一点事——就省了一点存储(代码存储的 memory)。执行没有省一丁点时间。

5.2 为什么 RISC-V 还要做压缩指令

在嵌入式场景中,代码存储空间非常宝贵。嵌入式系统的 Flash/ROM 容量有限,压缩指令能显著减少代码体积。虽然个人不太喜欢这个设计,但嵌入式开发者确实需要。

屁股决定脑袋。如果节约了给自己,马上可能就觉得好了。每个人看问题的立场不同——计算机组成这门课最大的关键就是告诉你坐在哪个位置上的时候,是怎么看待这个问题的。


六、32 位与 64 位 RISC-V

6.1 核心判断标准

一个指令集是多少位的,核心是什么?是那个数据总线。数据总线由谁决定?寄存器的宽度

寄存器的位宽 = 数据总线的位宽。你不可能说寄存器还小,每次取两个寄存器拼到数据线上——那是低效的。

  • RV32:32 位寄存器,32 位数据总线
  • RV64:64 位寄存器,64 位数据总线

注意:64 位指令指的不是每条指令 64 位长,而是处理器一次处理 64 位数据。64 位架构下指令仍然是 32 位的(除了 C 扩展的 16 位)。

6.2 64 位架构下加载 32 位数

在 64 位架构中将一个 32 位数装入 64 位寄存器,关键是高 32 位的扩展

  • 低 32 位直接加载
  • 高 32 位需要根据符号做符号扩展

依然使用 LUI + ADDI 这对标准指令来完成。


七、乘法与除法指令(M 扩展)

7.1 MUL 指令家族

RISC-V 的乘法指令有四个变体:

指令 含义
MUL 乘法,保存结果的低 32 位
MULH 有符号乘,保存结果的高 32 位
MULHSU 有符号 x 无符号,保存结果的高 32 位
MULHU 无符号乘,保存结果的高 32 位

7.2 为什么需要两条指令得到 64 位乘积

两个 32 位数相乘,结果理论上一定是 64 位的范围。但寄存器只有 32 位宽。

  • 光拿 MUL 只能得到低 32 位——这就像背乘法口诀”九九八十一”,光记末尾的”一”是不行的。
  • 必须再用 MULH(取高 32 位)把完整结果保存在两个寄存器中。

数据已经有了——一定是从高到低都存在,所以需要两条连续指令。但实际执行的时候,乘法器是一次就把完整结果算出来的,把它放到高 32 位和低 32 位两个寄存器里。

7.3 没有溢出检测

RISC-V 的乘除法指令不检查溢出,也不触发溢出异常。溢出与否由软件自己判断。

7.4 除零的特殊处理

和 x86 不同,RISC-V 的除法不触发硬件异常

  • x86:除零直接硬件中断,跳转到中断服务程序,代码不会继续执行
  • RISC-V:除零不报异常,代码继续跑。结果被设置为特殊值——商全 1,余数等于被除数

但问题在于:当你发现商为全 1、余数为被除数时,到底是溢出了还是除零了?——由程序员自己判断。

x86 是硬件直接中断了。对于 RISC-V 来讲,代码还在一步步跑着,没死。但查手册说——这不对。这就是 RISC-V 的设计选择:不报硬件错误,各凭本事去处理。


八、过程调用——JAL 与 JALR

8.1 为什么要有专门的过程调用指令

从硬件角度看,一个简单的 jump 或者 branch(BEQ/BNE)就能跳转到任何地方。但函数调用需要解决几个额外问题:

  1. 参数传递:如何从调用程序把参数传递到被调用程序?
  2. 控制转移:如何从调用程序执行转移到被调用程序?
  3. 返回机制:如何从被调用程序返回到调用程序?
  4. 寄存器保护:如何保证调用程序中的寄存器内容不被破坏?

8.2 JAL 与 JALR 的分工

  • JAL(Jump And Link):跳转到目标地址,同时把返回地址(PC+4)保存到 rd 寄存器
  • JALR(Jump And Link Register):跳转到 rs1 + 立即数 指定的地址,同时保存返回地址。用于函数返回时,把 ra 中的返回地址恢复到 PC

8.3 RISC-V 的寄存器调用约定

寄存器 别名 用途
x0 zero 恒为 0
x1 ra 返回地址(Return Address)
x2 sp 栈指针(Stack Pointer)
x3 gp 全局指针(Global Pointer)
x4 tp 线程指针(Thread Pointer)
x5-x7 t0-t2 临时寄存器(调用者不保存)
x8 s0/fp 保存寄存器 / 帧指针(Frame Pointer)
x9 s1 保存寄存器
x10-x11 a0-a1 函数参数 / 返回值
x12-x17 a2-a7 函数参数
x18-x27 s2-s11 保存寄存器(被调用者保存)
x28-x31 t3-t6 临时寄存器

8.4 参数传递:寄存器优先

RISC-V 使用寄存器来传递函数参数(前 8 个参数用 a0-a7)。这和 x86-32 不同(x86-32 用栈传参),x86-64 也改用寄存器传参了。

寄存器的好处是。放栈上是一次 memory 写操作——memory 多长时间能写进去?memory 有空没?也许 memory 说”我正忙着”。

如果参数太多(超过 8 个),可以通过指针指向内存中的数据结构来传递更多数据。返回值放在 a0 和 a1。

8.5 调用过程的六个步骤

从调用程序 P 调用被调用程序 Q 的完整流程:

  1. 传递参数:把参数放入 Q 能访问的地方(a0-a7 寄存器)
  2. 保存返回地址:把 P 中的返回地址存到特定地方(ra/x1 寄存器)
  3. 转移控制:执行 JAL 跳转到 Q 的入口
  4. 分配局部空间:为 Q 的局部变量分配空间(栈上分配 memory)
  5. 执行 Q:执行 Q 的逻辑,把返回值放到 P 能访问的地方(a0-a1)
  6. 返回:取出返回地址(从 ra),执行 JALR 跳回 P,恢复调用场景

8.6 寄存器保护:调用者保存 vs 被调用者保存

  • 临时寄存器(t0-t6):调用者不保存,被调用者可以随意使用。如果调用者需要这些值,自己先存好。
  • 保存寄存器(s0-s11):被调用者在使用前必须先把原来的值保存到栈上,返回前恢复。
  • ra(x1):特别重要——如果 Q 内部还要调用其他函数(嵌套调用),必须先把 ra 保存起来,否则会被覆盖。

8.7 栈——硬件视角

在硬件视角下,栈就是一个 memory 访问。只不过硬件上面的空间很小,放了一点点。约定哪一段是 user stack,哪一段是 kernel memory,只是大家知道了这个约定而已。

  • 栈就是一整块 memory,只是大家约定栈指针(sp)指向栈顶
  • 压栈 = 一次 memory 写操作
  • 返回地址可以放寄存器(ra)以求快,也可以放栈上(多做几次恢复操作)
  • 所谓”内核栈”和”用户栈”的本质区别只是地址范围不同、权限不同

都是起的名字来吓唬人的。在硬件眼里全都是 memory,就是地址。


附录:学术讨论

  • 流水线与中断:流水线 CPU 中中断处理的复杂性。
  • 大模型工具:推荐使用大模型辅助读论文、查资料,如 Google NotebookLM 等工具。
  • 学术研究:本科生解决有标准答案的问题,硕士生解决导师知道方法但没做过的问题,博士生在人类知识边界上”冒个小尖尖”。学术诚信极端重要——计算机领域造假相对容易发现,因为代码和数据可以复现。