RISC-V五级流水线CPU设计源码解析:冒险处理与Verilator仿真
简介基于RISC-V的五级流水线CPU设计项目是一套面向计算机体系结构学习与毕业设计场景的完整资源包。项目整合了处理器设计源码、工程构建脚本、自测试用例与项目说明文档既有助于在校学生理解取指、译码、执行、访存、写回各流水级的工作原理也为硬件设计实践提供了可直接修改的工程模板。压缩包共含98个文件以55个txt说明文本和27个v源码文件为主体辅以3个PDF参考手册、2个makefile构建文件、2个py辅助脚本及波形文件等整体体积约12.47MB目录结构清晰便于按模块检索。目前已有288人学习使用适合作为RISC-V入门和流水线CPU设计进阶的参考资料。包内源码覆盖IFU、IDU、EXU、MEMU等流水级模块并配有测试平台tb.v、仿真脚本与工程文件项目说明和RISC-V手册不仅给出了设计流程还阐述了数据冲突、控制冲突的解决策略及验证方法读者可依据这些材料进行功能扩展、性能优化或进一步整理为毕业设计中的技术章节。此外工程内部将流水线寄存器、冲突检测单元等独立划分便于对照教材逐步实现而测试脚本则能快速验证改动后的正确性。1. 这份 RISC-V 五级流水线源码不是课程演示用的空壳如果毕设只交一个能“点到为止”的单周期 CPU答辩时通常会被追问一句控制信号怎么来的流水线冲突怎么办这个基于RISC-V的五级流水线CPU设计源码好在它把取指、译码、执行、访存、写回完整切开还带上了总线仲裁、Timer、Verilator 仿真脚本和 self_tests 测试集属于那种“能跑、能看波形、能继续扩展”的工程包。一个反直觉的事实是五级流水线在缺少转发和停顿逻辑时性能不一定比单周期高分支指令甚至会更慢。所以这份源码的价值不在于“分了五级”而在于把数据冒险、控制冒险和总线访问这些真正难处理的地方写出来了。适合计算机组成原理课程设计、毕业设计以及想从 RTL 走向 SoC 集成的学生和工程师。2. 从 cpu_top 往下拆五级流水线、总线与外设的模块边界拿到源码先把 rtl 目录过一遍不要急着读代码。这个工程的结构和典型教学核不一样的地方在于cpu_core之外还挂了一套总线CPU 访问指令存储器和数据存储器都要经过总线仲裁。也就是说即使你只关心流水线也需要先理解模块边界否则会分不清哪些信号是流水线自己的哪些是总线协议带来的。rtl/ ├── cpu_top.v # 顶层例化 cpu_core、总线、ROM、RAM、Timer ├── cpu_core.v # 五级流水线核心连接各阶段模块 ├── IFU.v # 取指 ├── IDU.v # 译码 ├── EXU.v # 执行 ├── MEMU.v # 访存 ├── PC.v # 程序计数器 ├── regs.v # 寄存器文件 ├── CU.v # 控制单元 ├── ALU.v # 算术逻辑单元 ├── IFIDU.v # IF/ID 流水线寄存器 ├── IDEXU.v # ID/EX 流水线寄存器 ├── EXMEMU.v # EX/MEM 流水线寄存器 ├── bus.v # 总线协议 ├── bus_arbiter.v # 总线仲裁 ├── bus_master_mux.v # 主设备选择 ├── bus_slave_mux.v # 从设备选择 ├── ins_rom.v # 指令 ROM ├── data_ram.v # 数据 RAM └── riscv_define.v # 指令编码与常量定义模块边界清晰是这份源码最大的优点。IFU 只负责根据 PC 取指令IDU 只做译码和读寄存器EXU 做 ALU 运算和分支判断MEMU 做 load/store写回则由cpu_core通过IF_WB_MUX把结果选择回regs.v。每个阶段之间用IFIDU、IDEXU、EXMEMU三个流水线寄存器隔开避免组合逻辑路径跨时钟周期。这里没有单独的 MEM/WB 寄存器文件而是把写回信号直接组合选择属于面积换灵活的常见做法不影响理解五级流水线框架。在cpu_core.v里各阶段信号的命名往往带阶段前缀这种命名习惯在 RISC-V CPU 设计中非常值得模仿。比如下面这段简化的端口连接只能说明信号走向真实的位宽和字段以源码为准// 取指 - 译码 wire [31:0] if_pc, if_inst; IFIDU ifidu_inst ( .clk(clk), .rst_n(rst_n), .stall(stall), .flush(flush_id), .pc_in(if_pc), .inst_in(if_inst), .pc_out(id_pc), .inst_out(id_inst) ); // 译码 - 执行 wire [31:0] id_imm, id_rs1, id_rs2; IDEXU idexu_inst ( .clk(clk), .rst_n(rst_n), .stall(stall), .flush(flush_ex), .imm_in(id_imm), .rs1_in(id_rs1), .rs2_in(id_rs2), .imm_out(ex_imm), .rs1_out(ex_rs1), .rs2_out(ex_rs2) );这段代码里的stall和flush是流水线控制的两个关键输入。stall出现时流水线寄存器保持原值相当于插入气泡flush出现时把后续阶段的无效指令清掉配合 PC 跳转实现分支处理。参数上要特别关注rst_n低电平复位的同步释放方式如果复位不同步仿真初期很容易出现x态传播到总线地址上后面排错时我会专门说这一点。再往上看cpu_top.v把cpu_core接到bus_master_mux再把多个总线主设备请求合并到bus_arbiter最后由bus_slave_mux分发到ins_rom、data_ram和Timer。这种结构在真实 SoC 里很常见但课程设计里很少会有所以它比普通的“哈佛结构”多了一层总线竞争语义。各模块职责可以记成下面这张表方便对照源码看模块所在层级职责关键信号cpu_top顶层例化 CPU 核和总线外设clk,rst_n,io_*cpu_core流水线连接五级流水线和写回选择stall,flush,wb_dataIFIDU/IDEXU/EXMEMU流水线寄存器切分相邻阶段保存中间结果stall,flushbus_arbiter总线仲裁多个 master 的访问bus_req,bus_gntbus_master_mux总线选择当前 active mastermaster_idbus_slave_mux总线按地址选择访问哪个 slaveaddr_decTimer外设提供定时器中断和计数timer_msb,timer_lsb对照这张表读代码时注意bus_addr_dec.v决定了每个外设的地址范围。如果后续你想加一个 UART 或 GPIO只要在地址译码里新增一个区间再往bus_slave_mux上挂新从设备即可。这里不把 ROM/RAM 直接连在 CPU 端口上是为了让访存路径统一走总线时序代价是 load/store 的延迟会多一两个周期处理不好会引入结构冒险这也是很多人在五级流水线里部署总线 SoC 时踩到的地方。3. 数据冒险与控制冒险流水线暂停和转发是怎么落地的五级流水线最核心的部分不是五个阶段的名字而是冒险处理逻辑。源码里EXU.v、CU.v、IFIDU.v/IDEXU.v共同承担了这部分工作。读的时候你会发现RISC-V 的 32 个通用寄存器在regs.v里是多端口读、单端口写的但写入时机不固定所以必须用数据转发把后级结果提前送回前级。3.1 学习标准转发逻辑EXU 里的旁路选择数据转发最典型的场景是连续的 ALU 指令addi a0, a0, 1后面紧跟add t0, a0, a1第二条指令在 ID 阶段读到的是旧值因为第一条指令的写回还没发生。解决思路不复杂当 EX/MEM 或 MEM/WB 阶段的写寄存器号与当前指令的源寄存器号一致时把结果直接送到 ALU 输入。源码里EXU.v的旁路 mux 通常会写成下面这种形式逻辑上等价于wire [31:0] alu_src1; wire [4:0] ex_rd idexu_rd; // ID/EX 阶段的 rd wire [1:0] fwd_sel; wire [31:0] fwd_data1 (ex_mem_regwrite (ex_mem_rd ! 0) (ex_mem_rd ex_rd)) ? ex_mem_alu_out : (mem_wb_regwrite (mem_wb_rd ! 0) (mem_wb_rd ex_rd)) ? mem_wb_result : idexu_rs1;这里优先级很重要EX/MEM 的转发结果必须优先于 MEM/WB因为前者是更新的值。ex_mem_rd ! 0这个条件不能省略RISC-V 的x0寄存器硬连线为 0写它没有副作用但如果把rd0也纳入转发就会把垃圾数据送进 ALU。对应地第二条源操作数alu_src2也要做同样的旁路选择只是还要叠加立即数选择信号alu_src通常来自CU.v输出的alu_src控制位。3.2 load-use 停顿和分支 flush 的实现位置转发只解决“结果已经算出来但还没写寄存器”的冒险。load-use 更麻烦lw a0, 0(a1)后面紧跟add t0, a0, t1第二条指令在 EX 阶段需要的值在 ID/EX 边界上还不存在因为 load 的数据要到访存阶段结束才有。这个项目的常见处理方式是在 ID 阶段检测冒险插入一个周期的 stall同时把 IF/ID 寄存器冻结wire load_use_hazard idexu_memread (idexu_rd ! 0) (id_inst_rs1 idexu_rd || id_inst_rs2 idexu_rd); assign stall load_use_hazard; assign flush_id branch_taken || jump_taken;当stall1时IFIDU 和 IDEXU 的时钟使能都被关闭前面的取指阶段也被暂停相当于在 load 和 use 之间插入了一个气泡。这个损失在简单五级流水线里不可避免除非做乱序执行那已经超出这个项目范围。控制冒险的处理选择决定了分支惩罚周期。这个源码里没有复杂分支预测器所以常见做法是把分支判断放在 EXU 中完成当branch_taken或jump_taken为高时把 IFIDU 和 IDEXU 里的指令清掉并让 PC 载入目标地址。效果是分支成功时损失 2 个周期失败时无损失。这个折中适合教学和大部分基准测试因为 RISC-V 的 branch 指令在编译时通常会被组织成“大概率不跳转”失败分支不惩罚能拿回不少性能。3.3 CU 与 riscv_define 的指令译码落点riscv_define.v是理解整份源码的钥匙。它定义 opcode、funct3、funct7 以及各阶段控制信号的常量。CU.v根据译码结果产生reg_write、mem_write、mem_read、alu_src、branch、jump、mem_to_reg等信号。把这几个控制信号与指令类型对应起来你就能读懂模块里大量 case 语句指令类型opcode典型指令关键控制信号R-type0110011add, sub, and, orreg_write,alu_src0I-type ALU0010011addi, sltireg_write,alu_src1Load0000011lw, lh, lbmem_read,mem_to_reg1Store0100011sw, sh, sbmem_write,mem_to_reg0Branch1100011beq, bnebranch,reg_write0JAL/JALR1101111/1100111jal, jalrjump,link读CU.v时建议先看mem_to_reg它决定写回数据是 ALU 结果还是访存结果也是转发链路上最容易被忽略的信号。如果你发现某些测试指令写回值不对优先检查这条控制线而不是 ALU 功能这是我在调这个项目时最深的体会。4. 用 Verilator 跑仿真Makefile、sim_main.cpp 和 self_tests 的配合这个工程是 Verilator 仿真环境不是 Vivado 工程这是很多初次接触的人会困惑的地方。代码里没有.xpr也没有.vcd生成脚本而是通过 C testbench 驱动。理解了这一点你就能明白为什么目录里会有sim_main.cpp、module_tb.v、tb.v和run.bat。4.1 仿真环境启动链路Verilator 的工作方式是先把 Verilog 编译成 C 模型再和sim_main.cpp一起链接成可执行文件。按这个源码里的 Makefile 约定Linux 下直接执行make -f Makefile ./obj_dir/VtbWindows 下则运行run.bat脚本内部会调用verilator --cc加--exe sim_main.cpp最后用 g 链接。Makefile里常见的变量有VERILATOR、VERILATOR_ROOT、TOP_MODULE如果你的环境装了多个 Verilator 版本优先在 Makefile 里指定VERILATOR ? verilator不要在系统 PATH 里打架。sim_main.cpp的核心逻辑只有三件事创建顶层模型、初始化时钟和复位、按周期驱动并检查结果。剥去平台相关代码后骨架大致是这样#include Vtb.h #include verilated.h vluint64_t sim_time 0; Vtb* dut new Vtb; void tick() { dut-clk 0; dut-eval(); dut-clk 1; dut-eval(); sim_time; } int main(int argc, char** argv) { Verilated::commandArgs(argc, argv); dut-rst_n 0; for (int i 0; i 4; i) tick(); dut-rst_n 1; while (!Verilated::gotFinish()) { tick(); } dut-final(); delete dut; }tick()里先给低电平再给高电平是为了模拟完整时钟周期。rst_n在 4 个周期后拉高保证流水线寄存器完成复位否则第一条指令取址时会带着未知状态。仿真终止条件通常是测试台里执行到ebreak或系统调用Verilated::gotFinish()在这里负责接收 Verilog 里的$finish。如果你改了测试程序想提前停止可以在 C 里按指令计数退出比等$finish更直观。module_tb.v和tb.v是另一种测试入口它们不依赖 C直接在 Verilog 里产生时钟并用$display打印寄存器值。但 Verilator 对#延时支持有限所以如果run.bat走的是sim_main.cpp路径说明 testbench 的主体在 C 里Verilog 文件只用于层级定义。调试时优先看 C 里的循环边界很多“仿真卡死”其实是sim_time没有递增或eval()没有调用。4.2 把一段自定义汇编变成 CPU 的装入数据self_tests目录放的是汇编测试集但ins_rom.v装载的是二进制初始化数据。要让 RISC-V 指令跑起来需要把汇编编译成 hex 或二进制再生成.mem或$readmemh文件。常见做法是用 riscv-gnu-toolchain命令链如下riscv64-unknown-elf-gcc -marchrv32i -mabiilp32 -nostdlib \ -Ttext0x00000000 -o test.elf test.S riscv64-unknown-elf-objcopy -O binary test.elf test.bin python3 tools/make_hex.py test.bin test.hex这里的-marchrv32i是必须的如果你在 RISC-V CPU 里只实现了 RV32I 基础整数指令集就不要用默认的rv64gc否则编译出的mul、div、原子指令都不被识别。-Ttext0x00000000让代码段从复位入口开始和PC.v的复位值保持一致。生成 hex 后改ins_rom.v里的$readmemh文件路径重新make即可。make_hex.py如果源码里没有可以直接用这段通用脚本补齐做的是字节序和固定宽度的处理import sys with open(sys.argv[1], rb) as f: data f.read() width 4 words [int.from_bytes(data[i:iwidth], little) for i in range(0, len(data), width)] for w in words: print(f{w:08x})这个脚本假设指令按小端排列。RISC-V 的指令字在小端模式下低字节在前而$readmemh读入后通常要按字节再组装一次如果你发现第一条指令是addi却执行成别的指令先怀疑字节序不要怀疑 ALU 算错。self_tests里的rvi测试序列每条都会检查寄存器结果跑通它基本可以说明数据通路没问题。4.3 常见仿真错误定位我在跑这种项目时最常遇到四类问题可以从现象直接定位到位置现象可能原因处理方式仿真一开始就出x态复位不同步或rst_n未拉低在 C 里先跑 4~8 个周期再释放复位第一条指令取不到ins_rom路径错误或PC初值不匹配检查ins_rom.v的$readmemh路径和 ELF 加载地址所有寄存器写回都是x写使能信号没和时钟沿对齐在regs.v中确认写数据在 clk 下降沿更新测试跑到一半卡住触发死循环或$finish没执行在sim_main.cpp中设置最大指令周期数并打印地址当你用make重新编译后别忘了删除中间产物clear.bat或rm -rf obj_dir否则旧的模型会被复用导致修改过的 Verilog 没生效。这也是为什么源码里会同时放test.bat和clear.bat前者跑测试后者清构建缓存在真实开发流程中这两者缺一不可。5. 在现成五级流水线上做扩展总线 SoC、自定制指令与调试技巧这个项目不只是让你跑一个演示更重要的是它的总线结构允许继续往上面挂外设LITSoC.code-workspace这个 VSCode 工程文件说明作者是按 SoC 工程组织源码的。我建议拿到手后不要停在“仿真通过”这一步试着按下面三个方向做扩展能同时练到流水线和系统集成。5.1 挂在总线上扩展一个外设data_ram、ins_rom、Timer都挂在同一条总线上bus_addr_dec.v里应该有各自的地址区间。要加外设只需要复制Timer的接入方式把新外设映射到空闲地址段。地址分配要和应用层约定统一比如外设起始地址结束地址作用ins_rom0x0000_00000x0000_3FFF指令读取data_ram0x0000_40000x0000_7FFF数据读写Timer0x0001_00000x0001_000F定时器计数新外设0x0002_00000x0002_0003自定义控制寄存器挂新外设时需要在bus_slave_mux里增加一个从设备选择分支并且让bus_arbiter在总线忙时能正确返回ready。否则 CPU 在连续 load/store 时会出现一个周期里面有两个 master 同时选中仿真里表现为数据总线上的 q 值抖动。这时候可以打印bus_gnt信号看是否只有一个 master 被授权。5.2 自定义一条指令的五个落点如果你想加一条mymul之类的扩展指令完整的修改路径是五个文件顺序不能乱。先在riscv_define.v里定义新的 opcode/funct3再到CU.v增加控制信号然后在IDU.v里解出源寄存器在ALU.v或EXU.v里执行运算最后在cpu_core.v的写回 mux 里确认目标寄存器。伪代码核心是 ALU 扩展case (alu_op) 3b000: alu_out src1 src2; 3b001: alu_out src1 - src2; 3b010: alu_out src1 src2; 3b011: alu_out src1 | src2; 3b100: alu_out src1 ^ src2; 3b101: alu_out src1 src2; 3b110: alu_out src1 src2; 3b111: alu_out custom_operation(src1, src2); endcasealu_op的位宽由riscv_define.v决定新增操作时不一定扩展位宽可以用保留的 funct7 进一步细分。这里容易犯的错是只改 ALU忘了在IDEXU中传递alu_op导致 EXU 收到的是默认值这一点可以通过波形直接看到idexu_alu_op是否有正确的两拍延迟。5.3 用波形验证流水线气泡和转发调试五级流水线最重要的手段还是波形。Verilator 打开 fst dump 很简单在 C 里加两行或者直接用命令行参数verilator --trace --cc cpu_top.v --exe sim_main.cpp make -f Makefile ./obj_dir/Vtb trace然后在sim_main.cpp里判断Verilated::traceEverOn(true)并在 tick 后调用if (trace) tfp-dump(sim_time);波形里要重点看三个位置IFIDU 的inst_out是否为预期指令IDEXU 的rs1_out是否来自旁路而非原始寄存器文件MEMU 的读写地址是否落到data_ram的合法区间。具体来说load-use 插入气泡时IDEXU 里会连续两个周期出现同一条指令而 IFIDU 的pc_out保持不变分支 flush 时IFIDU 的inst_out会被清零同时PC.v的pc_out跳到目标地址。检查时可以先定位stall和flush的上升沿再看数据路径是否和推测一致。把dump(sim_time)放在时钟上升沿之后的那一刻能避免捕获到组合逻辑的中间毛刺。如果波形里看到寄存器写入和读出的时序差一个周期不要急着改代码先确定你对比的是哪个阶段的信号regs.v的输出本身就比写回晚半个周期这是正常现象。逐级核对流水线寄存器就能把问题缩小到具体的模块边界。本文还有配套的精品资源点击获取