简介:基于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_data |
IFIDU/IDEXU/EXMEMU | 流水线寄存器 | 切分相邻阶段,保存中间结果 | stall,flush |
bus_arbiter | 总线 | 仲裁多个 master 的访问 | bus_req,bus_gnt |
bus_master_mux | 总线 | 选择当前 active master | master_id |
bus_slave_mux | 总线 | 按地址选择访问哪个 slave | addr_dec |
Timer | 外设 | 提供定时器中断和计数 | 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,写它没有副作用,但如果把rd=0也纳入转发,就会把垃圾数据送进 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;当stall=1时,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-type | 0110011 | add, sub, and, or | reg_write,alu_src=0 |
| I-type ALU | 0010011 | addi, slti | reg_write,alu_src=1 |
| Load | 0000011 | lw, lh, lb | mem_read,mem_to_reg=1 |
| Store | 0100011 | sw, sh, sb | mem_write,mem_to_reg=0 |
| Branch | 1100011 | beq, bne | branch,reg_write=0 |
| JAL/JALR | 1101111/1100111 | jal, jalr | jump,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 -march=rv32i -mabi=ilp32 -nostdlib \ -Ttext=0x00000000 -o test.elf test.S riscv64-unknown-elf-objcopy -O binary test.elf test.bin python3 tools/make_hex.py test.bin > test.hex这里的-march=rv32i是必须的,如果你在 RISC-V CPU 里只实现了 RV32I 基础整数指令集,就不要用默认的rv64gc,否则编译出的mul、div、原子指令都不被识别。-Ttext=0x00000000让代码段从复位入口开始,和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:i+width], '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_rom | 0x0000_0000 | 0x0000_3FFF | 指令读取 |
| data_ram | 0x0000_4000 | 0x0000_7FFF | 数据读写 |
| Timer | 0x0001_0000 | 0x0001_000F | 定时器计数 |
| 新外设 | 0x0002_0000 | 0x0002_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) 3'b000: alu_out = src1 + src2; 3'b001: alu_out = src1 - src2; 3'b010: alu_out = src1 & src2; 3'b011: alu_out = src1 | src2; 3'b100: alu_out = src1 ^ src2; 3'b101: alu_out = src1 << src2; 3'b110: alu_out = src1 >> src2; 3'b111: 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的输出本身就比写回晚半个周期,这是正常现象。逐级核对流水线寄存器,就能把问题缩小到具体的模块边界。
本文还有配套的精品资源,点击获取