RISC-V 的五级流水线通常指经典的 IF -> ID -> EX -> MEM -> WB 五个阶段。下面这篇文章按整体目标、各级职责、级间寄存器、冒险处理和工程实现变化来系统梳理这套经典结构。

RISC-V 的“五级流水线”通常指经典的 IF → ID → EX → MEM → WB 五个阶段。RISC-V 很适合拿它讲清楚,因为指令格式规整、load/store 分离、编码相对干净,特别适合作为流水线教材。


一、五级流水线到底在解决什么

单周期 CPU 的思路是:一条指令从头到尾一次做完,再做下一条。
好处是简单,坏处也很明显:时钟周期必须迁就最慢指令路径,所以频率上不去。

流水线(Pipeline)的核心思想是:

  • 把一条指令的执行拆成多个阶段
  • 每个阶段只做一部分工作
  • 多条指令像工厂装配线一样重叠执行

于是理想情况下:

  • 单条指令的延迟没有明显减少,甚至可能更长一点
  • 吞吐率显著提高
  • 理想 CPI(每条指令平均周期数)接近 1

经典五级就是:

  1. IF:取指
  2. ID:译码/读寄存器
  3. EX:执行/算地址/比较分支
  4. MEM:访问数据存储器
  5. 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 位指令通常会拆出:

  • opcode
  • rd
  • funct3
  • rs1
  • rs2
  • funct7

不同格式(R/I/S/B/U/J)字段位置相对规整,但立即数拼接方式不完全一样。

寄存器堆读取

ID 阶段根据 rs1rs2 读出两个源操作数:

  • ReadData1
  • ReadData2

RISC-V 的整数寄存器堆有 32 个通用寄存器:

  • x0x31
  • x0 永远是 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:写回的数据来自内存还是 ALU
  • ALUSrc:ALU 第二操作数来自寄存器还是立即数
  • Branch:是否为条件分支
  • Jump:是否为 jal/jalr
  • ALUOp:ALU 做加减与或比较之类

这些控制信号通常也要跟着指令一起穿过流水线寄存器传下去。

ID 输出到 ID/EX 的内容

一般包括:

  • PC / PC+4
  • 读出的寄存器值 rs1_val / rs2_val
  • 立即数 imm
  • rd / rs1 / rs2 编号
  • 各类控制信号

3)EX:Execute,执行阶段

主要任务

  • ALU 运算
  • 分支条件判断
  • 计算 load/store 的访存地址
  • 计算跳转目标地址
  • 选择真正的下一 PC(某些实现)

ALU 做什么

对于不同指令,EX 阶段处理方式不同。

算术逻辑指令

例如:

  • add
  • sub
  • and
  • or
  • xor
  • sll
  • srl
  • sra
  • slt
  • sltu
  • addi

EX 直接输出 ALU 结果。

load/store

例如:

  • lw x5, 8(x1)
  • sw x6, 12(x2)

EX 负责计算有效地址:

[
addr = rs1 + imm
]

注意,真正读写内存在下一阶段 MEM。

分支

例如:

  • beq
  • bne
  • blt
  • bge
  • bltu
  • bgeu

EX 阶段通常会:

  • 比较 rs1 和 rs2
  • 判断是否跳转
  • 计算分支目标地址 pc + imm

跳转

  • jal:目标通常是 pc + imm
  • jalr:目标通常是 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/lbu
  • lh/lhu
  • lw

需要做:

  • 字节选择
  • 符号扩展或零扩展

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(冒险)

三类经典冒险:

  1. 结构冒险(Structural Hazard)
  2. 数据冒险(Data Hazard)
  3. 控制冒险(Control Hazard)

1)结构冒险

结构冒险是硬件资源不够用。

典型例子

如果指令和数据共用一个单端口存储器:

  • 某条指令在 IF 想取指
  • 另一条指令在 MEM 想读/写数据

两边都要访问内存,撞车。

解决办法

经典五级流水线通常直接避免它:

  • 指令存储器和数据存储器分离即类似 Harvard 架构
  • 或者至少逻辑上提供独立端口(I-cache / D-cache)

所以教学版五级流水线经常默认没有结构冒险,因为硬件资源被预先分开了。


2)数据冒险

这是最关键的。

数据冒险的本质

后一条指令需要前一条指令的结果,
但前一条还没来得及写回。

最常见的是 RAW

RAW = Read After Write,先写后读依赖。

例如:

1
2
add x5, x1, x2
sub x6, x5, x3

sub 需要 x5,但 add 的结果要到 WB 才写回。
sub 在更早阶段就要读 x5,于是读到旧值。

WAR / WAW 呢?

在经典 顺序发射、顺序执行、顺序写回 的五级整数流水线里:

  • WAR(写后读)
  • WAW(写后写)

一般不会出现为真正问题。
这些更多出现在乱序执行、多发射、寄存器重命名缺失等场景。

所以经典五级重点处理的是 RAW


数据冒险的解决方法一:停顿(stall)

最笨但最稳的办法:

  • 发现依赖
  • 让后面的指令等几拍
  • 在流水线中插入气泡(bubble)

气泡本质上就是一条“什么都不做”的假指令,通常相当于 NOP。

例如:

1
2
add x5, x1, x2
sub x6, x5, x3

如果没有转发,就可能要等到 add 写回后,sub 才能继续。

这显然很伤吞吐率。


数据冒险的解决方法二:前递/转发(forwarding / bypassing)

核心思想

不要等 WB 真把结果写回寄存器堆。
只要某阶段已经算出了结果,就直接旁路送给需要它的后续指令。

例如

1
2
add x5, x1, x2
sub x6, x5, x3
  • 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
