☰
基于Logisim搭建8条指令单周期MIPS CPU完整教程
2026/10/6 1:29:16 网站建设 项目流程

想把这个实验一次做明白,重点不是闷头连线,而是先把数据通路的结构在脑子里立起来。我写这篇教程的时候,参照的是华中科技大学计算机组成原理课程中那个经典任务:用 Logisim 搭建一个能跑 8 条指令的单周期 MIPS CPU。它的意义不在于做出一个多厉害的处理器,而是让你把取指、译码、执行、访存、写回这五个阶段装进一条清晰的物理链路里,看到指令到底是怎么一步步变成控制信号的。

这篇内容面向的是正在做计组实验、或者想自己动手验证 MIPS 单周期架构的同学。我会把指令集选型、数据通路设计、Logisim 实操连线和调试排错全串起来讲,尽量给你一份能直接照着做的参考方案。文章里的做法不一定是你老师要求的唯一答案,但它在逻辑上完整,在 Logisim 里能跑通,而且很容易改成 9 条、10 条指令。

1. 整体设计与思路拆解

1.1 为什么用单周期 MIPS 而不是别的方案

单周期 CPU 的意思是:一条指令在一个时钟周期内完成从取指到写回的全部操作,CPI(Cycles Per Instruction)恒等于 1。它不需要状态机,不需要流水线寄存器,甚至不需要考虑冒险,所以特别适合教学。相比之下,多周期方案需要引入状态转换逻辑,微程序方案要设计控制存储器,流水线方案要处理数据冒险和分支冒险,这些对新手来说都是额外负担。

MIPS 选择它还有一个原因:指令格式规整。R 型、I 型、J 型三类指令长度都是 32 位,操作码和功能码的位置固定,这让学生在做控制信号真值表的时候特别省事。你用 RISC-V 做单周期也不是不行,但教材、参考代码、网上资料绝大多数都是 MIPS,遇到问题好查。用 Logisim 搭建则是顺理成章的选择,它是纯图形化工具,不需要写 Verilog,连线即电路,看着自己的 CPU 一点一点跑起来,成就感是仿真波形图给不了的。

1.2 8 条指令怎么选才合理

实验要求是 8 条指令,但你选哪 8 条会直接决定后面控制逻辑的复杂程度。我的建议是不要只图简单,也不要盲目追求花哨,最好覆盖 R 型、I 型、访存、分支、跳转这几大典型类别,这样才能把单周期数据通路的所有关键部件都串起来。

我最终选择的是这 8 条:

