基于Vivado的RISC-V五级流水线CPU FPGA实现详解

手把手教你用 Vivado 实现一个 RISC-V 五级流水线 CPU(FPGA 实战全记录)


当问题从课本走向 FPGA 开发板

你有没有过这样的经历?在《计算机组成原理》课上听得头头是道:五级流水、数据旁路、控制冒险……可一旦打开 Vivado 想自己搭一个,瞬间懵了——PC 怎么跳?寄存器文件读写冲突怎么办?分支预测失败后怎么“擦屁股”?

别慌。我也是这么过来的。

今天,我就带你 从零开始,在 Xilinx Artix-7 FPGA 上实现一个完整的 RISC-V 五级流水线 CPU 。不是仿真玩玩,而是真正能跑通汇编程序、点亮 LED 的硬核项目。

我们不堆术语,不照搬教材框图,只讲你真正需要知道的实战细节:每个模块怎么写,关键信号怎么连,坑在哪里,怎么绕过去。

准备好了吗?让我们把理论变成看得见、摸得着的电路。


为什么选 RISC-V + 五级流水?

先说清楚一件事:我们为什么要做“五级流水”?为什么不直接做个单周期完事?

很简单—— 性能和资源的平衡

我在最初尝试时也做过单周期 CPU。代码写起来是简单,但综合结果让我傻眼:主频 barely 能上 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 到 WB,逐级打通

第一关:取指单元(IF)——让 CPU 知道下一步去哪

最简单的 IF 单元就是:

always @(posedge clk or negedge rst_n) begin if (!rst_n) pc <= 32'h0; else pc <= pc + 4; end 

但现实远没这么简单。

三大挑战:
  1. 跳转指令来了怎么办?
    - JAL 直接跳,BEQ 条件满足才跳
    - 必须在 EX 阶段判断后反馈给 IF,否则会多取一条错误指令
  2. 分支预测怎么做?
    - 别想太复杂,初期就用“默认不跳”
    - 如果跳了,那就清空 IF/ID 寄存器,重新从目标地址取指
  3. IMEM 怎么实现?
    - 用 Xilinx XPM 原语创建双端口 RAM:
    verilog 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)——拆包指令,读取操作数

ID 阶段的核心任务就两个字: 拆和读

  • 拆:把 32 位指令按格式分解
  • 读:根据 rs1 rs2 编号,从寄存器文件里拿出数据
寄存器文件实现要点

这是整个 CPU 最容易出错的地方之一。

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 

⚠️ 注意两点:
1. x0 寄存器必须永远返回 0 ,哪怕你往里写了值也不行 —— 这是 RISC-V 规范强制要求。
2. 读操作用 assign 实现异步读,避免额外延迟。

控制信号生成

建议单独做一个 ctrl_dec.v 模块,输入 opcode/funct3/funct7 ,输出一堆控制信号:

信号 作用
reg_write 是否允许写寄存器
alu_op ALU 操作类型
mem_read/write 是否访问内存
mem_to_reg 写回数据来自内存还是 ALU
branch 是否为分支指令

把这些信号打包成一个结构体,随指令一起在流水线中传递。


第三关:执行单元(EX)——真正的“大脑”

EX 阶段干三件事:
1. 选操作数 :第二操作数可能是寄存器值,也可能是立即数(比如 ADDI)
2. 算结果 :交给 ALU
3. 判分支 :如果是 BEQ/BNE,在这里比较两数是否相等

ALU 设计技巧

不要写一大坨 case,而是分层处理:

// 先决定第二操作数来源 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 阶段。听起来很美,但有两个问题:
1. 操作数可能还没准备好(比如前一条是 LOAD)
2. 增加了控制复杂度

我的建议: 老老实实放在 EX 阶段 ,配合数据旁路机制就够了。


第四关:访存单元(MEM)——小心字节对齐和端序

MEM 阶段主要服务两类指令:

  • Load :从 DMEM 读数据 → 送往 WB
  • Store :把数据写进 DMEM
DMEM 实现方式

同样使用 XPM 双端口 RAM:

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)——最后一公里

WB 阶段很简单,就一句话:

assign wb_data = mem_to_reg ? mem_data : alu_result; 

然后把这个 wb_data rd 地址一起传给寄存器文件,在下一个时钟上升沿写入。

但它却是数据依赖链的终点,直接影响能否实现“写后读”正确性。


冒险处理:让你的 CPU 不“抽风”

再好的流水线,遇上依赖也会崩溃。我们必须主动干预。

数据冒险:后面的指令等不到前面的结果

典型例子:

add x5, x6, x7 sub x8, x5, x9 # 依赖 x5,但还没写回 
解法一:暂停(Stall)

检测到 RAW 依赖且无法解决时,插入气泡:

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 等,不如直接把结果“抄近道”送过去。

我们在 EX 阶段之前加一个多路选择器:

// 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 

然后在 EX 输入端选择源操作数:

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 阶段早就把下一条指令取回来了。