2
add x5, x1, x2
sw x5, 0(x3)

store 需要写内存的数据是 x5,
这也可能需要转发,而不只是给 ALU 转发。

Forwarding Unit 通常检查什么

比较:

  • 当前 EX 阶段指令的 rs1, rs2
  • 和 EX/MEM、MEM/WB 中将写回的 rd

若满足:

  • 上游指令 RegWrite = 1
  • rd != 0
  • rd == rs1rd == rs2

则选择转发源。

优先级

一般 EX/MEM 比 MEM/WB 更新鲜,所以优先级更高。


但有一种依赖,转发也救不全:load-use hazard

看例子:

1
2
lw  x5, 0(x1)
add x6, x5, x2

问题在于:

  • 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 中下一条指令的 rs1rs2 相同

那么触发 stall。

[
ID/EX.MemRead \land ((ID/EX.rd = IF/ID.rs1) \lor (ID/EX.rd = IF/ID.rs2))
]

则:

  • PCWrite = 0
  • IF/IDWrite = 0
  • 把控制信号清零注入 ID/EX(形成 bubble)

3)控制冒险

控制冒险来自分支和跳转。

本质

IF 阶段要决定下一条取哪条指令,
可分支是否成立往往要到 EX 才知道。

于是 CPU 很可能先按 PC+4 取了几条,
后来发现“哎呀分支 actually taken”,前面取错了。

典型例子

1
2
3
beq x1, x2, target
add x3, x4, x5
sub x6, x7, x8

如果 beq 最终成立,
那么 addsub 可能其实不该执行。

解决办法一:stall until resolved

最朴素:

  • 遇到分支就等结果出来再取下一条

简单,但性能很差。

解决办法二:预测不跳转(predict not taken)

经典教学五级常这么干:

  • 默认按 PC+4 继续取
  • 如果 EX 阶段发现分支成立,再 flush 错取指令

flush 是什么

flush 就是把已经进入流水线、但不该执行的错误路径指令作废。

通常做法:

  • 把相关流水线寄存器中的控制信号清零
  • 让这些错误指令变成 bubble

分支代价

如果分支在 EX 才解析,
那通常会有 1~2 个甚至更多周期的 penalty,取决于你在错误路径上已经放进来了几条指令。


六、以几条指令看流水线时序

看时序最能抓住本质。

例 1:无冒险的理想情况

指令序列:

1
2
3
add x1, x2, x3
sub x4, x5, x6
and x7, x8, x9

时钟周期分布:

周期 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
2
add x5, x1, x2
sub x6, x5, x3
  • add 在 EX 算出结果
  • sub 在下一拍 EX 需要这个结果
  • EX/MEM → EX 转发即可
  • 不需要 stall

这就是转发单元存在的意义:
避免 CPU 傻坐着发呆。


例 3:load-use hazard,需要停顿

1
2
lw  x5, 0(x1)
add x6, x5, x2

一种典型时序:

周期 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 阶段用,比如:

  • ALUSrc
  • ALUOp

MEM 类控制信号

给 MEM 阶段用,比如:

  • MemRead
  • MemWrite
  • Branch

WB 类控制信号

给 WB 阶段用,比如:

  • RegWrite
  • MemToReg

这些信号在 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 扩展包含乘除法:

  • mul
  • div
  • rem

这些经常不是一个 EX 周期搞定,
可能要多周期执行单元,甚至导致流水线停顿或旁路更复杂。

常见变化 4:加入压缩指令 C 扩展

前端取指不再总是 PC+4
可能是 16 位或 32 位混合,取指和对齐逻辑就更麻烦。

常见变化 5:加入异常/中断/CSR

一旦要支持 precise exception(精确异常),
就要保证“看起来像按程序顺序执行到出错点为止”,
控制逻辑会比教学图复杂不少。


十二、如果你在实现一个 RV32I 五级流水线,最核心的模块清单

如果你是写 Verilog / Chisel / VHDL,大概率会拆这些模块:

前端

  • pc_reg
  • instr_mem
  • pc_next_logic

译码

  • decoder
  • regfile
  • imm_gen

执行

  • alu_control
  • alu
  • branch_unit

访存与回写

  • data_mem
  • wb_mux

流水线控制

  • if_id_reg
  • id_ex_reg
  • ex_mem_reg
  • mem_wb_reg
  • forwarding_unit
  • hazard_detection_unit
  • flush/stall control

其中真正最容易写错的往往不是 ALU,
而是 stall、flush、forward 三者同时存在时的优先级关系
这块很容易变成数字电路版章鱼打架。


十三、stall、flush、forward 的优先级直觉

这是设计里非常关键的一点。

一般来说:

  • forward:优先用于消除可消除的数据依赖
  • stall:用于无法通过 forward 解决的依赖,例如 load-use
  • flush:用于控制流错误路径清除,通常比普通数据推进更“强”

一个常见经验是:

  1. 先判断是否需要 branch/jump 导致 flush
  2. 再判断是否 load-use 需要 stall
  3. 否则正常推进并应用 forward

但实际优先级会依你的数据通路写法变化。重点是逻辑必须一致,不然就会出现:

  • 该停不停
  • 该冲不冲
  • 同一拍既写又冻,进入薛定谔状态

然后仿真波形看起来像喝了蘑菇汤。


十四、一个更完整的例子:lw 后接 beq

看这段:

1
2
3
lw   x5, 0(x1)
beq x5, x2, L
add x3, x4, x6

这比单纯 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 来处理数据和控制相关,从而在保持硬件复杂度适中的前提下,把吞吐率尽量推近每拍一条。