32位MIPS流水线CPU这个项目,几乎是每个计算机体系结构课程都会遇到的硬骨头。我在做这个设计的时候,最深的感受是:不管你在纸上把数据通路画得多漂亮,真正写Verilog、跑仿真、上板调试的时候,各种冒险和时序问题还是会把你折腾得够呛。这篇文章就把我自己完成MIPS32五级流水线CPU的过程、踩过的坑、以及调通的思路完整整理一遍,给正在做课程设计或者想深入理解CPU工作原理的朋友一个可以直接参考的实操记录。
这个项目适合谁?如果你是计算机专业学生,正在做“MIPS CPU设计”类的大作业,那这篇内容的每一个模块都可以直接当参考;如果你是嵌入式工程师,想补一补体系结构的基础,理解流水线为什么需要转发、为什么需要停顿,这里也会讲得很直白;如果你只是对CPU原理好奇,想看看一个最简单的流水线处理器是怎么从零写出来的,这篇文章同样能帮你建立起整体画面。
1. 项目概述与整体设计思路
1.1 项目要解决的核心问题
MIPS32流水线CPU设计,本质上要做的事情就是:用硬件描述语言(我用的Verilog)实现一个能执行MIPS32整数指令子集的处理器,并且采用五级流水线架构。所谓“32位”,指的是数据通路和寄存器的宽度都是32位;所谓“MIPS”,指的是一种典型的RISC指令集架构,指令定长32位,寻址方式简单,非常适合教学和入门级的CPU设计;所谓“流水线”,就是让多条指令像工厂流水线一样,在取指、译码、执行、访存、写回五个阶段重叠执行,从而提高整体吞吐率。
我设计的这个CPU,目标是能正确执行以下指令子集:
- R型指令:add、addu、sub、subu、and、or、xor、nor、slt、sltu、sll、srl、jr
- I型指令:addi、addiu、andi、ori、xori、lw、sw、beq、bne、bgez、bltz、slti、sltiu、lui
- J型指令:j、jal
选这个子集的原因很直接:它覆盖了大多数经典的数据处理、内存访问、分支跳转、函数调用场景。再往上去浮点指令、乘除指令等,对五级整数流水线来说属于拓展,可以放到后续迭代,但核心工作量已经能通过这个子集完整验证清楚。
1.2 方案选型与设计取舍
在设计启动前,我首先纠结过一个问题:是做单周期CPU还是直接做流水线?
我的建议是:如果时间允许,先做一个单周期版本,哪怕只是在纸上画出数据通路图、列清楚信号含义都行。单周期CPU里,每条指令在一个时钟周期内完成取指、译码、执行、访存、写回,控制信号之间的关系非常直观。没有单周期的概念打底,直接跳进流水线,你很容易被“这个信号到底在哪一级产生、在哪一级使用”这种跨级问题搞晕。
选定流水线架构之后,还要确定几件事:
流水线深度:经典的五级流水线(IF、ID、EX、MEM、WB)是最稳妥的选择。更深的流水线(比如7级、10级)在实现和分支惩罚方面复杂度更高,对课程设计没有必要;更浅的流水线(如三级)又不能完整体现数据冒险处理的思想。所以五级是黄金选择。
开发语言与工具:我用Verilog + Xilinx Vivado + ModelSim配合使用。Vivado负责综合、实现、上板;ModelSim(或者直接用Vivado自带的仿真器)负责功能仿真。如果你想用开源工具链,Icarus Verilog加GTKWave也能完成仿真,但波形体验不如ModelSim顺手。
是否上板:如果条件允许,强烈建议在FPGA板卡上跑一遍程序。仿真验证的是功能逻辑,上板验证的是综合后的电路在真实时序约束下是否还能正确工作。很多在仿真里好好的设计,一上板就跑飞,原因通常出在复位、时钟和存储器初始化上,这些坑只有上板才能暴露。
存储器的安排:为了规避结构冒险,最好把指令存储器和数据存储器分开设计,各用一块独立的RAM/ROM。经典的MIPS架构本身就是指令空间和数据空间分离的哈弗结构,这为流水线设计省去了“取指和访存同时访问同一个存储器”的冲突问题。
这套选型方案背后的逻辑很简单:用最经典、最标准的架构,把出错的变量降到最低。不要为了炫技引入太花哨的优化(比如动态分支预测、乱序执行),先把无冒险情况下的流水线跑通,再逐步加入转发和阻塞,这个顺序最不容易翻车。
2. 流水线架构与关键控制信号设计
2.1 五级流水线数据通路构成
五级流水线的每一级职责如下:
| 流水线级 | 中文名称 | 核心工作 |
|---|---|---|
| IF | 取指 | 根据PC从指令存储器读取指令,PC自增4 |
| ID | 译码 | 从指令中解析出操作码、寄存器号、立即数,读寄存器堆 |
| EX | 执行 | ALU运算,计算访存有效地址,判断分支条件 |
| MEM | 访存 | 访问数据存储器,读或写数据 |
| WB | 写回 | 把ALU结果或存储器读取结果写回寄存器堆 |
数据通路上的关键组件包括:PC寄存器、指令存储器ROM、寄存器堆RegFile、ALU、数据存储器RAM、立即数扩展模块、控制单元、以及四组流水线寄存器(IF/ID、ID/EX、EX/MEM、MEM/WB)。
流水线寄存器是这条数据通路上最容易被忽略却最重要的部分。IF/ID寄存器负责锁存取指阶段得到的指令和PC+4;ID/EX寄存器锁存译码阶段产生的所有控制信号、寄存器操作数、立即数、寄存器号;EX/MEM寄存器锁存ALU结果、写数据、目标寄存器号和控制信号;MEM/WB寄存器锁存访存结果和写回寄存器号等。总之,每一级流水线寄存器存的是“这一级需要向后传递的所有信息”,而不是随便挑几个信号存一下。
2.2 控制信号生成与译码
控制信号是整个CPU的“方向盘”。常见控制信号包括:
- RegWrite:是否写寄存器堆
- ALUSrc:ALU第二个操作数来自寄存器还是立即数
- MemWrite、MemRead:是否写/读数据存储器
- MemtoReg:写回寄存器的数据来自ALU结果还是存储器读数
- Branch:当前指令是否为分支指令
- Jump:当前指令是否为跳转指令
- ALUOp:ALU操作编码
- RegDst:写回寄存器号选择rt还是rd
需要特别注意的是,控制信号在ID级译码产生,但它们真正被使用的时间点可能在不同的流水线级。例如RegWrite在WB级才使用,MemWrite在MEM级才使用,ALUOp在EX级就使用。这就意味着控制信号必须跟着流水线寄存器一级一级往后传,否则就会出现过早生效或丢失的问题。我一开始就是忘记了在EX/MEM寄存器中锁存MemWrite,结果在仿真里发现sw指令根本没有把数据写进存储器。
用一段简化的控制真值表来示例(部分指令):
| 指令 | RegWrite | ALUSrc | MemWrite | MemtoReg | Branch | ALUOp |
|---|---|---|---|---|---|---|
| R型(add等) | 1 | 0 | 0 | 0 | 0 | 10 |
| lw | 1 | 1 | 0 | 1 | 0 | 00 |
| sw | 0 | 1 | 1 | x | 0 | 00 |
| beq | 0 | 0 | 0 | x | 1 | 01 |
| addi | 1 | 1 | 0 | 0 | 0 | 00 |
| j | 0 | x | 0 | x | 0 | xx |
这里的ALUOp编码,我约定“00”表示加法,“01”表示比较相等,“10”表示由funct字段决定具体运算。这是一种经典的两段式ALUDecode设计,主控制单元只负责粗粒度分类,ALU控制单元再根据funct或功能码细粒度确定运算类型。
2.3 流水线寄存器的位宽划分
流水线寄存器的位宽划分很考验对数据通路的理解。以ID/EX寄存器为例,它需要锁存的内容包括:
- 控制信号组:RegWrite、MemtoReg、MemRead、MemWrite、ALUSrc、RegDst、ALUOp
- 数据组:PC+4、寄存器堆读出的rs_value、rt_value、扩展后的立即数
- 寄存器号组:rs、rt、rd
在Verilog里,我建议把每个流水线寄存器定义成结构化清晰的模块,内部用一组寄存器变量组合起来。比如ID/EX寄存器的输出端口如下:
module id_ex_reg( input wire clk, input wire rst_n, input wire stall, input wire flush, // --- control in --- input wire [1:0] aluop_in, input wire alusrc_in, input wire regdst_in, input wire regwrite_in, input wire memwrite_in, input wire memread_in, input wire memtoreg_in, // --- data in --- input wire [31:0] pc_plus4_in, input wire [31:0] rs_data_in, input wire [31:0] rt_data_in, input wire [31:0] imm_ext_in, input wire [4:0] rs_addr_in, input wire [4:0] rt_addr_in, input wire [4:0] rd_addr_in, // --- control out --- output reg [1:0] aluop_out, output reg alusrc_out, output reg regdst_out, output reg regwrite_out, output reg memwrite_out, output reg memread_out, output reg memtoreg_out, // --- data out --- output reg [31:0] pc_plus4_out, output reg [31:0] rs_data_out, output reg [31:0] rt_data_out, output reg [31:0] imm_ext_out, output reg [4:0] rs_addr_out, output reg [4:0] rt_addr_out, output reg [4:0] rd_addr_out ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin // all outputs <= 0 end else if (flush) begin // all outputs <= 0 (for control) end else if (stall) begin // keep current value end else begin // load from inputs end end endmodule这段代码里interrupt形式可能看起来繁琐,但好处是每个信号的来源和去向一目了然。实际项目中,也可以用一个内部组合的“大向量”来简化位宽拼接,但初学者不建议直接做位宽压缩,调试时看信号会非常痛苦。
3. 冒险处理与核心难点突破
3.1 数据冒险:转发与阻塞
数据冒险是流水线CPU设计中第一个真正意义上的难点。简单说,下一条指令要用的数据,前一条指令还没写回寄存器堆,这时候直接去读寄存器堆就会读到旧值。
举个例子:
add $1, $2, $3 sub $4, $1, $5add指令在WB级才把结果写回$1,可是sub指令在ID级就需要读$1。在五级流水线中,这两条指令相隔一个周期,sub的ID级与add的WB级并不对齐,所以寄存器堆里读不到新值。解决办法有两个:转发和阻塞。
转发(Forwarding)的核心思路是:数据其实在EX级或MEM级就已经算出来了,没必要等WB写回寄存器堆,直接在流水线内部把结果旁路给需要的执行阶段。我实现了两种转发:
- EX/MEM转发:当前EX级指令的源寄存器与EX/MEM寄存器中的目标寄存器相同,且EX/MEM阶段的RegWrite有效,则把EX/MEM的ALU结果转发回ALU输入。
- MEM/WB转发:当前EX级指令的源寄存器与MEM/WB寄存器中的目标寄存器相同,且MEM/WB阶段的RegWrite有效,则把MEM/WB的结果转发回ALU输入。
用伪代码描述转发条件:
// forward A for rs if (EX_MEM_RegWrite && (EX_MEM_rd != 0) && (EX_MEM_rd == ID_EX_rs)) forwardA = 2'b10; // from EX/MEM else if (MEM_WB_RegWrite && (MEM_WB_rd != 0) && (MEM_WB_rd == ID_EX_rs)) forwardA = 2'b01; // from MEM/WB else forwardA = 2'b00; // from register file但转发不是万能的,有一种情况必须用阻塞(Stall):load-use冒险。比如:
lw $1, 0($2) add $3, $1, $4lw指令要到MEM级结束才能拿到数据,而add指令在EX级就要用$1,中间还隔着MEM/WB转发也无法覆盖到EX级。这时候只能让流水线停一拍:阻止IF和ID阶段前进,让lw指令继续走到MEM/WB,然后在下一拍通过MEM/WB转发给add。换句话说,流水线插了一个“气泡”。
阻塞逻辑的判断条件是:
if (ID_EX_MemRead && ((ID_EX_rt == IF_ID_rs) || (ID_EX_rt == IF_ID_rt))) stall_stall = 1;这句话的意思是:当前EX级正在执行的是一条lw指令(MemRead有效),它要读的目标寄存器rt,恰好是后面ID级指令的源寄存器rs或rt,那就必须停顿。
3.2 控制冒险:分支与跳转
控制冒险是由分支和跳转指令引起的。当流水线还在按顺序取后面的指令时,前面的分支指令还没有决定要不要改变PC,那些预取的指令就可能作废。
我的设计里,beq、bne这类分支指令在EX级比较两个寄存器是否相等,同时计算出分支目标地址。也就是说,在ID阶段结束时,分支结果还没有出来;流水线会先按“不跳转”的默认路径继续执行,如果后续发现应该跳转,就把已经进入流水线的那几条错误指令全部冲刷掉(flush),然后从目标地址重新取指。
分支在EX级判断时,错误路径的惩罚是两个周期。如果想减少惩罚,可以在ID级就完成寄存器比较和目标地址计算,但这需要额外的比较器和加法器,而且还会带来新的数据冒险——你必须在ID级就拿到最新寄存器值,否则分支比较会出错。考虑到课程设计的复杂度,我选择了在EX级判断分支,虽然惩罚稍大但代码逻辑更清晰。
关于MIPS传统的分支延迟槽,我也提一下:经典MIPS架构用分支延迟槽来掩盖分支开销,即分支指令后面的那条指令无论跳转与否都会执行。如果做课程设计,我建议不要开延迟槽,因为延迟槽会让汇编程序的编写变得非常反直觉,调试时也容易怀疑人生。现在很多教学版的MIPS流水线都默认不实现延迟槽,而是靠flush解决控制冒险。
3.3 结构冒险的规避
结构冒险指的是两条指令同时需要访问同一个硬件资源。经典的五级MIPS流水线设计中,主要潜在冲突是取指和访存同时访问存储器。解决方案就是我在第1章里提到的:使用分离的指令存储器和数据存储器。
还有一个容易忽视的资源冲突是寄存器堆的读写口。如果寄存器堆只有一个读端口和一个写端口,那么在WB级写回与ID级读寄存器之间也可能产生冲突。解决办法是让寄存器堆提供两个读端口和一个写端口,这在FPGA上是很容易实现的,用双端口RAM或者Verilog里的reg数组加多个读端口即可。我在实现时直接用了一个三端口寄存器堆模块,从而把这个潜在冲突彻底消掉了。
4. Verilog实现与仿真调试实录
4.1 顶层模块与关键例化
顶层模块是cpu_top,内部例化各级流水线模块。下面是一个简化的例化草图:
module cpu_top( input wire clk, input wire rst_n ); // wires between stages wire [31:0] pc_out; wire [31:0] instr; wire [31:0] if_id_pc_plus4, if_id_instr; // IF stage PC #(.WIDTH(32)) u_pc ( .clk(clk), .rst_n(rst_n), .stall(stall), .flush(flush), .pc_next(pc_next), .pc_current(pc_out) ); instr_mem u_imem ( .addr(pc_out), .instr(instr) ); // IF/ID pipeline reg if_id_reg u_if_id ( .clk(clk), .rst_n(rst_n), .stall(stall), .flush(flush), .pc_plus4_in(pc_out + 32'd4), .instr_in(instr), .pc_plus4_out(if_id_pc_plus4), .instr_out(if_id_instr) ); // ID stage, EX stage, MEM stage, WB stage ... endmodule这个例化结构里,pc_next的选择逻辑是重点。优先级应该是:stall时保持PC不变;flush时如果分支跳转,则PC等于分支目标;Jump时等于跳转目标;否则PC = PC + 4。这个优先级如果写错了,比如在stall和branch同时出现的时候选择了错误的PC,就会出现非常难查的跳转错误。
4.2 汇编测试程序与内存初始化
仿真时不能用真正的汇编器直接生成机器码,我用的方法是:自己写汇编程序,然后手动翻译成16进制机器码,再整理成初始化文件加载进指令存储器。
以斐波那契数列计算为例,我写过一个典型的测试程序:
# $8 = n, $9 = result addi $8, $0, 10 # n = 10 addi $9, $0, 0 # f(0) = 0 addi $10, $0, 1 # f(1) = 1 addi $11, $0, 1 # i = 1 loop: slt $12, $11, $8 # i < n ? beq $12, $0, done # if not, done add $13, $9, $10 # tmp = f(i-1) + f(i) add $9, $10, $0 # f(i-1) = f(i) add $10, $13, $0 # f(i) = tmp addi $11, $11, 1 # i++ j loop done: sw $10, 0($14) # store result这段程序里包含了addi、slt、beq、add、j、sw等多种指令,能很好地覆盖R型、I型、J型指令的通路,而且能测试到循环和分支行为。翻译成机器码时我习惯用一个脚本辅助,或者直接用在线MIPS汇编器生成。无论如何,把所有机器码放进一个.hex或.coe文件,再在testbench里用$readmemh读入指令存储器。
提示:在Vivado里上板时,指令存储器如果用block RAM,需要把.hex文件导入到IP核配置里,并注意路径不要有中文。如果用的是分布式RAM或者最简单的外部ROM实现,也要确保综合时文件能被正确识别。
4.3 仿真波形检查与自动化断言
仿真阶段最容易遇到的问题不是功能设计错,而是“不知道错在哪”。我会同时做两件事:写testbench生成波形和打印指令执行日志。
打印日志是非常有效的调试手段,核心思想是:在WB级打印当前写回的信息。
always @(posedge clk) begin if (mem_wb_regwrite && (mem_wb_rd != 0)) begin $display("Time=%0t WB: reg[%0d] <= %0d", $time, mem_wb_rd, mem_wb_write_data); end end通过比对每一条指令的写回值,就能快速锁定是哪条指令算错了。比如我在调试时发现某个循环里寄存器的值突然变成了一个很大的数,一查波形,发现是aluSrc信号在add指令时错误地选择了立即数而非寄存器值,导致加法算错。
还有一个建议:如果条件允许,在testbench里内嵌“黄金模型”,也就是用行为级代码模拟同一段汇编程序的正确寄存器和内存值,再在每个时钟周期和流水线的WB级写回结果做比对。一旦不一致就报错。这个自动化断言方法能帮你把调试时间从几天压缩到几小时。
4.4 上板验证要点
仿真通过后,我把设计下载到了FPGA开发板上。上板前需要处理几个关键点:
第一,时钟和复位。开发板上的100MHz时钟对于教学级CPU来说够用,但要注意做异步复位同步释放,避免复位释放时的不确定性。第二,要设置合理的约束。第三,建议把最终结果用板上的LED或者数码管显示出来。我在板级验证时把最终计算结果映射到数码管上,程序跑完后点亮对应数字,一目了然。
如果在板级调试中遇到问题,不要一上来就怀疑逻辑设计。先检查复位是否可靠,时钟是否正常,存储器的初始化是否成功,然后才是逻辑问题。网上很多“仿真对,上板错”的案例,绝大多数出在这三个基本环节。
5. 常见问题与调试技巧速查
下面这张速查表,是我在调试过程中最常遇到的问题清单,分享出来可以帮大家少走很多弯路。
| 症状 | 可能原因 | 排查思路 |
|---|---|---|
| 第一条指令执行就异常,PC乱跳 | 复位信号未正确拉高,或PC初值不是0 | 检查复位时序,确认rst_n信号有效沿 |
| 寄存器写回总是晚一个周期或早一个周期 | 对WB级寄存器堆写使能的时序理解有误 | 画出写回时序图,确认RegWrite在WB级有效 |
| lw后面的指令结果一直不对 | load-use冒险没有阻塞 | 检查ID/EX的MemRead信号和stall条件 |
| 分支跳转后PC完全错误 | flush没有覆盖IF和ID两级,或flush优先级低 | 检查flush信号是否同时清空IF/ID和ID/EX寄存器 |
| beq比较的结果是用旧数据 | 分支比较发生在ID级但没有做数据转发 | 要么把分支判断挪到EX级接收转发结果,要么在ID级额外做旁路 |
| 仿真正确,上板后偶尔出错 | 复位逻辑没有同步处理,或时钟域有问题 | 做异步复位同步释放,检查建立时间约束 |
| 用到了没有初始化的存储器地址 | 数据存储器读出来的值是x或未知态 | 检查存储器模块的初始化文件和读写使能 |
| 某些指令组合偶尔对,偶尔不对 | 控制信号在流水线寄存器中丢失 | 逐个确认控制信号是否逐级传递到位 |
5.1 我在调试中认为最有用的三个习惯
第一,画时序图再动手改代码。发现问题后先不要急着改逻辑,把相关信号的波形时序画出来,标清楚每个信号在哪个时钟沿变化、在哪个阶段生效。很多时候答案就在时序图里,根本不用猜。
第二,分级验证。不要等到全部模块写完才仿真。每写完一级模块,就单独用一个小的测试模块验证它的功能。比如写完了ALU,就单独给ALU喂几组数据看结果是否正确;写完了寄存器堆,就单独验证读写功能。分层验证能大大缩小问题范围。
第三,版本管理。哪怕是课程设计,也建议把能跑的版本打个存档。每加一个新功能(比如转发、阻塞、分支flush),都跑一遍之前所有测试用例。我吃过一次亏:加了load-use阻塞后,原来的分支程序突然不稳定,排查了半天才发现是阻塞信号的优先级把flush冲掉了。如果有版本对比,这个问题会更容易定位。
5.2 一个小技巧:用波形图对照汇编指令
当程序规模变大时,光靠打印日志检查每条指令的写回值已经不够高效。我习惯在波形图里同时把PC、指令编码、以及各级写回寄存器号拉出来,然后把波形和汇编代码逐行对照。具体做法是给指令存储器加一个辅助输出端口,在仿真时同时输出当前PC对应的指令十六进制值,然后在波形里找到对应的PC跳变点,看它和汇编代码中的指令是否一一对应。这样就相当于在波形图里直接看到了指令流。
6. 实际收获与一点个人体会
如果要说这个项目带给我最大的收获,可能不是“我写出了一颗CPU”,而是我终于把流水线冒险从书上的概念变成了实实在在的意识。以前看课本讲转发、讲停顿,总觉得是些绕口的名词。等自己真正调试过一遍,才知道一条add后面的sub为什么会读到旧值,一条lw后面的add为什么要插气泡,以及为什么分支预测错误会带来惩罚周期。
做设计的时候,很多选择并没有标准答案。我一开始的分支判断放在EX级,后来想做单周期惩罚就尝试在ID级提前判断,但这样又要给ID级做额外的数据旁路,代码复杂度瞬间上去了。最后权衡下来,还是保留了EX级判断。这也算是一种经验:不是越复杂越好,关键是让设计在可控的复杂度内正确工作。
如果你也在做同类项目,我的建议是:先把无冒险的流水线跑通,再一步步加入转发、阻塞、flush。每加一个机制就回归测试一遍,不要一口气全写完再调。另外,一定要自己动手翻译几条汇编指令到机器码,这个过程虽然繁琐,但能帮你真正理解指令的二进制编码结构,也会让你在调试时对“这条指令的各字段是怎么被解析出来的”更有把握。