ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

手写MIPS五级流水线CPU:Verilog实现与冒险处理全解析

手写MIPS五级流水线CPU:Verilog实现与冒险处理全解析 说实话这类“手写MIPS流水线CPU”的项目我前前后后做过好几版但真正敢拿出去讲的还是现在这版Verilog实现。原因是很多教程要么只给一张架构图要么丢一堆看着能用、综合起来全是坑的代码。这次我换了个思路从流水线阶段划分开始把每个阶段的寄存器、控制信号、冒险处理全落在RTL代码上再用一套自带的Testbench把数据冲突、分支重填这些场景全部跑通。整个过程适合学过数字逻辑和Verilog基础、想真正理解CPU内部工作流的同学也适合正在准备体系结构课程设计的人拿来当参考复现。在这篇文章里我会把整个5级流水线MIPS CPU的设计拆成几个层次来讲先是整体数据通路怎么搭然后是每个阶段的Verilog模块怎么写再单独讲透数据冒险和控制冒险的处理逻辑最后给出顶层集成、仿真环境以及我在调试过程中踩到的几个典型坑。代码我都按模块贴出来了注释也比较多你可以直接照抄再自己在仿真工具里跑一遍。1. 为什么用经典5级流水线做CPU设计项目1.1 指令集选择MIPS到底香在哪里做CPU设计第一件事是选指令集。市面上的教学CPU设计大多是MIPS、RISC-V、甚至x86风格的简化版本我最终选了MIPS不是因为它比RISC-V先进恰恰是因为它足够“老派”和“规整”。MIPS是典型的RISC架构指令长度固定32位寻址方式少指令格式只有R型、I型、J型三类。对流水线来说固定指令长度意味着取指阶段可以直接用PC加4的方式拿到下一条指令地址不需要额外判断指令长度可以省掉一整块解码逻辑。寄存器操作数位置固定比如rs、rt、rd永远在指令的固定bit位置上这样译码阶段读取寄存器堆的速度会非常快对建立时间预算也更友好。另外MIPS的访存指令只有lw和sw运算指令基本都是寄存器到寄存器没有复杂的内存间接寻址。这意味着流水线的访存阶段只处理简单load/store操作EX阶段的ALU运算结果可以直接送到MEM阶段不需要额外调整。这种结构对教学场景来说简直太合适了你可以把主要精力放在流水线冲突处理上而不是纠结某条指令的寻址模式会不会破坏流水线状态。有人会说RISC-V也很规整为什么不直接用RISC-V说实话RISC-V的生态确实越来越成熟但MIPS指令集在计算机体系结构课程中的地位已经稳固了几十年大量参考书、开源IP核、课件都围绕它展开。遇到问题堆栈溢出搜索一下能找到的MIPS资料数量远超RISC-V教学资料。对于“快速跑通一个能理解的流水线CPU”这个目标来说MIPS依然是性价比最高的起点。1.2 五个阶段划分与数据流约定经典5级流水线划分为IF、ID、EX、MEM、WB五个阶段。取指阶段负责根据PC从指令存储器中取出32位指令并计算出下一条指令地址译码阶段负责把指令拆解出寄存器操作数、立即数、功能字段并读取寄存器堆执行阶段做算逻运算和分支条件判断访存阶段访问数据存储器写回阶段把结果写回寄存器堆。阶段之间通过流水线寄存器隔离分别是IF_IDID_EXEX_MEMMEM_WB。这些寄存器不仅保存数据还保存控制信号。控制信号可以分为两类一类是贯穿多个阶段的比如reg_write要从ID一直传到WBMemToReg也要从EX传到WB另一类只在本阶段使用比如ALUOp在EX阶段就用掉了不需要继续向后传。这一点在写代码时特别容易出错很多初学者把所有控制信号一股脑全传到后面导致综合面积变大代码可读性也变差。我在设计数据通路时把这些约定写死跳转地址在EX阶段算出来所以IF阶段只需每周期把PC加4传给ID分支判断也在EX阶段完成结果送进EX_MEM寄存器由冒险控制器决定是否冲刷IF_ID和ID_EX。这样的好处是控制逻辑比较集中仿真时追踪分支从哪个阶段冲刷也比较直观。缺点是流水线在分支跳转时固定损失一个周期但作为教学实现完全可以接受。为了保证指令集可用我实现了所有R型指令的add/sub/and/or/or/slt以及I型的lw、sw、beq。时钟上升沿触发所有状态更新异步复位把PC初始化到起始地址0x00000000寄存器堆全部清零。2. 每个阶段的Verilog模块到底怎么写2.1 取指阶段PC寄存器、指令存储器与IF_ID寄存器取指阶段的第一个关键模块是PC。这个模块的核心逻辑是选择下一条PC如果遇到flush就加载分支目标地址如果流水线处于stall状态PC保持不变默认情况下PC加4。module pc_reg ( input wire clk, input wire rst_n, input wire stall_if, // IF阶段暂停 input wire flush_if, // IF阶段冲刷 input wire [31:0] branch_pc, // 分支目标地址 output reg [31:0] pc ); always (posedge clk or negedge rst_n) begin if (!rst_n) pc 32h0000_0000; else if (flush_if) pc branch_pc; else if (!stall_if) pc pc 32d4; // stall为高时保持pc不变 end endmodule指令存储器我直接用了一个只读的reg数组来建模因为这是仿真工程不需要真正综合成SRAM。如果后面要上板子再替换成IP核就行。注意这里我在写代码时省略了初始化文件实际工程中我会用$readmemh在仿真启动时把十六进制指令码加载进去。module instr_mem ( input wire [31:0] addr, output wire [31:0] inst ); reg [31:0] mem [0:4095]; wire [31:0] word_addr; assign word_addr {addr[31:2], 2b00}; assign inst mem[word_addr[13:2]]; endmoduleIF_ID寄存器是流水线里最重要的一组边界寄存器。它要锁存的是在这一拍刚开始取出的指令和它对应的PC值。这里有个细节PC值必须跟着指令一起传下去因为lw指令在WB阶段可能要计算访存地址如果后续需要精确异常处理PC是定位指令身份的唯一线索。虽然现在没做异常但把PC一路传下去是个好习惯。module if_id ( input wire clk, input wire rst_n, input wire stall_id, input wire flush_id, input wire [31:0] pc_if, input wire [31:0] inst_if, output reg [31:0] pc_id, output reg [31:0] inst_id ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin pc_id 32h0; inst_id 32h0; end else if (flush_id) begin pc_id 32h0; inst_id 32h0; // 冲刷成空指令 end else if (!stall_id) begin pc_id pc_if; inst_id inst_if; end end endmodule冲刷成空指令的做法相当于给流水线插入了一个nop。空指令在ID阶段会被译码成全零操作不会产生写寄存器或访存动作这个设计在控制冒险处理中非常关键。2.2 译码阶段寄存器堆、立即数扩展与控制信号ID阶段的重点是一个支持写优先的寄存器堆。写优先的意思是如果同一拍内某条指令在WB阶段写寄存器而当前指令正好也要读同一个寄存器那么读出的数据直接旁路掉当前写入的数据避免在WB阶段产生RAW冲突。这个技巧在很多真实CPU里都有代码实现也很简单。module regfile ( input wire clk, input wire rst_n, input wire we, input wire [4:0] raddr1, input wire [4:0] raddr2, input wire [4:0] waddr, input wire [31:0] wdata, output wire [31:0] rdata1, output wire [31:0] rdata2 ); reg [31:0] rf [0:31]; integer i; always (posedge clk or negedge rst_n) begin if (!rst_n) begin for (i 0; i 32; i i 1) rf[i] 32h0; end else if (we) begin rf[waddr] wdata; end end assign rdata1 (we raddr1 waddr) ? wdata : rf[raddr1]; assign rdata2 (we raddr2 waddr) ? wdata : rf[raddr2]; endmodule指令译码部分我按照MIPS的三种指令格式来分类。最常用的是R型指令opcode为0功能由funct字段决定。I型指令包括lw、sw、beq以及addi之类。这里我没有实现addi纯粹是为了精简代码你想扩展也只要在ALUOp译码表里加一行。wire [5:0] opcode inst_id[31:26]; wire [5:0] funct inst_id[5:0]; wire [4:0] rs inst_id[25:21]; wire [4:0] rt inst_id[20:16]; wire [4:0] rd inst_id[15:11]; wire [15:0] imm inst_id[15:0];控制信号的生成我用了一个always组合逻辑块分别输出RegDst、ALUSrc、MemToReg、RegWrite、MemRead、MemWrite、Branch、ALUOp。需要特别注意MemRead和RegWrite不能同时为高否则同一个周期又读存储器又写寄存器在真实的SRAM双端口设计中可能产生端口冲突。这个约束在Testbench里很难暴露但在综合时会很麻烦我建议在代码注释里明确写清楚。2.3 执行阶段ALU控制信号与分支判定EX阶段的核心是一个组合逻辑ALU。ALU控制信号由ALUOp和funct字段共同生成。比如ALUOp为2b10表示R型指令此时要根据funct区分add、sub、and、or、sltALUOp为2b00表示lw/swALU只需要执行加法ALUOp为2b01表示beqALU执行减法比较。module alu ( input wire [3:0] alu_ctrl, input wire [31:0] a, input wire [31:0] b, output reg [31:0] result ); always (*) begin case (alu_ctrl) 4b0000: result a b; 4b0001: result a - b; 4b0010: result a b; 4b0011: result a | b; 4b0100: result a ^ b; 4b0101: result a b[4:0]; 4b0110: result a b[4:0]; 4b0111: result ($signed(a) $signed(b)) ? 32h1 : 32h0; 4b1000: result b 16; // 用于lui扩展 default: result 32h0; endcase end endmodule分支判定我放在EX阶段。beq需要比较rs和rt的值是否相等ALU在此时执行减法然后判断结果是否为0。如果分支跳转需要把目标地址也送到流水线寄存器里并在下一个时钟周期加载到PC。wire branch_taken branch (alu_result 32h0); assign branch_pc (pc_id 32d4) ({{16{imm[15]}}, imm} 2);这里有个容易算错的细节分支目标地址是基于ID阶段里拿到的PC值加4再偏移而不是基于当前IF阶段的PC加4。因为beq进入EX执行时PC值已经指向下下条指令。很多初学者在这里把立即数offset左移2位后直接加到当前PC上算出来的跳转地址全错。2.4 访存阶段与写回阶段MEM阶段的数据存储器只需要支持单次读和单次写。控制信号里MemRead和MemWrite为高时分别进行读写。写回阶段则负责把来自数据存储器的读数据或ALU计算结果选择到寄存器堆写入端口。module mem_wb ( input wire clk, input wire rst_n, input wire reg_write_m, input wire mem_to_reg_m, input wire [31:0] alu_result_m, input wire [31:0] mem_data_m, input wire [4:0] waddr_m, output reg reg_write_w, output reg mem_to_reg_w, output reg [31:0] alu_result_w, output reg [31:0] mem_data_w, output reg [4:0] waddr_w );写回数据的选择逻辑就是一行MUXMemToReg为1时选择访存结果否则选择ALU结果。WB阶段已经不需要任何硬算逻辑主要工作就是保证RegWrite、写回数据和目标寄存器地址三者在同一个流水线寄存器组合里保持对齐。3. 流水线冒险处理CPU能不能跑稳的关键3.1 数据冒险能转发的就别停数据冒险的本质是后面指令要用前面指令还没写回的数据。最典型的场景是一串连续R型指令比如add $t0, $t1, $t2后面紧跟sub $t3, $t0, $t4。在理想流水线里add在WB阶段才写$t0而sub在ID阶段就要读$t0时间差导致sub读到旧值。解决办法是数据转发。我在EX阶段给ALU的两个输入各加了一个多路选择器转发源有两个EX_MEM阶段的结果和MEM_WB阶段的结果。当后面指令的rs或rt正好等于前面指令的rd且前面指令要写寄存器时就把前一条指令的结果直接送给ALU不等待写回。wire [1:0] forward_a (ex_mem_regwrite ex_mem_rd ! 0 ex_mem_rd id_ex_rs) ? 2b10 : (mem_wb_regwrite mem_wb_rd ! 0 mem_wb_rd id_ex_rs) ? 2b01 : 2b00; wire [1:0] forward_b (ex_mem_regwrite ex_mem_rd ! 0 ex_mem_rd id_ex_rt) ? 2b10 : (mem_wb_regwrite mem_wb_rd ! 0 mem_wb_rd id_ex_rt) ? 2b01 : 2b00;优先级方面离EX阶段最近的EX_MEM转发源优先级最高。如果EX_MEM和MEM_WB同时有写同一寄存器说明EX_MEM里的指令更年轻它写入的值才真正覆盖了寄存器最终值。3.2 Load-Use冒险这根刺只能靠停顿拔掉数据转发并不能解决所有RAW冲突。有一种情况必须停顿后面的指令需要读某个寄存器而前面紧邻的两条指令中第一条是load指令且还在MEM阶段它的数据只有到WB阶段才能拿到。也就是典型的两条连续指令lw $t0, 0($t1)add $t2, $t0, $t3。这种冲突发生在ID阶段因为这时add指令还没进入EX阶段无法通过EX/MEM转发拿数据。我的处理方法很直接在冒险检测单元里判断当前ID阶段的指令是否是load且ID_EX寄存器的rt字段与当前指令的rs或rt相等如果条件成立就产生一个stall信号。wire stall_if id_ex_memread (id_ex_rt if_id_rs || id_ex_rt if_id_rt);这里不用判断rd因为MIPS里load的目标寄存器写的是rt不是rd。很多资料在讲这条规则时写得含混我建议直接记住lw的写回目标在译码阶段用的是rt字段。如果你手抖写成对rd的判断仿真时会出现数据一直停在旧值的诡异现象。停顿的本质是把这个stall信号反向传播到流水线前端PC保持不动IF_ID寄存器保持不动ID_EX寄存器里插入一个空指令。空指令在下一周期进入EX阶段不产生任何写操作同时load指令的数据已经进入MEM_WB可以通过转发给后一条指令了。3.3 控制冒险分支重填的做法与代价分支指令在EX阶段判定是否跳转因此在判定完成之前IF和ID两个阶段已经各取了一条错误路径上的指令。为了让流水线回到正确状态我采用标准的flush策略当branch_taken拉高时将IF_ID和ID_EX寄存器里的内容全部清零同时PC在下一拍跳转到分支目标地址。wire flush_id branch_taken; wire flush_if branch_taken;注意这里PC在EX阶段分支判定后的下一拍才跳转所以流水线会损失一个周期。如果用跳转延迟槽就可以把这个损失砍掉但为了代码清晰我没有做延迟槽。一个周期气泡对教学项目来说完全可接受等以后做性能优化再考虑延迟槽和分支预测。有些资料会把分支判定放在ID阶段这样只需要冲刷IF_ID一个寄存器损失更小。但我把分支判定放在EX阶段的原因很朴素ID阶段的时间窗口太紧要在这一个阶段里完成寄存器堆读取、立即数扩展、控制信号译码、分支条件比较组合逻辑路径会变得很长时钟频率上不去。所以我把比较操作挪到EX阶段用ALU减法实现等值判断时序更均衡。4. 把五个阶段粘成整体顶层模块与仿真4.1 顶层模块的信号连接写顶层模块就是把前面那些子模块例化出来再把冒险处理、前递多路选择器、流水线寄存器之间的信号按数据通路要求连起来。下面是我项目中CPU顶层的一部分重点展示了EX阶段的前递和冒险控制。module mips_cpu ( input wire clk, input wire rst_n, output wire [31:0] pc_out, output wire [31:0] inst_out ); wire [31:0] pc_next, pc_if; wire [31:0] inst_id, inst_if; wire [31:0] imm_ext; wire stall_if, flush_if, stall_id, flush_id; wire reg_write_w, mem_to_reg_w, mem_read_m, mem_write_m, branch; wire [1:0] alu_op; wire [3:0] alu_ctrl; wire [31:0] reg_data1, reg_data2, alu_in1, alu_in2; wire [31:0] alu_result_m, mem_data_w, alu_result_w; wire [1:0] forward_a, forward_b; wire branch_taken; wire [31:0] branch_pc; wire id_ex_memread; wire [4:0] id_ex_rt, if_id_rs, if_id_rt; // IF阶段例化 pc_reg u_pc (...); instr_mem u_imem (...); if_id u_if_id (...); // 冒险控制单元 hazard_unit u_hazard ( .id_ex_memread(id_ex_memread), .id_ex_rt(id_ex_rt), .if_id_rs(if_id_rs), .if_id_rt(if_id_rt), .branch_taken(branch_taken), .stall_if(stall_if), .stall_id(stall_id), .flush_if(flush_if), .flush_id(flush_id) ); // 转发多路选择器 alu_mux u_forward_a ( .selector(forward_a), .orig(reg_data1), .ex_mem_data(alu_result_m), .mem_wb_data(alu_result_w), .out(alu_in1) ); alu_mux u_forward_b ( .selector(forward_b), .orig(reg_data2), .ex_mem_data(alu_result_m), .mem_wb_data(alu_result_w), .out(alu_in2) ); endmodule这种写法比把所有逻辑写在一个超级大模块里清晰很多出问题也容易定位。我强烈建议你按模块独立编译在ModelSim或Vivado里逐个run而不是等全部写完才一次性编译。每一层都仿真通过再往上拼能节省大量时间。4.2 Testbench设计用什么指令序列来验证仿真最重要的不是看波形多漂亮而是你的测试指令能不能覆盖到数据冒险、Load-Use、分支跳转三类关键场景。我设计了一套很小的指令序列专门针对这些冲突。lw $t0, 0($zero) # 读取内存地址0的数据 add $t1, $t0, $t0 # load-use冲突必须停顿一拍 sub $t2, $t1, $t0 # 普通RAW冲突可通过转发解决 and $t3, $t2, $t1 beq $t3, $zero, label # 分支跳转产生一个气泡 addi $t4, $zero, 8 # 这条不执行或延后执行 label: sw $t3, 4($zero)对应的机器码我用Python脚本生成然后$readmemh加载到指令存储器里。如果你不想写脚本也可以手算但MIPS指令编码规则多手算出错的概率很大。仿真Testbench里我加了好几个监视点检测寄存器堆写入使能、检测PC跳转目标、检测stall和flush是否按预期拉高。用这些监视点配合波形能很快定位到是哪一条指令引发了异常行为。initial begin clk 0; rst_n 0; #20 rst_n 1; // 跑足够周期数让所有指令流出 repeat (60) (posedge clk); $finish; end always #10 clk ~clk;4.3 从波形中确认转发和停顿是否生效仿真跑完之后怎么确认结果对不对我习惯先把所有寄存器堆的值打印出来特别是$t0到$t4。然后重点看几个波形节点stall_if信号和flush_id信号。如果lw和add之间出现stall_if拉高一个周期说明Load-Use停顿生效如果beq执行的周期里flush_id和flush_if同时拉高说明分支冲刷逻辑正确。我以前调试时碰到一个典型问题代码逻辑看着都对但波形里出现了连续两个周期被冲刷导致后面指令全部延后两拍。查到最后是分支目标PC计算时把偏移量算错了。MIPS的beq偏移量是以指令数为单位实际要乘以4再参与地址累加。这一点写代码时务必小心。5. 仿真与调试中的常见问题排查实录5.1 波形里全是X态仿真初期我遇到最多的问题是信号刚跑几个周期就变成X态后面全乱。X态的根源通常是某个模块没有正确复位。比如IF_ID寄存器复位时要把输出清零但如果不复位那么第一拍从指令存储器取出的指令数据里就会有未定义部分。我的排查方法是在Testbench里先复位100ns然后只在复位撤销后的第一个上升沿查PC、inst_id等关键信号。如果不复位模块里输出的仍然是X那基本可以确定是复位条件写错位或者某些信号没有接上。还有一个关键是寄存器堆的复位这个经常被漏掉。很多同学以为寄存器上电后是0但仿真时不初始化就会显示X。5.2 lw指令的rt字段千万别当rd用我在3.2节提过一次这里再强调一遍因为它真的太容易错了。在IDE阶段判断Load-Use时需要在ID_EX寄存器里保存rt信号而不是rd。load的目标寄存器永远是rt字段不是rd字段。后一条指令的rs或rt如果和这个rt相等就会冲突需要停顿。如果这里索引出错你会看到两种情况一是完全没有停顿结果寄存器的值不对二是停顿过于频繁对完全不该停顿的指令也卡了一拍。这两种症状都能通过打印寄存器值发现。5.3 分支指令测试出现连续重填分校测试还有一种情况是beq后面的指令被错误执行了。这通常是因为flush时只清掉了IF_ID寄存器忘了清ID_EX寄存器。如果ID阶段里已经有指令被译码但还没进EX阶段不清空ID_EX会导致这条错误指令继续向后传播最终写入寄存器堆。我的flush代码里同时对两个流水线寄存器做了异步清零并且还给EX阶段传入selecion控制信号。让ID_EX里的指令在下一拍不产生任何写操作。双重保险基本能杜绝错误指令漏过去。5.4 工具链选择手工编码还是自动汇编最后说说指令编码。如果只是测试几条指令手算完全可行但手算很容易犯符号扩展错误导致beq跳错位置。我建议直接用开源交叉汇编器比如llvm-mc或GNU binutils配合mips-linux-gnu工具链。用汇编器写源码再转换成Verilog可读的十六进制文本整个过程十分钟搞定。mips-linux-gnu-as test.s -o test.o mips-linux-gnu-objcopy -O binary test.o test.bin然后用一个简单的Python脚本把binary转成memh格式。这里有个经验MIPS工具链默认生成的目标文件可能带有额外的段信息最好用objcopy转成裸二进制再按4字节一组输出即可。根据我个人经验这种带流水线的CPU项目最好的学习路径是先看架构图再动手写其中一个模块然后赶紧跑仿真。等你写完一个完整版本再去读MIPS R4000的真实流水线设计文档你会发现自己能理解的内容深度完全不同。学习流水线CPU设计最忌讳的就是只看书不实操仿真波形不会骗人你的设计能不能跑通拉一条关键信号的波形就知道。
返回列表