☰
从单周期到微程序:MIPS CPU数据通路设计与调试
2026/10/29 12:24:38 网站建设 项目流程

简介:这份华中科技大学《计算机组成原理》CPU设计实验报告,面向计算机专业本科生及准备课程实验、课程设计的学习者,聚焦32位MIPS CPU的单周期与多周期实现。报告以logisim平台为主线,覆盖8条核心指令的数据通路设计、硬布线控制器与微程序控制器实现、sort.hex冒泡排序测试、溢出异常处理,以及故障调试、测试分析和实验总结等环节,能帮助读者理解CPU内部结构、控制信号组织与分阶段验证思路。压缩包共1个docx文件,约2.8MB,为完整实验报告文档,目录从设计要求、方案设计延伸到实验步骤、故障与调试、测试与分析,并附总结心得与参考文献,便于按实验流程对照阅读和整理笔记。目前已有12128人学习,适合用作实验预习、报告撰写参考及期末复习材料,也可辅助梳理单周期与多周期CPU设计的关键步骤。

1. 冒泡排序跑不出结果的那晚:一份 CPU 设计实验报告里真正值钱的部分

计算机组成原理的 CPU 设计实验,最容易让人误判进度的地方,是“电路能跑起来”和“结果是对的”根本是两件事。我在某高校计算机学院做这个实验时,单周期 MIPS CPU 第一次跑通冒泡排序,输出是一片零;把时钟降到手动单步,才看清是写回地址的 MUX 选错了。这份实验报告的目录里,方案设计占了十一页、实验步骤占了十页,故障与调试只有一页,可真正吃掉时间的恰恰是那一页。下面按单周期、多周期、微程序、硬布线四段,把 8 条核心指令的数据通路怎么合、13 个状态怎么切、微指令表怎么填讲透,新手能照着接线,熟手能对着核逻辑。

2. 单周期 MIPS CPU:8 条指令的数据通路怎么合并不打架

2.1 逐条指令画通路,是唯一靠谱的起手式

单周期的定义很朴素:一条指令的全部动作在一个时钟周期内做完,所以 CPI 恒等于 1,代价是最长的那条指令决定了整个时钟周期。8 条核心指令里,lw 一次用到了取指、读寄存器、ALU 算地址、访存、写回五段,它就是那个短板。这意味着画通路时不能一上来就对着综合示意图接线,得先把每条指令单独走一遍,记下它碰了哪些部件、在哪个多输入点需要加 MUX。

取指通路所有指令共用:PC 送指令存储器地址端,取出 32 位指令;同时 PC+4 回写到 PC。R 型(add、slt)从通用寄存器组读 rs、rt 两个操作数送 ALU,结果按 rd 写回。addi 是 rs 加符号扩展后的 16 位立即数,写回 rt。lw 用 rs 加立即数算出地址,从数据存储器读 4 字节写回 rt。sw 地址算法和 lw 完全一样,只是数据流方向反过来,把 rt 的值写进存储器。beq 和 bne 比较 rs、rt,靠 ALU 的 equal 输出判断。

syscall 这条最容易被轻视:它不需要任何数据通路,只需要把 halt 信号接到 PC 的使能端。使能端拉低之后 PC 不再递增,取指地址停在原地,CPU 相当于原地踏步,程序自然停住。这份报告就采用了这一方案,省掉了一个额外的停机状态机,这是我在别的实现里没见到的省事做法。

七条通路摆在一起,冲突点其实只有四类:写寄存器的地址来自 rd 还是 rt、写回的数据来自 ALU 还是存储器、ALU 的 B 端来自寄存器还是立即数、下一条 PC 来自 PC+4 还是分支目标。每一类冲突对应一个二路选择器,选择端全部由控制器输出驱动。把这四个位置记牢,综合图基本不会接乱;记不住就只能在几十根线里瞎猜,我见过有人为了一个写回错误拆了整张图重画。

2.2 合并时的三处硬骨头:branch、halt 和地址位宽

