RISC-V 五级流水线通览
RISC-V 的五级流水线通常指经典的 IF -> ID -> EX -> MEM -> WB 五个阶段。下面这篇文章按整体目标、各级职责、级间寄存器、冒险处理和工程实现变化来系统梳理这套经典结构。
RISC-V 的“五级流水线”通常指经典的 IF → ID → EX → MEM → WB 五个阶段。RISC-V 很适合拿它讲清楚,因为指令格式规整、load/store 分离、编码相对干净,特别适合作为流水线教材。
一、五级流水线到底在解决什么
单周期 CPU 的思路是:一条指令从头到尾一次做完,再做下一条。
好处是简单,坏处也很明显:时钟周期必须迁就最慢指令路径,所以频率上不去。
流水线(Pipeline)的核心思想是:
- 把一条指令的执行拆成多个阶段
- 每个阶段只做一部分工作
- 多条指令像工厂装配线一样重叠执行
于是理想情况下:
- 单条指令的延迟没有明显减少,甚至可能更长一点
- 吞吐率显著提高
- 理想 CPI(每条指令平均周期数)接近 1
经典五级就是:
- IF:取指
- ID:译码/读寄存器
- EX:执行/算地址/比较分支
- MEM:访问数据存储器
- WB:写回寄存器
二、五级流水线的整体数据流
先给你一个宏观图景:
- 指令存储器给 IF 阶段提供指令
- 寄存器堆在 ID 读出源操作数
- ALU 在 EX 做运算
- 数据存储器在 MEM 读写数据
- 最后在 WB 把结果写回寄存器堆
各阶段之间会插入流水线寄存器:
- IF/ID
- ID/EX
- EX/MEM
- MEM/WB
三、五个阶段逐级展开
1)IF:Instruction Fetch,取指阶段
主要任务
IF 阶段干两件事:
- 用 PC(Program Counter) 到指令存储器取出指令
- 计算下一条 PC,通常是
PC + 4
对于 RISC-V RV32I 基础整数指令集,如果不考虑压缩指令 C 扩展,指令长度固定 32 位,所以顺序执行时下一条就是 PC + 4。
IF 阶段常见硬件
- PC 寄存器
- 指令存储器(Instruction Memory 或 I-Cache)
- PC 加法器(算
PC+4) - 取指选择逻辑(顺序/跳转/分支目标)
输出到 IF/ID 的内容
通常会把这些东西送到 IF/ID:
- 当前指令
instr - 当前 PC
pc pc+4
pc+4 后面很有用,尤其是 jal / jalr 要写回返回地址时。
细节提醒
如果实现了分支,那么 IF 阶段可能会遇到:
- 下一条到底是
PC+4,还是分支目标 - 如果分支结果还没出来,往往先按顺序取,错了再冲刷流水线
这就引出了控制冒险,后面细讲。
2)ID:Instruction Decode,译码/读寄存器阶段
主要任务
- 解析指令字段
- 生成控制信号
- 从寄存器堆读取源寄存器
- 生成立即数
- 有些实现会在这里做早期冒险检测
RISC-V 指令字段解析
RISC-V 32 位指令通常会拆出:
opcoderdfunct3rs1rs2funct7
不同格式(R/I/S/B/U/J)字段位置相对规整,但立即数拼接方式不完全一样。
寄存器堆读取
ID 阶段根据 rs1、rs2 读出两个源操作数:
ReadData1ReadData2
RISC-V 的整数寄存器堆有 32 个通用寄存器:
x0到x31x0永远是 0,写了也白写,硅片版佛系选手
立即数生成器
RISC-V 的立即数不是一整段,经常需要重新拼位、符号扩展。例如:
- I-type:算术立即数、load、jalr
- S-type:store
- B-type:branch 偏移
- U-type:lui / auipc
- J-type:jal 偏移
所以 ID 阶段里通常有一个 Immediate Generator。
控制信号生成
ID 阶段会根据 opcode/funct 生成一堆控制信号,例如:
RegWrite:是否写寄存器MemRead:是否读数据存储器MemWrite:是否写数据存储器MemToReg:写回的数据来自内存还是 ALUALUSrc:ALU 第二操作数来自寄存器还是立即数Branch:是否为条件分支Jump:是否为 jal/jalrALUOp:ALU 做加减与或比较之类
这些控制信号通常也要跟着指令一起穿过流水线寄存器传下去。
ID 输出到 ID/EX 的内容
一般包括:
- PC / PC+4
- 读出的寄存器值 rs1_val / rs2_val
- 立即数 imm
- rd / rs1 / rs2 编号
- 各类控制信号
3)EX:Execute,执行阶段
主要任务
- ALU 运算
- 分支条件判断
- 计算 load/store 的访存地址
- 计算跳转目标地址
- 选择真正的下一 PC(某些实现)
ALU 做什么
对于不同指令,EX 阶段处理方式不同。
算术逻辑指令
例如:
addsubandorxorsllsrlsrasltsltuaddi等
EX 直接输出 ALU 结果。
load/store
例如:
lw x5, 8(x1)sw x6, 12(x2)
EX 负责计算有效地址:
[
addr = rs1 + imm
]
注意,真正读写内存在下一阶段 MEM。
分支
例如:
beqbnebltbgebltubgeu
EX 阶段通常会:
- 比较 rs1 和 rs2
- 判断是否跳转
- 计算分支目标地址
pc + imm
跳转
jal:目标通常是pc + immjalr:目标通常是rs1 + imm,且最低位清零
同时还要准备写回 pc+4 给 rd。
EX 阶段的选择器很多
典型有:
- ALU 输入 A:rs1_val / PC / 转发值
- ALU 输入 B:rs2_val / imm / 转发值
- 写内存数据也可能来自转发后的 rs2 值
EX 输出到 EX/MEM 的内容
一般包括:
- ALU result
- branch taken/not taken
- branch/jump target
- store data
- rd 编号
- 控制信号(MemRead、MemWrite、RegWrite、MemToReg 等)
4)MEM:Memory Access,访存阶段
主要任务
- 如果是 load,从数据存储器读数据
- 如果是 store,向数据存储器写数据
- 如果不是访存指令,这阶段很多时候只是“路过”
load
比如:
1 | lw x5, 8(x1) |
在 EX 阶段已经算出地址,这里根据地址读取内存数据。
对于字节/半字/字,RISC-V 还涉及:
lb/lbulh/lhulw
需要做:
- 字节选择
- 符号扩展或零扩展
store
比如:
1 | sw x6, 12(x2) |
MEM 阶段执行真正写内存动作。
分支处理的关系
有的更简单的教学设计会把分支决定放在 EX,
也有更老派/更偷懒的设计会拖到 MEM 或后面,这样分支代价更大。
经典五级里通常认为分支结果在 EX 可知,因此控制冒险一般要冲刷 IF/ID 或 IF/ID/EX 里的一部分内容,取决于具体设计。
MEM 输出到 MEM/WB 的内容
一般包括:
- 从数据存储器读出的值
- ALU 结果
- rd 编号
- 写回相关控制信号
5)WB:Write Back,写回阶段
主要任务
根据指令类型,从不同来源选择要写回的数据,写入 rd。
写回来源常见有三种
- ALU 结果例如
add,addi,and - 内存读取值例如
lw,lb PC+4
例如jal,jalr
有些实现还会有:
- U-type 的立即数结果
- CSR 相关结果(如果扩展到特权/CSR 指令)
写回条件
只有当:
RegWrite = 1- 且
rd != 0
才真正写寄存器。
因为 x0 永远不能被改。
四、为什么要有流水线寄存器
IF/ID
保存:
- 取出的指令
- 当前 PC
- PC+4
ID/EX
保存:
- rs1_val, rs2_val
- imm
- rd/rs1/rs2
- 控制信号
- PC / PC+4
EX/MEM
保存:
- ALU result
- store data
- 分支判断结果 / 跳转目标
- rd
- 控制信号
MEM/WB
保存:
- memory read data
- ALU result
- rd
- 写回控制信号
本质作用
它们让每一级只关心“这一拍自己该干什么”,
而不必跟前后级直接乱缠。
五、流水线的三大冒险:核心难点
流水线难点不在“分五段”,而在多条指令重叠后会互相绊脚。
这叫 Hazard(冒险)。
三类经典冒险:
- 结构冒险(Structural Hazard)
- 数据冒险(Data Hazard)
- 控制冒险(Control Hazard)
1)结构冒险
结构冒险是硬件资源不够用。
典型例子
如果指令和数据共用一个单端口存储器:
- 某条指令在 IF 想取指
- 另一条指令在 MEM 想读/写数据
两边都要访问内存,撞车。
解决办法
经典五级流水线通常直接避免它:
- 指令存储器和数据存储器分离即类似 Harvard 架构
- 或者至少逻辑上提供独立端口(I-cache / D-cache)
所以教学版五级流水线经常默认没有结构冒险,因为硬件资源被预先分开了。
2)数据冒险
这是最关键的。
数据冒险的本质
后一条指令需要前一条指令的结果,
但前一条还没来得及写回。
最常见的是 RAW
RAW = Read After Write,先写后读依赖。
例如:
1 | add x5, x1, x2 |
sub 需要 x5,但 add 的结果要到 WB 才写回。
而 sub 在更早阶段就要读 x5,于是读到旧值。
WAR / WAW 呢?
在经典 顺序发射、顺序执行、顺序写回 的五级整数流水线里:
- WAR(写后读)
- WAW(写后写)
一般不会出现为真正问题。
这些更多出现在乱序执行、多发射、寄存器重命名缺失等场景。
所以经典五级重点处理的是 RAW。
数据冒险的解决方法一:停顿(stall)
最笨但最稳的办法:
- 发现依赖
- 让后面的指令等几拍
- 在流水线中插入气泡(bubble)
气泡本质上就是一条“什么都不做”的假指令,通常相当于 NOP。
例如:
1 | add x5, x1, x2 |
如果没有转发,就可能要等到 add 写回后,sub 才能继续。
这显然很伤吞吐率。
数据冒险的解决方法二:前递/转发(forwarding / bypassing)
核心思想
不要等 WB 真把结果写回寄存器堆。
只要某阶段已经算出了结果,就直接旁路送给需要它的后续指令。
例如
1 | add x5, x1, x2 |
add在 EX 阶段末就得到 ALU 结果- 下一拍
sub进入 EX,正好要用 x5 - 可以把
add的 ALU 结果从 EX/MEM 直接转发到sub的 ALU 输入
这样不用等 WB。
典型转发路径
EX/MEM → EX
前一条指令刚算出的 ALU 结果,直接给后一条 EX 阶段使用。
MEM/WB → EX
如果结果更晚才可用,或者前面那条已经到了更后阶段,也可以从 MEM/WB 转发。
对 store data 的转发
比如:
1 | add x5, x1, x2 |
store 需要写内存的数据是 x5,
这也可能需要转发,而不只是给 ALU 转发。
Forwarding Unit 通常检查什么
比较:
- 当前 EX 阶段指令的
rs1,rs2 - 和 EX/MEM、MEM/WB 中将写回的
rd
若满足:
- 上游指令
RegWrite = 1 rd != 0rd == rs1或rd == rs2
则选择转发源。
优先级
一般 EX/MEM 比 MEM/WB 更新鲜,所以优先级更高。
但有一种依赖,转发也救不全:load-use hazard
看例子:
1 | lw x5, 0(x1) |
问题在于:
lw的数据直到 MEM 阶段末 才从内存读出来add下一拍进入 EX 时就想用 x5
时间对不上。结果还没出来,转发也无米下锅。
解决办法
通常必须 stall 1 个周期。
也就是:
- 冻结 PC 和 IF/ID
- 往 ID/EX 插入一个 bubble
这样 add 被推迟一拍,等 lw 数据在后面可转发时再执行。
Hazard Detection Unit 的经典判断
如果:
- ID/EX 当前是 load,且
MemRead=1 - 它的
rd与 IF/ID 中下一条指令的rs1或rs2相同
那么触发 stall。
[
ID/EX.MemRead \land ((ID/EX.rd = IF/ID.rs1) \lor (ID/EX.rd = IF/ID.rs2))
]
则:
PCWrite = 0IF/IDWrite = 0- 把控制信号清零注入 ID/EX(形成 bubble)
3)控制冒险
控制冒险来自分支和跳转。
本质
IF 阶段要决定下一条取哪条指令,
可分支是否成立往往要到 EX 才知道。
于是 CPU 很可能先按 PC+4 取了几条,
后来发现“哎呀分支 actually taken”,前面取错了。
典型例子
1 | beq x1, x2, target |
如果 beq 最终成立,
那么 add 和 sub 可能其实不该执行。
解决办法一:stall until resolved
最朴素:
- 遇到分支就等结果出来再取下一条
简单,但性能很差。
解决办法二:预测不跳转(predict not taken)
经典教学五级常这么干:
- 默认按
PC+4继续取 - 如果 EX 阶段发现分支成立,再 flush 错取指令
flush 是什么
flush 就是把已经进入流水线、但不该执行的错误路径指令作废。
通常做法:
- 把相关流水线寄存器中的控制信号清零
- 让这些错误指令变成 bubble
分支代价
如果分支在 EX 才解析,
那通常会有 1~2 个甚至更多周期的 penalty,取决于你在错误路径上已经放进来了几条指令。
六、以几条指令看流水线时序
看时序最能抓住本质。
例 1:无冒险的理想情况
指令序列:
1 | add x1, x2, x3 |
时钟周期分布:
| 周期 | I1 | I2 | I3 |
|---|---|---|---|
| 1 | IF | ||
| 2 | ID | IF | |
| 3 | EX | ID | IF |
| 4 | MEM | EX | ID |
| 5 | WB | MEM | EX |
| 6 | WB | MEM | |
| 7 | WB |
理想状态下,流水线填满后几乎每拍完成一条。
例 2:普通 ALU 依赖,可用转发解决
1 | add x5, x1, x2 |
add在 EX 算出结果sub在下一拍 EX 需要这个结果- EX/MEM → EX 转发即可
- 不需要 stall
这就是转发单元存在的意义:
避免 CPU 傻坐着发呆。
例 3:load-use hazard,需要停顿
1 | lw x5, 0(x1) |
一种典型时序:
| 周期 | I1=lw | I2=add |
|---|---|---|
| 1 | IF | |
| 2 | ID | IF |
| 3 | EX | ID |
| 4 | MEM | stall |
| 5 | WB | EX |
| 6 | MEM | |
| 7 | WB |
这里 add 至少要晚一拍进入 EX。
七、控制信号是如何在流水线中流动的
因为一条指令每一阶段需要不同控制信号,所以 ID 阶段生成后,必须跟着指令一路传下去。
常见可以分三类:
EX 类控制信号
给 EX 阶段用,比如:
ALUSrcALUOp
MEM 类控制信号
给 MEM 阶段用,比如:
MemReadMemWriteBranch
WB 类控制信号
给 WB 阶段用,比如:
RegWriteMemToReg
这些信号在 ID 生成后,会存进 ID/EX,然后跟着移动:
- EX 用一部分
- 剩下的继续送到 EX/MEM
- MEM 用一部分
- 剩下的继续送到 MEM/WB
- WB 最后使用
所以流水线寄存器里不仅存“数据”,也存“这条指令将来该怎么被处理的命令”。
八、RISC-V 五级流水线为什么很适合这样做
RISC-V 的一些 ISA 特性天然有利于经典流水线实现。
1)load/store 架构
只有 load/store 访问内存,
算术逻辑都只在寄存器之间进行。
这让 EX 和 MEM 的职责比较清晰。
2)寄存器字段位置比较规整
rs1/rs2/rd 在很多格式中位置稳定,
译码硬件更简单。
3)固定长度基础指令(不含 C 扩展)
RV32I 基础指令固定 32 位,
顺序取指和 PC+4 很自然。
一旦引入 C 扩展(16 位压缩指令),取指前端会复杂不少。
4)x0 零寄存器
很多数据路径和控制判断会更方便。
比如转发与写回时,通常直接排除 rd=0。
九、一个典型的五级流水线数据通路会包含哪些模块
你脑中可以想象一张经典 datapath 大图,里面通常有这些部件:
- PC
- Instruction Memory
- PC+4 adder
- IF/ID 寄存器
- Control Unit
- Register File
- Immediate Generator
- Hazard Detection Unit
- ID/EX 寄存器
- Forwarding Unit
- ALU
- Branch comparator / branch control
- Branch target adder
- EX/MEM 寄存器
- Data Memory
- MEM/WB 寄存器
- Writeback MUX
另有大量 MUX:
- ALU 输入选择
- 写回来源选择
- 下一 PC 选择
- 转发来源选择
真正的流水线设计,本质上就是:
大量寄存器 + 比较器 + 选择器 + 少量组合逻辑。
听起来不浪漫,但这就是现代数字电路的冷酷诗意。
十、经典五级流水线的局限
1)分支代价明显
如果分支在 EX 才知道结果,控制冒险很伤性能。
现代 CPU 会用更激进的分支预测、BTB、RAS 等。
2)load-use 仍需要停顿
即便有 forwarding,也很难完全避免。
除非你改内存时序、改阶段划分、或做更复杂旁路。
3)时钟频率受阶段不均衡影响
如果某一级逻辑太长,比如:
- 译码太重
- ALU 太复杂
- Cache 太慢
那整个时钟周期还是得迁就最慢一级。
4)不支持更高层次并行
经典五级通常是:
- 单发射
- 顺序执行
- 顺序提交
这和超标量、乱序执行、多发射相比,性能上有很大差距。
十一、现实中的 RISC-V 核会如何偏离“教科书五级”
这一点很重要。教科书五级是基础模型,但真实内核会魔改。
常见变化 1:分支提前
有的实现把分支比较提前到 ID,减少 penalty。
代价是 ID 变复杂,而且可能需要更复杂的转发到 ID。
常见变化 2:访存阶段拆分
Cache 命中判断、Tag 比较、数据返回可能让 MEM 变长,
于是可能拆成多级。
常见变化 3:乘除法不是单周期 EX
RISC-V 的 M 扩展包含乘除法:
muldivrem
这些经常不是一个 EX 周期搞定,
可能要多周期执行单元,甚至导致流水线停顿或旁路更复杂。
常见变化 4:加入压缩指令 C 扩展
前端取指不再总是 PC+4,
可能是 16 位或 32 位混合,取指和对齐逻辑就更麻烦。
常见变化 5:加入异常/中断/CSR
一旦要支持 precise exception(精确异常),
就要保证“看起来像按程序顺序执行到出错点为止”,
控制逻辑会比教学图复杂不少。
十二、如果你在实现一个 RV32I 五级流水线,最核心的模块清单
如果你是写 Verilog / Chisel / VHDL,大概率会拆这些模块:
前端
pc_reginstr_mempc_next_logic
译码
decoderregfileimm_gen
执行
alu_controlalubranch_unit
访存与回写
data_memwb_mux
流水线控制
if_id_regid_ex_regex_mem_regmem_wb_regforwarding_unithazard_detection_unitflush/stall control
其中真正最容易写错的往往不是 ALU,
而是 stall、flush、forward 三者同时存在时的优先级关系。
这块很容易变成数字电路版章鱼打架。
十三、stall、flush、forward 的优先级直觉
这是设计里非常关键的一点。
一般来说:
- forward:优先用于消除可消除的数据依赖
- stall:用于无法通过 forward 解决的依赖,例如 load-use
- flush:用于控制流错误路径清除,通常比普通数据推进更“强”
一个常见经验是:
- 先判断是否需要 branch/jump 导致 flush
- 再判断是否 load-use 需要 stall
- 否则正常推进并应用 forward
但实际优先级会依你的数据通路写法变化。重点是逻辑必须一致,不然就会出现:
- 该停不停
- 该冲不冲
- 同一拍既写又冻,进入薛定谔状态
然后仿真波形看起来像喝了蘑菇汤。
十四、一个更完整的例子:lw 后接 beq
看这段:
1 | lw x5, 0(x1) |
这比单纯 lw 后接 add 更有意思:
beq需要 x5- 如果分支比较在 EX 做,那么
beq的操作数也可能需要转发 - 但
lw的数据出来又比较晚
于是常常会需要:
- 至少 1 个 stall
- 之后分支在 EX 比较
- 若 taken,再 flush 错路径上的
add
这说明流水线问题不是彼此独立的,而是会叠罗汉。
真实设计里,控制单元的复杂度很多就来自这些交叉情况。
十五、五级流水线常见性能公式和直觉
理想加速
假设单周期执行总延迟为:
[
T = t_{IF}+t_{ID}+t_{EX}+t_{MEM}+t_{WB}
]
分成五级后,时钟周期大致变成:
[
T_{clk} = \max(t_{IF}, t_{ID}, t_{EX}, t_{MEM}, t_{WB}) + t_{reg}
]
其中 t_reg 是流水线寄存器开销。
理想吞吐提升接近 5 倍,但现实达不到,因为:
- 阶段不均衡
- 寄存器开销
- hazard 导致 stall/flush
CPI 模型
常用直觉:
[
CPI \approx 1 + \text{stall cycles per instruction}
]
所以性能优化的关键就是减少:
- load-use stall
- branch penalty
- cache miss 停顿
- 多周期单元阻塞
十六、学习这部分时最容易混淆的几个点
1)“结果出来”和“写回寄存器”不是一回事
很多 ALU 指令在 EX 就已经算出结果,
只是到 WB 才正式写回。
所以 forwarding 才有存在意义。
2)load 的结果比 ALU 结果晚
这是 load-use hazard 的根源。
ALU 结果 EX 末可得,load 数据 MEM 末才可得。
这点一旦脑中时序清楚,很多判断都会顺。
3)stall 不是“整个 CPU 都停”
通常是有选择地冻结部分寄存器、插入 bubble。
不是把所有东西一把掐死。
4)flush 不是 stall
- stall:先别动,等一下
- flush:这条不算,扔掉
十七、总结
RISC-V 五级流水线,本质上是:
把指令执行拆成 IF/ID/EX/MEM/WB 五个阶段,用流水线寄存器隔开,通过 forwarding、stall、flush 来处理数据和控制相关,从而在保持硬件复杂度适中的前提下,把吞吐率尽量推近每拍一条。
