一、中断与流水线的回顾
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)就能跳转到任何地方。但函数调用需要解决几个额外问题:
- 参数传递:如何从调用程序把参数传递到被调用程序?
- 控制转移:如何从调用程序执行转移到被调用程序?
- 返回机制:如何从被调用程序返回到调用程序?
- 寄存器保护:如何保证调用程序中的寄存器内容不被破坏?
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 的完整流程:
- 传递参数:把参数放入 Q 能访问的地方(a0-a7 寄存器)
- 保存返回地址:把 P 中的返回地址存到特定地方(ra/x1 寄存器)
- 转移控制:执行 JAL 跳转到 Q 的入口
- 分配局部空间:为 Q 的局部变量分配空间(栈上分配 memory)
- 执行 Q:执行 Q 的逻辑,把返回值放到 P 能访问的地方(a0-a1)
- 返回:取出返回地址(从 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 等工具。
- 学术研究:本科生解决有标准答案的问题,硕士生解决导师知道方法但没做过的问题,博士生在人类知识边界上”冒个小尖尖”。学术诚信极端重要——计算机领域造假相对容易发现,因为代码和数据可以复现。