branch 信号是第一个坑。equal 是 ALU 的输出,beq 要它在 1 时跳转,bne 要它在 1 时不跳转,所以 branch = Beq·equal + Bne·equal'。落到电路上就是一个非门、两个与门、一个或门的事,但常见的翻车是只接了 beq 忘了 bne,或者把 bne 也接成 equal 正相,结果冒泡排序里 bne 的分支永远不跳,循环提前退出,输出只排了一半。写好这个式子后,建议手动验一遍四种组合:beq 且 equal、beq 且不 equal、bne 且 equal、bne 且不 equal,只有第一种和第四种该输出 1。

halt 要接 PC 的使能端,不是复位端。接复位会把 PC 清零,程序从头再跑一遍,现象看着像死循环,实际是重启。另外要确认封装组件的使能有效电平,有的低有效有的高有效,接反了会出现一上电就停、或者到 syscall 根本停不下来两种极端。

地址位宽是第三个坑。PC 是 32 位字节地址,而指令存储器和数据存储器的地址输入端只有 10 位,且是字地址,所以要取 PC 的第 2 到第 11 位送进去,低两位因为 4 字节对齐恒为 0。Logisim 的分离器默认从最低位开始编号,接线前最好在纸上写明哪根线对应哪一位;接反的典型现象是取到的指令每隔 4 条才变一次,或者数据段读写打到了指令段上。复位信号记得接到数据寄存器和指令寄存器,16 位到 32 位的立即数扩展用现成的扩展器,别手接 16 根线。

2.3 硬布线控制器的三段拆法:译码、ALU 控制、控制信号

硬布线控制器的输入是指令的 Op 字段和 Func 字段,输出是一堆控制信号。直接写一张大真值表会崩溃,拆成三段就顺了:先把 Op/Func 翻成指令译码信号,再由译码信号决定 ALU 操作码,最后把译码信号或起来得到各个控制信号。

第一段是指令译码。I 型指令看 Op 字段就够了,用比较器逐个比对:LW 是 0x23、SW 是 0x2b、BEQ 是 0x04、BNE 是 0x05、ADDI 是 0x08。R 型指令的 Op 字段恒为 0,具体功能要看 Func 字段,ADD 是 0x20、SLT 是 0x2a、SysCall 是 0x0c,而且 Func 的比较结果必须再和“Op 等于 0”相与,否则任何一条 Op 恰好等于这几个值的指令都会被误判成 R 型。R_TYPE 本身的逻辑是 (ADD + SLT) & ~SysCall,因为 syscall 的 Op 也是 0,不排掉它就会把停机指令当成运算指令送进 ALU。

第二段是 ALU 控制。这个实验里运算器只用两种操作:加法和有符号比较,对应的内部操作码分别是 0x5 和 0xb,且只有 SLT 指令需要比较。所以拿一个二路选择器,两个输入固定接 0x5 和 0xb,选择端接 SLT 译码信号,输出送 ALU 的功能选择端就完事。

第三段是控制信号汇总,把上一步的译码信号按功能或起来,得到下面这张能直接照着接的表:

指令RegDstAluSrcBMemToRegRegWriteMemWriteBeqBneHaltALU_OP
add100100000x5
slt100100000xb
addi010100000x5
lw011100000x5
sw010010000x5
beq000001000x5
bne000000100x5
syscall000000010x5

这张表的意义在于可反向验证:任意一个控制信号,看它所在的列有哪些行是 1,把这些行的译码信号或起来就是它的表达式。比如 RegWrite 在 add、slt、addi、lw 四行是 1,那 RegWrite = R_TYPE + ADDI + LW,少写一项就有一条指令写不回寄存器,现象是排序结果里某个变量永远是初值。

3. 多周期 MIPS CPU:存储器复用之后,数据要锁在哪一级

3.1 单周期到多周期的八处结构性差异

多周期不是把单周期拆成几段那么简单,它改的是整个架构的组织方式。我把这份报告里提到的差异整理成一张对照表,接线之前先过一遍,能省掉大量返工:

差异点单周期多周期
存储器指令存储器与数据存储器分开合并成一个,取指与访存分时使用
功能部件每条指令独占一次存储器、ALU 在一条指令内多次分时复用
锁存寄存器无新增 DR、IR、A、B、C 五个寄存器
PC 控制仅受时钟驱动,靠 halt 禁止增加独立的写使能信号 PcWrite
ALU A 端只有寄存器输出增加 PC 值输入,用二路选择器选
ALU B 端寄存器或立即数二选一增加常数 4 和偏移量左移两位,共四路
存储器地址固定接 PC 或 ALU 结果用 IorD 信号在两路之间选
停机方式halt 信号禁用 PC状态自循环,不再需要 halt

新增五个寄存器的原因很直接:一条指令跨多个时钟周期,第一个周期算出来的值如果不用寄存器锁住,到第三个周期就被组合逻辑的新输出冲掉了。A 和 B 存的是译码阶段读出的两个源操作数,C 存的是 ALU 的输出,DR 存访存读回的数据,IR 存取回的指令。这五个寄存器全部要接复位信号,否则上电瞬间是随机值,第一条指令就可能译码错。

PC 加独立写使能是另一个容易忽略的点。单周期里每条指令都占一个周期,PC 每个时钟都更新;多周期里 lw 要走四个周期,如果 PC 每个时钟都加 4,一条指令还没执行完下一条就取进来了。所以只有当状态机处于取指状态时才拉高 PcWrite,其余状态 PC 保持。

3.2 13 个状态是怎么切出来的

状态划分的原则是:一个时钟周期内能并发完成的微操作,合在同一个状态里;有先后依赖的,拆开。取指和译码是所有指令的必经阶段,各占一个状态;执行阶段按指令类型展开。切完之后一共 13 个状态,编号 0 到 13(其中 0 号取指、1 号译码,执行阶段从 2 开始):

状态微操作关键控制信号
0 取指IR ← Mem[PC],PC ← PC+4MemRead、IrWrite、PcWrite、IorD=0
1 译码A ← R[rs],B ← R[rt],C ← PC+(S-EXT(imm)<<2)AluSrcA=PC、AluSrcB=偏移量
2 lw 算地址C ← A + S-EXT(imm)AluSrcB=立即数
3 lw 访存DR ← Mem[C]MemRead、IorD=1
4 lw 写回R[rt] ← DRRegWrite、MemToReg=1
5 sw 算地址C ← A + S-EXT(imm)AluSrcB=立即数
6 sw 访存Mem[C] ← BMemWrite、IorD=1
7 R 型运算C ← A+B 或 A<BAluSrcA=A、AluSrcB=B
8 R 型写回R[rd] ← CRegWrite、RegDst=1
9 beqif(A==B) PC ← CBEQ、PcSrc=1
10 bneif(A!=B) PC ← CBNE、PcSrc=1
11 addi 运算C ← A + S-EXT(imm)AluSrcB=立即数
12 addi 写回R[rt] ← CRegWrite
13 syscall空操作,锁住 PC无

这里有个设计取舍值得单独说:分支目标地址 C 在状态 1 译码阶段就算好了,而不是等到 beq/bne 状态再算。这样做的好处是 beq 和 bne 各自只需要一个状态,做一次比较、写一次 PC 就结束;代价是译码阶段 ALU 被占用一次,且 A 端要能从 PC 取值。如果不在译码阶段提前算,beq 就得拆成“算地址”和“比较跳转”两个状态,状态总数涨到 14 个,状态机的复杂度也跟着涨。

syscall 的处理是另一个巧思。它不设专门的停机信号,而是让状态机的下一状态字段指向自己,永远停在 13 号状态出不去。这样停机的实现就退化成了“状态不再迁移”,不需要额外的 halt 线,也不需要在数据通路上做任何改动。

3.3 多周期布线:比单周期多出来的那几个选择器

