从一块 FPGA 开始,亲手造一颗 CPU:RISC-V 五级流水线实战全记录
为什么是 RISC-V + FPGA?
- 开放免费:没有授权费,文档齐全,连寄存器编码都写得明明白白。
- 简洁清晰:RV32I 只有 40 多条指令,没有 x86 那样层层嵌套的历史包袱。
- 模块化扩展:基础整数指令够用,后续想加浮点、压缩指令、向量扩展,都可以一步步来。
FPGA 可以把它捏成任何你想要的电路形态。不像 ASIC 动辄百万成本,也不像 MCU 被固化功能束缚。改个逻辑,重新综合,几分钟后就能烧录验证。
更重要的是,你能看到一切。ILA(集成逻辑分析仪)可以抓取内部任意信号波形,就像给 CPU 做 CT 扫描。这种透明度,在真实芯片里根本不可能实现。
当你把 RISC-V 和 FPGA 放在一起,得到的不只是一个软核,而是一个可观察、可调试、可演进的计算系统沙盒。
五级流水线:让 CPU'并行'起来的秘密
现代 CPU 之所以快,靠的不是单条指令跑得飞快,而是让多条指令同时处于不同阶段,像工厂流水线一样源源不断地出货。
拆解五大阶段
| 阶段 | 对应操作 | 类比 |
|---|---|---|
| IF(Instruction Fetch) | 根据 PC 读取指令 | 店员去仓库拿食谱 |
| ID(Instruction Decode) | 解码指令,读取寄存器值 | 看懂食谱,准备好原料 |
| EX(Execute) | ALU 运算或地址计算 | 开始揉面、发酵 |
| MEM(Memory Access) | 访问内存(load/store) | 把成品放进/拿出烤箱 |
| WB(Write Back) | 写结果回寄存器 | 把做好的面包摆上货架 |
理想情况下,每个时钟周期都有一个新任务进入流程,也有一个成品离开。虽然单条指令仍需 5 拍才能完成,但吞吐率接近每周期一条指令。
关键指标:
- CPI(Clocks Per Instruction)≈ 1(理想状态)
- 加速比 ≈ 5 倍于非流水线设计
但现实没那么美好。流水线会遇到三大障碍:结构冲突、数据冒险、控制冒险。处理不好,性能反而不如单周期。
如何在 Xilinx FPGA 上搭建这个 CPU?
目标明确:使用 Digilent Nexys A7 开发板(XC7A35T),基于 Vivado 2023.1 工具链,构建一个能运行 RISC-V 汇编程序的完整 SoC。
系统架构总览
整个系统采用哈佛架构(分离指令与数据存储),关键模块如下:
+------------------+
| Clock (50MHz) |
+--------+---------+
|
+------------------+------------------+
| | |
+-------v--------+ +---------v---------+
| PC Reg | | Inst ROM (BRAM) |
+-------+--------+ +---------+---------+
| |
+-------v-----------------------------------+
| IF Stage |
| (fetch instruction & update PC) |
+-------------------+-----------------------+
|
+-------------------v-----------------------+
| ID |
| Control Unit ← Opcode Decoder |
| RegFile Read ← rs1, rs2 |
+-------------------+-----------------------+
|
+-------------------v-----------------------+
| EX |
| ALU Control ← funct3/funct7 |
| ALU Operation (add/sub/and/or/slt/etc.) |
+-------------------+-----------------------+
|
+-------------------v-----------------------+
| MEM |
| Data Memory (Block RAM) |
| Handle lw/sw |
+-------------------+-----------------------+
|
+-------------------v-----------------------+
| WB |
| Write Mux ← ALU out / MEM data |
| RegFile Write Enable |
+-----------------------------------------+
此外还有两个幕后英雄:
- Forwarding Unit(前递单元):解决数据依赖
- Stall Logic(暂停逻辑):应对 load-use 延迟
所有模块通过跨级寄存器(如 if_id_inst, id_ex_pc)连接,形成稳定的流水推进机制。
实战第一步:搭建基础流水线框架
建议采取三步走策略。
第一步:先让单条指令跑通
先把 IF→ID→EX→MEM→WB 串起来,不考虑并发,只验证一条 add x1, x2, x3 能不能正确执行。
重点检查:
- PC 是否自增 4
- 指令是否正确解析出 rs1/rs2/rd
- ALU 是否输出 x2+x3 的结果
- 最终是否写入 x1
此时可以用最简单的 testbench 模拟几个时钟周期,打印寄存器文件变化。
// 示例:最简 WB 阶段
always_ff @(posedge clk) begin
if (wb_we && rd != 0) begin
regfile[rd] <= wb_data; // 写回结果
end
end
第二步:引入流水线触发器
一旦单指令验证无误,就开始加入各阶段间的缓冲寄存器:
// IF/ID 寄存器组
always_ff @(posedge clk) begin
if (!stall) begin
if_id_pc <= pc;
if_id_inst <= inst_rom[pc>>2];
end
end
注意这里的 !stall 条件——这是未来插入气泡的基础。
此时你会发现一个问题:跳转指令会让后面的预取指令失效。比如:
beq x1, x2, label
add x3, x4, x5 # 这条不该被执行!
简单粗暴的办法:一旦发现 branch 且条件成立,立刻清空 IF/ID 流水线(即插入两个 NOP),并更新 PC。
这就是所谓的'静态预测 + 流水线冲刷'。
第三步:加入前递与暂停机制
数据冒险怎么破?
典型场景:
lw x1, 0(x2) # MEM 阶段才拿到数据
add x3, x1, x4 # 下一条就要用 x1 → RAW 危险!
ALU 在 EX 阶段需要 x1,但 x1 还在 MEM 阶段的路上,来不及写回。有两个选择:
- 暂停一拍(Insert Bubble)
- 前递(Forwarding)
对于 lw 之后紧接使用的情况,必须暂停;而对于普通算术指令之间的依赖,可以通过前递解决。
前递单元设计要点
我们需要监控 MEM 和 WB 阶段的输出,看看有没有正在返回的'热数据'可以提前借用:
// Forwarding Unit 核心逻辑
always_comb begin
forward_a = 2'b00;
forward_b = 2'b00;
// EX/MEM 阶段有写操作,且目标寄存器匹配源操作数
if (ex_mem_we && ex_mem_rd != 0 && ex_mem_rd == id_ex_rs1)
forward_a = 2'b01; // 来自 MEM 阶段 ALU 输出
else if (mem_wb_we && mem_wb_rd != 0 && mem_wb_rd == id_ex_rs1)
forward_a = 2'b10; // 来自 WB 阶段写回数据
// 同理处理第二个源操作数
if (ex_mem_we && ex_mem_rd != 0 && ex_mem_rd == id_ex_rs2)
forward_b = 2'b01;
else if (mem_wb_we && mem_wb_rd != 0 && mem_wb_rd == id_ex_rs2)
forward_b = 2'b10;
end
然后在 EX 阶段之前,用 MUX 选择实际输入:
assign src1 = (forward_a == 2'b01) ? ex_mem_alu_out : (forward_a == 2'b10) ? mem_wb_wdata : id_ex_src1;
assign src2 = (forward_b == 2'b01) ? ex_mem_alu_out : (forward_b == 2'b10) ? mem_wb_wdata : id_ex_src2;
这样一来,大多数数据冒险都被化解了,只有 load-use 需要额外处理。
处理 load-use 冒险
当检测到当前是 lw 指令,且下一条要用其结果时,必须插入一个气泡(bubble),也就是让 ID/EX 流水线停顿一拍。
assign stall = (id_ex_mem_read && (id_ex_rd == if_id_rs1 || id_ex_rd == if_id_rs2));
同时,在控制逻辑中阻止 PC 更新和流水线推进。
这样虽然牺牲了一个周期,但保证了正确性。
在 Vivado 中落地:那些容易踩的坑
必须设置的 SDC 约束
哪怕只是一个小型 CPU,也必须告诉 Vivado 你的时钟频率:
create_clock -period 10.000 [get_ports clk]
set_input_delay -clock clk 1.0 [all_inputs]
set_output_delay -clock clk 1.0 [all_outputs]
否则工具可能优化掉关键路径,导致实际运行主频远低于预期。
使用 BRAM 模拟内存
推荐将指令存储和数据存储分别映射到两个独立的 Block RAM 中,初始化为 .bin 文件。
技巧:用 Python 脚本把 RISC-V 汇编编译成机器码,生成 coe 文件直接导入 BRAM。
# 伪代码:asm → machine code → coe
with open("program.s") as f:
binary = riscv_assembler(f.read())
with open("inst.coe", "w") as f:
f.write("memory_initialization_radix=16;\n")
for word in binary:
f.write(f"{word:08x}\n")
调试利器:ILA 集成逻辑分析仪
提前插入 ILA 核,抓取以下关键信号:
pc,instructionregfile[32](建议只抓部分常用寄存器)alu_result,mem_data_outforward_a,forward_b,stall
你可以亲眼看到一条 beq 指令如何触发流水线刷新,或者一次 lw 如何引发一拍停顿。
它真的能跑吗?实测案例
我写了一段简单的 RISC-V 汇编程序,功能是计算数组求和:
.global _start
_start:
li x5, 10 # counter
li x6, 0 # sum
la x7, arr # base address
loop:
lw x8, 0(x7) # load element
add x6, x6, x8 # accumulate
addi x7, x7, 4 # next address
addi x5, x5, -1 # decrement counter
bne x5, zero, loop
trap:
j trap # infinite loop
烧录进 FPGA 后,通过 ILA 观测到:
x6最终值为 0x1E(即 30),符合预期- 循环共执行 10 次
- 每次
lw后确实插入了一拍停顿 - 分支跳转时 IF/ID 被清空
这意味着,我们的 CPU 不仅能跑,还能正确处理复杂的控制流与数据依赖。
这只是起点:下一步往哪走?
接下来可以尝试的方向:
✅ 加乘除法单元(MDU)
- 实现
mul,div指令 - 可采用组合逻辑(快但占资源)或多周期迭代(慢但省面积)
✅ 添加一级缓存(Cache)
- 在指令侧加入 I-Cache,减少 ROM 访问延迟
- 数据侧加 D-Cache,提升连续访存效率
✅ 接入外设构成微型 SoC
- UART:实现串口打印调试信息
- GPIO:点亮 LED、读取按键
- Timer:支持时间片调度
✅ 支持 RISC-V 压缩指令(RVC)
- 将 32 位指令压缩为 16 位,节省代码空间达 30%
- 需要在 IF 阶段增加解压逻辑
✅ 引入分支目标缓冲(BTB)
- 记录历史跳转地址,避免每次都冲刷流水线
- 显著提升含有大量循环/函数调用的程序性能
甚至有一天,你可以试着让它启动一个极简的操作系统内核——当然,前提是加上 MMU 支持虚拟内存。

