
这段时间陆陆续续有不少同学在问同一个问题用Logisim搭一个8指令MIPS CPU到底该怎么下手。尤其是华科计组的这个实验看着就是一个“加法器寄存器ROM控制信号”的拼图真坐到电脑前开始连线的时候才发现处处是坑。我当年做这个实验也是从凌晨一点连到三点第二天调试时发现RAM地址完全对不上气得差点把鼠标扔了。这篇文章就是把这些坑一次性讲清楚。从指令集怎么选、单周期/多周期怎么判断到PC、寄存器堆、ALU、控制单元怎么连再到调试验证阶段的各种玄学报错我会按完成实验的顺序一路捋到底最后还会给一份按模块拆开的“文字版电路图”保证你看完能少走一半弯路。1. 实验拆解先想清楚再动手1.1 8条指令怎么选才能覆盖控制信号全集我见过很多同学拿到题目第一反应是打开Logisim开始拖组件然后拖到一半发现控制信号越接越乱最后推倒重来。做8指令CPU的第一步不是连线而是先把指令集明确下来。这里给一套比较稳的默认组合add、sub、and、or、lw、sw、beq、j。为什么选这八条因为它们刚好覆盖了R型、I型访存、I型分支、J型跳转四类指令。R型有算术逻辑运算I型有加载/存储和条件分支J型有无条件跳转每种控制信号的组合方式都被测了一遍。尤其是beq要处理zero信号j要处理地址拼接这俩是整套实验里最容易出问题的两个地方躲不开不如直接面对。如果你的实验指导书里指定的是其他八条比如有addi没有and也没关系文章里关于R型、I型、J型的实现思路是通用的到时候只需要调整控制信号表和指令编码表就行。1.2 单周期还是多周期先看清实验要求MIPS CPU可以用单周期实现也可以用多周期实现。单周期是一条指令在一个时钟周期内完成取指、译码、执行、访存、写回数据通路比较直白控制单元就是一个组合逻辑真值表。多周期则是把一条指令拆成多个步骤用状态机控制复杂度会明显上升。8指令这个规模单周期是最适合的这也是绝大多数计组实验的默认要求。你可以在动手之前跟老师确认一句“是否必须用多周期”如果没特别说明直接做单周期省下的时间足够你慢慢调beq。这不是偷懒而是把精力花在真正要学的数据通路上状态机可以放到后面流水线实验里再折腾。2. Logisim里最容易踩的三个基础坑2.1 版本、Java库和“仿真为什么不动”工欲善其事必先利其器。Logisim老版本2.7.1在复杂电路里复制粘贴时经常崩溃我建议直接用Logisim Evolution版本它修复了大量稳定性问题自带Register File、RAM等组件也更好用。Evolution需要Java运行环境装OpenJDK 11以上版本基本不会出问题。装完以后打开软件先做两件事第一在“Project - Options - Simulation”里把Tick Frequency设置成比较低的值比如1Hz方便用自动时钟观察第二把“Repetition Error”提示打开后面遇到振荡问题能更早发现。还有一个特别基础但特别坑的点仿真不动的时候先检查你是不是开了组合逻辑分析视图或者时钟组件没连接。Logisim的Register、RAM都必须在时钟沿才会更新如果时钟源没接或者频率设成0整个电路就像死机一样但探针上又有电平非常迷惑。2.2 位宽、Splitter和“越连越晕”的解决办法Logisim的连线上默认位宽是1位而MIPS指令是32位寄存器堆是32位PC是32位。很多同学一上来就拖着线到处接结果某根线只有1位数据被截断。所以每根关键连线都要在属性面板里把Data Bits改成32、16、5或者1养成习惯之后能省一大堆事。拆分指令字段是另一个重灾区。从ROM读出来的是一条32位指令你要把opcode、rs、rt、rd、funct、imm区分出来。简单做法是每根线接一个Splitter把32位按你需要的区间拆开。注意Splitter的位序问题Bit 0是低位从上到下是低位到高位。如果你把rs和rt的顺序搞反了后面寄存器堆读出来的数据完全是乱的而且是那种“看似正常但结果诡异”的乱特别难查。我的建议是不要真的在电路图上引出十几根分支线而是用Tunnel隧道给每个字段命名比如opcode、rs、rt、rd、funct、imm16。这样主电路图看起来清爽查错时直接在Tunnel上右键看值效率翻倍。这一点在后面第5章还会再强调。2.3 “circuit oscillation”不是玄学是组合逻辑环路Logisim弹出“circuit oscillation”时很多人的第一反应是“我的电路是不是要炸了”。别怕这个报错的意思是电路里有组合逻辑闭环比如一个逻辑门的输出经过若干组件之后又绕回自己的输入导致信号永远无法稳定。最常见的3个来源第一PC模块的MUX选择信号误连了PC自己的输出第二ALU的结果又接回ALU输入比如想用ALU做总线回环但没加寄存器隔离第三某个控制信号本身由指令的某几位生成而那个位又从MUX输出反馈了回去。排查方法也很简单把整个电路的Tick暂停然后用探针Probe逐个检查信号值找到哪条线上出现“Err”或者一直跳动的值从那里开始往回查。适当地断开一条连线看报错是否消失是定位环路最快的方式。千万不要一整个全删了重做那才是真的浪费时间。3. 数据通路模块逐个搭3.1 PC、加法器与指令ROMPCProgram Counter我用的是Logisim自带的Register组件位宽设成32位时钟触发用上升沿。PC的输入不是直接接“PC4”而是接一个MUX的输出这个MUX有三个来源PC4顺序执行、分支目标地址beq跳转、跳转目标地址j跳转。跳转优先级要安排好通常j优先于beq。指令存储器用ROM组件Data Bits设成32位Address Bits根据内存空间大小设一般8位到10位就够用。但这里有一个特别容易踩的坑Logisim的ROM是“按字寻址”的不是按字节寻址的。也就是说地址0存第1条指令地址1存第2条指令。而MIPS的PC是字节地址顺序执行时PC要加4。如果你直接把0、4、8这些PC值接到ROM的地址线上其实是在读取ROM地址0、4、8上的内容中间全部跳空第二周期开始就全是空指令。解决办法有两个。第一个是在PC和ROM之间加一个右移2位的转换把字节地址变成字地址再接ROM第二个更简单直接把PC的输出导线在接入ROM前用Splitter截取PC的高位部分相当于除以4比如Address Bits为8就把PC[9:2]接进去PC0对应ROM地址0PC4对应ROM地址1。务必在画图阶段就把这个右移关系记住否则后面测试程序跑起来会一脸懵。3.2 寄存器堆和写入时序寄存器堆我是直接用的Logisim Evolution自带的Register File组件配置成32个32位寄存器两个读口一个写口。读口是组合逻辑只要给地址就能读出来写口是时钟上升沿触发必须RegWrite拉高并且时钟沿到来时数据才会写进去。这里有两个必须注意的细节。第一寄存器堆的写数据来源是MemToReg控制的MUXlw指令时写数据来自数据存储器其他指令时写数据来自ALU结果。第二寄存器堆的写地址来源是RegDst控制的MUXR型指令写rdlw指令写rt。这两个MUX接反一个整个测试程序的结果就全错了。如果老师不允许用自带的Register File需要你用16个寄存器、译码器和MUX自己搭也可以。但时序逻辑和读口设计会繁琐很多调试时间会翻倍。我个人建议除非课程明确禁止否则直接用组件把精力留在控制逻辑上。3.3 ALU与ALU控制信号MIPS的ALU只要支持四种运算就够了加法、减法、按位与、按位或。这不难实现用Logisim的加法器配合几个逻辑门就能拼出来。关键是控制ALU怎么知道该做哪种运算。在简化版实验里可以直接用控制单元给出的2位ALUOp来选择运算00加法、01减法、10与、11或。这样省去了根据funct再转换ALUSelect的过程画图快很多。要是老师要求严格按MIPS的ALUOpfunct方式设计那就需要再加一个ALUControl子电路把ALU取指后得到的funct字段翻译成ALU计算操作。两种方式区别不大但我推荐先跑通简化版如果学有余力再改成标准版。beq指令需要判断rs和rt是否相等最简单的实现是让ALU执行减法如果结果为0说明相等。所以一个Zero信号是必须的。Logisim里可以用Comparator组件把这个ALU结果和一个常数0比较输出1位Zero到控制逻辑和分支地址MUX。别自己拿一堆门去做32位判零很容易把延迟和极性做错。3.4 立即数扩展、数据RAM和地址字节对齐I型指令里的imm是16位而ALU输入是32位所以要先做符号扩展。Logisim里用Extender组件Input Bit Width设16Output Bit Width设32Extension Type选Sign Extension。注意lw/sw里这个立即数可能是地址偏移它必须带上符号否则负数偏移会变成很大的正数直接出错。数据存储器用RAM组件Data Bits为32位。关键是地址线的处理和指令ROM一样的问题RAM也是按字寻址的而MIPS的地址是字节地址。如果你们的实验约定内存按字节寻址那么ALU计算出的访存地址在接到RAM之前必须右移2位。如果实验简化成按字寻址那么直接接ALU结果就行。这个点最好在动手前跟老师确认因为很多实验指导书里默认“内存按字节寻址”但画图时又让大家直接对接导致同学们按那副图画出来以后程序跑到lw开始全部错乱。我在后面验证程序里会用一个“按字寻址”的假设来说明如果你那边是按字节寻址只需要在每个RAM地址输入前加一个右移2位的转换其余逻辑完全一样。3.5 控制单元真值表别靠脑子硬记控制单元是整个CPU的“翻译官”把opcode翻译成一组控制信号RegDst、ALUSrc、MemToReg、RegWrite、MemRead、MemWrite、Branch、Jump、ALUOp。建议先列一张真值表再接线。比如add指令的opcode是000000RegWrite1RegDst1ALUSrc0MemToReg0ALUOp00lw指令的opcode是100011RegWrite1RegDst0ALUSrc1MemToReg1MemRead1ALUOp00beq指令的opcode是000100Branch1ALUSrc0MemWrite0ALUOp01j指令是000010Jump1。这张表我会在第4章给全画控制单元时对着表连线不要凭感觉。4. 8条指令逐条落地4.1 机器码编码表动手接线前把指令编码表抄在纸上这是整张电路图的“语法词典”。下面给出这套8条指令的编码后面的调试全靠它。指令类型opcodefunct说明addR000000100000rd rs rtsubR000000100010rd rs - rtandR000000100100rd rs rtorR000000100101rd rs | rtlwI100011-rt M[rsimm]swI101011-M[rsimm] rtbeqI000100-if rsrt PC 4 imm2jJ000010-PC (PC4)高4位拼接(addr2)R型指令rs在25:21rt在20:16rd在15:11funct在5:0。I型指令低16位是immJ型指令低26位是address。你接线的时候先核对这个字段划分再动控制信号。4.2 R型add、sub、and、or的实现路径R型四条指令没有访存没有立即数没有分支跳转是整张电路里最好通的。数据流是寄存器堆读rs和rt两个数进ALUALU结果作为写数据RegDst选rd作为写地址RegWrite拉高MemToReg选ALU结果ALUOp按每条指令选择对应运算。连线的时候注意ALUSrc要设为0因为ALU第二个输入选的是寄存器堆的rt而不是立即数。这部分如果通了说明PC、ROM、寄存器堆、ALU、控制单元这几大件已经串起来了后续加lw/sw就轻松很多。4.3 I型访存lw、sw的实现细节lw和sw共用一条“地址计算”数据通路rs 符号扩展的16位偏移量由ALU完成。区别在于lw需要从RAM读出数据并写回寄存器堆sw需要把寄存器堆的rt数据写入RAM。lw的控制信号是RegWrite1RegDst0写地址选rtMemToReg1写数据来自RAM输出MemRead1MemWrite0ALUSrc1ALUOp00。sw的控制信号是RegWrite0MemWrite1ALUSrc1ALUOp00。这里有个很多人都会憋半天的点RAM的写使能必须配合时钟沿而且Logisim的RAM默认读取是异步的。也就是说只要你给了地址RAM输出端就能立刻反映那个地址的数据但只有时钟上升沿并且MemWrite为高时数据才能写进去。调试lw时如果你发现写回寄存器堆的数据不是RAM里真正存的值先查MemToReg的MUX是不是选反了再查RAM的Data Out接口是不是和写数据接口混了。4.4 beq与zero分支到底怎么接beq是分支指令它的数据通路分为两个部分一是比较rs和rt是否相等二是计算分支目标地址。比较通常用ALU减法实现结果为0则Zero1Zero信号进入分支控制逻辑。在Logisim中用Comparator比较ALU结果和0是最省事的做法。然后把Zero和Branch控制信号做一下与逻辑得出的BranchTaken信号去控制PC的MUXBranchTaken1时选分支目标地址否则选PC4。分支目标地址的计算方式是“PC4 符号扩展imm后左移2位”。不要忘记左移2位因为MIPS的分支偏移量是以“指令条数”为单位的左移2位才能变回字节偏移。少接一个左移器beq就会跳到错误的位置而且看起来像是“有时候跳对了有时候差了4”特别折磨人。4.5 j跳转address的拼接顺序和beq不同j指令的跳转地址不是相对偏移而是直接把指令里的26位address左移2位再和PC4的高4位拼成32位目标地址。这个拼接顺序千万别反了否则跳到火星上去。在Logisim中可以用Splitter把PC4的高4位拆出来再把指令低26位左移2位后得到的28位作为低位两者拼接成一个32位数。接线时注意低位对低位高位对高位。j的控制信号只要Jump1并且这个Jump信号的优先级要高于BranchTaken不然beq和j同时有效时会乱。4.6 控制信号速查表把八条指令的控制信号列成一张总表做成速查卡贴在屏幕旁边接线和排查时随时看。指令RegDstALUSrcMemToRegRegWriteMemReadMemWriteBranchJumpALUOpadd1001000000sub1001000001and1001000010or1001000011lw0111100000swX1X0010000beqX0X0001001jXXX00001XX这里X表示任意值实际接线时可以接0。仔细看这张表你会发现R型指令的RegWrite、ALUSrc、MemToReg组合和lw很像唯一的差别是写地址选rd还是rt、写数据选ALU还是RAM。设计控制单元时就按这个差异化来连接真值表输出即可。5. 联调、排错与工具技巧5.1 用探针和时钟步进做单步调试整个电路搭完后别急着按自动时钟一顿乱跑大概率会直接看到一堆诡异波形。正确的调试方法是先用Logisim的“Tick Once”单步执行每按一次PC跳一条指令然后用探针观察关键信号值。我会在电路里固定放这几个探针PC值、当前指令、ALU结果、寄存器堆的写地址和写数据、RAM的输出、Zero值。每次单步后对照指令编码表手动算一遍期望值再跟探针显示的实际值比对。这个方法虽然笨但它是定位错误最有效的手段。如果老师允许还可以在ROM里一开始只放一条add指令甚至只放一条空转指令把单条指令的数据通路调通再逐步加入其他指令。一次塞八条指令进去调试出了问题根本分不清是控制信号错还是RAM地址错。5.2 常见报错速查表这里把我自己和周围同学踩过的坑整理一下遇到类似情况可以直接对症下药。症状可能原因解决办法寄存器堆没写入RegWrite为0或写地址选错或写数据MUX选反查控制信号表和MemToReg、RegDst两个MUXPC不走一直停在第一条PC的时钟没接或PC MUX选择信号恒为0检查时钟连接、BranchTaken和Jump信号lw读到的数据不对RAM地址没右移2位或MemToReg选错了检查访存地址的字节/字对齐检查写回路径sw写不进去MemWrite为0或RAM的时钟没触发检查控制信号确认RAM的clk已接时钟源beq怎么都不跳Zero信号接反或分支地址没左移2位用探针看Zero检查分支目标计算链路j跳转位置错误address字段拼接顺序错或没左移2位核对Splitter位序确保地址低位对齐circuit oscillation组合逻辑产生反馈环路断开可疑连线逐段定位反馈ROM后面的指令全是0PC字节地址直接接ROM字地址跳过了字PC右移2位再接ROM地址这张表里的第二条值得多说一句。PC不走了很多时候你以为时钟没接其实时钟接了但PC的MUX永远选择了一个错误来源比如Jump信号乱拉高导致PC反复加载同一个数。所以遇到PC不走不要只盯时钟也要看PC输入端的MUX选择信号。5.3 用Tunnel和子电路救一下你的连线一个8指令单周期CPU动辄几十根连线全堆在主电路图里视觉压力极大。我的建议是模块内部再乱都行但模块之间的连接尽量用Tunnel命名再加上子电路划分。具体来说可以建几个子电路PC模块、寄存器堆、ALU、控制单元、数据存储器。主电路只负责把这些子电路摆好用Tunnel把关键信号连上比如rs、rt、rd、alu_result、mem_data、zero、branch_taken等等。这样排查时能顺着Tunnel的名字直达信号源头不用在一堆交叉线里用肉眼找线。还有一个小技巧给不同功能的线用颜色区分。Logisim里可以对连线和隧道设置颜色我会把时钟线设置成红色控制信号用蓝色数据通路用黑色。刚开始可能觉得花里胡哨等到某个信号查不出问题时颜色能帮你快速圈定范围。6. 附文字版电路图速查与自检清单6.1 模块布局与关键连线备忘这里给一个按模块划分的“文字版电路图”速查你照着这个顺序连不会漏线。时钟输入一个Clock组件并行接到PC的clk、寄存器堆的clk、RAM的clk。PC模块PC输出送PC4加法器PC4结果和分支目标地址、跳转目标地址一起进PC MUXMUX输出回PC输入。取指模块PC输出右移2位后接ROM地址ROM输出32位指令用Splitter拆出opcode、rs、rt、rd、funct、imm、address。寄存器堆rs接读口1地址rt接读口2地址读数据分别送ALU输入和beq比较rd和rt经过RegDst MUX后接写地址写数据来自MemToReg MUX。ALU与立即数rs送ALU输入AALU输入B由rt和符号扩展imm经过ALUSrc MUX选择ALU执行相应运算输出alu_result同时把结果送Comparator比较是否为0得到zero。分支与跳转imm符号扩展后左移2位再与PC4相加得到分支目标address左移2位后与PC4高4位拼接得到跳转目标zero和Branch做与逻辑产生BranchTaken。访存模块alu_result在接入RAM前按字节/字寻址决定是否右移2位RAM的Data In接寄存器堆rt的读数据RAM的Data Out接MemToReg MUX。写回MemToReg MUX输出接寄存器堆写数据RegWrite连控制单元。这八步连完单周期CPU的主体就算立起来了。6.2 上板前自检清单在给老师验收或者跑完整程序之前花两分钟做一遍自检能避免大量低级错误PC的初始值是0吗ROM里的第一条指令能在地址0读到吗PC每次是4吗ROM地址线是PC右移2位之后的吗立即数扩展用的是符号扩展不是零扩展lw/sw的地址在接入RAM前是否与实验要求的寻址方式对齐beq的分支目标计算里有没有做左移2位j指令的跳转地址拼接有没有低28位和高4位顺序颠倒RegDst、MemToReg两个MUX的控制信号指向正确吗有没有接反所有存储器的时钟都接到同一个Clock上了吗所有关键信号上都放了Probe探针吗这些问题如果都回答“确认”那电路基本不会大翻车。7. 用一段手写机器码做最终验证7.1 设计一个能测全部指令的小程序只做单条指令测试还不够最好写一段能连续执行的小程序验证指令间的衔接。假设内存按字寻址数据放在RAM地址0、4、8初始值为5、7、0然后依次执行lw R1, 0(R0)从RAM地址0读取数据到R1lw R2, 4(R0)从RAM地址4读取数据到R2add R3, R1, R2R3 R1R2 12sub R4, R3, R1R4 12-5 7and R5, R3, R4R5 12 7 4or R6, R1, R2R6 5 | 7 7sw R3, 8(R0)把12写到RAM地址8beq R2, R4, 1R27R47跳转一格到jj 0跳回开头这段小程序覆盖了lw、sw、R型算术逻辑、beq、j五类行为。执行完后R1、R2、R3、R4、R5、R6的值以及RAM地址8里的值都应该符合预期。汇编代码转机器码的时候手算最费时间。我一般先写好二进制再按四位一组转成十六进制然后写进ROM的加载文件。比如lw R1, 0(R0)的指令编码是opcode100011 rs00000 rt00001 imm16位全0也就是0x8C010000其他指令同理。这个过程相当于把指令编码表又复习了一遍。7.2 向多周期、流水线和更多指令扩展的思路等单周期跑通其实你已经把计组课里最核心的“控制通路数据通路”摸透了。这时候如果学有余力可以往回看多周期设计。多周期就是把单周期的“一条大组合逻辑”拆成五个阶段用状态机控制。你可以拿现在这套数据通路把指令执行过程拆成“取指、译码、执行、访存、写回”五步控制单元从组合逻辑改成有限状态机会有一种豁然开朗的感觉。流水线扩展更有意思但也会遇到冒险问题。数据冒险可以用前递/转发解决控制冒险依赖分支预测结构冒险需要分离指令存储器和数据存储器。你现在这套单周期电路里用的寄存器堆、ALU其实都能复用只要把PC到ROM这条路径拆成IF/ID两条寄存器之间的通路就能逐渐搭出五级流水线。指令扩展也同样方便。比如把addi、slt、ori加进来无非是在控制信号表里加几行在ALU里多一个比较运算。真正麻烦的其实是“指令权限和时序的协调”这恰恰是计组实验最有价值的部分。最后说几句个人体会。我做这个实验最大的教训就是不要急着在Logisim里连线先把指令集、控制信号表、数据通路草图画明白连线的过程会变得非常机械反而不容易错。还有一点尽量在调试中把探针放足每根关键信号线都要能看到值否则出了问题只能靠瞎猜。这个习惯我后来做别的硬件实验也一直在用帮了大忙。希望你也能一次通过少熬几个夜。