多周期数据通路的 MUX 比单周期多得多,接之前先列清楚有几路、选择端是谁,能省掉一半调试时间。

ALU 的 A 端从一路变成两路,输入是寄存器 A 的输出和 PC,选择端是 AluSrcA。B 端从两路变成四路:寄存器 B 的输出、常数 4(取指时算 PC+4 用)、符号扩展后的立即数、符号扩展后左移两位的偏移量,选择端是两位的 AluSrcB。四路选择器有个细节:未使用的输入端不要悬空,接一个确定值(比如常数 4 那一路),否则 Logisim 会报错或者给出红色的未知态。

存储器地址端也变成两路,IorD 为 0 时接 PC 取指令,为 1 时接寄存器 C 访存。PC 的输入端同样两路,PcSrc 为 0 时接 ALU 算出的 PC+4,为 1 时接 C 里的分支目标。

控制信号的数量比单周期多出不少,除了原来的 RegDst、MemToReg、RegWrite、MemWrite、MemRead、Beq、Bne,还要加上 IorD、PcSrc、AluSrcA、两位的 AluSrcB、IrWrite、PcWrite,以及两位的 ALU_Control。这些信号在微程序控制器里是微指令的一个字段,在硬布线控制器里是状态机的输出,两种实现共用同一张信号表,所以先把这张表定死,后面的两种控制器都轻松。

4. 微程序控制器:把 13 条微指令写成一张能自动生成的表

4.1 微指令字段排序与下址字段的三条规则

微程序控制器的本质是把“每个状态该发什么控制信号”写成一张表,存进控制存储器,运行时按状态号取出对应的一行,直接驱动数据通路。所以字段的顺序必须和数据通路上信号的物理顺序严格一致,一旦错位,整张表全废。

我用的字段顺序是:IorD、PcSrc、AluSrcA、AluSrcB、MemToReg、RegDst、IrWrite、PcWrite、RegWrite、MemWrite、MemRead、BEQ、BNE、ALU_Control(2 位)、下址字段(4 位)。这个顺序在书写的表格、导出的文本、控制存储器的位序三处必须完全一致,中间任何一处改了顺序都要三处同步改。

下址字段的填法有三条硬规则。第一,取指状态的下址填 1,指向译码状态;译码状态的下址填 0,表示“不要顺序执行,交给地址转移逻辑按指令类型跳转”。第二,每条指令执行阶段的最后一条微指令,下址也填 0,同样交给地址转移逻辑,让它跳回取指状态。第三,syscall 的下址填 13,指向自己,形成自循环实现停机。这三条规则覆盖了全部 13 条微指令,剩下的就是按状态顺序顺填。

顺填的过程中有个坑:容易把译码状态的下址填成 2,觉得“反正执行阶段从 2 号开始”。这样做的结果是所有指令都从 lw 的路径开始执行,add 指令会去访问存储器,程序跑出来的结果看着像随机数。这个错误在单步调试时很难发现,因为状态机确实在正常迁移,只是迁错了路。

4.2 地址转移逻辑交给真值表,别手推布尔表达式

地址转移逻辑干的事很单一:输入 7 个指令译码信号,输出 4 位的微程序入口地址。各指令对应的入口地址是固定的——R_Type 是 7(0111)、ADDI 是 11(1011)、LW 是 2(0010)、SW 是 5(0101)、BEQ 是 9(1001)、BNE 是 10(1010)、SYSCALL 是 13(1101)。每个入口地址就是该指令执行阶段第一条微指令在控制存储器里的位置。

手推布尔表达式会写疯,正确做法是列一张真值表交给工具。7 个输入、4 个输出,只有 7 行是真值,其余 121 行全部标成无关项 x。在 Logisim 里用组合逻辑分析功能,选“最小项”模式,把真值表贴进去,它会自动生成四个输出的表达式并直接生成电路。关键在于无关项必须标 x 而不是 0,标成 0 会让生成的表达式多出大量冗余项,电路反而更大更慢。