指令类型功能实际用途
addRrd <- rs + rt核心运算
subRrd <- rs - rt核心运算
oriIrt <- rs OR imm逻辑运算+立即数
lwIrt <- M[rs+imm]读内存
swIM[rs+imm] <- rt写内存
beqIif rs==rt then PC <- PC+4+offset条件分支
jJPC <- {PC[31:28], addr, 2'b00}无条件跳转
jalJ$ra <- PC+4; PC <- 目标跳转并链接

选 add 和 sub 是为了覆盖 ALU 的两类基本运算,ori 让你体会立即数的符号扩展与零扩展区别,lw/sw 强制你实现存储器与 CPU 的连接,beq 逼你处理分支目标地址计算和 PC 选择,j 最简,jal 则要求你有办法把返回地址写回寄存器文件,而且返回地址来源是 PC+4,不是 ALU 结果,这就在数据通路上增加了一个多路选择器。

为什么不选 mult、div、slt?因为 mult 和 div 的结果是 64 位,在单周期里处理需要额外寄存器 HI/LO,写完乘除法器之后数据通路就乱了。slt 看着简单,但它要求 ALU 能感知符号溢出,扩展起来也没有想象的那么直观。第一版 8 条指令,结构上的丰富程度已经够了,贪多反而容易把自己绕晕。

1.3 单周期架构的整体组成

单周期 MIPS 数据通路可以拆成几个大块:程序计数器 PC、指令存储器、寄存器堆、ALU、数据存储器、立即数扩展模块、以及一个把所有控制信号集中起来的控制单元。

这些模块的交互关系,一句话就能说清楚:PC 把地址送给指令存储器,取出的指令拆成字段,一部分送往寄存器堆作为读地址和写地址,一部分送往立即数扩展模块,一部分送往控制单元。控制单元根据操作码和功能码生成所有选择信号,ALU 根据控制信号做运算,数据存储器在 lw/sw 时被访问,最终结果要么是 ALU 结果、要么是内存读出的数据,被写回寄存器堆。同时,PC 的值根据分支、跳转或无跳转三种情况选择下一次取指地址。

做 Logisim 连线的时候,我建议你按这个顺序来:先把数据通路画出来,再去填控制逻辑。如果你上来就一个个摆组件,很容易线连到一半发现某个模块的输入输出口数量不对,然后全程返工。

2. 核心细节解析与实操要点

2.1 Logisim 组件选型与参数设置

Logisim 有一个自带的 MIPS 教学库,在左侧组件栏的“Misc”里面能找到 Register File、ALU、RAM 等组件。但说句实话,自带的 ALU 功能太单一,寄存器堆虽然能用但引脚定义跟 MIPS 教材上不完全一样,做实验的话我更推荐自己用基础组件搭。

先说你必须要搭的模块:

  • 寄存器堆:我用的是 Logisim 的 Register File 组件,参数设置里把 Data Bits 设为 32,Address Bits 设为 5,这样就有 32 个 32 位寄存器。它自带两个读端口(Read Register 1、Read Register 2)和一个写端口(Write Register、Write Data、RegWrite 使能)。

  • ALU:自己搭。用 32 位加法器(从 Arithmetic 库拖 Adder),配合 And、Or、Sub 逻辑,加上一个 4 选 1 多路选择器做结果选择。如果你只做 8 条指令,其实 3 个运算就够:add、sub、or,再加上一个相等判断输出 Zero。

  • 指令存储器和数据存储器:用 Logisim 自带的 RAM 组件,地址位宽和 Data Bits 都设为 32。注意一点:RAM 组件的输入输出口都是异步读、同步写,这个特性在你做单周期时反而方便,因为读数据不需要等时钟。

  • PC 和加法器:PC 用 Register 组件,Data Bits 设为 32,加 4 用普通 Adder。

  • 立即数扩展:需要一个 16 位到 32 位的扩展器。MIPS 里有两种方式:符号扩展(sign extend)和零扩展(zero extend)。明确一点:lw、sw、beq 都做符号扩展,ori 做零扩展。你可以在 Logisim 里直接用 Extender 组件,或者自己用 Splitter 配合 Constant 实现,我通常直接手搭,方便看清楚。

提示:Logisim 的 RAM 默认是按字节寻址还是按字寻址,容易搞混。建议把 RAM 的地址位宽设为 32,然后在数据通路里让地址统一用“字节地址 = 字地址 × 4”的方式处理。为了避免麻烦,也可以在加载程序时直接把指令和数据的地址都设置成按 4 递增的字节地址,配合后续的地址加法器做偏移运算。

2.2 数据通路连接顺序

我给你一个经过验证的连线顺序,避免搭完以后才发现某根线路径被占死:

  1. 先放 PC 寄存器,输出接 PC+4 加法器和指令存储器地址输入。
  2. 指令存储器输出是一个 32 位总线。用 Splitter 把它拆成 opcode(31-26)、rs(25-21)、rt(20-16)、rd(15-11)、shamt(10-6)、funct(5-0)这些字段。在拆的时候,我建议你直接给每根总线命名,Logisim 是支持给线打标签的,叫 rs、rt、rd 会让后续连线时脑子清醒很多。
  3. 把 rs、rt 接寄存器堆的读地址,寄存器堆输出 Read Data 1 和 Read Data 2。
  4. ALU 输入端口 A 接 Read Data 1,输入端口 B 接一个二选一多路器,这个多路器的两个输入分别是 Read Data 2 和扩展后的立即数,由 ALUSrc 控制。
  5. ALU 输出接数据存储器地址输入,同时接一个写回寄存器堆的多路器。
  6. lw 时数据存储器读出数据要接写回多路器的另一个输入。
  7. 写回多路器的输出接寄存器堆的 Write Data。
  8. Write Register 地址来自二选一:R 型指令用 rd,I 型指令用 rt。
  9. 最后做分支和跳转逻辑。beq 时 ALU 的 Zero 信号与 Branch 控制相与,决定 PC 的来源是 PC+4 还是分支目标。分支目标 = PC+4 + 符号扩展立即数左移两位。J 型指令直接把 PC 的高 4 位和跳转地址低位拼接。
  10. JAL 的引入让 PC 的来源多了一个:当指令是 jal 时,PC 跳到目标地址,同时把 PC+4 写进寄存器 31。此处需要额外加一个 PC 选择多路器,以及一个寄存器堆写数据来源的选择(是 ALU 结果、内存数据还是 PC+4)。

第 10 点是很多人第一次做时最容易漏的。因为寄存器堆的写数据来源,在只有 ALU 和内存时是两个输入,加了 jal 以后变成三个,你就得在写回路径上再加一级多路器。这个信号我习惯叫 MemToReg,但它实际控制的是三个来源之间的选择,所以更准确的叫法是 WriteDataSel。

2.3 控制信号真值表的编制方法

控制单元是整个 CPU 的灵魂,它的输出决定了所有多路器选谁、寄存器什么时候写、内存什么时候写。我建议你先把控制信号定义清楚,再填真值表。

8 条指令所需的控制信号集合如下:

信号名作用取值
RegDst写寄存器地址选择0=rt,1=rd
ALUSrcALU 输入 B 来源0=rt,1=立即数
MemtoReg写回数据来源0=ALU,1=内存
RegWrite寄存器写使能1=写
MemRead数据存储器读使能1=读
MemWrite数据存储器写使能1=写
Branch分支使能1=beq
Jump无条件跳转使能1=j/jal
ALUOpALU 操作编码2-3 bit

你直接在 Logisim 里用逻辑门把这些信号实现出来即可。我先把每个指令的 ALUOp 定好,再去做控制信号真值表,这样可以减少返工。

ALUOp 编码我采用了 2 位编码方式:

ALUOp用途
00lw/sw/ori:加法(或 or)
01beq:减法
10R 型:根据 funct 细分

然后在 R 型内部,funct 字段需要进一步决定 ALU 操作。比如 funct=32 是 add,34 是 sub,37 是 or。你需要一个小的译码电路,把 funct 和 ALUOp 联合起来,生成最终的 ALU 控制信号。这个思路跟教材上的经典做法一致:ALUOp 是控制单元输出的粗粒度信号,ALU 控制逻辑根据它和 funct 生成精确的 ALU 操作码。

我在做这个电路时,没有给 ALU 控制做成一个大真值表然后硬编码,而是直接用比较器:当 ALUOp=10 时,比较 funct 是否等于 32、34、37,分别输出对应的 ALU 控制组合。这么做的优点是后续想加指令,只需要往比较器里加一条线。

2.4 Logisim 里的引脚命名和标签规范

这个点看着小,但非常影响效率。Logisim 里连线多了以后,如果没有命名,满屏全是细细的黑线,稍微粗心一点就会连错。我的习惯是:

  • 给所有关键信号命名:PCWrite、RegWrite、ALUSrc、MemRead、MemWrite、Branch、Jump、Zero、ALUResult。
  • 用 Label 工具在线上打标签,这样即使不想拉很长的线,也可以通过同名标签实现“虚拟连接”,视觉上更干净。
  • 使用 Tunnel 组件(隧道)跨区域传递信号。比如控制信号从 Control 模块出来以后,用 Tunnel 命名 RegWrite,然后在寄存器堆附近再用同名的 Tunnel 接进去,物理上不需要把线拉一整条横穿屏幕。

Tunnel 是 Logisim 的神器,很多人做完一个完整 CPU 以后才知道,后悔不用。它能让你把电路分成几个可视化模块,每个模块只看到跟自己相关的输入输出标签,逻辑上清晰很多。

3. 实操过程与核心环节实现

3.1 Logisim 工程创建与窗口布局

新建工程后,我习惯先做一点“城市规划”:把画布大致分成四个区域,左上放 PC 和指令存储器,右上放控制单元,中间放寄存器堆和 ALU,下方放数据存储器。用 Logisim 的“编辑菜单-设置”把网格大小调小一点,方便精细布线。

因为电路会比较大,我建议做成分层结构。先用一个子电路模块叫 Control,再做一个子电路模块叫 ALU,然后在主电路中调用它们。Logisim 的 Sub-circuit 特性支持这样做,工具栏左侧的“Add Circuit”可以新建子电路。把控制逻辑封装到子电路里,主电路只负责数据通路的连线,出错了也容易定位。

3.2 搭建 PC 与指令存储器

第一步是 PC。从组件库拖一个 32 位 Register,初始值设成 0x00400000 或 0x00000000 都行,只要你的指令存储器里第一条指令放在这个地址就行。我这里使用 0x00000000 作为起始地址,避开后面计算偏移量时的复杂度。

PC 的输出接到两个地方:一个是 Adder 加 4,得 PC+4;另一个接到指令存储器的地址输入。指令存储器用 RAM 组件,设置宽度 32 位,地址位宽 32。这一步关键来了:RAM 的地址在 MIPS 里是按字节编址的,而我们的程序是按字存放的,所以取指地址和指令存储器的 A 端口连接时,需要保持 32 位全连接。如果你发现取出来的指令不对,多半是这个问题。

JAL 指令还需要用到 PC+4 作为返回地址写入寄存器 $31。我把这个信号连到写回多路器的一个新输入端,给它命名 ReturnAddr。

3.3 寄存器堆的实现

寄存器堆我直接用 Logisim 自带的 Register File。设置 Data Bits=32,Address Bits=5,时钟边沿选上升沿,RegWrite 使能高电平有效。五个端口分别是 Read Register 1、Read Register 2、Write Register、Write Data、RegWrite。

这里要特别注意 Register File 的写时序。Logisim 自带寄存器堆是边沿触发写入,RegWrite 为 1 时,只能在时钟上升沿把 Write Data 写入。而读取是组合逻辑,只要给地址,输出就稳定。这跟单周期 CPU 的假设是一致的:在周期前半段完成读取,后半段完成写入。如果你在仿真时发现数据写不进去,看一下时钟是否在跳变、RegWrite 有没有持续整个高电平阶段。

寄存器 0 恒为 0 的问题:Logisim 自带的 Register File 是否强制了 0 号寄存器恒为 0?老版本没有。如果你用的是老版本,需要自己保证不要往 0 号寄存器写入,或者在写地址为 0 时把 RegWrite 拉低。新版本我印象里已经做了处理,但保险起见,你自己测一下:把 0 号寄存器写入 1,读出来如果还是 0 就没问题。

3.4 ALU 子电路设计

ALU 子电路我这样设计:输入 A[31:0]、B[31:0]、ALUCtrl[2:0],输出 Result[31:0]、Zero。ALUCtrl 编码如下:

ALUCtrl操作
000A + B
001A - B
010A OR B
011A AND B

之所以用 3 位而不用 2 位,是为了后续扩展预留位置。你在加法器、减法器、OR门、AND门的输出端分别接一个 4 选 1 多路器,选择信号就是 ALUCtrl 的低两位。Zero 信号可以用一个比较器判断 Result 是否等于 0,再输出 1 位。也可以用 32 输入 OR 门求反,但比较器更简单。

ALU 的 B 端口已经被 ALUSrc 多路器提前选择过:当 ALUSrc=1 时,B 端口输入的是扩展后的立即数。所以 ALU 内部不要再对立即数做特殊处理,只要保证 A、B 两个输入位数都是 32 位即可。

减法器可以直接用 Adder 组件把第二个操作数取反加一实现,或者直接用 Logisim 的 Subtractor。我建议直接拖 Subtractor,省事。

3.5 数据存储器与 lw/sw 通路

数据存储器使用 RAM,它的 Din 来自寄存器堆的 Read Data 2,地址来自 ALU 输出。MemRead 和 MemWrite 是使能信号。Logisim 的 RAM 读写机制需要注意:读是异步的,地址一变,输出跟着变;写是同步的,需要在时钟边沿且写使能有效时才写入。

lw 指令的数据路径是:rs 值 + 符号扩展立即数 -> ALU 加法 -> 得到内存地址 -> RAM 读出数据 -> 写回到寄存器 rt。这条路径里 ALU 实际上只是当加法器用。sw 指令则是把 rt 里的数据写到内存指定地址,不需要写回寄存器堆。

有个细节:我在做数据存储器时,把 RAM 的 Data Bits 设为 32,地址按字偏移处理。也就是说,如果你想访问内存地址 0x4 的第二个字,那你就在 RAM 地址端口输入 1,然后自己心里清楚实际物理地址是 4。这个映射关系如果不对齐,程序跑起来会乱套。为了简化,我直接把 ALU 输出的字节地址右移两位再喂给 RAM。移两位可以用一个 32 位 Constant 除法或 Splitter 实现。其实更简单的做法是:反正测试程序很小,你直接把地址端口接 ALU 输出,然后加载 RAM 数据时,把数据文件里的地址按 4 的倍数放置。这样连移位器都省了。

3.6 立即数扩展模块

lw、sw、beq 的立即数是 16 位有符号数,需要符号扩展到 32 位。ori 的立即数应该是逻辑立即数,做零扩展。这里有一个 MIPS 的细节:ori 的立即数在 MIPS 规范里是零扩展的,R型指令没有立即数。如果你在 ALUSrc 电路里统一走符号扩展,那 ori 指令算出来的立即数高位会全是 1,OR 的结果就错了。

所以这里要有两个扩展器:一个符号扩展器,一个零扩展器。然后由控制信号 ExtSel(或者直接复用 ALUOp 编码)选择哪个扩展到 ALU 输入。我建议单独生成一个信号,叫 ExtOp,1 表示符号扩展,0 表示零扩展。真值表加一行:

指令ExtOp
lw1
sw1
beq1
ori0

3.7 分支与跳转的逻辑

beq 的实现:ALU 执行减法,Zero 信号为 1 表示 rs==rt。Branch 信号为 1 且 Zero 为 1,则 PC 要跳转。跳转目标 = (PC+4) + (符号扩展立即数 << 2)。立即数左移两位的操作在 Logisim 里可以直接用 Shifter 组件,左移 2 位,或者用 Constant 值 2 配合 Barrel Shifter。我用的是 Shifter 组件,选 Logic 移位,左移 2 位。移位以后和 PC+4 相加,得到分支目标地址。然后用一个多路器,在“是否分支”信号控制下选择 PC 的下一值来源是 PC+4 还是分支目标。

“是否分支”信号 = Branch AND Zero。这个逻辑一定要用 AND 门,别在控制单元里提前把 Zero 考虑进去,否则职责就混了。控制单元只管生成 Branch 信号,至于条件是否成立,由数据通路上的 Zero 决定。这个分离让控制信号的语义更清晰。

j 指令的实现:PC 的低 28 位替换为指令中的 26 位地址左移 2 位,高 4 位保持 PC+4 的高 4 位。用 Splitter 拆开 PC+4 的高 4 位和跳转地址,再拼接。注意左移两位以后低位补 0。

jal 指令在 j 的基础上多一个动作:把 PC+4 写入寄存器 31。这意味着寄存器堆的写地址多路器要多一个输入:常数 31。我直接用一个 Constant 组件输出 5 位二进制 11111。写数据来源多路器也要多一个输入:ReturnAddr。控制信号增加一个 Jal。当 Jal=1 时,RegWrite 必须为 1,RegDst 选择 31,WriteData 选择 ReturnAddr,PC 来源选择 Jump 目标。

3.8 控制单元的实现

控制单元是一个纯组合逻辑电路。输入是 opcode(6 位),输出是一堆控制信号。你可以用真值表硬编码,也可以用 Logisim 里的比较器加上逻辑门组合。我的方案是拉出 6 根 opcode 位线,然后用与门或比较器分别判断它是否等于某个指令的 opcode 编码。

8 条指令的 opcode:

指令opcode
add000000
sub000000
ori001101
lw100011
sw101011
beq000100
j000010
jal000011

add、sub 都是 000000,要再靠 funct 区分。所以在控制单元里,对 R 型指令(opcode=000000)只生成一个 IsRType 信号,然后送给 ALU 控制逻辑,再由 funct 决定最终运算。你也可以偷懒:控制单元直接输出一个 4 位的 ALUOp 给 ALU 控制。反正这块不复杂。

我用一个 6 位比较器来做指令识别。每一个指令都是一个比较器,输出 1 表示当前指令是该类型。然后把这些识别结果组合出各个控制信号。举个例子:RegWrite = IsRType OR IsOri OR IsLw OR IsJal。MemWrite = IsSw。Branch = IsBeq。Jump = IsJ OR IsJal。ALUSrc = IsOri OR IsLw OR IsSw。这个组合方法不容易出错,而且每加一条新指令,只是增加一个比较器和几条线,维护成本很低。

3.9 加载测试程序与仿真验证

全部连完以后,需要往指令存储器里放一段测试程序。我推荐用机器码,直接在 Logisim 的 RAM 里用十六进制编辑器输入。下面是随手编的一段测试程序,验证所有指令:

# 假设起始地址 0x00000000 # 初始化寄存器 ori $1, $0, 5 # $1 = 5 ori $2, $0, 3 # $2 = 3 add $3, $1, $2 # $3 = 8 sub $4, $1, $2 # $4 = 2 sw $3, 0($0) # 把 8 写入地址 0 lw $5, 0($0) # $5 = 8 beq $1, $2, skip # 5 != 3,不跳转 ori $6, $0, 9 # $6 = 9 skip: j end jal func # 测试 jal end: j end # 死循环 func: ori $7, $0, 11 jr $31 # 这条指令不在8条之内,替换成别的

这段程序里用了 jr,而 8 条指令集里没有 jr,所以我会把它替换成一个可以返回的方案,比如直接不调用 func,或把 jal 指令去掉。为了避免超纲,实际验证 8 条指令时,只写直线指令和分支跳转片段,但别指望功能上完全像一个完整程序。

我在实际操作中,更喜欢把每条指令的机器码算好填进去,然后逐步单步仿真,观察每个周期寄存器和 PC 的变化。Logisim 木有真正意义上的单步执行按钮,但你可以通过手动触发时钟(Ctrl+T)来模拟单步。每按一次,电路跳一个上升沿,观察 PC 和寄存器变化,验证一个周期。

3.10 时钟与复位设置

单周期 CPU 的时钟就是 Logisim 的 Clock 组件,频率可以设低一点,比如 1Hz,这样你能用肉眼看到状态变化。也可以在菜单“仿真-时钟”里设置Tick频率,配合自动 ticking 观察整个程序的跑动过程。

复位逻辑也很关键。PC 寄存器要有一个复位信号,把 PC 清零或设置到初始地址。Logisim 的 Register 组件有一个 Reset 输入,你可以接一个按钮。或者直接在寄存器属性里设置初始值。我习惯用按钮控制 Reset,因为实验演示时,按一下清零重新跑,很方便。数据存储器和寄存器堆也最好加复位,否则上一次运行留下的脏数据会干扰验证。

4. 常见问题与排查技巧实录

4.1 指令取出来全是 0 或乱码

这个是最常见的开局问题。原因基本有两种:一是 RAM 的地址线和 PC 的输出没有正确连接,导致读的地址不对;二是你加载程序的时候,没有把机器码写到正确的地址单元。Logisim 的 RAM 编辑器中,左边是地址,右边是数据,如果你把指令写到地址 0x0,而 PC 初始值是 0x00400000,自然读不到。

解决方案:统一 PC 初始值和 RAM 初始加载地址。我是直接都设为 0x00000000,省心。还有一种情况是 RAM 的数据位宽没设成 32,或者 Splitter 拆字段时把低 16 位和高 16 位搞反了,导致 opcode 变成了一堆 0。用探针(Probe)在指令存储器输出端看数据,再用 Splitter 分别看 opcode 位,很快就能定位。

4.2 寄存器写不进数据

现象是 RegWrite=1,Write Data 数据也对,但目标寄存器值不变。检查顺序:

  1. 时钟是否连到寄存器堆的 CLK 输入?
  2. Write Register 地址是否正确?R 型选 rd,I 型选 rt,jal 选 31,如果多路器选错,可能写进了一个你根本没观察的寄存器。
  3. 写数据多路器是否选对?如果 MemtoReg 信号反了,ALU 结果和内存数据之间选错,也会觉得“没写进去”。
  4. 还有一个隐蔽问题:如果 Write Register 地址为 0(比如 add $0, $1, $2),且寄存器 0 是硬连线恒 0,你看起来就像没写入。这其实是正常的。

注意:Logisim 的 Register File 在使能且时钟上升沿时写入。如果你的仿真时钟没有 tick,按 Ctrl+T 手动触发一次,它才会真正写入。很多人在自动仿真下以为信号变化就该写入,忽略了边沿触发的要求,于是调试半天找不到问题。

4.3 ALU 输出和预期不符

常见原因有三个。一个是 ALU 控制信号和真值表对应错了,比如 ALUCtrl=001 应该是减法,但你连到了 OR 门。一个是输入 B 的立即数扩展方式错了:该符号扩展的用了零扩展,或者反之。还有一个是 ALU 输入 B 的多路器(ALUSrc)选择信号反了,导致 should be 立即数的指令用了寄存器值。

排查方法:先手动设一个固定的指令,然后用探针挂到 ALU 的两个输入和控制信号上,看它们是不是你预期的值。如果你看到 ALUSrc=0 但指令是 lw,说明控制单元输出的 ALUSrc 信号接反了,回头查控制电路的逻辑。

4.4 beq 分支跳不过去或者乱跳

beq 不跳,先看 Zero 信号。如果 Zero 一直为 0,说明 ALU 减法结果不为 0,那 rs 和 rt 不相等,不跳是对的。如果 Zero 为 1 但 PC 还是 +4,问题出在 Branch AND Zero 那条链路上,或者 PC 多路器的选择信号没接对。

beq 乱跳,则多半是立即数左移两位没做对,或者分支目标地址计算用了 PC 而不是 PC+4。MIPS 规范里分支目标基于 PC+4,如果你用 PC 加偏移,差 4,整个程序就乱了。另外,比较是拿 rs 和 rt 比,别不小心用了 Read Data 1 和 Read Data 2 之外的东西。

4.5 jal 把返回地址写错

jal 要求把 PC+4 写入 $31,但很多人会把当前 PC 写进去,甚至在跳转指令本身还没执行完就把 PC 更新了。在单周期里,写回发生在当前周期末,PC 被更新也在周期末,所以采集 Write Data 必须是“跳转发生前的 PC+4”,也就是取指阶段算出来的那个 PC+4。连线时你要保证寄存器堆的 Write Data 接的是 PC+4,而不是某个更新后的 PC。

另外,如果你程序中 jal 后面正好是结束分支,那写 $31 的时机是当前指令周期,不会冲突。但如果你把 jal 和 j 混用,注意 j 不会写 $31,别把 Jump 和 Jal 的控制混成一个。

4.6 Logisim 仿真速度特别慢

如果电路逻辑环比较多,Logisim 的仿真器可能陷入振荡,表现为界面卡死或 Tick 后数据乱跳。单周期 CPU 是一个大的组合逻辑回路加时序寄存器,正常来说不会振荡,但如果你不小心把输出反馈到了输入,比如把 PC 的输出又接到 PC 的输入而不通过寄存器,就会振荡。

还有的慢是因为 RAM 容量设太大。你把 RAM 地址位宽设成 32,它内部实际是 2^32 个地址,虽然 Logisim 优化了存储,但仿真时仍旧吃力。建议在实验里把 RAM 的地址位宽改成 10 或 12 位,只模拟真正用到的地址空间。但这意味着 ALU 输出的地址要截断,我一般用一个 Splitter 在送入 RAM 前截取低 12 位,这样仿真体验会顺畅很多。

4.7 一个通用的调试思路

我调试单周期 CPU 时最喜欢用的工具不是波形,而是探针和标签。因为单周期电路控制路径简单,关键信号就那么几个,把探针挂在 PC、指令的高位字段、ALU 的两个输入、ALU 输出、Zero、RegWrite、MemWrite 上,然后一步一步手动 tick,观察每个周期这些值的变化,基本上一两轮就能定位问题。

如果你发现自己对着电路看了半天也找不到问题,我建议把所有数据通路上的多路器都检查一遍,确认选择信号从控制单元出来后没有接反。这个方法虽然笨,但真的有效,因为 8 条指令单周期 CPU 90% 的错误都出在多路器选择信号与指令不匹配上。

提示:在动手连线前,先在纸上把控制信号真值表完整写一遍,然后对照着连线。我在做这版 8 指令 CPU 的时候,提前花了一个小时画了一张信号表,贴屏幕旁边,后面连线和查错都靠它,避免了反复翻教材。这个习惯你值得试试。

我个人踩过最大的坑,是在做 jal 的时候忘了把 ReturnAddr 信号连进 WriteData 多路器,结果仿真时 $31 永远写的是 ALU 的运算结果。后来查了半天发现,根本不是控制逻辑的问题,而是 WriteData 多路器的第三个输入压根悬空了。所以你在做类似的扩展时,连完线一定要检查多路器是不是所有输入都接到了有效信号,悬空引脚在 Logisim 里默认是 0,而这个“0”很可能会静默地污染你的数据。

还有一个后续可以继续做的小扩展:在现有基础上加一条 andi、slt、或者 addi,你会发现只要 ALU 控制逻辑和立即数扩展逻辑稍微改一改,就能再跑通一条新指令。8 条只是起点,等能跑通了以后,再往多周期或流水线走,你对 CPU 的掌控感会完全不一样。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询