跳到主要内容基于Vivado的RISC-V五级流水线CPU FPGA实现详解 | 极客日志汇编算法
基于Vivado的RISC-V五级流水线CPU FPGA实现详解
基于 Vivado 在 Xilinx Artix-7 FPGA 上实现 RISC-V RV32I 五级流水线 CPU。涵盖取指、译码、执行、访存、写回五大模块设计,包括寄存器文件、ALU、IMEM/DMEM 实现细节。重点解决数据冒险(旁路转发与暂停)与控制冒险(分支预测与冲刷)。提供工程结构、固件编译流程及约束文件示例,通过 ILA 调试确保时序正确性,最终实现可运行汇编程序的处理器原型。
菩提3 浏览 项目概述
在《计算机组成原理》课程中,五级流水线、数据旁路、控制冒险等概念往往停留在理论层面。实际在 Vivado 中进行 FPGA 开发时,PC 跳转、寄存器读写冲突、分支预测失败后的处理等问题容易让人困惑。
本项目从零开始,在 Xilinx Artix-7 FPGA 上实现一个完整的 RISC-V 五级流水线 CPU。支持跑通汇编程序并验证硬件功能。
我们不堆术语,只讲实战细节:每个模块怎么写,关键信号怎么连,坑在哪里,怎么绕过去。
设计选型依据
为什么选 RISC-V + 五级流水?
性能和资源的平衡是关键。
单周期 CPU 虽然代码简单,但综合结果主频仅能上 50MHz,且大部分时间 ALU、内存处于空闲状态。一条指令走完所有阶段,延迟全压在一条路径上。
五级流水线将指令拆成五个小步,每步只做一点点事。第一条指令仍需 5 个周期完成,但从第 5 个周期开始,每个周期都能送出一条新指令的结果,实现吞吐率的飞跃。
RISC-V 的开放性和简洁性,特别是 RV32I 基础整数集只有几十条指令,控制逻辑清晰,非常适合 FPGA 新手练手。
技术选型:Artix-7 XC7A35T 开发板 + Vivado 2023.1 + 支持 RV32I 的轻量级核心设计
五级流水线架构
| 阶段 | 干什么 | 关键任务 |
|---|
| IF(取指) | 取指令 | 给 PC 找地址,从 IMEM 拿指令 |
| ID(译码) | 拆指令 | 解析 opcode,读寄存器,生成控制信号 |
| EX(执行) | 算东西 | ALU 运算,地址计算,判断分支 |
| MEM(访存) | 访问内存 | Load/Store 数据,其他指令透传 |
| WB(写回) | 写结果 | 把数据写回寄存器 |
理想状态下,每一拍都有五条指令分布在不同阶段,CPU 利用率达到极致。但这背后有个大前提:不能出乱子。一旦前面卡住,后面全得等着——这就是所谓的'流水线冒险'。
核心模块拆解
第一关:取指单元(IF)
最简单的 IF 单元就是更新 PC 地址,但现实远没这么简单。
- 跳转指令来了怎么办? JAL 直接跳,BEQ 条件满足才跳。必须在 EX 阶段判断后反馈给 IF,否则会多取一条错误指令。
- 分支预测怎么做? 初期用'默认不跳'。如果跳了,那就清空 IF/ID 寄存器,重新从目标地址取指。
- IMEM 怎么实现? 用 Xilinx XPM 原语创建双端口 RAM。
xpm_memory_sdpram #( .ADDR_WIDTH_A(10), // 1KB = 256 words .DATA_WIDTH_A(32) ) imem_inst ( .clka(clk), .addra(pc[3:2]), // 字对齐 .douta(inst_out) );
提示:PC 更新必须受控!加入 pc_en 和 pc_src 多路选择器,支持 jump、branch、exception 等多种来源。
第二关:译码单元(ID)
- 拆:把 32 位指令按格式分解
- 读:根据
rs1 和 rs2 编号,从寄存器文件里拿出数据
寄存器文件实现要点
module regfile ( input clk, input we, // 写使能 input [4:0] waddr, // 写地址 input [31:0] wdata, // 写数据 input [4:0] raddr1, input [4:0] raddr2, output [31:0] rdata1, output [31:0] rdata2 ); reg [31:0] regs [0:31]; // 同步写:只在上升沿更新 always @(posedge clk) begin if (we && waddr != 5'd0) // x0 永远为 0! regs[waddr] <= wdata; end // 异步读:组合逻辑输出 assign rdata1 = (raddr1 == 5'd0) ? 32'd0 : regs[raddr1]; assign rdata2 = (raddr2 == 5'd0) ? 32'd0 : regs[raddr2]; endmodule
- x0 寄存器必须永远返回 0 ,哪怕你往里写了值也不行 —— 这是 RISC-V 规范强制要求。
- 读操作用
assign 实现异步读,避免额外延迟。
控制信号生成
建议单独做一个 ctrl_dec.v 模块,输入 opcode/funct3/funct7 ,输出一堆控制信号:
| 信号 | 作用 |
|---|
reg_write | 是否允许写寄存器 |
alu_op | ALU 操作类型 |
mem_read/write | 是否访问内存 |
mem_to_reg | 写回数据来自内存还是 ALU |
branch | 是否为分支指令 |
把这些信号打包成一个结构体,随指令一起在流水线中传递。
第三关:执行单元(EX)
- 选操作数 :第二操作数可能是寄存器值,也可能是立即数(比如 ADDI)
- 算结果 :交给 ALU
- 判分支 :如果是 BEQ/BNE,在这里比较两数是否相等
ALU 设计技巧
// 先决定第二操作数来源 assign op_b = src_sel ? imm_val : rs2_data; // 再送入 ALU always @(*) begin case (alu_ctrl) OP_ADD: result = op_a + op_b; OP_SUB: result = op_a - op_b; OP_AND: result = op_a & op_b; OP_OR : result = op_a | op_b; OP_XOR: result = op_a ^ op_b; OP_SLT: result = ($signed(op_a) < $signed(op_b)) ? 32'd1 : 32'd0; OP_SLL: result = op_a << op_b[4:0]; OP_SRL: result = op_a >> op_b[4:0]; OP_SRA: result = $signed(op_a) >>> op_b[4:0]; default: result = 32'd0; endcase end
分支判断提前到 ID?没必要!
有人为了减少延迟,想把 BEQ/BNE 的比较放到 ID 阶段。听起来很美,但有两个问题:
- 操作数可能还没准备好(比如前一条是 LOAD)
- 增加了控制复杂度
我的建议:老老实实放在 EX 阶段,配合数据旁路机制就够了。
第四关:访存单元(MEM)
- Load :从 DMEM 读数据 → 送往 WB
- Store :把数据写进 DMEM
DMEM 实现方式
xpm_memory_tdpram #( .ADDR_WIDTH_A(12), // 4KB .DATA_WIDTH_A(32) ) dmem_inst ( .clka(clk), .ena(mem_en_a), .wea(byte_enable), // 字节使能! .addra(addr_a[3:2]), .dina(data_a), .douta(dout_a) );
关键点:字节使能(Byte Enable)
RISC-V 支持 LB/LH/SC.B/SC.H 等操作,必须通过 byte_enable 控制写哪些字节:
| 操作 | byte_enable |
|---|
| SB | 4'b0001 (假设 little-endian) |
| SH | 4'b0011 |
| SW | 4'b1111 |
同时注意:RISC-V 默认小端模式 ,低地址放低字节。
第五关:写回单元(WB)
assign wb_data = mem_to_reg ? mem_data : alu_result;
然后把这个 wb_data 和 rd 地址一起传给寄存器文件,在下一个时钟上升沿写入。
但它却是数据依赖链的终点,直接影响能否实现'写后读'正确性。
冒险处理
再好的流水线,遇上依赖也会崩溃。我们必须主动干预。
数据冒险:后面的指令等不到前面的结果
add x5, x6, x7
sub x8, x5, x9 # 依赖 x5,但还没写回
解法一:暂停(Stall)
if (id_ex_mem_read && (id_ex_rd != 0) && (id_ex_rd == if_id_rs1 || id_ex_rd == if_id_rs2)) begin stall = 1; end
此时冻结 PC 和 IF/ID 寄存器,同时把 ID/EX 的控制信号置空(相当于插入 NOP)。
解法二:数据旁路(Forwarding)——这才是重点!
与其让 CPU 等,不如直接把结果'抄近道'送过去。
// Forwarding Unit 示例 function [1:0] forward_A; input [4:0] rs1; input [4:0] ex_mem_rd, mem_wb_rd; input ex_mem_reg_write, mem_wb_reg_write; begin if (ex_mem_reg_write && ex_mem_rd != 0 && ex_mem_rd == rs1) forward_A = 2'b01; // 来自 EX/MEM else if (mem_wb_reg_write && mem_wb_rd != 0 && mem_wb_rd == rs1) forward_A = 2'b10; // 来自 MEM/WB else forward_A = 2'b00; // 正常路径 end endfunction
src_a = (forward_A == 2'b01) ? ex_mem_alu_out : (forward_A == 2'b10) ? mem_wb_data : id_ex_rs1_data;
实践效果:加上 EX→EX 旁路后,上面那个 add→sub 的例子就能无缝衔接,无需停顿!
控制冒险:分支跳错了怎么办?
当遇到 BEQ/BNE 时,即使我们在 EX 阶段才判断结果,IF 阶段早就把下一条指令取回来了。
- 在 EX 阶段发现要跳转
- 设置
pc_src = branch_target
- 插入一个 bubble 到 ID/EX(即清空控制信号)
- 下一拍从新地址重新取指
优化建议:后期可加入'分支目标缓存(BTB)'或'动态预测',但现在先把基础搞稳。
Vivado 工程搭建全流程
1. 项目结构建议
project/
├── src/
│ ├── cpu_top.v
│ ├── if_stage.v
│ ├── id_stage.v
│ ├── ex_stage.v
│ ├── mem_stage.v
│ ├── wb_stage.v
│ ├── regfile.v
│ └── imem_dmem.xpm
├── testbench/
│ └── tb_cpu.v
├── firmware/
│ ├── hello.S
│ └── Makefile
└── constraints/
└── board.xdc
2. 固件编译流程
.global _start
_start:
addi x5, x0, 100
addi x6, x0, 200
add x7, x5, x6 # 结果应为 300
loop:
j loop
riscv64-unknown-elf-gcc -march=rv32i -mabi=ilp32 -nostdlib -T linker.ld -o main.elf main.S
riscv64-unknown-elf-objcopy -O binary main.elf main.bin
再转换成 .coe 文件烧进 IMEM 初始化内容。
3. 综合与实现注意事项
- 启用优化级别较高设置 :
set_property SEVERITY {Warning} [get_drc_checks NSTD-1] 忽略非标准电平警告(用于内部信号)
- 关键路径加寄存器缓冲 :比如 ALU 输出端加 pipeline reg
- 使用 ILA 抓波形调试 :
tcl create_debug_core u_ila_0 ila
set_property PROBE_TYPE DATA_AND_TRIGGER [get_debug_cores u_ila_0]
connect_debug_port u_ila_0/clk [get_nets clk]
推荐监控:pc, instruction, alu_result, mem_rdata, rf_wdata
4. 约束文件示例(XDC)
create_clock -period 10.000 [get_ports clk]
set_input_delay 2.0 [all_inputs] -clock clk
set_output_delay 2.0 [all_outputs] -clock clk
set_false_path -from [get_pins id_ex_reg*/D] -to [get_pins ex_mem_reg*/Q]
常见问题排查清单
| 现象 | 可能原因 | 解决方法 |
|---|
| CPU 卡在第一条指令不动 | PC 没递增,或 IMEM 未加载 | 检查 reset 释放逻辑,查看 ILA 中 inst 是否有效 |
| 加法结果不对 | x0 寄存器没强制为 0 | 修改 regfile,读取 x0 时直接返回 0 |
| 分支永远不跳 | branch 控制信号没拉高 | 检查 EX 阶段比较逻辑和控制传递 |
| 时序报错 Failed | 关键路径太长 | 在 ALU 后加一级 pipeline register |
| Load 数据错位 | 字节使能或地址对齐有问题 | 打印 address 和 be,确认是否符合小端规则 |
总结
当你第一次看到 LED 按照你的汇编代码闪烁,那种成就感,远超任何考试满分。
这个五级流水 CPU,也许还很原始:没有中断、没有异常、没有缓存、不支持压缩指令……但它是一个完整闭环的自主可控处理器原型。
- 加个 UART,实现 printf 调试
- 接入 DDR 控制器,扩大内存空间
- 实现 Timer 中断,跑起简易 RTOS
- 甚至集成 NPU 模块,做成 AIoT 边缘芯片
而这一切的基础,就是你现在亲手搭起来的这个小小 CPU。
相关免费在线工具
- 加密/解密文本
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
- JSON 压缩
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online