RISC-V五级流水线CPU设计:从Load-Use冒险到转发机制详解
2026/9/16 20:58:33 网站建设 项目流程

简介:这是一份面向计算机体系结构学习者与数字IC设计初学者的RISC-V五级流水线CPU设计源码包,配套项目说明,适合用来理解流水线数据通路、控制信号、总线互联等核心概念。资源共包含98个文件,压缩包约12.47MB,以27个Verilog核心源码、55个txt辅助说明与测试文本、3个bat批处理脚本、3个PDF参考手册及Makefile、Python脚本等为主,支持iverilog和Verilator两种仿真流程,并配套gtkwave波形查看工具与交叉编译工具链,可跨Windows和Ubuntu环境直接运行。目前已有449人浏览学习。借助该资源可获得完整的五级流水线CPU设计,包括取指、译码、执行、访存、写回各阶段模块,以及寄存器堆、ALU、总线仲裁等组件,同时附带测试脚本、仿真生成文件和RISC-V中文手册,便于分段调试、快速验证和课程设计参考,是一份系统性较强且便于上手的入门实践资料。

1. 从一个 Load 指令挂死开始理解 RISC-V 五级流水线 CPU

把一个“源码+项目说明”的五级流水线 RISC-V CPU 工程拿到手,很多人第一件事是打开 RTL 看代码,但我建议先看那个“项目说明”。真正动手在 Verilog 里搭过流水线的人都有体会:最难的不是把 IF/ID/EX/MEM/WB 五级接起来,而是让lw指令后面紧跟一条使用目标寄存器的指令时,处理器还能给出正确结果。光是在这个点上,就能筛掉一批表面完整、实际一仿真就挂的设计。

下面我不去复述某个压缩包里的目录,而是按我在类似 RISC-V CPU 设计里常用的做法,把五级流水线的级间切割、数据通路、冒险处理、仿真验证四条主线拆开讲。代码按 RV32I 最小集来,不依赖具体厂商 IP,用常见 Verilog 仿真器都能跑。适合已经写过单周期 CPU、想往流水线跨一步的开发者;已经做过流水线的,重点看第 4 章的转发优先级和第 5 章的验证手段。

2. RISC-V 指令集与五级流水线的取指、译码和执行边界

2.1 RV32I 指令编码如何降低五级流水线设计难度

RV32I 的指令编码非常规整:低 7 位是 opcode,rs1固定在inst[19:15]rs2固定在inst[24:20]rd固定在inst[11:7]。这意味着译码器不需要像 x86 那样先解析指令长度,再在不同位置找操作数。对流水线设计来说,ID 级可以同时完成三个动作:固定位置读寄存器堆、固定位置做立即数扩展、根据 opcode 提前产生控制信号。

R、I、S、B 四种格式的立即数分布并不连续,但位置是固定的。代码里常见的写法是把这些位段直接提出来,让后续模块拿到统一位宽的 32 位立即数:

wire [31:0] inst; // IF/ID 级出来的 32 位指令 wire [6:0] opcode = inst[6:0]; wire [4:0] rs1 = inst[19:15]; wire [4:0] rs2 = inst[24:20]; wire [4:0] rd = inst[11:7]; wire [2:0] funct3 = inst[14:12]; wire [6:0] funct7 = inst[31:25]; wire [31:0] imm_i = {{21{inst[31]}}, inst[30:20]}; wire [31:0] imm_s = {{21{inst[31]}}, inst[30:25], inst[11:7]}; wire [31:0] imm_b = {{20{inst[31]}}, inst[7], inst[30:25], inst[11:8], 1'b0}; wire [31:0] imm_u = {inst[31:12], 12'b0};

imm_b的低位直接补零,是因为 branch 目标和跳转目标都要求 16 位地址对齐,这也是 RISC-V 规范里“最低位固定为 0”的落地方式。实现时不需要再去判断inst[0],直接把拼接结果当有符号数加到 PC 上即可。

2.2 五级流水线阶段划分与存储器同 CPU 的连接

