基于MIPS的五级流水线CPU设计:Verilog实现与冒险处理解析
简介面向计算机系统结构课程学习者与硬件设计开发者这是一份基于MIPS指令集的动态五级流水线CPU完整工程源自同济大学课程项目覆盖取指、译码、执行、访存、写回全阶段并包含流水线冒险处理与动态调度相关实现适合作为CPU设计实践和课程设计的参考模板。压缩包共154个文件整体约756KB主要包含34个Verilog源文件.v、仿真测试脚本.do/.vhd、Vivado工程配置文件.xpr/.xci/.qdb、存储器初始化文件.coe/.mif以及compile.bat、simulate.bat等自动化脚本目录结构清晰便于按模块阅读与复现仿真。资源目前已有65人学习下载。通过工程源码、测试平台与文档说明可以直观理解流水线各阶段的数据通路与控制信号设计掌握动态流水线中数据冒险、控制冒险的消除策略同时也可借鉴其工程组织方式与仿真流程提升数字系统设计能力。 做CPU设计这件事听起来唬人但真上手之后你会发现它其实是“最不玄学”的硬件实践所有信号都看得见、摸得着仿真波形一拉出来错误就藏不住。同济大学计算机系统结构课程里这个“基于MIPS指令集的动态流水线CPU设计与实现”项目我一直觉得是特别适合用来打通“指令集—数据通路—流水线控制”这条完整知识链的题目。它要求用Verilog实现一个支持取指、译码、执行、访存、写回完整五级流水线的MIPS处理器最终部署在FPGA平台上。无论你是正在做课设的本科生还是准备复试、想加深对CPU理解的同学这套流程走完你对计算机组成原理的理解深度会明显不一样。这篇文章我就结合自己实现这个项目的完整经历把整体方案、模块划分、冒险处理、验证调试这些环节掰开揉碎讲清楚。里面有很多是教科书上看不到、但实际写代码时一定会踩的坑。1. 整体设计与思路拆解1.1 为什么选MIPS和五级流水线MIPS之所以被这么多高校选作教学CPU的指令集核心优势就是“规整”。所有指令定长32位三种格式R型、I型、J型字段位置高度一致比如rs、rt、rd永远在固定的bit位这让译码电路非常简单特别适合让学生把精力放在数据通路和流水线本质上。换成x86那种变长指令光是译码就够喝一壶。五级流水线也是教科书里的经典结构。IF取指、ID译码、EX执行、MEM访存、WB写回每一级只做一件事时钟频率可以做得比较高。最开始设计时我也纠结过是直接做四级还是五级后来还是选了五级因为五级是后续理解分支延迟槽、load-use冒险、转发路径这些概念的“标准舞台”哪怕课程不强制也值得按五级来做。1.2 动态流水线的“动态”体现在哪很多人第一次听到“动态流水线CPU”会觉得是不是要支持乱序执行、动态调度。在做课设的语境下这里的“动态”更多指处理器在运行过程中能够动态地检测并处理流水线冒险而不是像静态流水线那样完全靠编译器规避冲突。具体到实现上就是三点数据冒险通过转发forwarding旁路和流水线暂停stall动态解决。控制冒险通过分支判断提前 指令冲刷flush动态解决。结构冒险通过分离的指令存储器和数据存储器从硬件架构上直接避免访问冲突。这也是这个项目最有价值的地方它不是一条直线连到底的电路而是要写一套“冒险检测—处理决策—控制信号生成”的机制让CPU在运行中自己纠错。2. 指令子集选型与工程搭建2.1 指令子集怎么划课程项目不需要支持完整MIPS指令集选一个“既能跑演示程序又不会让控制信号爆炸”的子集才关键。我最终裁剪出的指令集合如下指令类型具体指令说明R型add、sub、and、or、slt、sll算术逻辑运算I型addi、lw、sw、beq、bne立即数运算、访存、分支J型j无条件跳转这个集合刚好覆盖了寄存器堆读写、ALU运算、存储器访问、分支跳转这四类核心操作。你看addi负责立即数扩展lw/sw负责访存beq/bne负责控制流slt负责比较运算sll负责移位每一种指令都能逼你去处理对应的控制信号和流水线冲突。注意不要一上来就加mult、div、jal这些特殊指令它们会引入乘除法单元、链接寄存器、hi/lo寄存器等一大堆额外逻辑课设周期会明显拉长。2.2 开发工具链与加载流程我的开发环境是Verilog Vivado仿真指令内存初始化用MARSMIPS Assembler and Runtime Simulator来生成二进制文件。整个流程是这样的先用MARS写汇编测试程序确认功能正确保存为.hex或.data格式。把.hex文件通过Verilog的$readmemh加载到指令存储器ROM里。在Vivado里跑行为仿真观察每一条指令的PC值、关键寄存器、流水线寄存器变化。全部通过后再综合、上板验证。如果你不想装MARS也可以用GCC交叉编译工具链但MARS对教学用途更友好调试汇编时能单步看寄存器状态强烈建议用它验证基准程序。2.3 顶层模块规划顶层电路我按经典数据通路来搭信号流向清晰后期加调试逻辑也容易。核心模块包括pc_reg程序计数器inst_rom指令存储器ROMregfile寄存器堆alu算术逻辑单元data_ram数据存储器RAMctrl主控制单元hazard_unit冒险检测与转发单元流水线寄存器分别是IF/ID、ID/EX、EX/MEM、MEM/WB四个。我习惯把冒险检测和转发逻辑单独做成一个hazard_unit模块这样主数据通路不会乱掉。调试时如果CPU跑飞了优先去查hazard_unit输出的控制信号。3. 核心模块实现与代码解析3.1 IF取指阶段实现IF阶段的核心是PC更新逻辑这个看似简单但分支和跳转一来就复杂了。我的PC更新逻辑长这样always (posedge clk or posedge rst) begin if (rst) begin pc 32h0000_0000; end else if (branch_taken) begin pc branch_target; // 分支命中跳转到目标地址 end else if (jump_flag) begin pc jump_target; // j指令直接跳转 end else begin pc pc 4; // 顺序执行 end end这里有个小细节MIPS指令按字节寻址每条指令占4字节所以PC步长是4。指令存储器用$readmemh加载reg [31:0] inst_mem [0:4095]; initial $readmemh(inst.hex, inst_mem); wire [31:0] inst inst_mem[pc[13:2]];pc[13:2]其实是把字节地址转成字索引初学容易忘了地址除以4这件事导致读出来的指令永远是错的。3.2 ID译码阶段实现译码阶段我除了做常规的寄存器读取和立即数扩展还把控制信号集中在这里生成。MIPS的控制信号需要根据opcode和funct联合判断我直接写成组合逻辑查表always (*) begin case (opcode) 6b000000: begin // R型 reg_write_en 1; alu_src2 2b00; // 来自寄存器rt mem_write_en 0; mem_to_reg 0; branch_en 0; end 6b100011: begin // lw reg_write_en 1; alu_src2 2b01; // 来自立即数 mem_write_en 0; mem_to_reg 1; branch_en 0; end // 其他指令类似 endcase end这段查表逻辑要特别注意R型指令的opcode全0必须根据funct区分具体运算。我最初把add和sub的控制写反了仿真时怎么跑结果都不对后来把funct直接拉出来打印波形才发现。建议R型的控制信号单独用funct再case一次别和opcode搅在一起。3.3 EX执行阶段实现执行阶段有两个作用一是真正的ALU运算二是完成分支条件判断。ALU部分我用一个组合逻辑实现四则运算、逻辑运算和移位case (alu_ctrl) 4b0000: alu_result src_a src_b; // add/addi/lw/sw 4b0001: alu_result src_a - src_b; // sub 4b0010: alu_result src_a src_b; // and 4b0011: alu_result src_a | src_b; // or 4b0100: alu_result (src_a src_b) ? 1 : 0; // slt 4b0101: alu_result src_b shamt; // sll // ... endcase分支判断这里我选择在EX阶段进行这样虽然需要多冲刷两级但译码阶段的逻辑会简化不少。不过也有同学选择在ID阶段就判断分支那样分支延迟只损失一个周期。我用EX阶段是因为这样ALU可以复用比较逻辑不用单独做比较器更省事。3.4 MEM访存阶段实现访存阶段要处理lw和sw指令。数据存储器按字访问写使能来自控制信号always (posedge clk) begin if (mem_write_en) begin data_mem[mem_addr[13:2]] wb_data; end end assign mem_read_data data_mem[mem_addr[13:2]];注意这里我用的是同步写、异步读。如果你用异步写可能会出时序问题而且上板后容易莫名其妙出错。数据存储器的位宽是32位和寄存器堆保持一致不要搞成8位的。3.5 WB写回阶段实现写回阶段把结果写回寄存器堆。关键是写使能信号和写数据选择always (posedge clk or posedge rst) begin if (rst) begin for (i 0; i 32; i i 1) regfile[i] 32b0; end else if (reg_write_en (reg_write_addr ! 5b0)) begin regfile[reg_write_addr] reg_write_data; end end这里有个坑MIPS的0号寄存器必须恒为0。如果不判断reg_write_addr ! 5b0万一某条指令往$0里写值整个CPU就乱了。我在调试时遇到过斐波那契数列跑到一半变负数查了半天发现是addi $0, $0, 1把寄存器堆的0号位置改了。4. 流水线冒险的处理机制流水线CPU能不能正常工作冒险处理是关键中的关键。这里面的逻辑如果理不清CPU就跑不出正确结果。4.1 数据冒险转发 暂停数据冒险的本质是上一条指令的结果还没写回寄存器堆下一条指令就要读这个寄存器。教科书里的例子是add $t0, $t1, $t2 sub $t3, $t0, $t4sub在ID阶段读$t0时add还在EX阶段$t0的值根本没写回。解决办法是转发forwarding把EX/MEM或MEM/WB阶段的结果直接送给ID/EX阶段的操作数输入绕过寄存器堆。转发逻辑在EX阶段输入端做assign alu_src_a (forward_a 2b01) ? ex_mem_alu_result : (forward_a 2b10) ? mem_wb_result : id_ex_reg_a;forward_a由冒险检测单元产生判断条件是当前ID/EX阶段使用的rs或rt寄存器是否与EX/MEM或MEM/WB阶段的目的寄存器相同且对应阶段的写使能有效。但转发不是万能的。遇到“load-use”型冒险比如lw $t0, 0($t1) add $t2, $t0, $t3lw在MEM阶段才读到数据而add在EX阶段就需要$t0这就没法转发了必须暂停一个周期。我实现暂停的方法是在ID阶段检测到这种情况时拉高stall信号让PC不更新IF/ID寄存器内容保持不变同时向ID/EX寄存器插入一条空指令用nop填充控制信号。4.2 控制冒险提前判断 冲刷控制冒险来自分支跳转指令。流水线是顺序取指的遇到beq时下一条指令已经被取进流水线了。如果分支跳转这条指令就是错的要冲刷掉。我采用“分支在EX阶段判断 命中时冲刷IF/ID和ID/EX两级”的方案。实现起来就是assign branch_taken branch_en (alu_zero);当branch_taken为高时除了更新PC为目标地址还要让IF/ID寄存器和ID/EX寄存器的控制信号清零。清零最简单的方法是给这些寄存器加一个flush输入flush期间输出全部变成0译码出来就是nop。经验分支预测我没做复杂的动态预测器用的是“默认不跳转”策略。对课设演示程序来说这类分支指令占比不高性能损失可以接受。如果后续想扩展可以改成静态预测跳转甚至加一个简单的BTB分支目标缓冲器那就是另一个项目了。4.3 结构冒险从架构上直接避免结构冒险在MIPS五级流水线里最典型的表现是同一个时钟沿IF要取指令MEM要读写数据如果用同一个存储器就会冲突。我的解决方案和大多数教材一致——指令存储器和数据存储器完全分离用两个独立模块。FPGA上有足够多的BRAM资源做成两个RAM很方便。分离之后IF和MEM阶段就不存在结构冲突了。这里有个小提示如果用Vivado例化RAM IP核记得把端口位宽配成32位读端口和写端口独立开代码会好写很多。5. 测试程序编写与验证过程5.1 测试程序怎么写CPU验证最怕的就是“仿真通过上板跑飞”。我的经验是先写一个极简的测试程序确保最基本的数据通路没问题再逐步增加复杂度。我用的第一个测试程序是这样的# 计算 1 2 ... 10结果存入 $s0 addi $t0, $zero, 1 # $t0 1 addi $t1, $zero, 10 # $t1 10循环上限 addi $s0, $zero, 0 # $s0 0累加和 loop: add $s0, $s0, $t0 # sum i addi $t0, $t0, 1 # i blt $t0, $t1, loop # 如果 i 10 继续循环 end: j end # 死循环这个程序涵盖了addi、add、blt可以用slt bne组合、j等主要指令既能验证ALU运算又能测试分支跳转结果也容易人工核对55。我建议先在MARS上跑一遍记录每一步的寄存器值然后对照RTL仿真的波形逐条指令核对。5.2 Vivado仿真步骤在Vivado里仿真时testbench我习惯这样写initial begin clk 0; rst 1; #100 rst 0; end always #10 clk ~clk;复位时间给100ns等PC和流水线寄存器都稳定了再释放复位。仿真跑完看pc信号的变化对照MARS里的指令顺序如果PC跳转位置不对说明分支逻辑有问题如果PC正常但结果不对问题多半在数据冒险处理或写回控制信号。5.3 上板部署上板时最简单的方法是让CPU运行一个计算程序把结果通过LED灯显示出来或者通过串口发送到上位机。我用的是Nexys系列板子跑了一个累加程序计算完把结果写入特定的寄存器再映射到LED上。如果LED显示了预期值说明CPU能正常工作。考虑到上板时钟频率、复位信号等问题Vivado综合实现时会比较慢建议先在纯行为仿真里把所有测试用例跑完再上板。6. 常见问题速查与避坑心得6.1 常见问题速查表我在调试过程中踩过不少坑整理如下希望帮你省下几天时间现象可能原因排查思路PC跳变异常跑飞分支目标地址计算错误检查branch_target是否基于PC4计算结果始终是0寄存器堆写使能没生效检查reg_write_en链路lw后紧接运算结果错缺少load-use暂停检查hazard_unit是否插入一个气泡分支指令后的指令多执行一次未冲刷IF/ID寄存器确认flush信号是否清零控制位$readmemh加载失败路径不对或格式不符用绝对路径十六进制文件每行一条指令时序逻辑赋值用阻塞赋值仿真怪现象时序always块一律用非阻塞赋值6.2 避坑心得第一务必先实现单周期CPU再改流水线。这是很多老师的建议我当时没听直接怼五级流水线结果控制信号写错了一大片最后返工。单周期版本能保证你对数据通路的理解是准确的接下来再加流水线寄存器逻辑才清晰。第二转发单元和暂停单元一定要分开写。转发解决ALU运算结果的转发暂停解决load-use冒险两者优先级不同。我的做法是在hazard_unit里先判断是否暂停如果暂停则转发信号无效如果没暂停再判断转发条件。优先级写反的话会出现“暂停了但还在转发”的bug特别难查。第三仿真时要多用断点式观察。Vivado的波形窗口可以添加多个信号但我更习惯用testbench里的$display在每个时钟上升沿打印PC、指令、关键寄存器值。这样程序跑出来直接看打印日志一目了然比盯波形高效得多。第四代码风格要统一尤其是阻塞赋值和非阻塞赋值。我自己吃过亏在时序逻辑里用了阻塞赋值结果仿真和综合行为对不上白白排查了一整天。记住这个原则时序逻辑用组合逻辑用。7. 我从这个项目里收获的东西如果让我给正在做这个项目的同学一句建议我会说它是系统结构课程里最值得投入时间的一个实验。你会在里面看到MIPS指令集怎么变成电路看到流水线冲突怎么用逻辑动态化解看到一份Verilog代码如何变成FPGA上真正运行的程序。那些书本上抽象难懂的概念在这个项目落地之后都变得实实在在。而且这套基础是通用的。后来我去看RISC-V CPU设计、去理解cache的Verilog实现很多思路都是一脉相承的——数据通路如何组织、控制信号如何生成、流水线冲突如何化解。这个项目做完再接触更复杂的处理器设计你会发现自己已经有了很好的直觉。最后分享一个我建议的扩展方向在现有五级流水线基础上加入一项异常处理比如支持溢出异常。它逼你去思考流水线中断现场的保存与恢复整个CPU的鲁棒性会再上一个台阶。如果你还有时间也可以试试通过串口把计算结果送出来那样上板演示效果会好很多。愿你的CPU一次综合通过永远不会遇到那些让人挠头的冒险bug。本文还有配套的精品资源点击获取