如果你正在为计算机组成原理实验头秃,十有八九绕不开Logisim搭CPU这道坎。尤其是华中科技大学这类把实验抓得很实的学校,经典环节就是用Logisim搭一个支持8条指令的单周期MIPS CPU。别小看这“8条指令”,真动手的时候,寄存器堆、ALU、控制单元、数据通路、时钟信号,每个环节都能把人折磨到怀疑人生。这篇文章把我自己从零搭通8指令单周期MIPS CPU的完整过程、踩过的坑、以及最后验证用的测试程序全部写出来,给你一份可以照着做的保姆级教程,不管你是不是华科的学生,这套思路放到其他学校的计组实验里也一样适用。
1. 动手之前,先把8条指令和单周期的账算清楚
很多人的误区是一上来就打开Logisim开始拖元件,结果拖到一半发现寄存器堆不知道怎么做、控制信号不知道从哪里来。搭CPU之前,先把指令集和“单周期”这三个字理解透,后面所有连线都会顺很多。
1.1 为什么偏偏是这8条指令
单周期CPU实验最常见的8条指令组合是:add、sub、and、or、slt、lw、sw、beq。这8条不是随便凑的,它们涵盖了MIPS的三类指令格式,分别承担不同的硬件职责:
- R型算数逻辑指令:
add、sub、and、or、slt,这类指令从寄存器堆读两个寄存器,ALU计算,结果写回寄存器堆,需要同时打开寄存器堆的两个读端口和一个写端口。 - I型访存指令:
lw、sw,这类指令需要ALU计算访存地址(基址寄存器+立即数偏移),然后从数据存储器读或写。 - I型分支指令:
beq,两个寄存器相等时跳转,需要ALU做减法得出Zero信号,并根据Zero信号决定PC是否跳转。
选定这8条的好处很明显:没有J型跳转指令,分支逻辑相对简单;R型指令的opcode全是0,控制单元只需要区分几个opcode;lw和sw又是唯一涉及数据存储器的指令,访存通路也能覆盖到。换句话说,这8条做到了“用最少的指令覆盖所有核心部件”。
1.2 单周期CPU:一拍之内走完取指、译码、执行、访存、写回
所谓单周期,就是每条指令在一个时钟周期内完成所有操作。MIPS处理器执行一条指令一般分成取指(IF)、译码(ID)、执行(EX)、访存(MEM)、写回(WB)五个阶段,单周期CPU的做法就是把五个阶段需要的硬件全部组合在一起,让信号在同一个周期内走完一整条通路。比如lw指令,这个周期内PC要先给出地址、指令存储器读出指令、寄存器堆读出基址、立即数扩展、ALU计算地址、数据存储器读出数据、最后写回寄存器堆,一条龙全干完。
这带来一个问题:什么时候更新PC、什么时候往寄存器堆写数据?答案是要么都用时钟边沿触发,要么写使能控制好。在Logisim里,PC、寄存器堆、数据存储器的写操作全部接同一个时钟的上升沿。单周期CPU的关键约束是:组合逻辑(比如ALU计算、地址计算、控制信号生成)必须在下一个时钟上升沿到来之前稳定下来,这样每个周期寄存器里的值才是确定且正确的。
用生活类比来说,单周期CPU就像一条只有一站的流水线:每个工人都要把自己负责的活在一拍内全部干完,无论任务多长,大家都得等最慢的那个工位。所以单周期CPU的时钟频率不会高,但设计起来直观,特别适合教学实验。
1.3 从指令格式反推硬件清单
MIPS指令格式分成R型和I型两类(本指令集没有J型)。R型指令格式:opcode(6位) | rs(5位) | rt(5位) | rd(5位) | shamt(5位) | funct(6位)。I型指令格式:opcode(6位) | rs(5位) | rt(5位) | immediate(16位)。
从这两个格式就能推导出CPU需要哪些部件:
- 指令里要有
opcode、rs、rt、rd、funct、immediate这些字段,意味着指令存储器(ROM)输出32位指令后,要用多个Splitter把不同的位段拆出来。 - 要有通用的至少五条运算,需要一个ALU子电路。
- R型指令要读两个寄存器、写一个寄存器,需要一个双读口单写口的寄存器堆。
- I型指令要把16位立即数扩展成32位,需要一个符号扩展器。
beq要算分支目标地址,需要左移2位、PC+4、分支加法器。- 访存指令需要数据存储器(RAM)。
- 不同类型指令的控制信号不同,需要一个控制单元。
把这些部件列成一张清单:PC寄存器、指令ROM、PC+4加法器、分支目标加法器、寄存器堆、ALU、数据RAM、立即数扩展器、左移器、若干多路选择器、控制单元、ALU控制单元。有了清单再动工,心里就有底了。
2. Logisim环境、子电路拆分和两个必须提前定好的约定
实验环境本身很简单,但有一堆细节决定后面调试效率。尤其是“字节地址”和“字地址”的关系,如果不提前想清楚,后面测试lw、sw、beq的时候大概率会一脸懵。
2.1 用什么版本的Logisim,怎么弄到手
Logisim是一个基于Java的数字逻辑仿真软件,常见的教学版本是2.7.1中文版,另外还有功能更强的Logisim Evolution分支。做课程实验的话,我建议直接用中文版2.7.1,界面和老师上课演示的一致,网上一搜“Logisim中文版下载”就有很多资源。
下载后需要本机有Java运行环境。如果双击打不开,先装一个JRE 8以上版本,再在命令行里执行:
java -jar logisim.jar启动后能看到左侧一列元件库,右侧是画布。后面所有操作都在这个环境里完成。有一点要提醒:Logisim的文件格式是.circ,保存后是单个文件,方便拷贝和提交。
2.2 子电路划分:把CPU拆成6个能单独测试的模块
千万不要把所有元件都堆在主电路里,否则连线会乱到亲妈不认,而且一旦出错根本没法定位。强烈建议从一开始就按模块拆成子电路,每个子电路可以单独打开、单独测试,最后再在主电路里实例化拼接。
我推荐的模块划分:
| 子电路 | 功能 | 输入 | 输出 |
|---|---|---|---|
| RegisterFile | 寄存器堆 | ReadReg1[5:0]、ReadReg2[5:0]、WriteReg[5:0]、WriteData[31:0]、RegWrite、CLK | ReadData1[31:0]、ReadData2[31:0] |
| ALU | 算术逻辑单元 | A[31:0]、B[31:0]、ALUCtl[3:0] | Result[31:0]、Zero |
| ALUControl | ALU控制信号生成 | ALUOp[1:0]、Funct[5:0] | ALUCtl[3:0] |
| ControlUnit | 主控制单元 | Op[5:0] | RegDst、ALUSrc、MemtoReg、RegWrite、MemRead、MemWrite、Branch、ALUOp[1:0] |
| CPU | 顶层主电路 | CLK | 便于观察的探针信号(可选) |
在Logisim里创建子电路的方法是:菜单栏Project → Add Circuit,然后给电路命名。子电路内部可以定义自己的引脚(Pin),引脚名建议和信号名一致,这样在主电路里一眼能看出来谁是谁。
2.3 两个全局约定:字节地址→字地址,寄存器写使能高电平有效
第一个约定关乎地址。MIPS是字节寻址,一条指令占4字节,所以PC的值是0、4、8、12这种按4递增的地址;但Logisim的ROM和RAM是按“存储单元”组织的,地址端输入0、1、2、3代表第0个字、第1个字、第2个字。一句话:ROM/RAM地址端是字地址,PC和ALU算出来的是字节地址,两者之间需要右移2位。
所以我在数据通路里明确约定:PC >>> 2接指令ROM地址端,ALU结果 >>> 2接数据RAM地址端。这样lw $t0, 8($zero)算出来的地址是8(字节地址),右移2位后RAM真正访问的是第2个字(字地址)。
第二个约定关系到寄存器写。Logisim里的Register组件默认没有Enable端,必须在组件属性里把Enable勾选成Yes后,寄存器才会多出一个Enable输入引脚,而且高电平有效。我们的控制单元输出的RegWrite也是高电平有效,所以可以直接连。如果发现寄存器怎么都不写,先检查Register属性里的Enable是不是开着。
3. 数据通路分段搭:从PC到指令ROM,再到寄存器堆
数据通路是整个CPU的骨架,控制信号只是在这个骨架上“指挥”。我建议按取指、寄存器堆、ALU、数据存储器的顺序逐步搭建,每个模块搭完立刻用小测试电路验证。
3.1 取指段:PC、PC+4、指令ROM和分支目标加法
先放一个PC:在Memory库里拖一个Register组件,数据位宽设置成32,属性里勾选Enable。但是PC寄存器不需要Enable端,它每个时钟上升沿都要更新,所以可以把Enable悬空或者接常量1。为了美观,我习惯把Enable属性关掉。
再从Wiring库拖一个Constant常量器,值设为4,位宽32,和PC的输出一起接到Arithmetic库的Adder加法器上,就得到了PC+4。
分支目标计算需要几个部件:
- 取出指令的
immediate[15:0],接到Bit Extender(位扩展器),输入位宽16,输出位宽32,扩展方式选Sign extend,得到32位有符号立即数。 - 立即数左移2位,用Arithmetic库的Shifter,设为逻辑左移,移动距离2。
- 左移结果和PC+4一起送进另一个Adder,得到真正的分支目标地址。
PC的多路选择器用来选择下一条指令地址:正常情况用PC+4,分支条件成立时用分支目标地址。放一个MUX,数据位宽32,选择位宽1,输入0接PC+4,输入1接分支目标。MUX的select端后面接Branch AND Zero。
注意ROM组件从Memory库拖出来,属性设置:数据位宽32,地址位宽建议7(可以放128条指令,完全够用)。ROM地址端接PC输出右移2位的结果,这个右移用Shifter设置逻辑右移2位就行。ROM的输出就是32位指令线Instruction[31:0]。
3.2 寄存器堆子电路:双读口加单写口怎么实现
寄存器堆是整个CPU里连线量最大的模块,很多新手在这里阵亡。我提供一个比较稳妥的实现方式:32个Register组件,加上两个大MUX做读口,一个Decoder做写地址译码。
先在子电路RegisterFile里添加输入引脚:ReadReg1[5:0]、ReadReg2[5:0]、WriteReg[5:0]、WriteData[31:0]、RegWrite、CLK,输出引脚:ReadData1[31:0]、ReadData2[31:0]。
内部实现:
- 放32个Register,位宽都是32,属性里勾选Enable。每个寄存器代表MIPS的一个寄存器,$zero对应寄存器0,$t0是8,$t1是9,依次类推。
- 写端口:用一个Decoder(Plexers库),选择位宽5,输出32路。Decoder的输出每一路和RegWrite做AND门,生成对应寄存器的Enable信号。WriteData[31:0]接到所有寄存器的D输入端。CLK接到所有寄存器的时钟端。
- 读端口1:用一个数据位宽32、选择位宽5的MUX,它的32个输入分别接32个寄存器的Q输出。MUX的选择端接ReadReg1,输出就是ReadData1。
- 读端口2:完全同理再放一个MUX,接ReadReg2。
连线量确实大,但逻辑很机械,不容易出错。如果觉得32个寄存器太多,实验初期可以先做16个寄存器(地址位宽只接低4位),逻辑验证通过后再扩展成32个。测试程序只要用到$zero和$t0~$t6,16个寄存器版本足够。
有个细节:Logisim里复制Register组件后,每个寄存器的名字是默认的reg0、reg1……双击可以改名,比如$zero、$t0。虽然不影响功能,但名字清晰对调试很有帮助。
3.3 ALU子电路:五路运算并行算,再按控制信号挑一个
ALU不需要自己造加法器、比较器,Logisim的Arithmetic库里都有现成组件。用一个“并行计算+多路选择”的思路来实现:
- AND运算:Gates库的And门,位宽设32。
- OR运算:Or门,位宽32。
- 加法:Arithmetic库的Adder。
- 减法:Arithmetic库的Subtractor。
- SLT比较:Arithmetic库的Comparator,属性里把Signed设为有符号模式,比较A是否小于B,输出1位,再用Bit Extender扩到32位。
五个运算结果并行算好,用一个MUX挑一个输出。ALUCtl[3:0]作为MUX选择端,选择端位宽4,MUX会有16个数据输入,只占用前5个,其余输入全部接0。注意我们约定:
| ALU控制码 | 运算 |
|---|---|
| 0000 | AND |
| 0001 | OR |
| 0010 | ADD |
| 0110 | SUBTRACT |
| 0111 | SLT |
Zero信号的产生也很关键:用Comparator比较Result是否等于0,输出1位Zero。这个Zero给beq用。
3.4 数据存储器与写回路径
数据存储器直接用Memory库的RAM组件。设置数据位宽32,地址位宽建议7,足够放128个字。RAM有一个OE端(输出使能)和一个WE端(写使能)。在Logisim默认配置下,OE是低电平有效,所以把OE直接接一个常量0,让读出的数据始终有效;WE接控制单元的MemWrite信号,高电平有效。
RAM的地址端和ROM一样是字地址,所以ALU算出的访存地址要先右移2位再接入RAM地址端。RAM输出的ReadData接到一个MUX(MemtoReg选择)的1输入,MUX的0输入接ALU结果,选择端接MemtoReg,输出就是寄存器堆的WriteData。这样lw把从内存读回的数据写进寄存器,R型指令则把ALU结果写回寄存器。
写回路径还有一个高频踩坑点:R型指令要写rd字段,lw要写rt字段,这两个来源必须用MUX切换,选择端就是控制单元的RegDst信号。这个MUX放在寄存器堆的WriteReg输入之前。
4. 控制信号表定生死:ROM查表方式实现主控制器
数据通路一旦搭好,剩下的核心工作就是让控制单元为每条指令生成正确的控制信号。控制信号错了,CPU表现就是各种玄学问题:寄存器不写、该跳不跳、乱读内存。
4.1 8个控制信号逐个说清楚
主控制单元的输入是指令的opcode,也就是Instruction[31:26],输出9位信号。为了在Logisim里方便用ROM实现,我把信号按位排好:
| 位 | 信号 | 含义 |
|---|---|---|
| bit8 | RegDst | 为1时写寄存器地址用rd,为0时用rt |
| bit7 | ALUSrc | 为1时ALU的B端用立即数,为0时用寄存器读出的值 |
| bit6 | MemtoReg | 为1时写回数据来自内存读数据,为0时来自ALU结果 |
| bit5 | RegWrite | 为1时寄存器堆使能写入 |
| bit4 | MemRead | 为1时数据存储器读使能(实际用OE,低有效可忽略) |
| bit3 | MemWrite | 为1时数据存储器写使能 |
| bit2 | Branch | 为1时允许分支跳转 |
| bit1-0 | ALUOp[1:0] | 两位的ALU操作类型控制 |
逐个解释一下为什么需要它们:RegDst解决R型指令写rd而lw写rt的矛盾;ALUSrc解决lw/sw需要ALU计算地址(用立即数)而R型指令需要ALU计算寄存器值(用寄存器数据)的矛盾;MemtoReg解决写回数据来源是ALU还是内存的矛盾;RegWrite和MemWrite分别是寄存器堆和内存的写使能;Branch和ALU的Zero共同控制PC是否跳转;ALUOp是给后面的ALU控制单元用的“粗粒度”控制信号。
4.2 主控制真值表和控制ROM内容
先列出8条指令的主控制真值表。注意sw和beq某些信号是don't care,但在Logisim里不能留空,必须给确定值:
| 指令 | opcode | RegDst | ALUSrc | MemtoReg | RegWrite | MemRead | MemWrite | Branch | ALUOp |
|---|---|---|---|---|---|---|---|---|---|
| add/sub/and/or/slt | 000000 | 1 | 0 | 0 | 1 | 0 | 0 | 0 | 10 |
| lw | 100011 | 0 | 1 | 1 | 1 | 1 | 0 | 0 | 00 |
| sw | 101011 | 0 | 1 | 0 | 0 | 0 | 1 | 0 | 00 |
| beq | 000100 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 01 |
在Logisim里实现主控制器,我强烈推荐用ROM查表而不是纯逻辑门。6位opcode作为ROM地址,9位控制信号作为ROM数据,容量只需要64×9位,几条指令对应几十个地址。这个方法简单、不容易错、后面要扩展指令也很方便。
具体操作:新建子电路ControlUnit,加入输入引脚Op[5:0],8个输出引脚,放一个ROM,地址位宽6,数据位宽9。右键ROM选择Edit Contents,按十六进制填入:
- 地址0x00(R型指令):数据0x122。因为9位二进制是
100100010,按bit8到bit0拆分就是RegDst=1、ALUSrc=0、MemtoReg=0、RegWrite=1、MemRead=0、MemWrite=0、Branch=0、ALUOp=10。 - 地址0x23(lw):数据0x0F0,即
011110000,对应RegDst=0、ALUSrc=1、MemtoReg=1、RegWrite=1、MemRead=1、MemWrite=0、Branch=0、ALUOp=00。 - 地址0x2B(sw):数据0x088,即
010001000。 - 地址0x04(beq):数据0x005,即
000000101。
其余所有地址填0。这样遇到没有定义的指令时,所有控制信号都是0,不会写寄存器也不会访存,相当于一个安全的暂停状态。
4.3 ALU控制:ALUOp和funct联合生成最终运算选择
主控制单元的ALUOp只有两位,对于lw/sw只需要加法,对于beq只需要减法,对于R型指令则需要进一步看funct字段才能确定是加、减、与、或还是小于比较。所以需要一个ALUControl子电路,输入是ALUOp[1:0]和Instruction[5:0](funct),输出是4位ALUCtl。
真值表逻辑如下:
| ALUOp | 指令 | funct | ALUCtl | 运算 |
|---|---|---|---|---|
| 00 | lw/sw | 忽略 | 0010 | ADD |
| 01 | beq | 忽略 | 0110 | SUBTRACT |
| 10 | add | 100000 | 0010 | ADD |
| 10 | sub | 100010 | 0110 | SUBTRACT |
| 10 | and | 100100 | 0000 | AND |
| 10 | or | 100101 | 0001 | OR |
| 10 | slt | 101010 | 0111 | SLT |
ALUControl也可以做成ROM查表:地址 = {ALUOp[1:0], funct[5:0]},共8位地址,数据4位。填充几个关键地址:地址0xA0(ALUOp=10、funct=100000)填0x2,地址0xA2填0x6,地址0xA4填0x0,地址0xA5填0x1,地址0xAA填0x7。此外把地址0x00~0x3F都填0x2,地址0x40~0x7F都填0x6,这样lw/sw和beq组合下ALU一定输出加法或减法。剩余的地址填0不影响正常指令。
这个方法比画逻辑门省心太多。如果老师要求必须用逻辑门实现,也可以照着这个真值表推逻辑表达式,本质一样。
5. 把CPU串起来:连线顺序、排错习惯和第一次冒烟测试
模块都建好以后,回到主电路,把这些子电路像拼积木一样连起来。这一步最容易出现的问题是信号名混乱、位宽不匹配、漏接时钟。
5.1 推荐从PC起步的连线顺序
我习惯按“数据通路优先、控制信号其次”的顺序连线:
- 先放PC寄存器、PC+4加法器、PC选择MUX,把取指回路的骨架拉出来。
- 放指令ROM,PC输出右移2位接入ROM地址,ROM输出得到Instruction[31:0]。
- 把Instruction总线用Splitter拆出需要的位段,逐一送到寄存器堆、ALUControl、主控制单元。
- 放寄存器堆子电路,ReadData1、ReadData2接到ALU相关的MUX。
- 放ALU子电路,接好ALUSrc MUX。
- 放数据RAM,ALU结果右移2位接入RAM地址,RAM读数据接MemtoReg MUX。
- 把寄存器堆的WriteData、WriteReg来源接到MemtoReg MUX和RegDst MUX的输出。
- 最后再接控制信号:ControlUnit输出分别连到各个MUX的选择端、寄存器堆的RegWrite、RAM的MemWrite、ALUControl的ALUOp和funct。
- 所有需要时钟的部件(PC、寄存器堆、RAM)的CLK端接同一个全局Clock组件,方便仿真时统一控制。
关于拆分Instruction总线,有个必须注意的坑:Logisim的Splitter输出的位段不能重叠。比如你想从Instruction[15:0]同时拆出[15:11]和[5:0],这两段有重叠部分,一个Splitter做不到。我的做法是“每个信号一个Splitter”:分别放一个Splitter取31:26、一个取25:21、一个取20:16、一个取15:11、一个取15:0、一个取5:0,每个Splitter的输入都连到同一个Instruction总线上。虽然看起来冗余,但不会出现位段冲突,也不会把总线搞乱。
5.2 浮空线检查:蓝线、红线的潜台词
Logisim里连线的颜色就是调试信号:绿色代表有确定逻辑值,蓝色代表浮空(该信号没有被任何元件驱动),红色代表冲突(多个元件在同时驱动同一个信号)。任何一个控制端出现蓝色,仿真结果就不可信。
每次连完一批线,先别着急跑仿真,用鼠标在空白处拖一下看整条线是什么颜色。尤其是MUX的选择端、Register的Enable端、RAM的WE端,一旦控制单元输出没接对,这几个点很容易飘蓝。还有一点容易被忽略:子电路里的引脚(Pin)是有输入/输出方向属性的,主电路连错方向的话信号会被直接忽略,表现形式也是浮空。
5.3 第一次冒烟测试:先跑通lw和beq
整机第一次测试不建议直接跑完整测试程序,而是先用最少的指令验证通路。我会先在指令ROM地址0放一条lw $t0, 8($zero),地址1(字地址)放一条beq $t0, $t0, 1,然后在数据RAM字地址2处预置一个数,比如5。
打开Logisim的Simulate → Ticks Enabled,第一次点击Tick Once(快捷键Ctrl+T)前,先观察组合逻辑:
- PC当前是0,ROM第一个字应该输出lw指令。
- ControlUnit输出应该能看到RegWrite=1、ALUSrc=1、MemtoReg=1。
- ALU把寄存器0的值(0)加上立即数8,结果应该是8,右移2位后RAM地址端是2,RAM输出的数据应该是5。
点一次Tick后,PC变成4,寄存器堆里的$t0(寄存器8)在下一次上升沿写入5。如果这一步观察到$t0=5,说明取指、寄存器堆、ALU、RAM、写回通路基本是通的。
再单独测beq:让beq $t0, $t0, 1执行时,ALU做减法结果为零,Zero=1,控制单元Branch=1,PC应该从4跳到8而不是走PC+4到8?这里要仔细算:beq指令在字地址1,PC=4,PC+4=8,偏移1左移2位是4,分支目标是12(字地址3),所以PC应该跳到12。如果看到PC变成了12,说明分支通路没问题。
6. 覆盖全部8条指令的测试程序:源码、机器码和预期结果
只跑两条指令只能验证局部。等冒烟测试通过,就该用一段覆盖全部8条指令的测试程序做完整回归。这一节我把程序、机器码、预置数据全部给你,可以直接抄。
6.1 测试程序的设计思路
程序要覆盖三条主线:R型指令的ALU计算与写回、lw/sw访存、beq跳转。我设计的是:
lw $t0, 8($zero) # 从内存读取5到$t0 lw $t1, 12($zero) # 从内存读取7到$t1 add $t2, $t0, $t1 # $t2 = 12 sub $t3, $t0, $t1 # $t3 = -2 and $t4, $t0, $t1 # $t4 = 5 or $t5, $t0, $t1 # $t5 = 7 slt $t6, $t0, $t1 # $t6 = 1 beq $t0, $t1, 1 # 不跳转,因为5 != 7 sw $t2, 16($zero) # 把12写入内存 beq $t0, $t0, 1 # 必然跳转 sw $t3, 20($zero) # 不会执行 sw $t4, 24($zero) # 跳转到这里执行,写入5这样分支不跳转和跳转都能覆盖到,并且能观察哪些sw真的执行了、哪些没执行。数据RAM需要预置:字地址2放5(对应字节地址8),字地址3放7(对应字节地址12)。
6.2 手工汇编:挑两条指令算给你看
先看lw $t0, 8($zero)。opcode是100011,rs是基址寄存器$zero,编号0,即00000;rt是$t0,编号8,即01000;16位立即数是8,即0000000000001000。拼接后:
100011 00000 01000 0000000000001000按字节分组:1000 1100 0000 1000 0000 0000 0000 1000,就是0x8C080008。
再看add $t2, $t0, $t1。opcode是000000,rs=$t0=01000,rt=$t1=01001,rd=$t2=01010,shamt=00000,funct=100000。拼接:
000000 01000 01001 01010 00000 100000结果是0x01095020。我用同样的方法把整个测试程序翻译成机器码,注意最终的十六进制值一定要自己核对一遍,高位和低位不要写反。
6.3 把程序和数据装进Logisim
进入指令ROM的属性,右键选择Edit Contents,在十六进制模式下按地址填入机器码:
| ROM地址 | 机器码 |
|---|---|
| 0 | 8C080008 |
| 1 | 8C09000C |
| 2 | 01095020 |
| 3 | 01095822 |
| 4 | 01096024 |
| 5 | 01096825 |
| 6 | 0109702A |
| 7 | 11090001 |
| 8 | AC0A0010 |
| 9 | 11080001 |
| 10 | AC0B0014 |
| 11 | AC0C0018 |
数据RAM同理,右键选择Edit Contents,在字地址2填5,字地址3填7。注意这里用的是字地址,不是字节地址,因为数据通路里已经把字节地址右移2位了。
6.4 如果跑出来不对,按这个顺序排查
完整跑一遍需要多个时钟周期,每点一次Tick Once就推进一个周期。我建议每个周期都停下来观察PC、寄存器堆里的关键寄存器、RAM的输出,对照预期值看是哪一步出错。
按优先级排查:
- 如果PC根本不增长,先看PC的CLK有没有接到全局Clock,以及是否打开了
Ticks Enabled。 - 如果指令读出来不对,检查PC右移2位和ROM地址端的连接,很多问题都是PC直连ROM地址导致指令地址对不上。
- 如果R型指令结果不对,先单独打开ALU子电路,手动输入A、B和ALUCtl,看输出是否符合预期。重点检查funct字段有没有拆错,
slt的比较器是不是设成了有符号。 - 如果
lw读出来的数不对,检查ALU计算地址后有没有右移2位,以及数据RAM的OE是否被拉低。Logisim的RAM如果OE配置不对,读数据可能一直是0。 - 如果
beq不跳或者乱跳,检查立即数扩展是不是符号扩展、左移2位有没有做、Zero信号是不是真的由ALU减法产生。还有分支目标加法器加的是不是PC+4,不是PC本身。
一个调试习惯非常有用:在关键节点放Probe(探针)组件,比如PC的值、Instruction的值、ALU结果、Zero信号、RAM输出。这样仿真时不用把鼠标悬停在线上一个一个看,探针直接把数值显示在电路上,一目了然。
7. 调试心得和几个能省时间的习惯
最后分享几个我在实际动手过程中总结出来的经验。第一个是子电路一定要先单独验证再拼到主电路。寄存器堆和ALU这种连线量大的模块,直接放进整机再调试会很痛苦,单独建一个测试电路,手动给输入引脚接常量或拨码开关,确认输入输出完全正确后再集成,能帮你快速定位问题。
第二个是别怕用隧道(Tunnel)。主电路里信号多,很多线从左上角绕到右下角非常乱,用同名隧道代替物理连线,既整洁又安全。但注意隧道一定不要起重名,尤其是Clock、RegWrite这种全局信号,一旦重名冲突会莫名奇妙连到一起。
第三个是保持ROM和RAM内容可追溯。Logisim的ROM/RAM内容可以导出和导入,建议在工程目录下保存一份.hex文件,每次修改内容后顺手导出,这样就算工程文件坏了或者要提交实验报告,也能快速恢复。我当年就是因为没保存测试数据,不小心改坏RAM内容后重填了半天。
最后再说一个一般人容易忽略的点:单周期CPU的时钟频率在Logisim仿真里并不重要,重要的是“一个时钟周期内信号要稳定”。所以仿真的时候不要疯狂加快速度,而是用Tick Once一步一步走,每一步都观察PC、控制信号、数值结果,这对理解CPU工作原理的帮助远远大于直接跑通。把这一步走扎实,后面的多周期CPU、流水线CPU实验你会轻松很多。