把这门课的课设当成一场小型 CPU 设计实验,是我最近做完之后印象极深的一件事。数电模型机设计,简单来说就是让你用数字电路基本模块(门电路、触发器、寄存器、ALU、存储器)拼出一台能运行有限指令集的“计算机核心”。很多同学第一反应是“这不就是计算机组成原理吗”,其实它更像是数电知识的综合大作业——把组合逻辑和时序逻辑全部压在一条指令周期里跑通。这篇文章把我从拿到题目到最后验收的全过程、踩过的坑、再开始会怎么做的思路都记下来,给同样在做模型机设计的同学一个可参考的路线图。如果你用的是 Logisim 这类仿真工具,或者正在纠结指令集和数据通路怎么编排,看这篇就够了。
1. 拿到题目之后,我是怎么拆解“模型机”的
1.1 模型机到底是什么
模型机不是真的要你做一块能跑操作系统的处理器,它的目标非常明确:用数字电路的方式实现一个能取指、译码、执行若干条机器指令的完整数据通路。我当时理解的三个关键词是“指令周期”“数据通路”“控制信号”。把这三件事搞明白,整个项目就剩动手连线了。
从一个最朴素的角度看,一台模型机就是在每个时钟周期里做固定动作:
- 先按程序计数器(PC)指向的地址,从内存里取出一条指令;
- 把指令放到指令寄存器(IR)里,由控制器译码出“这一步要做什么”;
- 控制器再发出各种控制信号,让 ALU、寄存器、存储器按规则工作;
- 最后根据指令类型更新 PC,进入下一条指令。
听上去很简单,但真正头痛的是这些动作不能乱,必须严格按照时序来。比如“取指”的时候内存地址总线必须被 PC 占用,而“执行”的时候地址总线可能要放操作数地址,如果时序安排不对,指令和数据就混了。我在开头花了不少时间把这一层关系理清楚,后面连线反而快。
1.2 工具和硬件平台怎么选
我们课程设计提供两个方向:一是用 Logisim 做纯原理图仿真,二是用 Verilog 写硬件描述再综合下载到 FPGA 开发板。我选的是 Logisim,原因是它更贴近数电课上学过的门电路和触发器,连线直观,查错也能直接看每一根线的高低电平。如果你对 FPGA 更熟,用 Verilog 也没问题,只是要注意仿真和上板的条件不同。
说实话,Logisim 的缺点是大型电路连线满天飞,稍微不注意就乱成一团。所以我建议无论如何都用“分层模块”来搭:把取指单元、ALU、寄存器组、控制器各自做成子电路。主电路只保留几条总线,每个子电路留好输入输出引脚,调试的时候逐层打开看。很多人一开始把所有模块全部放在一张图纸上,后面查一个问题要找半天线,纯属给自己挖坑。
1.3 整体架构:从指令到数据通路的分解
我在动手之前画过一张很粗糙的框图,虽然画得难看,但很有用。我的模型机可以拆成四个大块:
- 存储部分:指令/数据共用一块 RAM,通过地址选择器和读写信号区分“取指令”和“读写数据”;
- 运算部分:一个 8 位 ALU,支持加、减、与、或、取反、直通等操作;
- 寄存部分:8 个通用寄存器 R0~R7,每个 8 位,接在内部总线两边;
- 控制部分:由状态机产生微操作序列,决定数据流的走向。
这里最容易犯的错误是“先画电路再定指令”,结果发现指令编码和数据通路互相打架。正确顺序应该是先确定指令集,根据每条指令需要的微操作去设计数据通路。比如我一开始想要一条“寄存器间接寻址”指令,但发现内存地址线不够接,果断砍掉,换成立即数和直接地址两种寻址方式。课设不是做商用 CPU,目标是在有限时间内把基础逻辑跑通,所以指令集宁可精简也不要复杂到控制信号失控。
2. 指令集设计:先定规则再动电路
2.1 指令集怎么选:够用且能交差
我的模型机定了 8 条指令,操作码统一用 4 位二进制,这样译码电路不会太复杂。指令集包含:
- MOV:寄存器之间搬运数据,或者把立即数装入寄存器;
- ADD:两个寄存器相加,结果存回某寄存器;
- SUB:寄存器相减;
- AND / OR / NOT:基本的逻辑运算;
- LOAD:从内存指定地址读数据到寄存器;
- STORE:把寄存器数据写入内存指定地址;
- JMP:无条件跳转到指定地址;
- BEQ:如果两个寄存器相等则跳转,否则顺序执行;
- HLT:停机。
你可能注意到我写了不止 8 条,其实 JMP 和 BEQ 我后面才加进去,编码还留了扩展位。课设的要求里“无条件跳转”和“条件跳转”通常是加分项,所以建议从一开始就把操作码留一个空余位,后面加指令不用推翻整个电路。
2.2 指令编码与 ROM 设计
指令编码是让后续电路不发疯的关键。我的方案是:每条指令固定两个字节。第一个字节高 4 位是操作码,低 4 位是目标寄存器编号;第二个字节可以是立即数、内存地址,也可以是源寄存器编号加寻址方式。这样编码长度统一,取指和状态机的节拍很好控制。
以 ADD 为例,我设计的格式是:0001+目标寄存器编号+ 第二个字节高 4 位表示源寄存器 1,低 4 位表示源寄存器 2。 这条指令执行时,ALU 的 A 端和 B 端分别选通两个源寄存器,输出根据操作码片选到目标寄存器。如果要把 ADD 扩展到支持立即数,第二个字节直接当成 8 位立即数,控制信号里加一个“ALU B 端选择立即数总线”的选项就行。
ROM 里的微操作序列可以通过真值表生成,但更省事的是直接用 Logisim 的 ROM 元件,把每条指令对应的控制信号按地址存进去。这个思路其实就是后面要讲的微程序控制,我建议先用手写译码逻辑跑通,再改成微程序,这样对控制逻辑理解会更深刻。
2.3 为什么需要扩展位和立即数寻址
很多第一次做模型机的同学会觉得指令集里只要有 ADD、MOV 就够了,但如果真想运行一段有意义的小程序(比如循环累加),没有跳转是玩不起来的。所以我在编码设计里专门留了两类扩展:
- 操作码扩展位:4 位操作码最多 16 条指令,我目前用了 10 种,还剩 6 个空位,后续加移位、比较指令都不用改地址宽度;
- 寻址模式位:第二个字节的最高位用来区分“寄存器立即数模式”和“内存地址模式”,译码电路根据这个位决定把第二个字节送给 ALU 还是送给地址总线。
立即数寻址很方便,但要注意它和直接寻址的区别:直接寻址是“把内存某个地址的内容读出来”,立即数就是“这个数本身”。我在写汇编测试程序的时候,这两条指令很容易写混,调试时需要在波形里确认数据来源。建议所有数据都用十六进制监视图标出来,一眼就能看出控制信号是否选对了通路。
3. 数据通路搭建:让数据和指令各走各的路
3.1 存储器的读写逻辑
模型机用的是同步 RAM 还是异步 RAM,直接影响状态机节拍。Logisim 默认的 RAM 元件有同步读写模式,我当时用的是异步读、同步写,因为取指阶段只要地址稳定,数据立刻就能出现在输出端,少等一拍。这样整个指令周期可以控制在 4~6 个时钟节拍内。
读写控制是另一个重点。我在 RAM 的输入端加了一个三态缓冲,避免指令地址和数据地址同时驱动存储器的地址线。控制器里有两个状态:取指状态把 PC 的值送到地址总线;执行状态如果涉及 LOAD/STORE,就把指令寄存器里的低 12 位地址字段送到地址总线。两个状态各自使能对应的地址源,绝不共用一个驱动。这里有一个坑:如果忘记给 RAM 写使能加默认值,RAM 可能会在非 STORE 指令的某个时刻被意外写入,数据就全乱了。我后来把所有操作都改成“仅在 STORE 指令的特定节拍置写使能为 1”,别的时候强制为 0,问题才消失。
3.2 ALU 与寄存器组设计
ALU 用 Logisim 自带的“Arithmetic Unit”元件改就行,它支持多种运算函数,通过函数选择位来控制。不过自带的元件输出结果虽然是正确的,但它的运算函数编码和我的操作码并不一致,所以我在 ALU 外面加了一个“函数译码器”,把 4 位操作码转换成 ALU 需要的函数选择信号。这个做法也方便后续换指令:只要改译码真值表,不用动数据通路。
寄存器组我一开始想用 Logisim 里的寄存器堆元件,后来发现它自带的功能不透明,不方便接控制信号。于是干脆用单独 8 位寄存器拼了 8 个,每个寄存器配一个写入使能,由控制器根据指令里的目标寄存器编号译码出“寄存器的片选信号”。这种方案虽然连线多一点,但是每一步控制都看得见,课堂上讲起来也更好说明白。
3.3 总线结构:一根线还是多根线
内部总线我选了“单总线”结构:所有寄存器的输出都通过三态缓冲接到同一条 8 位总线,ALU 的两个输入端一直接着总线上的“当前值”和“寄存器值”,需要时用锁存器把输进去的数据暂存到内部寄存器里。单总线的优点是连线少、电路清爽,缺点是同一时刻只能有一个设备往总线上放数据,所以控制信号必须保证互斥。
我在单总线上被折磨得最惨的就是“总线竞争”:两个寄存器同时把数据驱动到总线上,结果高低电平打架。后来我强制给每个三态缓冲的使能端做了一组互斥编码,由控制器统一生成。举个例子,如果 ADD 指令第二拍要把 R1 的内容送到总线,那么 R1 的三态缓冲使能有效,其他所有的三态缓冲使能全部无效。这里的“其他全部无效”通常用译码器默认输出实现,如果译码器没有默认零输出,就要加下拉电阻来保证总线在不被驱动时为低电平。
4. 控制器的两种思路:组合逻辑和微程序
4.1 用状态机实现取指-执行流程
我用一个状态机作为控制器核心。状态机有 6 个状态:S0 取指、S1 译码、S2 执行第一阶段、S3 执行第二阶段、S4 写回、S5 更新 PC。每个状态对应一个时钟节拍,状态转换由时钟沿触发,也就是说我的指令周期是固定节拍数。这个方案的优势就是逻辑简单,所有的控制信号都通过“当前状态 + 当前指令”查真值表生成。
真值表是让很多人崩溃的地方:指令条数多的时候,每个状态都要考虑每一条指令的动作,表格会变得巨大。我的经验是先把所有指令按功能分组:运算类(ADD、SUB、AND、OR、NOT)执行流程一样,只是 ALU 运算函数不同;访存类(LOAD、STORE)需要额外的地址发送和读写信号;跳转类(JMP、BEQ)直接在译码状态就判断条件并修改 PC。分组之后,真值表只需要描述组类型的行为,再叠加上各自的操作码片选,逻辑就清晰多了。
4.2 微程序控制实现与对比
状态机方案在有人问“怎么改成微程序”时往往会卡住,因为组合逻辑的真值表很难解释。我后来把控制器改成微程序方案,用一块 ROM 来存放每一拍的控制信号。ROM 的地址就是“当前状态号 × 指令数 + 指令编号”,输出就是使能各种三态缓冲、写使能、RAM 读写的信号。
微程序的优点是把复杂译码变成查表操作,调试时直接看 ROM 里某一项的值就知道该触发什么。缺点是 ROM 容量很大,如果指令多,改一条控制信号就要重新计算地址。我当时试过直接用手算地址填 ROM,改到第三轮就快崩溃了,最后还是写了简单的 Python 脚本生成 ROM 内容。对这种课设级别项目,微程序最大的意义是让控制器结构更统一,但速度和面积其实没有优势。我最终提交的是状态机方案,因为老师要求能现场改指令并检查连线,状态机的真值表更容易对着一张表讲清楚。
4.3 控制信号的时序排布
不管用哪种控制器,控制信号必须卡着时钟沿来。我在设计时就拟了一张时序表:每个状态填上该状态要拉高的信号。比如 S0 状态:PC_ENABLE、RAM_READ 必须为高,IR_LOAD 也可以同时为高,因为 RAM 输出的是指令码;S2 状态如果是 ADD:R_BUS_ENABLE 拉高,ALU_A_LATCH 在时钟上升沿前已经保存了第一个操作数,ALU_B_LATCH 也在同一拍保存第二个操作数。
这里有一个容易忽略的细节:寄存器堆的写入使能如果在时钟上升沿和数据输入同时变化,很容易出现“写进旧值”的竞争。解决办法是让写回操作发生在时钟下降沿,即负载的正沿前先把数据稳定在总线上,这样寄存器就能在新一个时钟周期开始时可靠装入新值。我做了一个简单版本的“双相时钟”来规避竞争:主状态机上升沿动作,寄存器写入下降沿动作,实测下来稳定很多。
5. 调试阶段踩过的坑和排查顺序
5.1 总线竞争和时序冲突
我最有代表性的一个 bug 出现在 LOAD 指令:RAM 读取出来的数据总是没错,但寄存器收到的却是上一个值。查了很久发现是“写回状态”和“更新 PC 状态”没有时序错开。LOAD 执行中要先从内存读数据,然后写寄存器,同时还要保证 PC 不被旧指令继续驱动地址线。我给 RAM 地址源选的优先级顺序不对,结果在写回那拍,地址总线既接了 PC 又接了指令里保存的地址。这个教训让我养成了一个习惯:任何一条总线的驱动源,必须是“每个状态只有一个使能”的互斥逻辑。
以后遇到奇怪现象,我建议按这个顺序排查:
- 先看时钟是不是连续稳定,有没有毛刺;
- 再看状态机是不是按预期跳转,把状态变量引脚接出来观察;
- 然后看 RAM 读写信号有没有在非访存指令时误触发;
- 最后看数据总线上多个使能有没有同时为高。
这个顺序能过滤掉 80% 以上的问题,因为多数模型机坏就坏在控制信号找错了时间。
5.2 调试方法:模块级验证与波形观察
我一开始是整体连完再看结果,结果一运行全是红色错误。后来老老实实把子电路一个一个验证:先单独测试 ALU,输入固定值看输出符不符合;再测寄存器组,一边写使能一边观察存储的值;最后才组合成主电路。这样做的好处是能把问题局限在“接口层”而不是“内部逻辑”。
Logisim 的仿真支持用时钟步进和触发信号观察每一拍状态。我习惯把 PC、IR、RAM 地址总线、数据总线、控制信号若干位全部引出到引脚,然后设置时钟频率为 1Hz,用“步进时钟”一帧一帧推演。比如执行一条 LOAD,就应该看到:地址是 PC 输出 -> 切换为指令里的地址 -> RAM 输出数据 -> 寄存器写使能拉高。如果哪一帧对应不上,基本就能定位到控制器真值表写错还是三态缓冲插反了。
5.3 从仿真到上板的注意事项
如果要做 FPGA 实物,仿真通过和上板通过是两件事。Logisim 的默认延迟模型和真实 FPGA 器件有差异,尤其是时钟频率。我建议上板前先把时钟频率调到很低,比如 1Hz,确认每个按钮和拨码开关能控制复位与时钟。另外要注意开发板上的按键会产生抖动,需要加一个简单的防抖电路,否则按一次可能跳两拍,状态机直接乱掉。
还有一个特别容易忘的点:复位信号。模型机上电时所有寄存器初始值不定,如果 PC 不是从 0 开始,程序根本跑不起来。所以在主电路里必须设计一个全局复位开关,把所有计数器和状态机清零。同理,程序计数器和 RAM 内容在上板前也要用初始化文件写入,避免随机值导致死循环。
6. 做完之后的体会和可以扩展的方向
整套模型机做完后,我最大的收获是终于把“冯·诺依曼结构”这个概念从字面上变成了具体电路。以前在数电课上学到的译码器、触发器、三态缓冲,在这里全都变成了有明确用途的积木块。特别是控制器那部分,它让我明白计算机的“聪明”并不是某个神秘部件,而是一张精确到拍级的控制信号时序表。你甚至可以把每一条指令的执行过程口述出来,然后用波形图验证。
如果要我给下一届做这门课设的同学建议,我会说:别急着打开仿真工具,先用 1~2 天把指令周期图示画清楚,把每条指令的每一拍需要哪些控制信号列成表格。这个表格可能只有一页纸,但它比任何工具都重要。连电路时每接一根线,先问自己“这根线在哪个状态起作用”,答不出来就先不要接。
如果用 Verilog 写,同样不要一上来就写一百行代码。先画数据通路,再写状态机,最后补各个模块的赋值逻辑。另外,可以在做完基础版本后尝试加两个扩展:一是把这套模型机的 ALU 并入乘除模块,二是把单总线改成双总线或三总线结构。改总线结构虽然会让连线更复杂,但你会更直观地感受到数据通路的带宽变化。我当时只加了一个“取反”指令,但光是这个改动,就逼着我又重新调整了一版控制信号。课程答辩的时候,能讲清楚自己每一步选择的理由,往往比功能做得花哨更重要——这也是我最后整理这篇文章的初衷。