
简介面向处理器设计与计算机体系结构学习者的 RISC-V 五级流水线 CPU 完整工程提供从取指、译码、执行、访存到写回的全模块 Verilog 源码并支持基于 iverilog 与 Verilator 的两种仿真流程。资源共 119 个文件压缩包 12.49MB包含 27 个 Verilog 模块、配套测试文件、C 协同仿真入口、交叉编译工具链示例、中文 RISC-V 手册与架构图等适合课程设计、毕业设计或自学实践。已有 40 人学习。通过 run.bat 可一键完成全局仿真test.bat 支持分模块调试Makefile 自动编译 Verilator 协同环境工程内还提供寄存器堆、数据 RAM、总线仲裁与定时器等模块便于理解数据通路、流水线冲突处理与访存时序。附带的 VS Code 工作区、波形查看配置及中文手册可帮助快速搭建开发环境是一份开箱即用的 RISC-V 处理器设计参考。 一直在用FPGA做各种控制器和接口逻辑工作里始终绕不开对处理器内部行为的好奇。前阵子终于下定决心用Verilog从零写了一个RISC-V五级流水线CPU配套源码、仿真脚本和完整工具链流程算是把这个好奇心彻底落地了。项目覆盖了RV32I基础整数指令集实现了取指、译码、执行、访存、写回五个阶段并且加入了数据前递、分支跳转冲刷等实际处理器必备的机制。不管你是计算机体系结构方向的学生还是想从接口逻辑转向处理器设计的嵌入式工程师或者只是想在一块FPGA开发板上跑通软硬件协同流程这套东西都能当一份不错的地图来用。代码写好只是第一步真正麻烦的是怎么高效地仿真、编译测试程序、加载到不同环境里跑这些环节如果没打通Verilog写得再漂亮也是空中楼阁。这篇文章我会从整体设计思路开始把五级流水线的核心细节、Verilog关键模块实现、仿真脚本与多工具链支持全部过一遍最后列几个我在调试中踩过的坑帮你少走点弯路。1. 项目整体设计与指令集规划1.1 为什么选择RV32I和五级流水线RISC-V的指令集规范有很多扩展最基础的RV32I只有四十多条指令指令格式规整非常适合作为自研CPU的起点。相比x86那种变长指令、复杂寻址模式RV32I的解码逻辑能简化到几乎不需要查大表这对于纯手写Verilog来说太友好了。流水线方面市面上主流教学和商业处理器都倾向于三到五级。三级流水线虽然简单但无法很好地隐藏访存延迟五级是经典教材里最常讲的架构每个阶段只做一件事时序清晰而且分支预测、数据前递这些机制都能在上面找到合适的落点。再往上做七级甚至更深就要开始处理更复杂的前递网络和刷新逻辑对于第一次完整实现一个CPU来说调试难度会明显上升。我在这个项目里选择的是经典五级结构IF取指、ID译码、EX执行、MEM访存、WB写回。每一拍只推进一个阶段流水线寄存器存放阶段间的中间结果。这套结构最大的优势是每一级的关键路径都不长综合出来后频率比较好看同时各种冒险处理机制有清晰的理论参照。1.2 指令集覆盖范围与硬件资源取舍RV32I虽然只有四十多条指令但完整的实现还是包含不少细节。我一开始就把目标定在“足够跑通C程序”所以除了基础整数运算指令还实现了lui、auipc这类立即数加载指令以及jal、jalr和条件分支指令访存指令覆盖了lb、lh、lw、lbu、lhu、sb、sh、sw这些常见类型。ecall和ebreak暂时只做了异常占位方便后续接系统总线或者做中断扩展。这里有个取舍问题完整的RV32I还包括fence指令和CSR指令。fence我直接在译码阶段当成NOP处理CSR指令则完全不接毕竟接CSR寄存器堆会引入很大一块逻辑。访存部分我用的是哈佛结构指令存储器和数据存储器分开这样取指和访存在同一拍内互不干扰可以省掉一个冲突检测逻辑。代价是如果以后要接真实的存储器总线两边要加仲裁但对于教学和验证用途这个取舍非常值。2. 五级流水线各阶段实现要点2.1 取指阶段与指令存储器取指阶段的职责非常简单把程序计数器PC送到指令存储器读出一条32位指令然后送进IF/ID流水线寄存器。我额外做了一件重要的事——PC更新逻辑。顺序执行时PC加4遇到跳转指令时直接覆盖为跳转目标地址这两个操作都发生在EX阶段所以IF阶段实际上只是给PC寄存器一个“下一拍用哪个地址”的值。指令存储器我用的是Verilog里的reg数组加$readmemh加载这样仿真时可以随时更换测试程序。由于是教学用途没有做指令缓存直接用单周期SRAM模型。这里有几个容易踩的细节指令存储器的读地址没有做字节对齐检查理论上RV32I要求PC对齐到4字节我在实现时默认测试程序都是对齐的。另外复位时PC必须初始化为固定入口地址我选的是0x00000000然后在链接脚本里把代码入口也设成这个地址两边保持一致。2.2 译码阶段与控制信号生成ID阶段是三部分组成的指令译码、寄存器堆读取、立即数扩展。RV32I的指令格式分成R型、I型、S型、B型、U型、J型六类译码的核心就是根据opcode和funct3、funct7字段确定这条指令属于哪个类型、需要哪些控制信号。我把控制信号设计成了一个结构体包含寄存器写使能、ALU操作选择、内存读写使能、写回数据选择等字段这样流水线寄存器里传递起来比较干净。立即数扩展是新手最容易写错的地方。RV32I的立即数扩展不是简单的符号扩展而是要按指令类型重新拼接。比如B型分支指令的立即数分散在指令的多个位段里需要先把它们按规范拼成一个13位带符号数再扩展成32位。我一开始把B型和S型的立即数扩展逻辑写混了导致分支跳转地址一直不对最后对着RISC-V规范逐位核对才解决。寄存器堆是异步读、同步写的双口RAM读端口在同一个时钟沿上升沿之前输出数据写端口在时钟上升沿写入。这种结构可以让ID阶段在一个周期内读到上一拍WB阶段写回的数据减少一个周期的阻塞。2.3 执行、访存与写回通路EX阶段是整条流水线的核心ALU负责算术和逻辑运算分支判断电路比较两个操作数并决定是否跳转。这里有个关键设计ALU的操作数不是直接来自寄存器堆而是来自前递电路选择的“真实最新值”也就是说如果上一条指令的写回数据已经算出来了但还没写回寄存器堆那么本条指令可以直接用前递过来的值不用白白停顿一拍。MEM阶段处理加载和存储指令。数据存储器同样用reg数组实现读操作是组合逻辑写操作在时钟沿触发。加载指令需要根据访问宽度和地址的低两位做字节选择也就是说如果是lb指令要挑出对应字节并做符号扩展如果是lhu要选半字做无符号扩展。这部分处理比较繁琐但是我建议不要偷懒用位拼接拼接完事要写成通用逻辑方便后面升级到更多字节操作指令。WB阶段把结果写回寄存器堆。回写数据有三个来源ALU运算结果、访存读取结果、以及PC4用于jal和jalr指令。我在写回通路上加了一个直接连线到ID阶段寄存器堆写端口的路径配合前递电路可以把RAW冒险的停顿数量降到最低。2.4 数据冒险与控制冒险处理五级流水线里最典型的RAW数据冒险我采用“前递必要时停顿”的组合方案。前递网络从EX/MEM和MEM/WB流水线寄存器里取出最新计算结果直接送到EX阶段的ALU输入。这样绝大多数连续算术指令可以一条接一条跑不需要停顿。但load指令有特殊性它要到MEM阶段结束才拿到数据而下一条指令在EX阶段就需要这个值所以必须停一拍。这也是经典的“load-use”停顿。控制冒险主要是分支和跳转。因为没有做复杂的分支预测我选择最简单粗暴的方案在ID阶段把分支目标地址解算出来如果判断为跳转就冲刷IF/ID流水线寄存器并重定向PC。这意味着每条分支指令固定带来一个周期的性能损失但对于教学CPU是完全可以接受的。实现时有一点要特别注意冲刷信号必须精准清除IF/ID寄存器而EX/MEM和MEM/WB寄存器里的数据不要动否则会把已经正确执行的指令序列搞坏。3. Verilog源码关键模块拆解3.1 流水线寄存器设计的关键取舍流水线寄存器是整个CPU里最繁重也最容易写错的模块。每个阶段之间都有独立的寄存器模块整体上我选用了“时钟沿触发同步使能同步复位”的模式所有控制信号和数据信号捆在一起作为一个大向量传递。设计时有个比较关键的点使能信号和冲刷信号需要分开。使能是正常流水线推进时用的冲刷是分支跳转或者异常处理时用“清除无效指令”的。如果混在一起很容易在保留合法数据时把该清的数据也清了。我把两类信号分开处理使能时数据照常装入冲刷时输出直接置为NOP指令对应的数据形态。NOP指令我定义为addi x0, x0, 0这样即使NOP进入执行阶段也不会改动任何寄存器或者内存副作用为零。3.2 数据通路与控制单元的实现重点数据通路我画成了一张Verilog模块图最左边是PC寄存器和指令存储器中间是几个流水线寄存器右边是寄存器堆和存储器ALU挂在EX阶段。控制单元挂在ID阶段根据指令生成控制信号后随流水线寄存器一路向后传递。实现时要注意控制信号里的“写使能”信号必须跟着流水线走到WB阶段。很多入门设计把寄存器写使能直接由ID阶段的信号驱动结果一条指令在ID阶段产生的写使能等它走到WB阶段时早就失效了。正确做法是把写使能作为流水线寄存器的一部分逐级向后传递保证对齐到正确拍的写端口。此外寄存器堆的写数据选择信号也需要在WB阶段才能确定来源因此同样要放到流水线寄存器里。我就在这个细节上栽过一次现象是几条指令写回的数据错位过了好几个时钟周期才在波形里定位到是写数据选择信号没对齐。3.3 顶层模块与外部接口顶层模块把IF、ID、EX、MEM、WB这几个阶段模块例化连接起来同时暴露外部接口方便挂到FPGA开发板或者仿真环境。外部接口主要包括时钟、复位、指令存储器读地址、数据存储器读写端口以及一个调试用的寄存器观察端口。FPGA验证时我加了两个很实用的调试接口一个是把7段数码管接到某个寄存器的值上另一个是用UART回传CPU内部寄存器的内容。这样不用接逻辑分析仪就能快速确认程序在板上运行是否正确。顶层模块还留了一个简单的AXI-Lite桥接接口方便以后把CPU接到SoC总线上外挂内存和外设。4. 仿真脚本与多工具链支持4.1 基于Icarus Verilog的仿真流程仿真环境我首先推荐Icarus Verilog加GTKWave的组合免费、跨平台、用法简单。项目里写了三个仿真脚本编译脚本、运行脚本、波形查看脚本。编译脚本负责收集所有Verilog源文件和testbench一条命令生成可执行文件运行脚本真正跑仿真并生成VCD波形文件波形查看脚本调用GTKWave打开波形。写testbench时有一个比较实用的做法用$readmemh把编译好的机器码二进制文件加载进指令存储器同时初始化寄存器堆的初始值。仿真跑完后在testbench里做一次自动比对把最终PC值和几个关键寄存器的值与预期结果对比通过断言输出PASS或FAIL。这样跑回归测试时不用一个个打开波形看结果效率高很多。4.2 RISC-V工具链交叉编译与程序加载要让CPU跑C程序必须完成“C代码到机器码”的完整转换链。我使用的是riscv64-unknown-elf-gcc交叉编译器编译时指定-marchrv32i -mabiilp32这样生成32位RISC-V指令。链接脚本要自己写指定内存布局从0x00000000开始避免使用某些操作系统相关的默认段。具体流程是C源码经过gcc编译生成elf文件再用objcopy把.text段抽出为纯二进制文件最后用hexdump或者一个小Python脚本转换成Verilog的$readmemh格式。期间还会用objdump反汇编检查生成的指令是否在CPU支持的指令集内。如果程序里包含未实现的指令比如用了除法器或者浮点指令反汇编结果会立刻暴露。这里还要提一下LLVM工具链。RISC-V的LLVM后端已经相当成熟只需要下载预编译的riscv32目标版本或者用标准clang加--targetriscv32-unknown-elf参数同样能完成交叉编译。LLVM的编译产物往往更精简在某些场景下比GCC产生的代码更少也更容易检查。两种工具链我都在项目里做了对应脚本通过参数切换。4.3 扩展支持Vivado / Verilator的注意事项如果要把工程拿到Vivado里综合有几个额外的注意点。Icarus Verilog比较宽容很多语法错误在仿真时不会暴露但Vivado综合时会对设计规则做严格检查。最常见的问题包括位宽不匹配、未初始化的信号、敏感列表里遗漏信号等。建议从一开始就打开Verilog的严格警告模式在仿真阶段就把这些隐患清干净。Verilator是另一个优秀的选择它的编译速度极快而且可以把Verilog编译成C模型跑大型C测试程序时比事件级仿真快几个数量级。不过它要求代码必须是可综合风格不能有完整的testbench原语而且对initial块、延迟控制等行为级语法支持有限。如果想用Verilator跑软硬件协同验证建议把处理器本身写成可综合风格测试程序用单独的C驱动来加载和读取存储器。项目里我也加了Verilator的Makefile支持跑完整C程序时性能提升非常明显。5. 常见问题与调试心得5.1 仿真结果异常从波形定位问题仿真的第一准则永远是先看波形不要瞎猜。我在调试过程中遇到最多的现象是PC正确但寄存器写回值错误这种问题通常发生在控制信号对齐上。把IF/ID、ID/EX、EX/MEM、MEM/WB四级流水线寄存器的内容全部dump到波形里逐拍对比指令流和寄存器的变化很快就能看出是哪一级的控制信号出现错位。另外一个非常实用的技巧是加一个“指令跟踪模块”在仿真时逐拍打印出当前正在执行什么指令、写哪个寄存器、写入什么值。这个打印信息配上波形索引定位问题速度能提升不少。打印模块用initial块加一个全局时钟计数器实现不影响硬件功能只作为调试用。5.2 流水线冒险导致的数据错乱数据前递处理不好时典型现象是一段连续的算术指令中某几条指令的结果偶尔错误而且错误没有规律。这种问题很难通过看单条指令发现最好的办法是构造一组简洁的指令序列专门触发某一种冒险比如先写寄存器紧接着读同一个寄存器然后把结果独立对比。我建议把冒险测试用例拆分成三类ALU-ALU、ALU-Load、Load-ALU逐类验证前递和停顿是否正确。控制冒险的错误通常表现为分支跳转后PC指向了错误位置或者跳转后的第一条指令被错误地执行了。定位这种问题时我一般会在testbench里加一个基于时钟周期的断言记录每次PC更新时是否发生了分支跳转以及跳转目标是否与预期一致。通过对比汇编程序的期望执行路径和波形实际路径能快速锁定问题发生在分支判断还是PC重定向逻辑。5.3 时序收敛与FPGA实现经验如果目标是上板跑时序收敛是一个绕不开的问题。五级流水线的关键路径通常位于EX阶段的ALU运算和ID寄存器堆读端口的组合逻辑上。我第一版综合在50MHz时钟下都没法收敛主要原因是寄存器堆的读地址到数据输出的组合逻辑太深。后来把可综合风格改为“读操作在时钟沿内完成”也就是让寄存器堆在读时打一拍寄存器时序改善非常明显代价是load-use停顿会增加一个周期。对于FPGA实现我还有一个建议尽量避免在时钟上升沿同时做“寄存器堆读”和“寄存器堆写”两个动作。虽然双口RAM理论支持但很多FPGA原语在同一端口进行读写时会有资源冲突。我最后选择了写优先策略写操作占优先读操作给足组合逻辑时间这样综合工具能更好地推断出BRAM结构。最后的个人体会这套五级流水线CPU写下来最大的感受是“仿真通过”和“综合通过”完全是两码事。代码在Icarus里跑得顺顺利利拿到Vivado里综合后各种时序告警再跑到Verilator里又暴露出一堆行为级语法问题。现在回过头看最早就应该三种工具链同时跑把兼容性作为设计目标的一部分而不是最后再补课。如果你也想动手做一个我强烈建议从最小的RV32I子集开始先把不包含分支跳转的算术逻辑指令跑通再加访存最后再加分支和跳转。每一轮都做一次完整仿真回归确认没破坏已有功能再继续加新指令。另外一个值得早点做的工程化动作是把所有测试用例固化成一个命令行脚本加入PASS/FAIL断言。这样后面改动任何模块跑一遍回归就知道有没有引入新问题。CPU设计很容易在“看起来对了”的状态下藏着细微的bug自动化验证是唯一靠得住的手段。本文还有配套的精品资源点击获取