
32位MIPS流水线CPU设计这个项目我前前后后折腾了三周从刚开始连流水线寄存器都画不明白到最终在FPGA板上跑通冒泡排序程序中间踩过的坑能写满一个笔记本。这篇文章把完整的实现思路、关键代码、调试方法都整理出来给正在做课设的同学以及想通过手写CPU来真正理解计算机组成的自学者一份可以照抄的作业。MIPS作为教学级指令集最大的优势就是指令格式规整、经典的五级流水线划分方式清晰是入门CPU设计的绝佳切入点。整个项目的目标很明确用硬件描述语言实现一个支持基础整数指令集的32位MIPS流水线CPU并解决流水线带来的结构冒险、数据冒险和控制冒险。我默认使用的工具链是Vivado Verilog仿真在Vivado Simulator里完成最后综合到入门级FPGA开发板上跑测试程序。如果你之前在Logisim里画过单周期MIPS的数据通路这次换成Verilog来写流水线版本思路其实一脉相承。1. 项目概述与整体设计思路1.1 为什么选择MIPS架构来做流水线CPU很多人会问现在外面流行的明明是x86和ARMRISC-V也起来了为什么课程设计还要选MIPS我的理解是MIPS在“教学友好”这件事上几乎没有对手。指令长度固定32位格式只有R型、I型、J型三种译码逻辑简单到让人感动寄存器模型规整所有运算都在通用寄存器之间完成访存只有lw和sw两条指令再加上Patterson和Hennessy的经典教材通篇用它举例参考资料多到看不过来。相比之下x86的变长指令光是译码就够写几百行状态机ARM虽然也有整洁的一面但条件执行、桶形移位这些特性会给流水线的冒险分析增加不少复杂度。MIPS把大部分复杂性剥离掉只留下流水线技术的核心矛盾这就是它的价值所在。做这个项目的时候我最大的感受是如果把MIPS流水线这套东西吃透了再去看RISC-V的五级流水线实现几乎是无缝迁移。1.2 指令集选型先把9条基本指令跑通指令集不是越多越好尤其是第一次做流水线CPU的时候。我自己第一次动手就贪多想把乘法、除法、无符号运算全塞进去结果控制信号表膨胀到完全没法验证最后推倒重来。第二次我收敛指令集只保留下面这9条发现验证工作量和调试难度都降了一个量级。指令类型功能说明add rd, rs, rtRrd rs rt有符号加法sub rd, rs, rtRrd rs - rt有符号减法and rd, rs, rtRrd rs rt按位与or rd, rs, rtRrd rs | rt按位或slt rd, rs, rtRrd (rs rt) ? 1 : 0有符号数比较addi rt, rs, immIrt rs sign_ext(imm)立即数加法lw rt, offset(rs)Irt MEM[rs sign_ext(offset)]取字sw rt, offset(rs)IMEM[rs sign_ext(offset)] rt存字beq rs, rt, offsetIif (rs rt) PC 4 sign_ext(offset)2相等跳转这9条指令覆盖了R型运算、I型访存、I型分支三类基本形态足以构造出所有经典冒险场景。J型跳转指令可以留到后续扩展阶段再加思路是在ID阶段把PC的高4位、26位立即数和00拼接成32位跳转地址再做一个PCSrc多路选择。但如果你时间紧9条指令已经能支撑完整的五级流水线验证。1.3 五级流水线的整体框架流水线的思想用工厂来类比最好懂组装一台手机如果每个工人都把一整台手机从零件装到成品效率极低改成流水线作业每个人只负责一道工序拧螺丝的只拧螺丝贴屏的只贴屏产能立刻上去了。CPU里的“工人”是组合逻辑电路“工序”是一条指令的五个阶段取指IF、译码ID、执行EX、访存MEM、写回WB。五级流水线的整体架构可以分成两部分看。第一部分是数据通路PC寄存器、指令存储器、寄存器堆、ALU、数据存储器以及连接这些部件的多路选择器和线网。第二部分是流水线寄存器IF/ID、ID/EX、EX/MEM、MEM/WB这四级它们把组合逻辑切成了五段每个时钟上升沿把上一阶段的中间结果锁存传给下一阶段。理解这条主线之后所有模块都围绕“数据怎么在五级之间流动”来设计。2. 数据通路设计从单周期到流水线的演进2.1 单周期CPU的瓶颈在哪里在写流水线版本之前我曾在Logisim里搭过单周期MIPS当时觉得挺简单但后来算了一笔账就明白了单周期方案的局限。单周期CPU每个指令用一个时钟周期完成时钟周期长度必须覆盖整条数据通路的最长延迟路径也就是lw指令先取指、读寄存器堆、ALU计算地址、读数据存储器、再写回寄存器堆这条路径上的所有组合逻辑延迟加起来决定了时钟能跑多快。举个例子如果单周期CPU的最长路径延迟是10ns那么时钟周期至少10ns执行100条指令需要100 * 10ns 1000ns。流水线把这10ns的路径切成五段每段延迟约2ns不考虑流水线寄存器的开销时钟周期理论上可以缩短到2ns级别。虽然执行单条指令的延迟没有变短甚至变长了但吞吐率大幅提升100条指令在理想情况下只需要102ns左右。这就是流水线的本质——用单条指令的延迟换整个程序的吞吐率。2.2 流水线寄存器流水线的心脏流水线寄存器是最容易被忽视但也是最关键的模块。没有它们五级阶段之间就会互相干扰IF阶段还在取第1条指令ID阶段已经把第2条指令的译码结果传给了EX信号在一条长组合逻辑路径上乱串整个CPU就废了。四级流水线寄存器的职责就是“打拍”把每一阶段的输出锁存住让各阶段独立工作。以IF/ID寄存器为例它的输入是PC4和从指令存储器读出的32位指令输出送到ID阶段。ID/EX寄存器要锁存的东西更多PC4、寄存器堆读出的两个操作数、符号扩展后的立即数、Rs/Rt/Rd三个寄存器编号、以及这一条指令要用到的所有控制信号。写Verilog的时候流水线寄存器本质上就是一组带时钟的寄存器控制信号和操作数一起打拍万万不能漏掉某一个字段。我最初实现时就漏了把Rs地址传下去结果前递逻辑全部失灵这个问题相当隐蔽后面的章节会详细说。2.3 控制信号的生成与逐级传递控制信号的生成集中在ID阶段由指令的opcode和funct字段译码得到。但这9条指令的控制信号值所有模块并不是在同一时刻使用ALUSrc在EX阶段用MemRead和MemWrite在MEM阶段用RegWrite和MemToReg在WB阶段用。所以控制信号不能只生成一次就完事必须跟着流水线寄存器逐级往后传。指令RegDstALUSrcMemReadMemWriteMemToRegRegWriteBranchALUOpadd/sub/and/or/slt100001010addi010001000lw011011000swx101x0000beqx000x0101这里ALUOp我采用两位编码00表示加法操作01表示减法操作10表示由funct字段进一步决定。ld和sw的地址计算都是基址加立即数本质是加法beq比较两个寄存器是否相等本质是减法后判断结果是否为0。这样设计可以把ALUOp的译码逻辑压缩得很精简。控制信号逐级传递的细节可以用一个很常见的Bug来说明。如果RegWrite信号没有经过MEM/WB寄存器打到WB阶段而是直接从ID阶段拉到寄存器堆的写使能端那么所有R型指令都会提前半拍写入寄存器堆造成写端口冲突。我在第一次上板调试时连续遇到寄存器值错乱查了两天波形才发现是控制信号没打拍教训极其深刻。3. 冒险处理流水线设计的灵魂3.1 结构冒险与存储器分离流水线里最直观的问题叫作结构冒险意思是多个阶段同时想访问同一个硬件资源但资源只有一个。典型场景是IF阶段取指需要访问存储器MEM阶段访存也需要访问存储器。如果指令存储器与数据存储器共用一块两条指令就会在同一个时钟周期里打架。解决办法很直接把指令存储器和数据存储器分成两块形成哈佛结构。在FPGA里这就是两块独立的块RAM在仿真里就是两个独立的reg数组互不干扰。另一种做法是使用双端口RAM一个读端口给IF、另一个读写端口给MEM也能解决冲突。但是双端口RAM占用资源更高教学实验环境下完全没必要。如果你在Logisim里画过单周期CPU应该记得存储器是合一的因为每个周期只有一条指令在工作到了流水线就必须分家这是结构冒险最简单的解法。3.2 数据冒险与前递机制数据冒险是流水线CPU最核心的问题不解决它整个CPU都是错的。看这个序列add $t0, $t1, $t2 sub $t3, $t0, $t4add指令在WB阶段才把结果写回寄存器堆而sub指令在EX阶段就要读$t0。按正常的流水节奏add在第5个周期末尾写回sub在第4个周期就已经进入EX并使用$t0的新值显然不对。最笨的办法是插入气泡等add写回后再执行sub但这样每条相关指令都要白等好几个周期性能损失不可接受。前递forwarding的思路非常巧妙add的结果在EX阶段结束时就出来了存在EX/MEM寄存器里根本不需要等它写回寄存器堆直接从这个寄存器里抄一条近路送到ALU的输入口就行。实现前递需要一个专门的Forwarding单元它比较流水线寄存器里的目标寄存器号与当前指令的源寄存器号// EX hazard: 上一条指令的结果前递到当前ALU输入 assign forwardA (exmem_regwrite exmem_rd ! 0 exmem_rd idex_rs) ? 2b10 : (memwb_regwrite memwb_rd ! 0 memwb_rd idex_rs) ? 2b01 : 2b00; assign forwardB (exmem_regwrite exmem_rd ! 0 exmem_rd idex_rt) ? 2b10 : (memwb_regwrite memwb_rd ! 0 memwb_rd idex_rt) ? 2b01 : 2b00;forwardA和forwardB各为两位00表示使用寄存器堆读出的原始数据01表示使用MEM/WB阶段的结果10表示使用EX/MEM阶段的结果。这个优先级很重要如果某条指令同时与上一条、上上条存在依赖必须优先选择最近的那条否则会前递到旧值。3.3 Load-Use冒险与气泡插入前递不是万能的有一种情况它处理不了lw指令从存储器读出数据要等到MEM阶段结束而紧跟其后的指令在EX阶段就要用这个数据。数据还没出来再怎么前递也递不过来只能让流水线暂停一拍。lw $t0, 0($t1) and $t2, $t0, $t3and指令在ID阶段发现自己的源寄存器$t0与ID/EX寄存器里的rt地址相同而ID/EX里的MemRead信号有效就知道发生了Load-Use冲突。这时需要让ID和IF阶段各自保持不动PC寄存器不更新IF/ID寄存器不更新同时把ID/EX寄存器的控制信号全部置零相当于在EX阶段插入一个空操作气泡。等lw的数据从MEM/WB寄存器里出来再通过前递送到and的ALU输入端。Load-Use检测的逻辑比前递还简单本质就是一个条件判断assign stall idex_memread (idex_rt ifid_rs || idex_rt ifid_rt);我调试的时候在这个环节栽过跟头stall信号生成了但只冻结了PC忘记冻结IF/ID寄存器结果取指阶段停住了译码阶段的指令却继续往前走气泡根本没插进去。所以冻结PC和冻结IF/ID寄存器必须同时做写代码的时候要注意。3.4 控制冒险分支指令的处理控制冒险源于分支指令CPU不知道下一条该执行哪条指令只能先猜。我的实现里用的是“预测不跳转”策略默认所有分支都不跳顺序取指。分支判断在EX阶段进行用一个ALU做减法判断操作数是否相等如果Branch信号有效且相等说明预测错了这时必须把已经进入流水线的错误指令全部作废。分支在EX阶段才出结果意味着beq之后的连续两条指令已经被取进流水线一条在ID阶段、一条在ID/EX里。所以分支命中时需要复位IF/ID和ID/EX两级寄存器把控制信号全部清零并让PC从分支目标地址重新取指。相当于一次分支命中浪费两个周期。如果能把分支判断提前到ID阶段可以只浪费一个周期代价是ID阶段需要一个额外的比较器和分支目标加法器这个优化留给你自己权衡。还有一个trick值得了解异常处理、中断等场景也会导致流水线冲刷处理方式和分支flush类似。所以设计时最好把所有Flush信号汇总同一个复位端口控制流水线寄存器后续需要扩展中断功能时不用大改。4. 核心模块实现与关键细节4.1 取指阶段PC与指令存储器PC模块的逻辑比较直白但有几个选择来源顺序执行时PC加4分支命中时PC取分支目标地址跳转指令时PC取跳转目标地址。现代CPU用分支预测器动态选择教学CPU用多路选择器就够了。always (posedge clk or posedge rst) begin if (rst) pc 32h0; else if (stall) pc pc; else pc pc_next; end assign pc_next branch_taken ? branch_target : pc 4;注意stall的优先级最高因为Load-Use冒险时PC不仅要停还要保证如果当前周期正好有分支冲突必须先暂停而不是跳走。指令存储器可以用块RAM或者组合逻辑ROM实现仿真阶段用$readmemh初始化最方便reg [31:0] imem [0:4095]; initial $readmemh(inst.hex, imem); assign instr imem[pc[15:2]];为什么用pc[15:2]而不是直接用pc因为MIPS按字节寻址一条指令占4字节pc的低两位永远是0。如果忽略这一点指令地址会错位一上电就乱掉。换成人话说就是地址要除以4才能得到指令数组的下标。4.2 译码阶段寄存器堆与立即数处理寄存器堆是CPU里最常用的部件这里需要两个读端口一个写端口因为流水线里最多同时有两条指令需要读寄存器。读端口是组合逻辑写端口是时钟边沿触发always (posedge clk) begin if (regwrite wa ! 5b0) regfile[wa] wd; end assign rs_data regfile[rs_addr]; assign rt_data regfile[rt_addr];这里有个非常关键的细节寄存器0必须永远是0。MIPS的约定是$zero寄存器恒为0硬件上不能让它被写入。所以在写使能条件里加上了wa ! 5b0的判断这是新手最容易忽略的坑。我在仿真里跑过一次addi $0, $0, 5结果整个寄存器堆的值全乱了查了半天才反应过来。立即数处理同样重要。I型指令的立即数是16位有符号数addi、lw、sw都需要先进行符号扩展到32位再参与运算。这里的第15位是符号位如果扩展逻辑写错负数立即数就会被当成大正数加法结果完全错误。如果你在做32位有符号整数相关的运算这个问题直接决定结果对不对。assign imm_ext {{16{instr[15]}}, instr[15:0]};这个写法就是把instr[15]的符号位复制16份填充到高16位。我见过有同学偷懒直接在高位补0导致addi的负数运算一塌糊涂属于非常经典的低级错误。4.3 执行阶段ALU操作数与运算逻辑ALU是执行阶段的核心但流水线版本和单周期版本的最大区别在于ALU的输入不再是直接从寄存器堆读出而是要考虑前递逻辑。具体来说ALU的两个操作数分别来自ALU输入多路选择器由Forwarding单元控制选择寄存器堆原始值、EX/MEM阶段的结果还是MEM/WB阶段的结果同时ALUSrc信号还要决定第二个操作数是用rt寄存器的值还是立即数。ALU控制信号的译码逻辑我采用ALUOpfunct两级译码方式。ALUOp来自控制信号用于区分大类别R型指令的funct字段再细分出具体运算always (*) begin case (aluop) 2b00: aluctrl 3b010; // add 2b01: aluctrl 3b110; // sub 2b10: case (func) 6b100000: aluctrl 3b010; // add 6b100010: aluctrl 3b110; // sub 6b100100: aluctrl 3b000; // and 6b100101: aluctrl 3b001; // or 6b101010: aluctrl 3b111; // slt endcase endcase endALU内部实现比较常规slt指令稍微特殊一点需要比较两个32位有符号数。我一开始直接拿减法结果的符号位判断大小后来才发现如果发生溢出符号位会反结果不对。严谨的做法是考虑溢出标志无溢出时看减法结果的符号位有溢出时看两个操作数的符号关系。不过现在Verilog里可以直接调用$signed()比较综合工具会映射到合适的比较器电路。4.4 访存与写回数据存储器与回写逻辑数据存储器的设计和指令存储器类似但多了写使能。访存阶段MemeRead和MemWrite控制信号的优先级必须分清lw指令要读数据存储器sw指令要写数据存储器其他指令两者都不操作。如果忘记把MemWrite信号打拍到EX/MEM寄存器而只传给数据存储器那么所有指令在MEM阶段都会尝试写一个随机地址导致数据被覆盖。always (posedge clk) begin if (memwrite) dmem[alu_result[15:2]] rt_data_mem; end assign mem_read_data dmem[alu_result[15:2]];写回阶段是流水线的最后一步数据来源有两个ALU的计算结果和存储器读出的数据。MemToReg信号控制这个选择。另外写回的目标寄存器也要选择R型指令目标是rd第15到11位I型指令目标是rt第20到16位RegDst信号控制这个选择。这两个选择信号虽然不起眼但任何一个选错写回的数据和写回的寄存器编号就会错位。5. 验证与调试如何证明你的CPU是对的5.1 写一套能自检的测试程序很多同学写完CPU的第一件事是跑一个hello world但hello world根本验证不了流水线的正确性它只验证了最粗浅的功能路径。我自己的经验是设计一套分层的测试程序每一层针对一个特定问题。这才是验证流水线CPU的正道。第一层测试R型指令的基本运算。比如addi $t0, $zero, 10、addi $t1, $zero, 5、add $t2, $t0, $t1检查$t2是否等于15。第二层测试数据冒险刻意让上一条指令的结果紧接着被下一条指令使用比如add之后立刻sub用来验证前递逻辑。第三层测试Load-Use冒险lw之后立刻用lw的结果做运算。第四层测试分支构造一组相等和不相等的比较验证beq跳转和不跳转的行为。第五层把前面所有场景串成一段程序连续执行几百条指令不跑飞。# 测试前递 addi $t0, $zero, 10 addi $t1, $zero, 5 add $t2, $t0, $t1 # t2 15 sub $t3, $t2, $t0 # t3 5这里用到了上一条add的结果 # 测试load-use lw $t4, 0($t2) # 需要预先在MEM[15]写入一个值 add $t5, $t4, $t1 # t5 mem[15] 5load-use冒险 # 测试分支 addi $t6, $zero, 3 addi $t7, $zero, 3 beq $t6, $t7, label # 应该跳转 addi $t6, $zero, 99 # 这行不该执行 label: add $t8, $t6, $t7 # t8 6这里有一个我自己常用的技巧把不该执行的指令后面接一个会破坏结果的指令如果分支预测错寄存器值就会异常这样从最终结果一眼就能看出控制冒险是否处理正确。5.2 用波形逐级追踪指令执行仿真波形是调试CPU的显微镜。如果CPU跑出的结果不对第一件事不是改代码而是打开波形图找到第一条出错的指令然后从IF到WB逐级追踪这条指令的完整生命周期。我最常用的定位方法找到PC值确认IF阶段取到了正确的指令然后看IF/ID寄存器的输出确认PC4和指令都正确锁存继续追踪ID/EX寄存器重点检查控制信号是否和指令匹配特别是RegWrite、MemToReg这些回写关键信号再到EX/MEM和MEM/WB确认数据有没有在前递路径上被错误覆盖。实际调试中最痛苦的场景是波形里全是红色的X这意味着信号没有被初始化或者出现了时序问题。遇到这种情况我建议先把测试程序简化成一条指令比如只有addi $t0, $zero, 1然后看复位结束后第一个时钟上升沿每个流水线寄存器里到底是什么值。只要一条指令的错误全部排除了后面的问题基本都是冒险处理逻辑不会涉及全局时序。5.3 上板验证与性能指标如果条件允许把CPU综合到FPGA板上跑一遍是很有成就感的。我在上板阶段一般用拨码开关输入立即数或地址用数码管显示寄存器堆的值再配合按键手动产生单步时钟这样每按一次按键就能看到一条指令执行完的效果比看波形直观得多。第一次在板上看到数码管正确显示冒泡排序结果的时候那种感觉和仿真跑通完全不一样。性能指标方面流水线CPU理想的CPI是1也就是每个时钟周期完成一条指令。但由于Load-Use冒险至少要插入一个气泡分支预测失败要冲刷两条指令实际CPI会大于1。我的测试程序一共86条指令运行过程中发生2次Load-Use冒险和3次分支冲刷总周期数是86 2 3*2 94CPI约等于1.09。这个数据说明冒险处理产生了约9%的性能损失属于正常范围。如果你的测试程序CPI突然变成1.5以上那大概率是冒险检测条件写得过于保守产生了不必要的暂停。6. 经验教训与常见问题速查6.1 最容易翻车的5个细节第一个细节是控制信号没打拍。这是所有问题的重灾区。比如RegWrite信号如果没有经过MEM/WB寄存器传递而是直接从ID阶段拉到寄存器堆的写使能那它就会在指令还在EX阶段的时候提前写寄存器造成数据错乱。我在第一次上板调试时连续遇到寄存器值错乱查了两天波形才发现是控制信号没打拍。第二个细节是寄存器0的写保护。MIPS规定$zero寄存器恒为0如果代码里出现addi $0, $0, 1这种指令或者某些指令的目标寄存器恰好是0你不加wa ! 5b0的判断寄存器堆就会被写脏。第三个细节是Load-Use冒险的处理不完整。stall条件检测到了但忘记同时冻结PC和IF/ID寄存器或者只冻结了其中一个。结果是取指停了但译码还在动气泡根本没插进去后面所有指令的时序全乱。第四个细节是前递优先级搞错。当前指令与EX/MEM阶段指令和MEM/WB阶段指令同时存在依赖时必须优先选择EX/MEM里更新的值。如果你把优先级写成先选MEM/WB得到的就是一个旧值这种Bug不仔细看还真找不出来。第五个细节是复位和初始化。指令存储器和数据存储器在上电时必须被正确初始化否则波形全X。用$readmemh装载hex文件时必须确认文件路径正确、格式正确否则仿真器会静默失败最容易引起“明明写了程序为什么跑不动”的困惑。6.2 常见问题排查表问题现象可能原因排查方向波形全X存储器未初始化或复位信号有问题检查$readmemh路径和hex格式检查复位信号是否正常释放第一条指令正确第二条开始乱控制信号没有随流水线寄存器逐级打拍从ID/EX寄存器开始逐级检查RegWrite、MemToReg等信号lw后紧跟使用结果错误Load-Use冒险没有stall或没有前递检查stall信号是否同时冻结PC和IF/ID检查MEM/WB到EX的前递路径beq跳转指令执行结果颠倒分支比较逻辑或flush时机不对确认ALU在beq时执行减法并判断zero标志确认flush作用到IF/ID和ID/EX两级综合后时钟频率明显偏低关键路径过长通常是ALU级联太长检查是否有大位宽乘法器或连续多级组合逻辑串在一起寄存器堆里的值被莫名其妙改写寄存器0没有被保护或RegWrite信号时序提前检查写使能是否加了wa ! 0检查RegWrite是否打到WB阶段这张表是我在调试过程中总结出来的基本覆盖了新手遇到的高频问题。如果你遇到的问题不在表里最稳妥的办法还是回到波形盯着出错的那条指令从IF一级一级往后看找到信号第一次发生异常的位置Bug基本就在那附近。6.3 从这个项目往后能走多远做完这个项目之后我对“CPU是怎么跑起来的”这个问题终于有了具象的答案。以前看计算机组成原理教材总觉得流水线寄存器和冒险单元是纸上谈兵直到自己亲手把它们一个模块一个模块搭起来才发现这些概念背后都是非常朴素的工程问题数据到了哪里信号有没有跟上组合逻辑路径是不是太长。从这个项目往后你可以把J型指令加上把bne、lui、jr这些常用指令补齐进一步扩展指令集可以尝试把分支判断提前到ID阶段减少分支损失可以加一个简单的Cache模型体验一下存储层次对性能的影响也可以把这套五级流水线的结构迁移到RISC-V指令集上就又是一次完整的实战项目。MIPS在真实商业处理器领域确实不如当年辉煌了但作为训练CPU设计思维的载体它依然无可替代。我个人在实际操作中的体会是这个项目八成以上的时间都花在冒险处理与调试上。如果你也正准备做这个项目别急着把功能堆满先把一套最小指令集跑通再把每类冒险的波形画出来看懂然后逐步扩展。把这个流程走完你对流水线CPU的理解会比看十遍教材都深刻。