说实话,这类“手写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_ID,ID_EX,EX_MEM,MEM_WB。这些寄存器不仅保存数据,还保存控制信号。控制信号可以分为两类:一类是贯穿多个阶段的,比如reg_write要从ID一直传到WB,MemToReg也要从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 <= 32'h0000_0000; else if (flush_if) pc <= branch_pc; else if (!stall_if) pc <= pc + 32'd4; // 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], 2'b00}; 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 <= 32'h0; inst_id <= 32'h0; end else if (flush_id) begin pc_id <= 32'h0; inst_id <= 32'h0; // 冲刷成空指令 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] <= 32'h0; 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为2'b10表示R型指令,此时要根据funct区分add、sub、and、or、slt;ALUOp为2'b00表示lw/sw,ALU只需要执行加法;ALUOp为2'b01表示beq,ALU执行减法比较。
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) 4'b0000: result = a + b; 4'b0001: result = a - b; 4'b0010: result = a & b; 4'b0011: result = a | b; 4'b0100: result = a ^ b; 4'b0101: result = a << b[4:0]; 4'b0110: result = a >> b[4:0]; 4'b0111: result = ($signed(a) < $signed(b)) ? 32'h1 : 32'h0; 4'b1000: result = b << 16; // 用于lui扩展 default: result = 32'h0; endcase end endmodule分支判定我放在EX阶段。beq需要比较rs和rt的值是否相等,ALU在此时执行减法,然后判断结果是否为0。如果分支跳转,需要把目标地址也送到流水线寄存器里,并在下一个时钟周期加载到PC。
wire branch_taken = branch && (alu_result == 32'h0); assign branch_pc = (pc_id + 32'd4) + ({{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 );写回数据的选择逻辑就是一行MUX:MemToReg为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) ? 2'b10 : (mem_wb_regwrite && mem_wb_rd != 0 && mem_wb_rd == id_ex_rs) ? 2'b01 : 2'b00; wire [1:0] forward_b = (ex_mem_regwrite && ex_mem_rd != 0 && ex_mem_rd == id_ex_rt) ? 2'b10 : (mem_wb_regwrite && mem_wb_rd != 0 && mem_wb_rd == id_ex_rt) ? 2'b01 : 2'b00;优先级方面,离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设计最忌讳的就是只看书不实操,仿真波形不会骗人,你的设计能不能跑通,拉一条关键信号的波形就知道。