标准五级流水线把指令生命周期切成五个阶段,每个阶段只做一件事:取指、译码、执行、访存、写回。这个划分不是拍脑袋定的,它让每个时钟周期内最多只访问一次寄存器堆或一次存储器,避免了一条指令占用两个存储端口。

输入来源主要工作输出到
IFPC取指令、PC+4、处理分支目标IF/ID
IDIF/ID读寄存器堆、扩展立即数、译码控制信号ID/EX
EXID/EXALU 运算、分支比较、地址计算EX/MEM
MEMEX/MEM数据存储器读写、透传 ALU 结果MEM/WB
WBMEM/WB选择写回数据、写寄存器堆

这里最容易被忽略的是存储器与 CPU 的连接方式。单周期 CPU 通常只有一个统一的内存,访问指令和数据共用一套地址线。五级流水线里 IF 阶段和 MEM 阶段可能落在同一个时钟周期,如果只有一个存储器端口,取指和访存就会互相卡住。教学级设计普遍采用哈佛结构:指令存储器挂在 IF 级,数据存储器挂在 MEM 级,两者独立编址。

实际在 Vivado 或 Quartus 里,指令存储器和数据存储器可以用两个独立的 RAM IP,也可以用两个初始化内容不同的 ROM/RAM 模型。仿真时更省事的方式是写一个行为级reg [31:0] mem [0:255],分别放在两个模块里。

2.3 级间寄存器边界和 valid 位的作用

五级流水线之间一共夹着四个流水线寄存器:IF/ID、ID/EX、EX/MEM、MEM/WB。它们的作用是给每个阶段的结果一个“时钟周期缓存”,让不同指令同时处于流水线不同位置时不互相覆盖。

除了数据和控制信号,每个流水线寄存器组里都应该带一个valid位。这个位是整条流水线的灵魂:它标记当前阶段承载的是一条真实指令还是一个空泡。stall 和 flush 时会向级间寄存器插入 bubble,如果valid=0的指令仍然被当成真实指令去写寄存器堆,就会产生假写回。

always @(posedge clk or posedge rst) begin if (rst) begin ex_mem_valid <= 1'b0; end else if (flush_ex) begin ex_mem_valid <= 1'b0; // 分支冲刷时丢弃这条指令 end else begin ex_mem_valid <= id_ex_valid; end end assign mem_reg_write = ex_mem_regwrite_ctrl & ex_mem_valid;

flush 时把valid置 0,而不是把整组数据清零,这样下游模块看到valid=0就知道不该写回。单纯清控制位容易漏掉地址、立即数等字段,留下很难排查的 X 态。

3. 数据通路与流水线寄存器的 Verilog 实现

3.1 顶层数据通路的模块划分

一个可复现的五级流水线 CPU 顶层,通常由下面这些模块组成。模块边界不要按指令类型去拆,而是按流水线阶段去拆,否则后加的转发逻辑会无处安放。

模块归属级职责
pc_regIF保存当前 PC,支持 stall 和 flush
inst_romIF根据 PC 输出 32 位指令
if_idIF/ID缓存指令和 PC
regfileID32 个 32 位通用寄存器,x0 恒为 0
imm_extID把指令立即数位段展开成 32 位
control_unitID产生 alu_op、mem_read、mem_write、reg_write、wb_sel
id_exID/EX缓存读出的寄存器数据和立即数
aluEX算术逻辑运算
branch_unitEX比较分支条件并计算目标地址
ex_memEX/MEM缓存 ALU 结果和写存储器的数据
data_ramMEM读写数据存储器
mem_wbMEM/WB缓存访存结果和回写数据
hazard_unit全局产生 stall、flush、forward 信号

在小型设计里,control_unithazard_unit不需要做成独立文件,但建议保留模块边界。特别是 hazard unit,后面每加一条指令或一种冒险类型,都得回去改它。

3.2 取指级与 IF/ID 寄存器的代码