生成的表达式形式是每个输出对应一个或项,七个译码信号各贡献一个最小项,比如输出最高位 S3 的表达式里含 ADDI、BEQ、BNE、SYSCALL 这四项,因为这些指令的入口地址最高位都是 1。核对的办法很简单:把七个入口地址的二进制列出来,竖着看每一位,哪些指令这一位是 1,对应的信号就该出现在哪个输出表达式里。

4.3 导出、加载与二次校验

微指令表填好之后要变成控制存储器能读的格式。常见的做法是在表格软件里填完,复制成一列纯十六进制文本,每行一条微指令,然后加载到控制存储器组件里。手动拼二进制再转十六进制容易出错,我一般写个脚本把字段拼装和转换都交给机器:

# 字段顺序必须与 Logisim 控制存储器里的位序完全一致 # 元组为 (字段名, 位宽),位宽按你电路里实际的控制信号宽度填 FIELDS = [ ("IorD", 1), ("PcSrc", 1), ("AluSrcA", 1), ("AluSrcB", 2), ("MemToReg", 1), ("RegDst", 1), ("IrWrite", 1), ("PcWrite", 1), ("RegWrite", 1), ("MemWrite", 1), ("MemRead", 1), ("BEQ", 1), ("BNE", 1), ("AluCtrl", 2), ("NextAddr", 4), ] def pack(sig: dict) -> str: """按 FIELDS 顺序拼成二进制串,未给出的字段默认为 0""" bits = "" for name, width in FIELDS: val = sig.get(name, 0) bits += format(val, f"0{width}b") return bits def to_hex(bits: str) -> str: """二进制串补齐到 4 的倍数后转十六进制,方便直接粘进文本文件""" pad = (-len(bits)) % 4 return format(int(bits + "0" * pad, 2), f"0{(len(bits) + pad) // 4}X") # 状态 0 取指:读存储器拿指令、写 IR、PC+4、下址固定跳状态 1 fetch = {"MemRead": 1, "IrWrite": 1, "PcWrite": 1, "AluCtrl": 0b00, "NextAddr": 1} print(to_hex(pack(fetch)))

这段脚本的作用是消除手工转换的错位风险。FIELDS决定了位序,改宽度只改这一处;pack按字段名取值,缺省填 0,避免了“这条指令忘了写某个信号”的低级错误;to_hex负责补齐和进制转换。实际使用时把 13 条微指令的字段值写成 13 个字典,循环调用pack和to_hex,一次性生成整个文本文件。

生成之后再跑一遍反向校验,把文本读回来检查位宽和下址的合法性:

# 校验导出的文件:每行位宽一致,且下址字段要么是 0(交地址转移逻辑),要么不超过最大状态号 WIDTH = sum(w for _, w in FIELDS) MAX_STATE = 13 for lineno, line in enumerate(open("ucode.txt"), start=1): bits = bin(int(line.strip(), 16))[2:].zfill(WIDTH) # 十六进制还原成定宽二进制 nxt = int(bits[-4:], 2) # 最后 4 位是下址字段 assert nxt == 0 or nxt <= MAX_STATE, f"第 {lineno} 行下址 {nxt} 越界" print("校验通过,共", lineno, "条微指令")

反向校验的价值在于抓“填了但填错”的情况。位宽对不上说明某个字段宽度改了没同步;下址越界说明填了个不存在的状态号,运行到那一条时控制存储器会读到空白行,表现为某个控制信号全 0、某条指令莫名其妙不写回。

5. 跑 sort.hex 时的五类翻车:现象、原因和改法

5.1 数据通路与译码类:结果对不上但电路不报错

坑一:输出一片零,状态机却在正常迁移。现象是所有寄存器读出来都是 0,程序像是没执行。原因是 PC 的 32 位直接接到了存储器的 10 位地址端,高 22 位被截断后地址恒为 0,每个周期取到的都是同一条指令。解决办法是用分离器取 PC 的第 2 到第 11 位送地址端,接完随手在单步模式下看一眼取指地址是不是每次加 1。

