从单周期 CPU 到五级流水线 SoC:miniRV 的设计、AXI 集成与 FPGA 验证
本文记录一个 miniRV CPU/SoC 项目的完整实现过程:先完成支持全部实验指令的单周期 CPU,再将数据通路改造成五级流水线,加入数据前递、流水线暂停和静态分支预测,最后集成 ICache、DCache、AXI 总线及外设,并在 FPGA 上完成 C_TEST 与 CoreMark 验证。
1. 项目目标与最终结果
这个项目并不只是实现一个能够执行几条指令的 CPU,而是要逐步建立一套可以仿真、可以连接总线、可以运行 C 程序并且能够真正下板的处理器系统。
最终完成的主要功能如下:
- 支持 miniRV 测试框架中的 44 条指令,包括算术逻辑、移位、比较、分支跳转、访存以及乘除法指令;
- 完成完整单周期 CPU,并通过 Basic Trace;
- 将单周期数据通路改造成 IF、ID、EX、MEM、WB 五级流水线;
- 实现 EX/MEM/WB 到 ID 的数据前递;
- 实现 load-use 冒险检测、气泡插入和多周期操作暂停;
- 使用“默认不跳转”的静态预测处理控制冒险;
- 集成 ICache、DCache 和基于状态机的 AXI4 主控制器;
- 支持拨码开关、LED、数码管、UART 和计时器五类外设;
- Cache 开启后通过 AXI Trace 44/44;
- 在 FPGA 上通过 UART、格式化输入输出、排序和动态内存测试;
- 在 50 MHz 下完成 700 次 CoreMark 迭代,CRC 校验正确;
- Vivado 实现后的 WNS 为 +5.728 ns,TNS 为 0,无时序违例。
下面按“单周期 CPU—流水线 CPU—SoC—验证”的顺序介绍整个设计。
2. 从完整单周期 CPU 开始
单周期 CPU 的特点是:一条指令从取指、译码、执行、访存到写回的全部组合逻辑都在一个时钟周期内完成。它的结构直观,很适合先验证指令译码和基本功能,但时钟周期必须覆盖最慢指令的完整路径,因此主频提升空间有限。
2.1 单周期数据通路
下图是本项目绘制的完整单周期 CPU 数据通路图。图中包含 PC/NPC、Controller、SEXT、RF、ALU、MREQ、MEXT、数据存储器接口和写回选择器。
图 1 完整单周期 CPU 数据通路图(draw.io 绘制)
一条普通算术指令的通路可以概括为:
PC → 指令存储器 → Controller/RF/SEXT→ ALU 输入选择 → ALU 运算→ 写回选择器 → RF
访存指令则在 ALU 计算地址后经过 MREQ 和 MEXT:
MREQ根据ram_rop、ram_wop产生字节写使能、访问地址和对齐后的写数据;MEXT根据加载类型和地址低两位,从返回的 32 位数据中选出字节、半字或字,再进行符号扩展或零扩展;lb/lbu/lh/lhu/lw的结果最终由写回 MUX 送回寄存器堆。
乘除法器是多周期部件。启动后 busy 拉高,CPU 暂停提交新指令;运算完成后 busy 拉低,结果才写回。需要注意的是,PC 并不是因为执行了乘法就永远停止,而是只在多周期运算进行期间保持,完成后继续更新到下一条指令。
2.2 控制信号的作用
Controller 读取 opcode、funct3 和 funct7,产生以下几类控制信号:
| 控制信号 | 作用 |
|---|---|
npc_op |
选择顺序执行、分支、JAL 或 JALR 的下一 PC |
sext_op |
选择 I/S/B/U/J 型立即数格式 |
alu_op |
选择 ALU 的算术、逻辑、移位、比较或乘除法操作 |
alua_sel、alub_sel |
选择 ALU 两个操作数的来源 |
ram_rop、ram_wop |
指定加载扩展方式和存储字节使能 |
rf_wsel |
选择写回数据来自 ALU、PC+4、立即数还是存储器 |
rf_we |
寄存器堆写使能 |
单周期阶段最重要的工作,是让每条指令的控制信号和数据通路严格对应。Basic Trace 全部通过后,才适合继续进行流水化改造。
3. 五级流水线数据通路
流水线版本采用经典的五级结构:
| 流水级 | 主要工作 |
|---|---|
| IF | 产生取指地址,通过 ICache 取回指令 |
| ID | 指令译码、读取寄存器、立即数扩展、选择前递数据 |
| EX | ALU 运算、乘除法、分支判断和目标地址计算 |
| MEM | 通过 MREQ/DCache 完成数据访问并扩展加载结果 |
| WB | 选择最终结果并写回寄存器堆 |
相邻流水级之间加入 IF/ID、ID/EX、EX/MEM 和 MEM/WB 四组流水寄存器。除了数据本身,目标寄存器号、写使能、访存类型和写回选择等控制信息也必须与对应指令一起向后传递,否则就会发生“后一条指令的控制信号写回前一条指令结果”的错误。
3.1 完整流水线数据通路
下图是按照实际 RTL 信号绘制的完整流水线数据通路。由于原图较宽,建议点击图片查看 SVG 原图;后文也给出了各区域的局部图。
图 2 五级流水线 CPU 完整数据通路图
3.2 IF 与 ID:带缓冲的取指前端
这一部分对应图 2 左侧的 IF、IF/ID 与 ID 区域。由于完整图较宽,可以点击图 2 打开 SVG 后放大查看 Controller、取指缓冲以及 ID Instruction Flags 之间的具体连线。
流水线取指端不能只写成简单的 ifetch_req = 1。ICache 使用 ready/valid 握手,流水线又可能因为访存、乘除法或 load-use 暂停,因此本项目增加了请求记录和响应缓冲:
fetch_pending:已有一个被 ICache 接收的请求正在等待响应;fetch_req_pc:保存该请求对应的 PC;fetch_buf_valid:暂停期间返回的指令已经保存在缓冲区;fetch_discard:分支冲刷后,尚未返回的旧路径响应需要丢弃。
前端只有在没有复位、没有冲刷、没有暂停且缓冲区有空间时才发出新请求:
wire fetch_request = !cpu_rst && !flush && !fetch_discard &&!pipeline_stall &&(!fetch_buf_valid || fetch_buf_consume);
这个缓冲逻辑解决了两个容易出错的问题:一是流水线暂停时,已经返回的指令不能丢;二是 AXI/Cache 请求发出后不能随意取消,发生跳转时必须把错误路径响应标记并丢弃。
3.3 EX:运算、分支与多周期控制
ID/EX 寄存器把源操作数、立即数、PC、目标寄存器号以及控制信号送到 EX。ALU 两个输入分别由 ex_alua_sel 和 ex_alub_sel 控制,因此能够支持寄存器—寄存器、寄存器—立即数以及 PC—立即数等组合。
本项目采用静态预测不跳转:IF 始终先按 PC+4 取指,分支和跳转在 EX 阶段得到实际结果。当实际需要跳转时,flush 拉高,PC 改为正确目标,同时将年轻指令替换为 NOP:
wire ex_bj_taken = (ex_npc_op == `NPC_JMP)| (ex_npc_op == `NPC_JALR)| ((ex_npc_op == `NPC_BRA) & ex_br);assign flush = ex_bj_taken;
assign npc = flush ? ex_bj_target : npc_pc4;
乘除法同样位于 EX,但它们不能在一拍内完成。首次进入 EX 时产生启动条件,busy 有效期间保持相关流水寄存器,完成后才允许流水线继续前进。
4. 数据冒险:前递与 load-use 暂停
经典顺序五级流水线主要需要处理 RAW(Read After Write)冒险。后一条指令在 ID 读取源寄存器时,前一条指令可能仍处于 EX、MEM 或 WB,尚未完成寄存器堆写回。
4.1 EX/MEM/WB 到 ID 的数据前递
本设计比较 ID 阶段的 rs1/rs2 与 EX、MEM、WB 阶段的目标寄存器 rd。只有前级确实写寄存器、rd != x0 且寄存器号相等时,才允许前递。
wire fwd_ex_rs1 = ex_rf_we & (ex_wR != 5'h0) & (ex_wR == id_inst[19:15]);
wire fwd_mem_rs1 = mem_rf_we & (mem_wR != 5'h0) & (mem_wR == id_inst[19:15]);
wire fwd_wb_rs1 = wb_rf_we_reg & (wb_wR != 5'h0) & (wb_wR == id_inst[19:15]);wire [31:0] id_rD1 = fwd_ex_rs1 ? ex_fwd_result :fwd_mem_rs1 ? mem_result :fwd_wb_rs1 ? wb_rf_wD : id_rD1_raw;
两个源操作数的选择优先级均为:
EX > MEM > WB > Register File
优先选择最近的结果非常重要。例如 EX 和 MEM 中两条指令都准备写同一个寄存器时,ID 应使用程序顺序上更新的 EX 结果,而不是较旧的 MEM 结果。
4.2 load-use 为什么仍需暂停一拍
普通 ALU 指令的结果在 EX 末尾已经产生,可以直接前递;但 load 的有效数据要等到 MEM 阶段收到 daccess_rvalid 后才得到。因此相邻的 load-use 不能只靠 EX 前递解决。
本项目的检测条件是:EX 当前为加载指令、目标寄存器非零,而且 ID 指令实际使用的源寄存器与其相同。
wire load_use_hazard = ex_rf_we& (ex_ram_rop != `RAM_EXT_N)& (ex_wR != 5'h0)& ((id_uses_rs1 & (ex_wR == id_inst[19:15]))| (id_uses_rs2 & (ex_wR == id_inst[24:20])));
检测到冒险后,PC 和 IF/ID 保持,ID/EX 写入一个 NOP。等 load 数据在 MEM 阶段有效后,再通过 MEM→ID 前递送给依赖指令。实际波形中使用过下面这一组相邻指令进行检查:
lw x14, 4(x1)
addi x6, x14, 0
这里暂停不是越多越安全。早期版本直接比较指令的 rs1/rs2 字段,可能把立即数字段误认为源寄存器,造成无意义停顿;最终版本增加 id_uses_rs1/id_uses_rs2,只在指令真正读取对应寄存器时判断冒险。
5. MEM、WB 与数据访问
EX/MEM 中的 ALU 结果既可能是算术结果,也可能是访存地址。MREQ 将内部加载/存储控制转换为 DCache 请求;加载数据返回后,MEXT 根据 mem_ram_rop 和地址低两位完成字节选择与扩展。
MEM/WB 保存四类可能的写回来源:
- ALU 结果;
- 扩展后的加载数据;
- PC+4;
- U 型立即数。
WB MUX 根据 wb_rf_wsel 生成 wb_rf_wD,再与 wb_wR、wb_rf_we 一起返回寄存器堆。wb_rf_wD 同时也是 WB→ID 的前递数据,因此写回和前递使用的是同一份最终结果。
6. ICache、DCache 与 AXI 总线
流水线核心之外,SoC 还加入了独立的 ICache 和 DCache。两者在 L1 层面构成哈佛结构,可以让取指和数据访问使用独立接口;Cache miss 后则共同通过 AXI 主控制器访问统一主存。
6.1 Cache 的基本组织
本工程 Cache 行大小为 4 个 32 位字,也就是 16 字节。一次命中通过 Tag、Valid 和 Index 判断;未命中时请求整行回填。
ICache miss 的主要时序如下:
CPU 取指请求→ ICache 查找未命中→ bus_rreq 拉高,给出 16 字节对齐地址→ AXI AR 通道握手,ARLEN=3→ AXI R 通道返回 4 个数据拍→ fill_count 从 0 增加到 3→ RLAST 到达,写入 Tag/Valid→ 输出请求指令并拉高 inst_valid
DCache 的读缺失流程类似。MMIO 访问不进入 DCache,避免外设读写被缓存;外设写请求也只提交一次,防止 UART 重复发送字符。
6.2 AXI 主控制器状态机
AXI 读写通道相互独立,不能假设地址和数据一定同拍握手。因此 axi_master 使用状态机管理事务:
| 状态 | 含义 |
|---|---|
ST_IDLE |
仲裁指令读、数据读和数据写请求 |
ST_AR |
保持 ARVALID,等待 ARREADY |
ST_R |
接收一个或多个 R 数据拍,直到 RLAST |
ST_AW_W |
分别完成 AW 和 W 握手 |
ST_B |
等待写响应 BVALID |
控制器还使用 read_src 记录当前读事务来自 ICache 还是 DCache,保证返回数据送到正确的请求方。AXI 写地址和写数据可以在不同周期被接收,因此代码分别记录 aw_done 与 w_done,两者都完成后才进入写响应状态。
7. SoC 外设与 C 程序接口
在总线系统之上,SoC 通过 MMIO 提供五类外设:
- 拨码开关输入;
- LED 输出;
- 八位数码管显示;
- UART 收发;
- 64 位计时器。
UART 软件驱动通过状态寄存器判断 TX FIFO 是否已满、RX FIFO 是否非空,再读写数据寄存器。初始化时通过控制寄存器清空收发 FIFO。该接口既支撑简单字符输入输出,也支撑 printf、scanf、程序下载和 CoreMark 结果打印。
8. 验证方法与结果
CPU 设计不能只看综合是否通过。综合通过只说明 RTL 可以映射成硬件,并不能证明指令结果、总线握手或流水线冒险处理正确。本项目采用了四层验证。
8.1 Basic Trace
Basic Trace 直接比较 CPU 提交结果与参考模型,主要验证指令语义和流水线控制。最终结果为:
Passed Tests (44)
Failed Tests (0)
覆盖的指令包括 add/addi、逻辑与移位、六类条件分支、jal/jalr、五类加载、三类存储以及 mul/div/rem 等。
在 WSL 中可以使用以下方式重新构建流水线 Basic Trace:
cd /mnt/c/Users/lenovo/Desktop/CPU/cdp-tests
make clean
make -f Makefile.pipeline_basic build
python3 run_all_tests.py
8.2 AXI Trace(Cache 开启)
AXI Trace 使用完整 miniRV_SoC,仿真模型中包含流水线核心、ICache、DCache、AXI Master 和 AXI BRAM。最终在 Cache 开启状态下同样通过 44/44。
cd /mnt/c/Users/lenovo/Desktop/CPU/cdp-tests
make clean
make build SOC_DIR=../miniRV_basic_FLOAT/miniRV_basic_FLOAT/miniRV_basic/src/rtl
python3 run_all_tests.py
调试 Cache miss 时,重点观察:
ICache.state、cpu_hit、bus_rreq、bus_raddr
axi_master.state、m_axi_arvalid、m_axi_arready
m_axi_rvalid、m_axi_rready、m_axi_rdata、m_axi_rlast
fill_count、inst_valid
它们能够完整展示“未命中—AXI 地址握手—四拍回填—重新命中”的全过程。
8.3 C_TEST 下板
流水线 SoC 完成了三个接口测试程序的实板验证:
- UART 输出
Hello World!,并能逐字符接收输入; - 格式化输入
21 G good,能够正确解析整数、字符和字符串; - 固定数组排序正确,
malloc创建的 30 元素数组也能完成排序和释放。
这些测试共同覆盖了 UART、格式化 I/O、递归/排序、动态内存和计时器访问,比只观察 LED 更接近真实软件运行环境。
8.4 CoreMark 与时序
本项目选择 CoreMark 作为最终性能下板程序。Cache 开启、CPU 主频 50 MHz、迭代 700 次时,串口输出为:
Iterations : 700
Correct operation validated.
CoreMark 1.0 : 54.0495
CoreMark/MHz : 1.0809
FINISH
同时 Vivado 实现结果为:
| 项目 | 结果 |
|---|---|
| CPU 主频 | 50 MHz |
| WNS | +5.728 ns |
| TNS | 0 ns |
| WHS | +0.024 ns |
| THS | 0 ns |
| DRC violations | 0 |
| Routing errors | 0 |
WNS 为正、TNS 为 0,说明设计在 50 MHz 约束下不存在 setup 时序违例。CoreMark 的 CRC 全部正确,说明得到分数的前提是程序执行结果正确,而不是仅仅“串口打印完成”。
9. 调试过程中最值得记录的几个问题
9.1 不要把 ready/valid 当成固定延迟
Cache 和 AXI 的响应时间不是固定一拍。请求方必须保持有效信号直到握手,响应方也只能在 valid 与 ready 同时有效时推进状态。大量总线问题都来自“默认下一拍一定返回”的单周期思维。
9.2 冲刷不仅要清流水寄存器
分支跳转时清空 IF/ID、ID/EX 还不够。已经被 ICache/AXI 接收的旧路径请求无法撤销,所以还要使用 fetch_discard 丢弃未来到达的错误响应,否则错误路径指令仍可能进入流水线。
9.3 暂停和保持不是同一个概念
load-use 冒险需要保持 PC 与 IF/ID,同时向 ID/EX 插入气泡;访存等待或乘除法执行期间,则需要保持正在执行的 EX/MEM 指令。把所有寄存器统一“全部保持”或“全部清零”,都可能造成重复执行或指令丢失。
9.4 波形要围绕因果链观察
分析数据前递时,不应只看 fwd_ex_rs1 是否为 1,还要同时检查:
id_inst 的 rs1/rs2
ex_wR / mem_wR / wb_wR
各级 rf_we
ex_fwd_result / mem_result / wb_rf_wD
id_rD1_raw 与最终 id_rD1
分析 load-use 时,则应把 load_use_hazard、pipeline_stall、ID/EX 是否写入 NOP、数据响应和后续 MEM 前递放在同一时间轴上。只有形成完整因果链,波形截图才真正有说服力。
10. 总结
这个项目的难点不在某一条 Verilog 语句,而在于让多层协议同时保持一致:指令语义要正确,流水级之间的数据和控制要对齐,暂停与冲刷不能丢指令,Cache 必须正确处理命中和回填,AXI 又必须遵守各通道的独立握手规则。
从单周期 CPU 到五级流水线,再到能够运行 C 程序的 AXI SoC,整个过程让我对“CPU 是怎样真正运行程序的”有了更具体的认识。最终的 44/44 Basic Trace、44/44 AXI Trace、C_TEST、CoreMark 和正时序裕量,也分别从指令、总线、软件和硬件实现四个层面证明了系统的完整性。
后续如果继续优化,可以从以下方向入手:减少暂停周期、改善 Cache 组织和替换策略、提高乘除法吞吐率、缩短关键路径,并在保证时序收敛的前提下继续提高主频。
项目关键词:RISC-V、miniRV、Verilog、五级流水线、数据前递、load-use、Cache、AXI4、FPGA、CoreMark