IF 级本身很简单,难的在于 stall 和 flush 时 PC 和 IF/ID 寄存器如何配合。下面这段代码是五级流水线里常见的 IF/ID 寄存器写法:

module if_id ( input wire clk, input wire rst, input wire stall, input wire flush, input wire [31:0] pc_in, input wire [31:0] inst_in, output reg [31:0] pc, output reg [31:0] inst ); always @(posedge clk) begin if (rst) begin pc <= 32'h8000_0000; inst <= 32'h0000_0013; // nop: addi x0, x0, 0 end else if (flush) begin pc <= pc_in; inst <= 32'h0000_0013; // 冲刷时插入 nop end else if (!stall) begin pc <= pc_in; inst <= inst_in; end // stall=1 时保持原值,让指令在 IF/ID 里再停一拍 end endmodule

nop在这里用0x00000013,也就是addi x0, x0, 0。选它是因为对任何架构都没有副作用,而且 opcode 落在 RV32I 的 I 型指令范围里,下游 ALU 和写回控制都能自然处理。如果你用的是 SystemVerilog,可以把stallflush组合成 2-bit 的pipeline_control,但表达逻辑并没有变简单。

stall 和 flush 同时有效时,flush 优先。因为 load-use 暂停期间也可能插入分支冲刷,如果 flush 不覆盖 stall,分支指令后面的旧指令会被留在流水线里,下一拍继续执行,造成控制冒险未被真正清掉。

3.3 译码级:寄存器堆读端口与立即数扩展

寄存器堆是五级流水线的关键部件。读写端口必须满足“ID 级读,WB 级写”这一结构:两个异步读端口给 ID 级立即取数,一个同步写端口在 WB 级边沿写入。

