如果你看到这个题目的时候,第一反应是“单周期和5级流水不是两套东西吗,怎么能做成一个实验”,那我太理解你了。我当年在华中科技计组实验里第一次拿到这个题,也懵了半天。但实际上把这两个设计合在一起,恰恰是理解MIPS CPU执行本质最快的一条路:你先用单周期把每条指令“该干什么”搞清楚,再把它拆成五个阶段,让五条指令同时处于不同生命周期,背后的数据通路和控制信号其实几乎不会变。这也是这门课里最值得认真做的一个实验。
这篇文章我会按我自己从零搭通的思路来写:先讲清楚实验要解决的真正问题,再讲Logisim里搭建CPU必须掌握的操作细节,然后给出单周期数据通路和控制信号的具体设计,再从单周期改造出5级流水,重点展开转发、阻塞和控制冒险的处理,最后讲怎么设计测试程序、怎么快速定位bug。整篇文章更适合《计算机组成原理》做到流水线这一部分的同学,也适合所有想用Logisim手写一个MIPS CPU的人,过程中踩过的坑我都会一一点名,照着走能省很多时间。
1. 先想清楚:这个实验到底在考什么
1.1 从单周期到流水线,本质是“分时复用”变成“分工流水”
很多同学容易陷入一个误区,觉得单周期CPU和5级流水CPU是两种完全不同的设计,要搭两遍。实际上不是,它们的数据通路骨架完全一样:PC、指令存储器、寄存器堆、ALU、数据存储器,这五大件一根线都不少。区别只在于,单周期在一个时钟周期内完成所有工作,时钟周期必须拉长到能容纳“取指+读寄存器+运算+访存+写回”这整条链路;而5级流水把这五个动作切到五个硬件阶段里,每个时钟周期只做其中一个动作。
用流水线来类比:单周期就像是一个人同时负责煮饭、洗菜、炒菜、上菜,一个客人吃完了才轮到下一个;5级流水就像后厨分成五个岗位,每个岗位只做固定一步,五道菜同时在不同的岗位上流转。所以流水线不会让单条指令变快,它变快的是“吞吐率”——每个时钟周期都能完成一条指令(理想情况下)。
对实验而言,这意味着你只需要把单周期的数据通路搭通,然后在IF和ID之间、ID和EX之间、EX和MEM之间、MEM和WB之间各插入一组流水寄存器,让每一级的结果不直接往后传,而是先“锁存”一拍,流水线就成型了。控制信号也要跟着数据一起往后传递,因为不同指令在不同阶段需要的控制信号完全不同。
1.2 指令集范围:控制信号设计的边界
做CPU设计前第一件事,就是把指令集范围定下来,否则控制信号表没法画。华科这个实验通常要求支持基础MIPS指令,我按最常见的范围列一下:
| 指令 | 类型 | 作用 | 关键控制点 |
|---|---|---|---|
lw | I型 | 从内存读数据到寄存器 | RegWrite,MemtoReg=1,ALUSrc=1 |
sw | I型 | 把寄存器数据写到内存 | MemWrite,ALUSrc=1,RegWrite=0 |
add | R型 | 寄存器加 | RegWrite,RegDst=1,ALUOp决定 |
sub | R型 | 寄存器减 | RegWrite,RegDst=1,ALUOp决定 |
and | R型 | 与运算 | RegWrite,RegDst=1,ALUOp决定 |
or | R型 | 或运算 | RegWrite,RegDst=1,ALUOp决定 |
slt | R型 | 小于置位 | RegWrite,RegDst=1,ALUOp决定 |
beq | I型 | 相等则分支 | Branch=1,ALUOp做减法比较 |
j | J型 | 无条件跳转 | Jump=1 |
这里有个值得注意的点:实验里如果没有addi,那你很难通过单条指令直接给寄存器赋初值。常见的做法是先用lw从内存里加载数据,或者让寄存器堆支持初始化文件。如果你们的实验框架允许扩展一两条I型运算指令,我建议优先加addi和ori,这样测试程序写起来会顺很多,而且只是控制信号表里多两行,不会增加太多工作量。
1.3 整体设计路线:搭两遍,还是一遍改?
我的建议非常明确:先老老实实把单周期搭通、测通,再在单周期基础上改流水。不要想着一步到位直接搭流水线。
原因有两条。第一,单周期调试简单。每条指令一个周期,寄存器、内存的结果一拍就能看出来,控制信号表哪一行错了也很容易对照。而流水线一旦出错,你看到的现象是“某条指令多等了一拍”“寄存器写进了错误的值”,根本分不清是转发条件写错了还是流水寄存器信号传丢了。第二,单周期的数据通路是流水的“骨架”,你搭单周期时已经把PC、寄存器堆、ALU、数据存储器的连接方式做完了,后面加流水寄存器只是在骨架上“切几刀、插四个盒子”,不会推倒重来。
2. Logisim里的CPU搭建操作基础
2.1 版本选择和工程组织
Logisim这个工具有点老,但做教学CPU设计依然是最好用的。我强烈建议用Logisim-Evolution这个维护版本,它比原始版修了很多bug,尤其是总线位宽显示、RAM/ROM加载、组件复制粘贴这些方面体验好很多。用原始版本也不是不行,但我在实际使用中遇到过组合逻辑环报错提示不清晰、位宽不匹配时线直接变红不影响运行这类问题,排查起来非常痛苦。
工程组织上,建议按层次结构来做:顶层用Main画完整的CPU数据通路,寄存器堆、ALU、控制单元、转发单元等模块分别做成子电路。Logisim左侧栏的“电路”面板可以新建子电路,双击就能进去画内部逻辑。这样做的好处是顶层图不会乱成蜘蛛网,排查某一模块的问题时也能单独看。我在搭流水线的时候,还专门把“转发单元”和“冒险检测单元”各做了一个子电路,里面的逻辑纯组合电路,方便单独用探针测试。
2.2 连线、位宽和标签
Logisim里最容易被新手忽略的就是线宽。默认连线是绿色的,表示1位;当总线宽度大于1位时,线会变成深蓝色。如果你的输入引脚是4位、输出引脚是8位,直接把两根线连在一起,Logisim会显示红色并报错。这时候要用Splitter(分裂器)来拆分或合并总线,或者把引脚位宽改成一致。
我要重点推荐的是Label(标签)功能,这是多级流水线设计的救命稻草。数据通路里从寄存器堆读出的ReadData1,要一路传到ID/EX流水寄存器、再传到EX阶段、还要接进转发单元的mux,如果用物理硬连线拉过去,图面会绕成迷宫。正确的做法是:在起始位置放一个标签写上ReadData1,在需要用到这个信号的每一处,用同样的标签直接“空中接上”。Logisim会自动认为同名标签是同一个网络。我在设计5级流水时,几乎所有跨级信号的传递都用标签而非物理连线,顶层图面干净很多,排查起来也直观。
2.3 时钟与触发沿
Logisim的时钟元件默认是方波,周期可以调。CPU设计里必须统一一个原则:寄存器类元件都在时钟上升沿写入。包括PC、流水寄存器、寄存器堆里每个寄存器单元。这一点如果没做对,就会出现“数据还没稳定就被写进去了”的竞争问题。
Logisim里寄存器元件默认是上升沿触发,这个不用改。但寄存器堆如果你是自己用D触发器阵列搭的,注意触发沿一定要和PC保持一致。华科实验通常允许直接用Logisim的Register组件搭寄存器堆,或者用RAM组件模拟,这两种方式都能保证触发沿一致,省很多事。
有个小技巧:做单周期调试时,把时钟频率调到1Hz甚至更低,用Clock组件的“手动触发”模式,点一次走一个周期,这样每条指令的执行过程都能看得清清楚楚。到了流水线阶段再改用自动时钟跑测试程序。
2.4 快速调试工具
Logisim自带的调试工具虽然简单,但用好了效率极高:
- 探针(Pin/Probe):直接放在某根线或某个组件输出上,实时显示当前值。我在每个控制信号输出端、ALU输出、PC值输出处都放了探针,测试时一眼就能看出是哪个信号不对。
- LED灯:适合观察1位控制信号,比如
RegWrite、Branch,亮表示1。 - RAM/ROM的右键查看:指令存储器和数据存储器都能右键打开编辑界面,直接看里面内容,验证程序加载和内存写入是最快的。
3. 单周期CPU:数据通路先搭骨架
3.1 五大部件连接顺序
单周期的数据通路是有固定套路的,按顺序一步步接就行:
- PC:32位寄存器,输出当前指令地址,输入是下一条指令地址。下一条地址由多路选择器决定:默认PC+4,分支时PC+4+(立即数左移2位),跳转时取跳转目标。
- 指令存储器:用ROM组件,地址输入接PC输出,数据输出32位指令字。指令的最高6位是
opcode,要引出来送到控制单元。 - 寄存器堆:两个读地址端口
ReadReg1、ReadReg2,一个写地址端口WriteReg,一个写数据端口WriteData,还有一个写使能RegWrite。读端口是组合逻辑,只要地址稳定就能读出数据;写端口需要时钟触发。 - ALU:两个32位输入,一个4位控制信号
ALUControl,输出32位结果和一个1位Zero标志。Zero用来给beq做判断。 - 数据存储器:用RAM组件,地址接ALU结果,写入数据接寄存器堆的
ReadData2,读写使能分别接MemRead和MemWrite。
按这条链连下来,再把控制信号接上,单周期CPU就基本成型了。实际接线顺序我建议是:先接PC到ROM、ROM到寄存器堆、寄存器堆到ALU到RAM,把数据通路的主干打通,再接控制单元和各个多路选择器。
3.2 控制信号表与ALUOp编码
控制单元是个纯组合逻辑,输入是opcode,输出是一堆控制信号。它的输出我建议在单周期阶段就做成一张对照表,方便查错:
| 指令 | RegDst | ALUSrc | MemtoReg | RegWrite | MemRead | MemWrite | Branch | Jump | ALUOp |
|---|---|---|---|---|---|---|---|---|---|
lw | 0 | 1 | 1 | 1 | 1 | 0 | 0 | 0 | 00 |
sw | X | 1 | X | 0 | 0 | 1 | 0 | 0 | 00 |
| R型 | 1 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 10 |
beq | X | 0 | X | 0 | 0 | 0 | 1 | 0 | 01 |
j | X | X | X | 0 | 0 | 0 | 0 | 1 | XX |
ALUOp两位编码的含义是:00表示ALU做加法,01做减法,10表示“看funct字段决定”。R型指令的funct字段有5位,alucontrol子电路需要根据funct和ALUOp生成真正的ALU控制信号:
| funct | 指令 | ALUControl |
|---|---|---|
| 100000 | add | 0010 |
| 100010 | sub | 0110 |
| 100100 | and | 0000 |
| 100101 | or | 0001 |
| 101010 | slt | 0111 |
这一步有个容易踩的坑:beq的ALUOp设计成01,但R型里的sub的funct是100010,两者最终都让ALU做减法,但这并不冲突,因为beq根本不会走到funct解析那一步,它的ALU控制信号直接由ALUOp=01决定,不需要管funct。
3.3 寄存器堆读写时序的坑
单周期里最隐蔽的坑,在寄存器堆的读写时序上。寄存器堆的读端口是组合逻辑,只要ReadReg1地址在,输出马上就有;但写端口是时钟上升沿触发。所谓“先读后写”其实有隐含条件:一个时钟周期内,先让读地址稳定,读出旧值,然后在时钟上升沿把新值写入。如果你在逻辑设计时不注意,比如用同一个时钟沿既想让某条指令读旧值、又想让上一条指令写新值,就会出现“读到刚写入的新值”这种错误。
我当时是用Logisim的Register组件阵列来实现寄存器堆的,每个周期在上升沿统一写入,读端口直接用组合逻辑从各寄存器输出里选择,效果和真实寄存器堆一致,没出过时序问题。如果你自己搭D触发器,千万要确认所有触发器的时钟都接在同一个时钟源上,不要出现一半上升沿写入、一半下降沿写入的情况,否则单周期可能碰巧能跑,到了流水线一定乱套。
4. 从单周期改出5级流水:流水寄存器与控制信号分段
4.1 四级流水寄存器分别寄存哪些信号
单周期数据通路天然就有五个阶段:IF(取指)、ID(译码读寄存器)、EX(运算)、MEM(访存)、WB(写回)。你只需要在四个交界处各插一组寄存器,把所有要跨级传递的信号都锁存住:
- IF/ID寄存器:寄存
PC+4(用于分支目标计算)和32位指令字。这一级的输出就是ID阶段使用的指令。 - ID/EX寄存器:寄存控制信号组、
PC+4、两个读数据ReadData1、ReadData2、立即数扩展结果、指令低5位rt和次低5位rd。寄存器的写使能要受冒险检测单元控制,load-use阻塞时会禁止这一级更新。 - EX/MEM寄存器:寄存MEM阶段需要的控制信号、ALU结果、
ReadData2(用于sw写内存)、写寄存器地址。 - MEM/WB寄存器:寄存WB阶段需要的控制信号、ALU结果、存储器读出数据、写寄存器地址。
每一组寄存器的位宽等于所有信号位宽之和。我当时最常犯的错是漏掉某个信号,导致流水线后面某级拿不到数据。强烈建议先列一张信号传递表,把每个信号从哪一级产生、传到哪一级、存进哪个寄存器都写清楚,再动手接线。
4.2 控制信号怎么拆分传递
控制信号不是全都要传到最后的。以RegWrite为例,它只在WB阶段才用得上,但它经过IF/ID、ID/EX、EX/MEM时都要跟着指令一起往后传,直到MEM/WB寄存器到WB阶段才真正使用。而MemRead、MemWrite则只需要传到MEM阶段,过了MEM阶段就可以丢弃。ALUSrc、ALUOp、RegDst只需要传到EX阶段。
所以在ID阶段,控制单元生成的所有信号要分成三组:EX组(ALUOp、ALUSrc、RegDst)、MEM组(MemRead、MemWrite、Branch)、WB组(RegWrite、MemtoReg、Jump其实放在IF/ID阶段用)。每一级寄存器只负责保存本组以及后面所有级的控制信号。我当时就在ID/EX寄存器里同时存了EX、MEM、WB三组;EX/MEM寄存器里存MEM、WB两组;MEM/WB寄存器里只存WB组。这样控制信号自然跟着指令走,到了对应阶段就能用。
4.3 转发单元(Forwarding Unit)的三个比较条件
数据冒险最典型的情形是:前一条add的结果还没写回寄存器堆,下一条指令就要读同一个寄存器。如果等写回再读,就要白白多等两拍。处理方式是加旁路,把还在流水线里的结果直接送到ALU输入端。这就是转发单元。
转发单元是纯组合逻辑,输入是各级流水寄存器里的RegWrite和写寄存器地址,输出是两个两位选择信号ForwardA和ForwardB,分别控制ALU两个输入端的mux:
- 条件1:
EX/MEM.RegWrite == 1,且EX/MEM.RegisterRd != 0,且EX/MEM.RegisterRd == ID/EX.RegisterRs,则ForwardA=10,表示选EX/MEM的ALU结果。 - 条件2:
MEM/WB.RegWrite == 1,且MEM/WB.RegisterRd != 0,且MEM/WB.RegisterRd == ID/EX.RegisterRs,且条件1不成立,则ForwardA=01,表示选MEM/WB的结果。 - ForwardB同理,只是把
RegisterRs换成RegisterRt。
这里有个先后顺序问题:如果EX/MEM和MEM/WB两级都在写同一个目标寄存器,那EX/MEM里的是最新结果,条件1的优先级必须高于条件2。我当时忘了在条件2里加“条件1不成立”的判断,结果两条连续指令写同一寄存器时,转发到了旧值,调试了整整一个晚上。这个优先级关系,写实验报告的时候也应该专门解释。
还有一类特别容易漏掉的转发:sw的写数据路径。sw在MEM阶段要把寄存器值写入内存,这个值来自ID/EX的ReadData2。如果sw的前一条指令正好是写同一个寄存器的运算指令,你也得把结果转发到EX/MEM寄存器的写入数据端口,否则内存里就写进了旧值。我用的做法是在EX/MEM寄存器的写入数据输入端再加一个mux,转发条件复用ForwardB的判断逻辑。这属于Patterson教材里不太会细讲、但实际做实验一定会遇到的细节。
5. 流水线冒险处理:load-use阻塞与控制冒险
5.1 load-use的检测和阻塞实现
转发解决的是“结果还在流水线里但还没写回”的情况,但有一种情况转发也救不了:lw指令要等MEM阶段末尾才能从内存读出数据,而紧跟它的指令在EX阶段就要用这个值。即使你把MEM/WB的结果转发回EX,也晚了整整一个周期,因为lw的结果在下一级流水寄存器里根本没有。这时候只能阻塞(stall),也就是让流水线停一拍。
阻塞的具体实现方式,我按我的做法来描述:
- 检测条件:
ID/EX.MemRead == 1,且ID/EX.RegisterRt等于IF/ID.RegisterRs或IF/ID.RegisterRt。这说明EX阶段的lw和ID阶段正在被译码的指令之间存在数据依赖。 - 插入气泡:把ID/EX的写使能置0,让
lw之后那条指令在EX阶段执行的是空操作(所有控制信号为0,即NOP)。 - 冻结PC和IF/ID:把PC的写使能置0,IF/ID的写使能也置0。这样取指和译码两级的当前状态保持不变,等待
lw结果出来。 - 一个周期后再转发:下一拍开始,
lw的结果已经进入MEM/WB寄存器,转发单元就能把它转发到EX阶段的ALU输入端了。
这里最容易被忽略的是:阻塞时PC和IF/ID不能动,一旦被清零,那条依赖lw的指令就被彻底丢掉了。我用的是PC和IF/ID寄存器的Enable引脚接冒险检测单元的输出,同时用控制置零的方式插入气泡。如果你的Logisim版本里流水寄存器没有Enable脚,可以用一个与门把时钟信号和使能信号做门控,或者把寄存器的数据输入端接回它自己,这样即使时钟触发也不会更新。
5.2 分支和跳转:flush几条指令
beq的控制冒险处理起来比数据冒险稍微大一点。最规范的教材做法是把分支判断放在ID阶段,比较两个读出的寄存器值,这样只需在分支发生时清掉IF/ID寄存器里的那一条指令,分支损失为1拍。但这要求ID阶段有独立的比较器,还需要把前面指令的结果转发到ID阶段的比较器输入,连线的复杂度会增加不少。
我在实验里选了一个更简单的方案:让beq仍然在EX阶段完成判断,也就是使用ALU的Zero输出。这样做的代价是分支发生时,IF/ID和ID/EX两个寄存器里已经取进来、正在译码的两条指令都必须被清掉,分支损失为2拍。对于正确性验证来说,2拍和1拍的差别完全不影响结果。实现上只需要把IF/ID和ID/EX的控制信号清零端口接上分支判断输出,同时让PC选择分支目标地址就行。
j同样要处理:无条件跳转的next PC由指令低26位拼接而成。我当时的做法是让Jump信号直接参与PC多路选择器的选择,把跳转目标通过一个Splitter从指令字里取出来,左移2位再拼上PC高4位。因为在单周期里j没有分支延迟槽的需求,只要PC下一条地址选择正确就行。到了流水线里,j的影响和beq一样,需要在跳转发生后清掉已经进入流水线的错误指令。
5.3 冒险处理的常见错误
我在实际调试中遇到过的冒险处理相关错误,列几个典型的,给正在做的同学一个心理准备:
- 转发条件里忘了排除寄存器号
$0:$zero恒为0,不能写也不能转发。如果不排除,sub $t0, $zero, $zero这类指令会被误判为数据依赖,造成莫名其妙的结果。记住:RegisterRd != 0这个条件必须加。 - 阻塞时不冻结IF/ID寄存器:只冻结了PC,没冻结IF/ID,结果PC没动,但IF/ID里已经换了新指令,照样丢指令。
- 分支flush时忘了清控制信号:只把指令清成0,但ID/EX里残留的
RegWrite、MemWrite还拉着,导致后续某级误写入。 - 转发mux和数据通路串联顺序颠倒:如果先接了ALUSrc的mux再接转发mux,
sw的写数据就会转发不进去。顺序应该是:寄存器堆输出 → ALUSrc/RegDst等选择器 → 转发mux → ALU输入。
6. 测试程序和排查思路
6.1 最小测试集怎么设计
测试程序的好坏,决定你调试验证的效率。不要一上来就跑一大段汇编,而是准备一个能“精确击中”每个冒险分支的小程序。我用的最小测试序列是这样组织的:
# 用lw加载数据到寄存器 lw $1, 0($0) # 假设内存地址0存了值10 lw $2, 4($0) # 地址4存了值20 add $3, $1, $2 # 触发ALU-ALU转发:$1、$2都是上一条lw的结果 sub $4, $3, $1 # 触发EX/MEM转发:$3刚算完 or $5, $4, $2 # 再触发一次转发 lw $6, 8($0) # 地址8存了值30 add $7, $6, $3 # 触发load-use阻塞:$6要等lw完成 sw $7, 12($0) # 写内存,顺便验证sw写数据路径的转发 beq $1, $1, next # 恒成立的分支,跳转后不要死循环 add $8, $1, $2 # 这条在beq后,应该被flush,不执行 next: j end add $9, $1, $2 # 这条在j后,应该被flush,不执行 end: sw $1, 16($0) # 最后再写一个值,验证跳转之后还能正常执行这段程序把三种数据冒险都覆盖了:ALU-ALU转发、从MEM/WB转发、load-use阻塞,还覆盖了beq和j两种控制冒险。最后检查$3到$9的值以及内存地址12和16的内容,全部符合预期,就说明CPU基本正确。
测试用的ROM内容,Logisim可以直接从文件加载。我习惯用文本文件,一行一个16进制数,对应一条指令的机器码。写汇编再手动转机器码很痛苦,我当时用MARS模拟器先把汇编转成机器码,再把机器码导成ROM文件加载进Logisim。这个流程能省一个小时以上。
6.2 定位bug的顺序
流水线一旦跑不出预期结果,不要盲猜。我的排查顺序是固定的,从前往后逐步收窄范围:
- 先检查PC的跳变:PC是否按预期+4?分支和跳转时有没有跳到错误地址?如果PC都不对,后面全不用看。
- 再看寄存器堆的写回:每条指令执行后,目标寄存器值是否正确?重点检查
RegWrite时序和写寄存器地址,这一步能快速发现控制信号传丢的问题。 - 再看数据存储器:
lw读出的值对不对、sw写进内存的值对不对。内存写错,问题大概率在MEM阶段的控制信号或写数据路径的转发上。 - 最后看控制信号:用探针观察每个阶段的
RegWrite、MemWrite信号,对照周期逐个验证。流水线里控制信号是跟着指令走的,在某级发现MemWrite异常,就去上一级流水寄存器里查它是不是在那里就已经错了。
6.3 实测中遇到的几个问题
我把自己在调试里真实遇过的几个问题放这里,它们都很有代表性:
第一个问题是立即数扩展没有做符号扩展。slt指令没问题,但lw的地址计算如果遇到负数偏移量就完全错了。MIPS的I型立即数是16位有符号数,扩展成32位时,最高位需要复制到高16位。很多同学图省事直接把高位补0,结果正数没问题、负数偏移量全错。这个bug在测试程序里一定要安排一个负偏移量的lw或sw来验证。
第二个问题是分支目标地址计算中的左移2位。beq的偏移量是以“字”为单位的,后接的立即数要先左移2位,再加上PC+4。我一开始忘了左移,导致所有分支都跳到错位置。这个错很容易被忽略,因为如果偏移量恰好是0,左移不左移结果一样,测试用例一不留神就绕过这个bug了。
第三个问题是**j指令的目标地址拼接**。MIPS的J型指令目标地址是{PC[31:28], instr_index, 2'b00},也就是指令低26位左移2位后,和高4位的PC拼接。我在Logisim里用Splitter拆位的时候,位序搞反了,导致跳转地址错得离谱。建议做完后手算一遍j的机器码,对着排查。
还有一个Logisim特有的坑:组合逻辑环。我在实现PC更新逻辑时,图方便让PC的输出直接参与next PC的计算,结果Logisim报“circular dependency”错误。这里的正确做法是把PC的输出先接进一个寄存器,再用寄存器的输出去计算next PC,确保next PC路径里没有组合环。
写在最后:几个值得记住的操作习惯
回头看我折腾这个实验的时间,真正花在写逻辑上的时间其实不多,一半以上都耗在排线检查和工具使用上。作为收尾,我分享几个对我帮助最大的习惯。
第一个习惯是给每一根有明确含义的信号命名标签。ReadData1、ALUResult、PCPlus4这些标签不仅让图面干净,更重要的是Logisim在生成“簇”的时候会把相同标签的线合并成同一个网络,你不需要去物理上把线绕半张图。改线宽的时候也不用担心连线断裂。
第二个习惯是分模块测试,别等全搭完再上电。我每完成一个子电路(ALU、寄存器堆、控制单元、转发单元),都会单独建一个测试电路,喂几组输入看输出。尤其是控制单元,我把所有指令的控制信号都手动验证了一遍,确保表里每一行都和理论一致,再接进CPU。这样后面流水的bug基本都和数据冒险有关,不会和控制逻辑纠缠在一起。
第三个习惯是保留一版“已知正确”的单周期工程副本。从单周期改流水的时候,我复制了一份单周期工程作为备份。流水线改挂了随时回去对比,看看到底是数据通路哪里被我改坏了。这个习惯帮我迅速排掉了至少三个因为误删连线导致的问题。
做CPU实验本质上就是在做“精确的状态控制”,每一根线、每一个触发沿、每一个控制信号都得有据可依。希望我的这些经验和踩坑记录能帮你少走点弯路,如果你在搭的时候还遇到别的奇怪问题,也可以沿着这条思路——先查PC、再查写回、最后查控制信号——慢慢把问题范围缩到最小,它真的能解决90%的流水线bug。