这多取的一条指令怎么办? 扔掉!

具体做法:

  1. 在 EX 阶段发现要跳转
  2. 设置 pc_src = branch_target
  3. 插入一个 bubble 到 ID/EX(即清空控制信号)
  4. 下一拍从新地址重新取指

损失一个周期,但比什么都不做强。

💡 提升方向:后期可加入“分支目标缓存(BTB)”或“动态预测”,但现在先把基础搞稳。

Vivado 工程搭建全流程:从 RTL 到上板

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 

用 RISC-V 工具链编译:

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。

如果你在实现过程中遇到了别的问题,欢迎留言交流。我们一起把这条路走得更远。

Read more

【FPGA雷达信号处理完全指南】从采样到目标检测,实现毫米波雷达信号处理系统(含完整代码+性能优化)

【FPGA雷达信号处理完全指南】从采样到目标检测,实现毫米波雷达信号处理系统(含完整代码+性能优化) 📚 目录导航 文章目录 * 【FPGA雷达信号处理完全指南】从采样到目标检测,实现毫米波雷达信号处理系统(含完整代码+性能优化) * 📚 目录导航 * @[toc] * 概述 * 一、雷达信号处理基础概念与系统架构 * 1.1 雷达工作原理快速入门 * 1.1.1 基本工作流程 * 1.1.2 距离与速度测量 * 1.1.3 雷达方程 * 1.2 雷达信号处理流程 * 1.3 FPGA在雷达系统中的角色 * 1.4 系统架构设计 * 1.4.1 典型系统框图 * 1.4.2 FPGA内部架构 * 1.

By Ne0inhk
手把手教你用 OpenClaw + 飞书,打造专属 AI 机器人

手把手教你用 OpenClaw + 飞书,打造专属 AI 机器人

手把手教你用 OpenClaw + 飞书,打造专属 AI 机器人 当前版本 OpenClaw(2026.2.22-2)已内置飞书插件,无需额外安装。 你有没有想过,在飞书里直接跟 AI 对话,就像跟同事聊天一样自然? 今天这篇文章,带你从零开始,用 OpenClaw 搭建一个飞书 AI 机器人。全程命令行操作,10 分钟搞定。 一、准备工作 1.1 安装 Node.js(版本 ≥ 22) OpenClaw 依赖 Node.js 运行,首先确保你的 Node 版本不低于 22。 推荐使用 nvm 管理 Node

By Ne0inhk
汽车雷达在多径存在下的幽灵目标检测——论文阅读

汽车雷达在多径存在下的幽灵目标检测——论文阅读

汽车雷达在多径存在下的幽灵目标检测 D. Sharif, S. Murtala and G. S. Choi, “A Survey of Automotive Radar Misalignment Detection Techniques,” in IEEE Access, vol. 13, pp. 123314-123324, 2025, doi: 10.1109/ACCESS.2025.3584454. 摘要 共置多输入多输出(MIMO)技术已被广泛应用于汽车雷达系统,因为它能够以相对较少的发射和接收天线数量提供精确的角度估计。由于视距目标的发射方向(DOD)和到达方向(DOA)重合,MIMO信号处理允许形成更大的虚拟阵列用于角度查找。然而,多径反射是一个主要的限制因素,雷达信号可能从障碍物反弹,创建DOD不等于DOA的回波。因此,在具有多个散射体的复杂场景中,目标的直接路径可能被其他物体的间接路径破坏,导致不准确的角度估计或产生幽灵目标。

By Ne0inhk

实测|龙虾机器人(OpenClaw)Windows系统部署全攻略(含避坑指南)

作为一名热衷于折腾新技术的ZEEKLOG博主,最近被一款名为「龙虾机器人」的开源AI工具圈粉了!它还有个更正式的名字——OpenClaw(曾用名Clawdbot、MoltBot),不同于普通的对话式AI,这款工具能真正落地执行任务,比如操作系统命令、管理文件、对接聊天软件、自动化办公,而且支持本地部署,数据隐私性拉满。 不过调研发现,很多小伙伴反馈龙虾机器人在Windows系统上部署容易踩坑,官方文档对Windows的适配细节描述不够细致。今天就结合自己的实测经历,从环境准备、分步部署、初始化配置,到常见问题排查,写一篇保姆级攻略,不管是新手还是有一定技术基础的同学,都能跟着一步步完成部署,少走弯路~ 先简单科普下:龙虾机器人本质是一款开源AI代理框架,核心优势是“能行动、可本地、高灵活”——它不内置大模型,需要对接第三方AI接口(如GPT、Claude、阿里云百炼等),但能将AI的指令转化为实际的系统操作,相当于给AI配了一个“能动手的身体”,这也是它和普通对话大模型的核心区别。另外要注意,它还有一种“生物混合龙虾机器人”的概念,是利用龙虾壳改造的柔性机器人,本文重点分享的是可本

By Ne0inhk