module regfile ( input wire clk, input wire we, input wire [4:0] rs1_addr, input wire [4:0] rs2_addr, input wire [4:0] rd_addr, input wire [31:0] rd_data, output wire [31:0] rs1_data, output wire [31:0] rs2_data ); reg [31:0] regs [0:31]; always @(posedge clk) begin if (we && (rd_addr != 5'd0)) regs[rd_addr] <= rd_data; end assign rs1_data = regs[rs1_addr]; assign rs2_data = regs[rs2_addr]; endmodule

这里最需要注意的规范是 x0 不能被写入。如果we=1rd_addr=0,直接改变regs[0]的值,后续所有读 x0 的地方都会变成非零值,整条流水线就没法对齐 RISC-V ABI 了。另一个细节是不要给regs[0]initial regs[0]=0来偷懒,有些综合工具会把它综合成带复位值的 RAM,反而变复杂。

立即数扩展代码可以放在imm_ext里,也可以直接内联到 ID 级。上面 2.1 节已经给出四种格式的拼接方式,实现时根据 opcode 选择对应立即数,再根据控制信号传给 ALU 和分支单元。

3.4 执行、访存和写回级的连接

EX 级核心是 ALU。五级流水线的 ALU 输入不会直接接 ID/EX 寄存器,而是接一个三选一 mux,mux 的选择来自转发单元。ALU 输出同时进入 EX/MEM 寄存器和分支判断逻辑。

always @(*) begin case (alu_op) 4'b0000: alu_result = src_a + src_b; // ADD 4'b0001: alu_result = src_a - src_b; // SUB 4'b0010: alu_result = src_a & src_b; // AND 4'b0011: alu_result = src_a | src_b; // OR 4'b0100: alu_result = src_a ^ src_b; // XOR 4'b0101: alu_result = src_a << src_b[4:0]; // SLL 4'b0110: alu_result = $signed(src_a) >>> src_b[4:0]; // SRA 4'b0111: alu_result = src_a < src_b; // SLTU endcase end

src_asrc_b不直接连id_ex.rs1_data,而是连到转发 mux 的输出。转发 mux 的细节在第 4 章展开,这里只用 4 位alu_op掩盖了复杂控制逻辑。访存级只需要把 ALU 结果当作地址传给数据存储器,再读取或写入 32 位数据。

写回级最常踩的坑是wb_sel选择错了数据源。lw要写回数据存储器读出的值,alu类型指令要写回 ALU 结果,jal要写回 PC+4,这三个来源缺一个,CPU 在跑函数调用时就会算错返回地址。

4. 冒险处理:转发、暂停与冲刷的判定代码

4.1 数据冒险何时发生,为什么不能全交给转发

数据冒险的根源是:五级流水线里,一条指令还没写回寄存器堆,后面指令就要读同一个寄存器。单周期 CPU 里写回和读取可以在一个时钟周期内完成,流水线把这个完成时间拖到了 WB 级,于是产生了时间差。

冒险类型典型指令序列触发条件解决手段
ALU-ALU 前向add x1,x2,x3紧接sub x4,x1,x5EX 级结果要立刻给下一条 EX 级用EX/MEM 向 EX 转发
load-use 数据前向lw x1,0(x2)紧接add x3,x1,x4load 结果要等到 MEM/WB 才出来暂停一拍 + MEM/WB 转发
控制冒险beq x1,x2,target后方两条分支在 EX 级才确定跳转冲刷前两级,插入两个 nop

要理解转发为什么解决不了所有问题,先要看懂“数据从哪一级出来,下一级在哪一级要用”。ALU 的结果在 EX 级结束时已经产生,但还没写入寄存器堆;如果下一条指令正好在 EX 级要用同一个寄存器,不能等 WB,而应该从 EX/MEM 寄存器里把结果直接引到 ALU 输入。

4.2 EX/MEM 与 MEM/WB 转发优先级

转发单元的判断标准只有三条:rd 地址相同、rd 不是 x0、前一条指令确实要写寄存器。拿 ALU 第一个源操作数举例,判断逻辑如下:

wire forward_a_1 = ex_mem_regwrite && (ex_mem_rd != 5'd0) && (ex_mem_rd == id_ex_rs1); wire forward_a_2 = mem_wb_regwrite && (mem_wb_rd != 5'd0) && (mem_wb_rd == id_ex_rs1) && ~(ex_mem_regwrite && (ex_mem_rd != 5'd0) && (ex_mem_rd == id_ex_rs1)); always @(*) begin if (forward_a_1) alu_src_a = ex_mem_alu_result; else if (forward_a_2) alu_src_a = mem_wb_wbdata; else alu_src_a = id_ex_rs1_data; end

forward_a_2里额外加的否定条件特别重要。如果不判断 EX/MEM 是否已经转发,当 EX/MEM 和 MEM/WB 同时命中同一个rs1时,MEM/WB 会覆盖 EX/MEM 的最新值。两条路径都在往同一个 ALU mux 里灌数据,必须给 EX/MEM 更高优先级。原因是 EX/MEM 里的指令比 MEM/WB 里的指令更年轻,数据更新。

转发还需要覆盖 store 的写数据端口。sw x1, 0(x2)在 ID 级读出x1,但如果x1是上一条 ALU 指令的结果,EX 级才产生新值,这时必须把 EX/MEM ALU 结果直接接到 MEM 级的写数据 mux 上。漏掉这一路,会让 store 写入旧寄存器值,而且很难从波形里一眼发现。

4.3 Load-Use 冒险的暂停条件

load 指令的数据要到 EX 级结束才进入 EX/MEM,真正拿到访存结果要到 MEM/WB。因此当 load 还在 EX 级、下一条指令在 ID 级刚读出寄存器值时,无论 EX/MEM 转发还是 MEM/WB 转发都救不了,只能让下一条指令在 ID 级多待一拍。

wire lw_stall = id_ex_memread // EX 级指令是 load && (id_ex_rd != 5'd0) && (id_ex_rd == if_id_rs1 || id_ex_rd == if_id_rs2); assign pc_write = ~lw_stall; assign if_id_write = ~lw_stall; assign id_ex_clear = lw_stall;

pc_write=0让 PC 保持当前值,if_id_write=0让 IF/ID 寄存器里的指令再停留一拍,id_ex_clear=1则把 ID/EX 寄存器强制清零。这样 load 指令在 EX 级正常执行,下一条指令在 ID 级被做成空泡,下一拍再重新读一次寄存器堆。等 load 走到 MEM/WB 级,下一条指令进入 EX 级,MEM/WB 转发就能把 load 结果喂给 ALU。

这个暂停策略里,if_id_write=0id_ex_clear=1必须同时出现。只清 ID/EX 不清 IF/ID,下一条指令会被直接丢掉;只停 IF/ID 不清 ID/EX,load 后面会重复执行一条原来的指令。

4.4 分支冲刷的代价与处理

控制冒险发生在分支指令还没确定是否跳转时,后面两条指令已经进入了流水线。如果分支在 EX 级用 ALU 比较结果决定是否跳转,那么 IF/ID 和 ID/EX 里已经各有一条不该执行的指令,需要把它们都冲刷掉。

分支冲刷的代码通常挂在 EX/MEM 寄存器边界上:

always @(posedge clk) begin if (rst) begin if_id_flush <= 1'b0; id_ex_flush <= 1'b0; end else begin if_id_flush <= branch_taken_ex; id_ex_flush <= branch_taken_ex; end end

branch_taken_ex是 EX 级算出来的跳转结果。它同步到流水线寄存器后,下一拍 IF/ID 和 ID/EX 都插入 nop。PC 更新为branch_target_ex,这个目标地址是在 EX 级用 ALU 计算出来的。每次分支预测失败,CPU 要浪费两个时钟周期,也就是两条指令的取指无效。如果把这个比较逻辑提前到 ID 级,分支代价会降到 1,但那需要在 ID 级多加一个比较器,属于进阶优化,简单版五级流水线不推荐一上来就做。

5. 用波形验证 Load-Use 冒险,并随手一版项目说明

5.1 把 load-use 场景变成自检程序

五级流水线最容易出错的数据冒险是 load-use,验证时就该把它放在测试程序的最前面。假设指令存储器从prog.hex加载,测试程序的汇编可以写成:

addi x1, x0, 16 lw x2, 0(x1) addi x3, x2, 1 add x4, x3, x2

预期行为是:x2是内存地址 16 处的值,x3等于x2+1x4等于x2+1+x2。仿真时不要仅对着波形看 ALU 输出,应该让 testbench 等待x4变成预期值:

initial begin #3 rst = 0; wait (tb.u_cpu.regs[4] == 32'h21); $display("PASS: x4 = 0x%08x", tb.u_cpu.regs[4]); $finish; end initial begin #5000; $display("TIMEOUT"); $fatal(1); end

层次引用tb.u_cpu.regs[4]在仿真里很实用,它能让你看到 RTL 内部寄存器堆的真实状态,省去从波形文件里找信号的功夫。不过这只适用于仿真,综合时层次引用不会被保留。

5.2 项目说明里要写清的三件事

一个自带“项目说明”的 CPU 工程,文档里必须回答三件事。第一,指令存储器和数据存储器从哪里初始化,是用$readmemh.hex,还是用 IP 核的.coe文件;第二,复位后 PC 从哪个地址开始,常见是0x8000_0000,要和工具链链接脚本一致;第三,冒险处理到什么程度,是否支持 load-use 转发,还是不转发只暂停。这三件事不写清楚,别人拿到的源码几乎无法跑出第一个波形。

5.3 一条命令跑完的最小仿真流程

如果是开源仿真器,推荐用 Icarus Verilog 跑回归,命令极短:

iverilog -o sim.vvp tb_top.v rtl/*.v vvp sim.vvp

-o指定输出文件,rtl/*.v一次性把 RTL 目录下所有 Verilog 文件收进来。跑完后若显示PASS,再把测试程序换成连续两条lw,用相同 testbench 观察是否出现多拍暂停。把这两条命令写进 Makefile,后面每改一次转发逻辑,只需要make sim一次。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询