ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

从单周期到五级流水线:32位MIPS CPU设计实战

2026/10/7 21:58:04 拓冰建站 浏览量
从单周期到五级流水线:32位MIPS CPU设计实战 做32位MIPS单周期CPU的时候我一度觉得计算机体系结构也就那么回事取指、译码、执行、访存、写回一条指令一口气走完控制信号查表就能写出来。但老师看完我的单周期设计问了一个问题你这个CPU的主频为什么上不去我才开始认真算那笔账——单周期的时钟周期被迫等于所有阶段耗时的总和每一条指令都要等最慢的那个部件其他部件全在空转。于是第二版我决定上流水线。这篇文章把从单周期到32位MIPS五级流水线的完整改造过程记录下来包括数据通路怎么拆、三类冒险怎么填、Verilog模块怎么写、仿真和FPGA上板踩了什么坑。核心方案是经典的IF、ID、EX、MEM、WB五级流水线采用转发加停顿解决数据冒险分支判断放在EX级并用冲刷处理控制冒险。内容面向正在做课程设计、或者第一次尝试写五级流水线的同学也适合想在手写Verilog和FPGA实测之间建立完整闭环的人。1. 为什么从单周期升到流水线性能账先算清楚1.1 单周期CPU到底慢在哪单周期CPU的设计思路非常直观每个时钟周期完成一条完整指令从取指到写回全部在这一个周期内做完。问题也出在这里时钟周期必须覆盖最坏情况。假设取指组合逻辑要2ns、寄存器文件读取1ns、ALU运算2ns、数据存储器访问3ns、寄存器写回1ns加起来9ns。哪怕是一条什么都不干的add指令也得等这9ns跑完因为硬件不知道当前这条指令用不到哪个部件。这9ns里各部分部件的利用率其实很低。ALU只用了2ns数据存储器只用了3ns剩下的时间全在空转等时钟沿。单周期CPU的主频被最慢路径死死卡住这是它的结构决定的不是换一家FPGA或者换个综合选项就能绕过去的。当我第一次把单周期代码放到Vivado里做时序分析发现关键路径恰恰是PC - 指令存储器 - 寄存器文件读 - ALU - 数据存储器 - 写回这条链路。最慢的一段是数据存储器的组合读延迟整颗CPU的主频就这样被一个部件拖垮了。1.2 五级流水线的理想加速比与真实差距流水线的思路是把一条指令切成五段每段由一个独立部件负责五条指令同时在不同阶段流动。理想情况下时钟周期只需要等于最长那个阶段的延迟而不是五个阶段延时的总和。还是用上面那组数字取指2ns、译码1ns、执行2ns、访存3ns、写回1ns五级流水线的时钟周期可以压到3ns理想加速比是9除以3等于3倍。注意不是5倍因为五级延迟并不均衡这是流水线设计最容易被忽略的一点级间平衡决定了上限。但理想加速比永远达不到。流水线会带来额外开销流水线寄存器本身有建立保持时间和传输延迟各级之间不可能完全无成本。更致命的是冒险指令之间存在数据依赖、控制依赖还有资源冲突每处理一次冒险就要插入停顿或者冲刷这些气泡周期直接吃掉性能。从课程设计的角度看五级流水线的实际收益比单周期高出多少取决于测试程序里冒险出现的频率。我后来用一段包含算术运算、访存、分支的混合测试程序实测大约跑到单周期的2.2倍速度。看起来比理想的3倍低但在教学场景里已经能充分说明问题。更重要的是写五级流水线的过程逼着我把数据通路的每一条信号、每一个控制位的生命周期彻底搞清楚这种收获不是几倍性能能衡量的。2. 数据通路拆解各级在干什么、边界存什么2.1 IF/ID/EX/MEM/WB五级职责划分五级流水线的划分不是随便切五刀而是按照MIPS指令的自然处理顺序切的。IF级负责根据PC从指令存储器取出32位指令同时计算PC4作为顺序下一条地址ID级负责译码从指令字段中提取操作码、寄存器编号、立即数读出寄存器文件并生成这条指令所需的全部控制信号EX级执行真正的运算R型指令在这里用ALU完成算术逻辑操作I型指令在这里完成立即数运算访存指令在这里计算有效地址分支指令在这里比较两个操作数决定是否跳转。MEM级只做一件事访问数据存储器。lw在这里读出数据sw在这里写入数据。WB级把ALU计算结果或者存储器读出的数据写回寄存器文件。这里有一个值得强调的设计选择分支判断放在EX级而不是ID级。虽然分支判断放ID级能少损失一拍但它要求ID阶段就准备好两个正确无比的比较源操作数一旦前序指令还没写回就得在ID级再做一套前递逻辑。放在EX级可以复用现有转发通路拿ALU的zero输出直接做分支判断逻辑简单很多。课程设计首要目标是正确所以我选了后者。2.2 流水线寄存器里到底放哪些信号流水线寄存器是各级之间的边界也是冒险控制的发力点。IF/ID寄存器保存取出的指令字和PC4。因为指令字段在ID级要被拆成rs、rt、rd、imm、funct等各个部分所以IF/ID寄存器实际上保存的是完整的32位指令字拆字段的工作在ID级组合逻辑里完成。ID/EX寄存器需要保存的东西最多控制信号RegDst、ALUSrc、MemRead、MemWrite、MemtoReg、RegWrite、Branch、ALUOp、从寄存器文件读出的两个32位数据、符号扩展后的32位立即数、rt和rd的编号。EX/MEM寄存器保存ALU运算结果、准备写入存储器的数据、目标寄存器编号以及访存和写回阶段需要的控制信号。MEM/WB寄存器保存访存读出的数据、ALU结果、目标寄存器编号还有写回使能。这些边界寄存器的宽度加起来不小但背后逻辑很整齐每一级只把下一级需要的东西往后传用不到的信号就地丢弃。比如lw指令在ID/EX阶段还要把RegDst设置为0表示写回的目标寄存器应该是rt而不是rd这个控制位要一直传到WB级去控制多路选择器。2.3 控制信号怎么跟着指令走完全程控制信号在ID级生成后要跟着指令一路穿过后续各级直到使命完成。问题在于每个控制信号的有效窗口不一样。RegWrite需要贯穿到WB级才会真正使用MemWrite到MEM级就结束了ALUOp在EX级用完后就可以丢掉。更准确地说有效的办法是每经过一级流水线寄存器就只把当前级和后续级还会用到的控制信号打包传递已经用过的控制位不再继续往下传。这个思路在代码里体现为控制信号的分段使能。我习惯把控制信号定义成一组单独的信号在ID级计算好后整体塞进ID/EX寄存器EX/MEM寄存器只接收其中的MemWrite、MemRead、MemtoReg、RegWrite、BranchMEM/WB寄存器只接收MemtoReg和RegWrite。如果图省事把全部控制信号无脑传递到底功能上一般也能跑但综合出来会多出不少无意义的触发器而且仿真波形里挤满用不到的信号排查问题非常费眼。这里还要注意一个细节控制信号清零动作。当流水线插入气泡时插入位置的控制信号必须全部置零确保这条气泡指令不会误写寄存器、误写存储器、误跳转。在RTL实现里这个动作不是简单地把整组信号赋值成0而是要跟流水线寄存器的使能信号配合避免在停顿周期把错误控制信号推进下一级。3. 三类冒险的实战处理转发、停顿、冲刷3.1 数据冒险转发优先级与load-use停顿数据冒险的核心场景是后面一条指令要用到的寄存器值恰好被前面一条还没写回的指令修改。比如add r3, r1, r2后面紧跟着sub r5, r3, r4当sub在EX级需要r3时add的结果才刚算出来还在EX/MEM寄存器或者MEM/WB寄存器里躺着没有写回寄存器文件。解决思路是转发也叫前递。既然数据已经在流水线里了就没必要等它写回寄存器文件再读出来直接从流水线寄存器里把结果送给ALU输入。转发单元需要比较当前EX级指令的源寄存器编号与前面指令的目标寄存器编号。实现时有两条转发路径一条来自EX/MEM寄存器即前一条指令刚算出的结果另一条来自MEM/WB寄存器即前两条指令的结果。转发优先级的判断有讲究。假设EX/MEM和MEM/WB同时都想写同一个寄存器EX/MEM里的指令更新因为它更靠前转发源应该选它。两条路径的条件判断里EX/MEM的优先级必须排在MEM/WB前面。另外无论如何都要排除零寄存器条件里必须加上rd ! 5b0否则即使写的是r0转发逻辑也会介入等于把组合逻辑和寄存器文件的读口瞎折腾了一遍。load-use停顿是最特殊的场景。当EX级是lw、ID级紧接着的指令想用lw的目标寄存器作为源时转发也救不了因为lw的数据要等MEM级访存完成才能真正拿到。处理办法是让流水线停一拍冻结PC和IF/ID寄存器同时把ID/EX寄存器的控制信号清零插入一个气泡。检测条件就是ID/EX.MemRead为1且ID/EX.Rt匹配IF/ID.Rs或IF/ID.Rt。这里有个工程上的取舍如果IF/ID这条指令并不真的使用rt字段比较或者读取严格来说这个停顿是多余的但为了简化控制逻辑很多设计选择无条件停顿。功能正确只损失一点性能。3.2 控制冒险分支代价从2拍到1拍的选择控制冒险来自分支和跳转指令。CPU在IF级取指时默认按PC4顺序取直到EX级算出分支结果才知道要不要跳。如果跳转发生后面已经取出来的两条指令全部作废必须在EX级的这个周期同时冲刷IF/ID寄存器和ID/EX寄存器把这两条废指令变成气泡代价是损失两个周期。这是分支判断放在EX级的标准代价。如果把分支判断挪到ID级损失周期能从2拍降到1拍因为分支在ID级就能决定要不要跳此时只有IF/ID寄存器里那一条顺序后继指令需要冲刷。听起来很诱人但实现时要额外加一个32位比较器还得处理ID源操作数的数据依赖。前序lw还没访存结束分支却要在ID级比较这种场景单靠原有的EX级转发和load-use检测兜不住需要在ID级再做一级前递控制复杂度涨得很快。课程设计里我最终选了分支判断在EX级接受2拍损失。理由很现实这个方案与现有的数据冒险转发逻辑天然衔接不用单独给ID级写一套比较器的前递判断代码更短调试更快。如果后续想优化性能把分支前移到ID级是一个明确的改进方向但先把正确性做扎实更重要。跳转指令j的处理就简单多了指令里直接带26位目标地址在ID级就可以拼出跳转目标同样需要冲刷IF/ID寄存器里的顺序后继指令。因为跳转不依赖任何运算结果不需要额外的数据前递实现成本可以忽略。3.3 结构冒险哈佛结构带来的隐性问题MIPS五级流水线天然采用分离的指令存储器和数据存储器也就是哈佛结构IF级取指和MEM级访存互不争抢从根源上解决了经典的访存结构冒险。这是MIPS这个教学ISA设计得很聪明的地方。如果在单片RAM上既放指令又放数据取指和访存同一拍撞车就得插入停顿性能立刻难看下去。但在实现层面结构冒险并没有完全消失它会换一副面孔出现。FPGA上的RAM分为Block RAM和分布式RAMBlock RAM是同步读地址在时钟上升沿采样后数据要到下一次沿之前才稳定输出。这意味着如果用BRAM实现指令存储器IF级取指就会多出一拍延迟打乱整个流水线的节奏。我的经验是在教学板上做验证时指令存储器用分布式RAM当ROM用会省很多事或者把BRAM配置成异步读模式数据存储器同样要注意读写共口时的冲突规则。寄存器文件也有类似的结构风险。WB级要写入寄存器ID级的后续指令又要读寄存器如果恰好读写同一个寄存器编号必须定义清楚先读旧值还是先写后读。我实现的寄存器文件采用写优先即写使能有效时读端口直接返回正在写入的新值。这个设计在和转发逻辑配合时更稳能少掉一批奇怪的仿真毛刺。4. 核心模块的Verilog实现代码与血泪4.1 寄存器文件与ALU写使能、零寄存器与ALUOp寄存器文件是32个32位寄存器两个读端口、一个写端口。读是组合逻辑assign rd1 (addr1 5b0) ? 32b0 : regs[addr1]这种写法可以把零寄存器固定成0。写是时序逻辑时钟上升沿且写使能有效时写入。这里特别要注意复位之后寄存器文件不一定需要全清零但程序计数器PC必须有一个确定的初始值否则仿真第一步取指就是X态后面全崩。ALU本身并不复杂但ALUOp到ALUControl的译码表必须和指令格式严格对应。我的实现用的是经典表格lw、sw、addi这类指令的ALUOp设为00ALUControl强制为加法beq的ALUOp设为01ALUControl送减法R型指令的ALUOp设为10再根据funct字段在EX级译码出具体的加、减、与、或、比较指令。wire [3:0] alu_control; reg [1:0] aluop_tmp; always (*) begin case (alu_op) 2b00: alu_control 4b0010; // add 2b01: alu_control 4b0110; // sub 2b10: case (funct) 6b100000: alu_control 4b0010; // add 6b100010: alu_control 4b0110; // sub 6b100100: alu_control 4b0000; // and 6b100101: alu_control 4b0001; // or 6b101010: alu_control 4b0111; // slt default: alu_control 4bxxxx; endcase default: alu_control 4bxxxx; endcase end有个容易翻车的地方ALU的加法和减法标志位。MIPS课程设计里一般不需要实现状态寄存器但减法指令的正确性依赖ALU内部完整地做减法zero输出在分支判断时要用。具体到代码要避免只写assign result a b这种用法减法必须真正走减法逻辑不然仿真里看起来没什么上板跑分支程序一定会跳错。4.2 转发单元与冒险检测千万别写反顺序转发单元是数据冒险处理的枢纽。它有五个输入EX/MEM和MEM/WB两个阶段的目标寄存器编号与写使能、ID/EX阶段的源寄存器编号。输出两个两比特选择信号分别控制ALU的两个输入端。// ForwardA 控制 ALU 第一个输入 always (*) begin if (ex_mem_regwrite (ex_mem_rd ! 5b0) (ex_mem_rd id_ex_rs)) forward_a 2b01; // 来自 EX/MEM.ALUResult else if (mem_wb_regwrite (mem_wb_rd ! 5b0) (mem_wb_rd id_ex_rs)) forward_a 2b10; // 来自 MEM/WB.WBData else forward_a 2b00; // 来自寄存器文件 endForwardB逻辑完全一样只是把id_ex_rs换成id_ex_rt。很多人第一次写转发单元时会把两个分支的顺序写反先判断MEM/WB后判断EX/MEM。这在大多数测试程序里能跑但遇到连续两条都把结果写进同一个寄存器的指令时选到的就是旧数据仿真偶尔出现神秘错误定位困难。正确顺序是EX/MEM判定在前。冒险检测单元比转发单元简单核心就是load-use检测。一个常见误区是检测条件没有限制ID/EX里那条指令真的访存把MemRead当作对所有指令都为1来查。正确条件是只有MemRead为高才需要停顿。还有前面提到的检测源寄存器编号匹配时最好判断是否为零寄存器否则一条addi r0, r0, 1会把流水线白白停一拍。4.3 流水线寄存器的使能、清零组合每个流水线寄存器都需要支持两种特殊动作正常传递、清零。IF/ID寄存器在load-use停顿时要冻结在分支跳转时要清零ID/EX寄存器在load-use停顿时要清控制信号在分支跳转时也要全体清零。工程上我习惯把控制信号和数据信号分开写这样清控制信号很方便不会误伤数据。// ID/EX 控制信号的更新 always (posedge clk or negedge rst_n) begin if (!rst_n) begin id_ex_regwrite 1b0; id_ex_memwrite 1b0; id_ex_memread 1b0; end else if (flush_ex) begin id_ex_regwrite 1b0; id_ex_memwrite 1b0; id_ex_memread 1b0; end else begin id_ex_regwrite id_regwrite; id_ex_memwrite id_memwrite; id_ex_memread id_memread; end endflush_ex信号在分支跳转和load-use停顿两种情况下都要拉高。load-use停顿要走另外一个分支那拍IF/ID不更新、PC不更新但ID/EX的控制信号要清掉相当于在EX级插了一个气泡。两个场景同时发生时要让暂停逻辑优先于清零逻辑否则会出现本该停顿的周期被分支冲刷抢走了信号。还有一个小坑PC寄存器的清零与使能。PC在复位后要加载初始地址在load-use停顿周期保持不变其他周期根据PCSrc选择PC4、分支目标还是跳转目标。写next_pc的多路选择逻辑时一定要把跳转目标的拼接算对。j指令的26位立即数左移两位后与PC4的高四位拼成32位地址低两位固定为0。这个拼接我写错过一次当时仿真跑跳转循环死活跳不进指定标签查了半天才发现是低两位没有对齐。5. 验证与上板仿真波形、FPGA实测、常见误判5.1 最小指令集覆盖程序怎么写验证流水线设计正确性最有效的是一段人工安排的汇编测试程序而不是随机指令流。测试程序的每一部分必须能精准打击某个冒险场景。我的建议是至少覆盖五类纯R型连续运算检查基础数据通路R型指令结果紧接着被下一条指令用作源检查EX/MEM转发lw后紧跟使用该结果的指令检查load-use停顿beq和j跳转检查冲刷周期和PC跳转目标最后一段循环加数组求和把分支、访存、算术混合起来检查整体协同。测试程序不要一上来就写几十条指令。先让五级流水线里每条指令都互不依赖验证基础流动再逐渐加入依赖关系让冒险逐类曝光。每加一类冒险就对比一遍寄存器文件的最终值和单周期CPU跑同样的程序做结果比对这是最直观的正确性判据。我现在写仿真testbench时会在顶层模块里加一个简单的指令计数器和终止机制。测试程序结尾放一个sw指令把某个哨兵寄存器的值写到约定地址testbench监测到该地址写入后自动停止仿真。这个技巧能避免手动去数波形周期数非常省事。5.2 仿真波形调试顺序从流水线状态表入手波形文件第一步不是看具体信号而是先把五级流水线的状态列出来。我习惯在testbench里写一个任务每个时钟周期把当前处在该级的指令序号打印出来形成一张流水线状态表。检查这张表能迅速看出指令在哪一拍被停顿、在哪一拍被冲刷、是否有两条指令碰到了同一个阶段。确认了各级指令流动正确后再逐个检查数据冒险场景中转发选择信号forward_a、forward_b的取值。我的经验是把测试程序里每一条会触发转发的指令在状态表里标出来然后在波形上对照那一段周期的转发电平是不是期望值。如果转发电平在某个周期出现X态八成是某条指令的控制信号没有在ID级正确生成。load-use停顿的波形检查要特别注意PC和IF/ID输出。停顿那一拍PC和IF/ID输出应该完全不变而ID/EX控制信号应该是0。很多初学者看到流水线里出现一个异常指令第一反应是改转发单元其实真正的根源是控制信号清零的时机不对。波形里ID/EX寄存器的MemRead在气泡周期是否为0是判断这个问题最快的手段。5.3 FPGA上板时钟分配、VIO/ILA观测与频率提升仿真通过后上板实测又有一批新问题等着。外部时钟通常不能直接接给CPU要先经过MMCM/PLL做频率配置和时钟约束。建议先用10MHz左右的低速时钟跑通流程确认功能和仿真一致后再逐步提高频率每次翻倍测试观察出错点。用ILA观测内部信号时触发条件的设置很关键。直接触发PC等于某个特定值通常不靠谱因为这个值持续周期短ILA可能没采到。更好的办法是设置一个从PC值变化开始连续采样N个周期的捕获窗口把五级流水线的关键信号全部加进探头这样能看到一段完整的流水过程。VIO则可以手动控制复位信号和单步使能FPGA跑调的时候比反复改代码重综合快得多。上板频率跑到100MHz以上时时序瓶颈一般出现在两个地方一个是寄存器文件的组合读地址到ALU输出的路径偏长另一个是数据存储器的同步读路径。可以用流水线级的寄存器打拍优化但要注意打拍会改变访存阶段的数据时序这时候必须同时调整MEM/WB的转发逻辑。总的来说把指令存储器和数据存储器都配置为流水线输出模式再配合寄存器的读延迟通常能把主频拉上来一截。6. 重新做一遍会怎么改工具链与时间分配6.1 Verilog、Logisim还是Chisel这轮设计我全程用Verilog写RTL用Vivado做综合和仿真。Verilog最大的优势是大家对它足够熟悉网上可参考的MIPS流水线代码一抓一大把遇到问题找资料容易。缺点也很明显位宽、控制信号的传递全靠手写拼错一位很难发现。如果现在重新做一遍我会在主流程不变的前提下把控制信号定义成立即数的结构体或者SystemVerilog的packed struct。这能让ID/EX、EX/MEM、MEM/WB各级寄存器的代码量直接缩掉一半更重要的是清除控制信号时不用一条条列信号名一个结构体整个清零代码出错的概率会大幅下降。Chisel和SpinalHDL这些高级硬件描述语言我也简单试过生成代码很快但调试时需要额外理解它们生成的RTL对初学者不一定更友好。6.2 制作时间线与避坑清单一个比较稳妥的时间分配是数据通路画图和五级职责确认占20%RTL实现占40%仿真验证占30%上板综合占10%。仿真验证的时间必须给足宁可多花时间把冒进指令的波形一条条抠干净也不要拿着未验证的代码直接去下板。这次做完之后我总结了一张自己的避坑清单第一流水线寄存器控制信号清零要单独处理不能和正常更新混在一个always块里第二转发优先级永远是EX/MEM优先于MEM/WB零寄存器必须排除第三load-use停顿要同时冻结PC和IF/ID还要清ID/EX的控制信号第四分支和跳转的冲刷范围不同分支在EX级要冲两个寄存器跳转在ID级只要冲IF/ID第五FPGA的BRAM同步读延迟会改变IF级节奏仿真组合读、上板同步读的差别必须提前意识到。最后想单独说一句。单周期CPU让我理解了指令怎么走完数据通路但只有写完流水线我才真正理解为什么指令集设计、寄存器数量、访存时序这些一开始觉得抽象的东西如此重要。32位MIPS流水线是个被无数人做过的题目但自己做一遍和看十遍书完全是两码事。如果你正在被冒险检测和转发单元折磨那说明你正处在最该坚持下去的节点——把这些逻辑理顺之后再看任何处理器架构视角都会完全不一样。