
做华中科技大学计组多周期CPU实验那两周我最大的体会是如果你不先把状态机画清楚就打开Logisim连线后面百分之百要推倒重来。我自己就因为这个返工了两次。这篇保姆级教程目标是帮你在Logisim里搭出一台支持8条指令的多周期MIPS CPU指令集选了add、sub、and、or、lw、sw、beq、j正好覆盖R型运算、访存、分支、跳转四条典型执行路径。文章会把指令编码、数据通路、状态机、控制信号真值表、Logisim实操顺序全部串起来讲尤其详细拆解状态机的每个状态和转移条件这部分既是理解CPU工作方式的关键也是实验最容易丢分的地方。这篇内容适用于正在做计组课程设计、被多周期CPU折磨的本科生也适合想用Logisim验证自己对CPU理解的自学者。我会假设你已经会基本操作Logisim比如放置元件、连线、翻转、使用隧道Tunnel但即使你只是刚打开软件跟着顺序走也能跑通。1. 多周期CPU的核心思路硬件如何复用1.1 为什么计组实验要你做多周期先说一个很多同学没想明白的问题单周期CPU不是更简单吗每条指令一个时钟周期就完成了逻辑看起来直接多了为什么实验偏要让你做多周期因为单周期CPU有一个致命问题时钟周期长度必须足够容纳最慢的指令。比如lw指令要完成取指、读寄存器、算地址、访存、写回这一长串延迟全部塞进一个时钟周期里。于是其他指令比如add明明很快也得跟着这个超长周期走。结果是整个CPU都在陪lw加班性能被最慢指令拖死。还有一个问题单周期CPU里每个功能部件在一个周期内只能被分配给一条指令用。同一个ALU既要做PC4又要算分支地址还要做算术运算可它们发生在不同指令里所以单周期设计里这些部件必须重复摆放。硬件利用率极低。多周期CPU的思路完全不同把一条指令拆成几步每步一个时钟周期。不同指令需要的步骤数不一样比如R型指令4个周期lw要5个周期分支只要3个周期。这样时钟周期可以压到一步的延迟而不用照顾最慢的整条指令。更关键的是同一个ALU可以在不同周期干不同的事状态0算PC4状态1算分支目标地址状态2算访存地址状态6算R型运算。一个部件反复用硬件规模显著缩小。1.2 五步执行模型IF、ID、EX、MEM、WBMIPS多周期的基础是把所有指令统一成最多五个标准阶段IF取指从指令存储器读指令同时PC自增4ID译码读寄存器堆译码顺便算出潜在的分支目标地址EX执行R型做ALU运算访存类指令算内存地址分支指令做比较MEM访存lw从数据存储器读sw向数据存储器写WB写回把ALU结果或读到的内存数据写回寄存器堆不是每条指令都走完全部五步。add只走IF、ID、EX、WB四步sw没有WBbeq和j更短。多周期CPU的设计本质就是给每条指令安排一条路径让它在正确的状态做正确的事。1.3 指令在状态机里的行程表我建议你在动手前先做一件事把每条指令要经过的状态列表写出来。我最后的设计里8条指令的状态路径分别是指令状态路径周期数add/sub/and/orS0 → S1 → S6 → S74lwS0 → S1 → S2 → S3 → S45swS0 → S1 → S2 → S54beqS0 → S1 → S83jS0 → S1 → S93S0和S1是所有指令都要经历的因为取指和译码谁也躲不掉。差异从S1结束那一刻开始根据指令的opcode分道扬镳。把这个表贴在显示器旁边调试时你会感谢自己。2. 指令编码8条指令的机器码和字段切分2.1 三种MIPS指令格式MIPS指令固定32位分为三种格式R型用于寄存器之间运算。字段为opcode(31:26)、rs(25:21)、rt(20:16)、rd(15:11)、shamt(10:6)、funct(5:0)I型用于带立即数的指令。字段为opcode(31:26)、rs(25:21)、rt(20:16)、imm(15:0)J型用于跳转。字段为opcode(31:26)、target(25:0)注意R型和I型的opcode都是6位但R型的运算功能靠funct区分I型靠opcode本身区分。这正是控制逻辑里分支的依据。2.2 指令编码汇总表我这套8条指令的编码如下指令格式opcode(6位)funct(6位)功能add rd, rs, rtR000000100000rd rs rtsub rd, rs, rtR000000100010rd rs - rtand rd, rs, rtR000000100100rd rs rtor rd, rs, rtR000000100101rd rs | rtlw rt, offset(rs)I100011无rt MEM[rs offset]sw rt, offset(rs)I101011无MEM[rs offset] rtbeq rs, rt, offsetI000100无if rs rt then PC PC4(offset2)j targetJ000010无PC {PC[31:28], target2}我特意没有放addi因为加一条立即数算术指令会让状态机多出两个状态初学阶段控制逻辑容易乱。这套指令集已经覆盖了四种执行路径先把这套调通再往里面加addi扩展路径清晰很多。2.3 在Logisim里截取字段指令存储器ROM输出的32位指令在Logisim里要用Splitter分流器把字段拆出来。我的做法是建一个子电路专门做指令字段切分用Splitter把31:26拆出来作为opcode送到控制单元25:21拆出来作为rs20:16拆出来作为rt15:11拆出来作为rd送到寄存器堆15:0拆出来作为立即数送到符号扩展电路5:0拆出来作为funct送到ALU控制逻辑符号扩展用Logisim的Sign Extend组件输入16位输出32位立即数左移2位用Shift Left组件。这两步要提前做因为它们都要送进ALU的输入选择MUX里。初学者最容易在这里搞混符号扩展出来的数一个是原始立即数一个是左移2位之后的数两个都要保留分别接给ALUSrcB MUX的10和11两路。3. 数据通路搭建元件清单与关键连线3.1 元件清单你需要准备什么打开Logisim新建项目后你需要准备这些元件元件数量说明Register32位4~5个PC、IR、MDR、ALUOut、状态寄存器ROM1个指令存储器IMRAM1个数据存储器DMAdder1个ALU里做加法Subtractor1个ALU里做减法AND/OR门32位各1个ALU里做逻辑运算MUX多选一6~7个数据选择位宽32MUX4选1位宽11个寄存器堆写地址选择Sign Extend1个16位扩展到32位Shift Left 21个立即数左移两位Constant若干产生常量4、常量0Splitter若干指令字段切分Tunnel很多避免连线交叉组织信号Probe若干调试观察信号Clock1个全局时钟3.2 几个必须理解的中间寄存器IR、MDR、ALUOut多周期CPU和单周期最大的不同就是多了几个中间暂存寄存器它们是时序正确性的关键。IR指令寄存器S0状态从ROM读到的指令必须在S0结束的时钟边沿锁存进IR否则下一个周期ROM地址一变指令就丢了。所以IRWrite只在S0为1。MDR存储器数据寄存器lw在S3从RAM读到的数据要留到S4才写进寄存器堆。如果不锁存S4时RAM地址可能已经变了数据就错。所以S3结束时要把RAM输出锁进MDRS4再从MDR取数据。ALUOutALU结果寄存器多周期里ALU在不同状态干不同活计算结果如果不暂存到了下一个状态就被覆盖了。我设计的ALUOutWrite在S1、S2、S6为1分别保存分支目标地址、访存地址、R型运算结果。注意S8状态下ALU在做rs-rt比较这个周期不允许写ALUOut否则分支目标地址会被覆盖。这里有同学会问为什么不干脆让ALUOut每个时钟都锁存ALU输出因为beq在S8要用的分支目标地址是在S1算好的如果S8时ALUOut被比较结果覆盖PC就写到错的地方去了。这就是我加ALUOutWrite信号的原因比教科书上含混的处理方式更稳。3.3 存储器的字地址陷阱PC和地址线的错位问题这是我见过最多人栽坑的地方必须单独说。MIPS的PC是以字节为单位的每条指令占4字节所以PC是0、4、8、12这样递增。而Logisim的RAM和ROM地址线是按字寻址的也就是地址1对应第二个存储单元而不是字节地址4。如果你的PC输出直接接ROM地址端口那PC4时ROM读的是第4个字直接读错指令。解决办法很简单存储器地址端口只接PC的[6:2]位相当于把PC除以4。PC0→地址0PC4→地址1PC8→地址2完全对应。对RAM同理。ALUOut算出来的访存地址是字节地址接到RAM地址端口前也要取[6:2]位。我在实际搭建时用Splitter把PC和ALUOut的[6:2]取出来再接一个2选1MUXIorD控制选择走PC还是ALUOut作为存储器地址。这个操作虽然小但它决定你后面调试是不是顺利千万别省。3.4 ALU和寄存器堆的落地实现Logisim没有现成MIPS ALU组件需要自己搭。我的ALU子电路输入是A和B两个32位数控制信号ALUOp取2位00加法、01减法、10按位与、11按位或输出Result和Zero。实现方式加法器、减法器、位与门、位或门各来一个四个结果送4选1MUX选择。Zero信号用Result所有位做或运算后再取反即所有位都是0时Zero为1。R型指令的funct译码有个很省事的技巧我仔细看了四条指令的funct编码发现可以直接用funct的某些位组合出ALUOpfunct[2] 1 对应 and(100100)、or(100101)这两位都是按位运算funct[1]|funct[0] 1 对应 sub(100010)、or(100101)所以 ALUOp[1] funct[2]ALUOp[0] funct[1] | funct[0]。验证一下add(100000)→00加法sub(100010)→01减法and(100100)→10与or(100101)→11或全对。这个技巧能省一整块复杂的译码逻辑。寄存器堆的搭建是另一个体力活。如果实验环境允许用Logisim-evolution版可以直接用自带的Register File组件。如果用老版本Logisim只能自己搭32个32位寄存器写端口用5-32译码器生成写选通读端口用两个32选1MUX。这个子电路建好后可以做得很干净缺点是排线量大建议复制粘贴寄存器然后批量改标签。4. 状态机详解每个时钟周期CPU在做什么4.1 十个状态的定义我设计的控制器是Moore型状态机输出只由当前状态决定共10个状态S0取指把PC送给指令存储器IR锁存指令同时ALU计算PC4并写回PCS1译码读寄存器堆ALUSrcA选PCALUSrcB选立即数左移2位ALU计算分支目标地址存进ALUOut等待opcode分派S2访存地址计算ALU计算rs 符号扩展立即数结果存入ALUOut作为lw/sw的访存地址S3存储器读IorD选ALUOutRAM读使能读出的数据锁存进MDRS4lw写回把MDR数据写回寄存器堆的rt字段S5存储器写IorD选ALUOutRAM写使能数据来自寄存器rtS6R型执行ALU做运算Funct产生ALUOp结果存入ALUOutS7R型写回把ALUOut数据写回寄存器堆的rd字段S8分支判断ALU计算rs-rt根据Zero信号决定是否更新PC为ALUOut里的分支目标地址S9跳转PC直接更新为跳转目标4.2 状态转移规则怎么分派状态转移的岔路口主要在S1。所有指令都从S0走到S1然后按opcode分派opcode000000R型→ S6opcode100011lw或101011sw→ S2opcode000100beq→ S8opcode000010j→ S9S2还要再分一次如果是lw去S3如果是sw去S5。这个判断同样靠opcodelw100011sw101011用一个比较器就能区分。其余状态都是单向串行S3→S4→S0S6→S7→S0S8→S0S9→S0。完整转移表如下当前状态转移条件下一状态S0无条件S1S1opcode000000S6S1opcode100011或101011S2S1opcode000100S8S1opcode000010S9S2opcode100011S3S2opcode101011S5S3无条件S4S4无条件S0S5无条件S0S6无条件S7S7无条件S0S8无条件S0S9无条件S04.3 为什么beq需要先算地址再比较两个ALU用途很多同学不理解beq为什么不能一个状态搞定。我拆开解释。beq要做的有两件事算目标地址判断是否跳转。算目标地址本质是PC4加偏移量左移2位这个在S1就顺手做好了存在ALUOut里。判断是否跳转需要比较rs和rt的值这个比较在S8用减法完成看Zero是否为1。为什么不合并因为两个操作都要用到ALU。S1已经在算分支目标地址了不可能同时做减法。如果非要合并就得给ALU再接一个整体复制品那多周期的意义就没了。所以beq必须拆成至少两个状态。到这里你也能看出来S1是纯译码状态它做的事不只是读寄存器还顺手把所有分支指令的目标地址算好。这是一种典型的提前计算设计用面积换时间用状态换部件复用。5. 控制信号真值表状态翻译成电平5.1 信号含义速查先列一遍控制信号的含义后面表格才看得懂信号含义PCWrite无条件写PCPCWriteCond条件写PCZero1时才写IorD存储器地址来源0选PC1选ALUOutMemRead存储器读使能MemWrite存储器写使能IRWriteIR寄存器写使能MDRWriteMDR寄存器写使能ALUOutWriteALUOut寄存器写使能RegWrite寄存器堆写使能RegDst写寄存器地址0选rt1选rdMemtoReg写回数据0选ALUOut1选MDRALUSrcAALU输入A0选PC1选寄存器rsALUSrcBALU输入B00选rt01选常量410选符号扩展立即数11选立即数左移2位ALUOpALU操作00加法01减法10按funct控制PCSourcePC数据来源00选ALU输出PC401选ALUOut10选跳转目标5.2 逐状态真值表这是我的完整控制信号真值表照着这张表接控制单元所有状态的行为就都定死了。状态PCWritePCWriteCondIorDMemReadMemWriteIRWriteMDRWriteALUOutWriteRegWriteRegDstMemtoRegALUSrcAALUSrcBALUOpPCSourceS0100101000000010000S1000000010000110000S2000000010001100000S3001100100000000000S4000000001010000000S5001010000000000000S6000000010001001000S7000000001100000000S8010000000001000101S9100000000000000010其中标为00、01、10、11的多位信号要按位拆开连到对应控制端。RegDst和MemtoReg在非写回状态取0完全没问题不用设成x这样电路更简单。5.3 控制单元的两种实现方式组合逻辑和ROM查表控制单元在我实验里是独立子电路输入是当前状态4位和opcode6位输出是上面一堆控制信号加下一状态4位。两种实现方法我都试过。第一种是纯组合逻辑。用比较器判断当前状态是S0还是S1还是其他用比较器判断opcode的值然后连成MUX。这种办法最贴近教科书但连线非常多大概要花两三个小时而且改一个信号就要跟着改一排线容易错。第二种是ROM查表法强烈推荐。把状态4位和opcode6位拼成10位地址送进一个ROMROM的数据输出就是所有控制信号加下一状态。我只需要把上面真值表按地址展开填进去。ROM地址1024项看着多其实只有几十项有效其余重复填0。用Logisim自带的ROM组件可以按列编辑数据把16进制数直接填进去就行。省事不易错而且后期想改指令只需改ROM内容。6. Logisim实操从空画布到跑通第一条指令6.1 推荐的搭建顺序我用先通后控的次序也就是先把不带控制信号的数据通路接好再接状态机和控制逻辑。否则一旦出错你分不清是通路问题还是控制问题。具体步骤是新建子电路alu、regfile、controller在主电路里放PC、IR、MDR、ALUOut、ROM、RAM、ALU、regfile先接PC到ROM地址的字节地址转字地址逻辑接ROM输出到IR接IR的Splitter字段到寄存器堆和立即数扩展接寄存器堆输出到ALU的MUX输入接ALU输出到ALUOut、ALUOut到RAM地址接RAM输出到MDRMDR到寄存器堆写数据MUX最后把所有MUX选择端、写使能端用Tunnel引出接到controller6.2 一步步接线的关键细节接PC时要特别注意Logisim的Register组件默认是上升沿触发的。PC的数据输入是PCSource那个4选1MUX的输出时钟接全局Clock使能端接PCWrite和PCWriteCond的或逻辑。我建议用一个OR门把这两个信号合并后接到PC的使能这样无条件写和条件写都能触发。IR和MDR的使能端分别接IRWrite和MDRWrite。ALUOut的使能端接ALUOutWrite。这三个信号在真值表里都有明确取值接线时直接从controller对应输出拉Tunnel过来就行。寄存器堆的写端口要注意RegWrite是总的使能写地址是RegDst MUX的输出rt还是rd二选一写数据是MemtoReg MUX的输出ALUOut还是MDR二选一。如果这两个MUX接反lw和R型写回就会互串这是最常见的低级错误。跳转地址的拼接在Logisim里可以用Splitter实现把PC[31:28]接出来IR[25:0]经过Shift Left 2得到28位然后拼接成32位。拼接时注意高4位在前低28位在后顺序反了跳转目标就完全不对。6.3 时钟、探针与运行方式Logisim默认Clock会以固定频率自动跳变调试时一定要先把仿真设置里的Ticks Enabled关掉改成手动单步。每按一次CtrlTMac是CmdT或者点一下时钟按钮模拟一个时钟上升沿观察状态机走一步。在状态寄存器输出、PC输出、ALUOut输出、MDR输出、MDR旁的IR输出都挂上Probe。我建议用持续显示数值的Probe接线后立刻能看到当前值。当状态机卡住时先看Probe上显示的状态编号对照状态表定位是哪个状态出了问题。7. 测试与调试把Bug扼杀在计数器里7.1 测试程序与预期结果我准备了一段12条指令的测试程序覆盖了全部指令类型还顺便验证分支是否跳过指令。在Logisim的ROM里按字填入下列机器码注意ROM地址是按字索引的。字地址字节地址汇编机器码00x00lw $t0, 0($zero)0x8C08000010x04lw $t1, 4($zero)0x8D09000420x08add $t2, $t0, $t10x0109502030x0Csub $t3, $t0, $t10x0109582240x10and $t4, $t0, $t10x0109602450x14or $t5, $t0, $t10x0109682560x18sw $t2, 8($zero)0xAD0A000870x1Cbeq $t0, $t0, 30x1108000380x20sw $t3, 12($zero)0xAD0B000C90x24sw $t3, 16($zero)0xAD0B0010100x28j 00x08000000RAM要预置数据字地址0放1000x64字地址1放2000xC8。运行前把所有寄存器归零。预期结果$t0100$t1200$t2300$t3-100即0xFFFFFF9C$t410020064$t5100|200236RAM字地址2变成300。beq两条应该成功跳过所以RAM字地址3和字地址4保持初始值不被写入。如果RAM字地址3或4出现了-100说明beq没跳去查S8状态。7.2 单步跟踪状态机的技巧我第一次跑通时状态寄存器显示的状态序列应该是这样的循环0→1→2→3→4→0→1→2→3→4→0→1→6→7→0→1→6→7……遇到beq时是0→1→8→0遇到j时是0→1→9→0。如果状态序列乱序比如从S0直接跳到S6说明状态转移逻辑里opcode判断错了。这时候把IR的Probe调出来看看当前32位指令是什么然后手动对照opcode。八成是Splitter的位序接反了。如果状态序列看起来对但寄存器堆结果不对那问题大概率不在状态机而在数据通路的选择MUX上。优先检查RegDst、MemtoReg、ALUSrcA、ALUSrcB这几个MUX的选通。7.3 高频Bug排查表我把自己和周围同学踩过的坑汇总成表你遇到的问题大概率逃不出这几类现象可能原因排查点寄存器堆全为0RegWrite没在写回状态置1查看S4、S7的RegWrite信号寄存器写入的寄存器号不对RegDst MUX接反地址7写入的是rd还是rt寄存器写入的数据不对MemtoReg MUX接反检查写数据选的是ALUOut还是MDRlw读出数据不对MDR时序问题确认S3结束时MDRWrite1PC不更新或乱跳PCWrite或PCSource错误看S0的PCWrite、PCSource信号beq不跳ALU在S8算的不是减法或Zero没接查S8的ALUOp和Zero连线跳转地址完全错字节地址和字地址混淆检查存储器地址端是否接了[6:2]状态机卡在S1不动opcode比较器判定不出确认IR里确实读到了指令调试工具方面Logisim的Poke工具可以直接戳RAM和ROM看内容方便确认预置数据和结果。遇到复杂问题时把Clock掰到手动单步一个状态一个状态点然后对着真值表逐项核对控制信号基本没有查不出的问题。最后分享一个小建议状态机设计时最好先在纸上把状态转移图画到熟练再进Logisim。不是说要画得多漂亮而是让你头脑里清楚每一个状态要开哪些阀门、关哪些阀门。我当时就是因为S8状态没想清楚ALUOut写使能的问题导致分支目标地址被覆盖调了一整天才发现是少了一个控制信号。把这篇文章里的真值表吃透再去接线你可以在一个晚上把它跑通。