坑二:排序结果里负数全变成大正数。现象是含负数的数组排序完成后,负数被排到了最大位置。原因是 16 位立即数扩展用了零扩展而不是符号扩展,lw/sw 的负偏移和 beq/bne 的负偏移全变成了正数,数组下标越界。解决是换成 16 到 32 位的有符号扩展器,测试时专门造一个含负偏移的用例。

坑三:循环提前结束,只排了一半。现象是冒泡排序跑完只做了两三轮就停了。原因是 branch 信号只接了 beq 忘了 bne,bne 的分支条件永远不成立,内层循环跳不回去。解决是把 branch 改成 Beq·equal + Bne·equal',并对四种输入组合逐一验证。

5.2 时序与状态类:单步能走、连跑就崩

坑四:多周期下 ALU 结果时对时错。现象是单步执行时结果正确,连续跑就出错。原因是 ALU 的输出没有锁进 C 寄存器,直接组合逻辑连到了下一级,等到下一个周期寄存器 A、B 更新之后,上游的结果已经变了。解决办法是所有跨周期使用的数据必须经过寄存器锁存,ALU 输出先写 C,后面的状态只读 C。

坑五:到达 syscall 后 PC 还在加。现象是停机指令执行后程序继续往下跑,跑到空白指令区才乱掉。原因是多周期里 PC 的写使能没有和状态挂钩,或者 halt 信号只接到了单周期的使能端。解决是在状态机里把 PcWrite 的拉高条件限定在取指状态,或者在 13 号状态里把 PC 的写使能强制拉低。微程序实现里更简单,让 13 号状态的下址字段指向自己即可。

坑六:微程序版本所有指令走同一条路。现象是把微程序控制器接上之后,lw 能跑,add 也跑去访存了。原因是译码状态的下址字段填成了具体地址而不是 0,地址转移逻辑根本没被触发。解决是检查状态 1 的下址字段是否为 0,以及地址转移逻辑的输出是否接到了控制存储器的地址输入端。

这六条里我踩过其中四条,最耗时间的是坑一和坑六:前者现象太像“程序没加载”,后者现象太像“指令译码错了”,都会把人往错误的方向引。后来我养成了一个习惯,任何一次改动之后先看取指地址序列,它是最靠前的观测点,能一次性排除掉一半的可能性。

6. 从能跑到跑对:我会强制走一遍的验收顺序

到这一步,电路基本能跑,剩下的问题是如何确认它真的跑对了,而不是碰巧输出看着像。我后来固定了一套自下而上的验收顺序,每次重做这个实验都照着走,比直接跑 sort.hex 再猜错在哪快得多。

顺序验收项通过标准
1取指序列单步时地址每次加 1,指令内容随 PC 变化
2单条 add两个已知操作数相加,写回目标寄存器正确
3单条 lw/sw写入后立刻读回,值一致
4单条 beq/bne相等时跳、不等时不跳,各测一次
5短循环三到五条指令的小循环能正确退出
6sort.hex输出与预期完全一致

这个顺序的关键是第 1、2 步:取指序列错了,后面全白搭;单条 add 错了,说明写回通路的 MUX 有问题。把这两步卡死,剩下的调试基本都在可控范围内。

还有一个我每次都用的技巧:在 sort.hex 跑之前,先手写一段只有四条指令的小程序——两条 addi 赋初值、一条 add、一条 syscall,把结果手动算出来存着。这段程序能同时验证立即数扩展、ALU 加法、寄存器写回和停机四件事,三分钟就能过一遍,比在大程序里大海捞针划算得多。

回过头看,这个实验真正教我的不是 MIPS 指令集长什么样,而是“每个多输入点都必须有明确的选择依据”这件事。后来做任何带数据流的系统,我都会先问一句:这个点上,谁在决定走哪条路,这个决定信号是从哪来的。想不清楚这一句,电路跑起来也是玄学。这份实验报告里那一页故障与调试,比前面十一页方案设计更值得反复看。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询