简介:这份课程设计文档面向电子信息类专业学生,服务于计算机组成原理课程的实验环节,聚焦用Verilog HDL实现简化计算机核心部件这一典型任务。内容围绕简单指令系统展开,涵盖idle、load、move、addp、subp、mulp、logp、comp、sh、meth1、swap、jmp、jz、read、write、stop等指令的编码与执行流程,并给出处理器模块中寄存器、指令寄存器、存储器地址寄存器的底层设计思路,以及64×8存储器模块的调用方式,最后说明顶层连接与指令fetch、decode、execute、store的完整执行分析。资源包共1个doc文件,约384KB,为完整实验报告文档,包含顶层BLOCK图、Verilog源码片段与状态机实现细节,可直接对照复现实验流程。已有382人学习,适合需要完成同类课设、理解处理器与存储器协同工作原理的读者参考借鉴。
1. 简化计算机系统:为什么一条指令要拆成六个时钟周期
很多人第一次看到status状态机从 0 走到 5、再回到 0 的循环时,第一反应是「这不是把简单问题复杂化了吗」。一条addp指令,硬件上明明一个加法器就能算完,为什么非要拆成取指、译码、取操作数、执行、写回这么多个节拍?这个疑问恰恰是理解计算机组成课程设计的关键入口。简化计算机系统的设计,本质上不是「把 CPU 做小」,而是把「指令如何在时间上被分解、在空间上被分配硬件资源」这件事显式地摊开给你看。用 Verilog HDL 实现一个处理器模块,再挂上一块 64×8 的存储器,这套组合能跑通,说明你已经摸到了冯·诺依曼结构最核心的那根筋:指令和数据共享同一套地址空间,靠时序状态机驱动它们轮流上场。搞懂这套机制,后面看任何真实处理器的流水线、冒险、旁路,都不会再觉得是玄学。
2. 状态机驱动:一条指令的生命周期为什么按节拍走
2.1 status 状态机是整个 CPU 的心跳
这份设计里最值得先盯住的不是运算逻辑,而是那个status寄存器。它用always @(negedge reset or posedge clock)触发,在时钟上升沿推进状态,复位时归零。整个 CPU 的所有动作——取指、译码、访存、执行——全部由这个 3 位状态值调度。换句话说,status就是这颗简化 CPU 的「程序计数器之外的第二个时间轴」。
always @(negedge reset or posedge clock) begin: status_change if (reset == 1'b0) status <= 0; else case (status) 0: status <= 1; // 取指节拍 1: if (IR[15:12] == stop) status <= 1; // 停机则锁死 else status <= 2; // 否则进入译码 2: case (IR[15:12]) swap, jmp, jz, read, write: status <= 3; default: status <= 0; // 运算类直接回 0 endcase 3: if (IR[15:12] == swap) status <= 0; else status <= 4; 4: status <= 5; 5: case (IR[15:12]) read: status <= 6; default: status <= 0; endcase default: status <= 0; endcase end这段代码的机制含义很明确:状态 0 负责把存储器数据打进指令寄存器 IR,状态 1 做初步译码并决定后续路径,状态 2 到 5 根据指令类型走不同的访存或执行分支。注意stop指令在状态 1 就把状态锁在 1,形成死循环——这就是「停机」在硬件上的真实样子,不是断电,而是让状态机原地踏步。read指令是唯一能走到状态 6 的,因为它需要额外的存储器读周期。这种「按指令类型分叉状态路径」的写法,是教学型 CPU 的典型设计:牺牲了通用性,换来了每条指令流程的完全可见。
2.2 双 always 块的分工:状态推进与数据通路分离
整个 CPU 被切成两个always块。第一个块只管status的迁移,第二个块always @(negedge reset or negedge clock)在时钟下降沿执行实际的数据操作。为什么一个用上升沿、一个用下降沿?这是这份设计里一个容易被忽略但很关键的细节。
上升沿推进状态,意味着状态值在时钟高电平期间是稳定的;下降沿执行数据操作,意味着数据通路的读写发生在状态稳定的后半拍。这种「前半拍定状态、后半拍动数据」的错拍设计,本质上是一种最朴素的时序隔离——避免状态迁移和数据写入在同一时刻竞争,减少建立保持时间上的隐患。常见的实现思路里,教学 CPU 经常用这种双沿触发来模拟「组合逻辑 + 时序逻辑」的分层,虽然综合到真实 FPGA 上不一定是最优解,但它把「控制流」和「数据流」在代码结构上彻底分开了。
第二个块里声明了一堆局部寄存器:PC、R0到R3、累加器A、以及temp和temp2两个临时变量。注意PC、R0~R3、A用的是阻塞赋值=,而IR、MAR、MDR、overflow用的是非阻塞赋值<=。这个混用不是随手写的——IR、MAR、MDR是要驱动外部存储器接口的信号,需要保持到下一个节拍;而R0~R3是块内临时状态,用阻塞赋值保证同一节拍内后续语句能立刻看到更新后的值。这种混用在教学代码里很常见,但也是后面要讲的坑之一。
2.3 取指与译码:IR 为什么是 16 位而存储器只有 8 位
存储器是 64×8 的,每个单元 8 位,但指令寄存器IR是 16 位。这个位宽不匹配不是设计缺陷,而是刻意为之。看状态 0 的代码:
0: begin IR <= {M_data_in, 8'b00000000}; // 高 8 位来自存储器,低 8 位清零 PC = PC + 1'b1; end取指节拍只从存储器读一个字节,放到 IR 的高 8 位,低 8 位补零。这意味着指令的高 4 位IR[15:12]是操作码,IR[11:8]和IR[7:0]是操作数字段。但低 8 位在取指阶段是零,真正的操作数要么来自寄存器编号(IR[11:8]),要么来自后续的存储器读周期。这种「单字节取指 + 字段复用」的设计,是为了在 8 位存储字宽的约束下塞下 16 种操作码。IR[15:12]正好 4 位,能编码 16 条指令,和parameter里定义的idle到stop十六个状态一一对应。
这里有个设计取舍值得说透:如果存储器字宽是 16 位,取指一次就能拿到完整指令,状态机可以少一个节拍。但课程设计选 8 位字宽,逼着你在取指和译码之间做拆分,反而让「指令是分阶段进入 CPU 的」这件事变得可观察。我一般会这么理解:教学设计的价值不在于跑得快,而在于每个节拍干了什么都能在波形图上指出来。
3. 数据通路:寄存器堆、累加器与运算单元的协作机制
3.1 寄存器堆的寻址逻辑藏在 case 分支里
这份设计没有独立的寄存器堆模块,R0到R3直接声明在seq块内部,靠IR[11:8]或IR[9:8]的 case 分支来选择。以move指令为例:
move: case (IR[11:8]) 4'b0001: R0 = R1; 4'b0010: R0 = R2; 4'b0011: R0 = R3; 4'b0100: R1 = R0; // ... 其余组合省略 default: ; endcaseIR[11:8]是 4 位,理论上能编码 16 种寄存器对,但实际只用了 12 种有效组合(排除源和目的相同的四种)。这种「用操作码字段直接当寄存器选择信号」的做法,省掉了独立的寄存器地址译码器,代价是指令格式不规整——move用IR[11:8],sh用IR[11:10]和IR[9:8]两级选择,meth1又是另一套。每条指令的字段含义都得单独记,这正是简化指令集「简化了硬件、复杂了编码」的体现。
3.2 累加器 A 的角色:运算类指令的公共操作数暂存
addp、subp、mulp、logp、comp、swap这几条指令有一个共同点:它们都在状态 1 先把某个寄存器搬到A,然后在状态 2 用A参与运算。看这段:
addp, subp, meth1, mulp, logp, comp, swap: case (IR[9:8]) 2'b00: A = R0; 2'b01: A = R1; 2'b10: A = R2; default: A = R3; endcaseA是一个 8 位累加器,作用是暂存第二个操作数。为什么需要它?因为状态 1 和状态 2 是两个不同的时钟节拍,状态 1 读出的寄存器值必须有个地方存着,等到状态 2 再和目的寄存器做运算。如果没有A,状态 2 就得重新根据IR去选寄存器,而那时IR可能已经被后续取指覆盖(虽然这份设计里 IR 在状态 0 才更新,但依赖这个时序是脆弱的)。A的存在把「取操作数」和「执行运算」在时间上解耦,这是所有多周期 CPU 的共同套路。
3.3 溢出判断:temp[8] ^ temp[7] 背后的补码逻辑
加法用 9 位临时变量temp承接 8 位加 8 位的结果,最高位temp[8]是进位,temp[7]是结果的符号位。溢出标志overflow <= temp[8] ^ temp[7]这个异或,是补码溢出的标准判据:当进位和符号位不一致时,说明结果超出了 8 位有符号数范围。
2'b00: begin temp = ({R0[7], R0[7:0]}) + ({A[7], A[7:0]}); R0 = temp[7:0]; overflow <= temp[8] ^ temp[7]; end注意{R0[7], R0[7:0]}这个拼接——把符号位复制一份放到最高位,做符号扩展成 9 位再相加。减法则是({R0[7], R0[7:0]}) + (~({A[7], A[7:0]})) + 1'b1,用补码加法实现减法。这套写法在教科书里叫「符号扩展 + 补码运算」,是理解 ALU 溢出检测的最小完整例子。乘法用temp2承接 16 位结果,高 8 位和低 8 位分别写回两个寄存器,这也是教学 CPU 里常见的「乘法结果双寄存器存放」约定。
4. 存储器接口:64×8 存储模块与 CPU 的握手时序
4.1 MAR、MDR 与 Write_Read 的三线协议
CPU 对外只有三组信号:M_address(12 位地址)、M_data_in/M_data_out(8 位数据)、Write_Read(读写控制)。存储器是 64×8,但地址线给了 12 位,高 6 位实际未用——这是为了给后续扩展留余量,也说明这份设计的地址空间规划并不紧凑。
MAR是存储器地址寄存器,MDR是存储器数据寄存器。在状态 1,MAR <= PC把下一条指令地址送出去;在状态 0,IR <= {M_data_in, 8'b00000000}把读回的数据锁进指令寄存器。Write_Read信号在状态机里没有显式赋值分支,说明它要么是组合逻辑根据状态和指令类型生成,要么在顶层原理图里直接连到存储器的读写使能。这种「地址先稳定、数据后到达」的时序,就是最简单的存储器握手:CPU 给出地址和控制信号,等一个节拍,数据线上出现有效数据。
4.2 指令序列如何存入存储器:mif 文件的角色
实验要求把指令序列存入存储器,顶层用原理图连接。这里的存储器模块通常需要一个初始化文件(mif 格式),把机器码按地址逐条写进去。比如一条load R0, #5的机器码,高 4 位是load的操作码0001,低 4 位是立即数0101,拼成0001_0101存在地址 0;下一条指令存在地址 1,依此类推。CPU 复位后PC从 0 开始,状态 0 读地址 0 的内容进 IR,PC加一,状态 1 译码执行,然后回到状态 0 读地址 1。
这个流程里有个容易翻车的点:PC在状态 0 就自增了,但MAR <= PC发生在状态 1。也就是说,状态 0 读的是PC自增前的地址,状态 1 送出的MAR是自增后的地址。如果指令序列里混用了jmp或jz,PC会被改写,而MAR的赋值时机决定了跳转目标从哪个节拍开始生效。这个时序细节在波形图上看得很清楚,但在代码里不显眼,是调试时最常见的困惑来源。
5. 避坑与排查:这份设计里最容易翻车的几个地方
5.1 现象:波形上 IR 一直是零,状态机却在正常跳
原因通常是存储器初始化文件没加载,或者M_data_in在状态 0 时没有有效数据。解决方法是先单独仿真存储器模块,确认给定地址能读出预期值,再联调 CPU。另外检查reset的极性——代码里是negedge reset且reset == 1'b0时复位,如果顶层原理图把复位按钮接成了高有效,状态机永远起不来。
5.2 现象:加法结果正确但 overflow 永远为 0
检查temp的位宽声明。如果temp被声明成 8 位而不是 9 位,temp[8]就越界了,综合工具可能直接把它优化掉。代码里reg [8:0] temp是 9 位,但如果在修改代码时手滑改成[7:0],溢出判断就失效。这类位宽错误在 Verilog 里不会报错,只会静默出错,属于血泪经验级别的坑。
5.3 现象:跳转指令执行后程序跑飞
jmp和jz在状态 2 进入分支,状态 3 和 4 继续处理,但PC的赋值时机需要和MAR的更新对齐。如果PC在状态 2 就被改写,而MAR在状态 1 已经锁存了旧值,跳转目标就会晚一个节拍生效。排查时把PC、MAR、status三条信号放在同一张波形图上,看跳转指令执行前后地址线的变化,就能定位是哪个节拍错位。
5.4 现象:乘法结果高低字节颠倒
mulp里R0 = temp2[7:0]; R1 = temp2[15:8];的低高分配是约定俗成的,但如果后续指令按相反顺序读这两个寄存器,结果就全错。这不是硬件 bug,是指令集约定和软件写法不一致。解决方法是把乘法结果的高低字节约定写进指令集文档,所有用到乘法结果的指令序列都按同一约定来。
5.5 现象:综合后资源占用异常高
两个always块里混用阻塞和非阻塞赋值,加上大量 case 分支,综合工具可能推断出大量锁存器。检查每个case是否有default分支、每个if是否有else,确保所有信号在所有路径下都有明确赋值。教学代码里default: ;这种空分支是锁存器的温床,仿真时看不出来,上板就翻车。
6. 设计取舍:为什么教学 CPU 宁可「慢而可见」也不做流水线
把这份设计和真实处理器放在一起看,最大的差异不是指令数量,而是「时间维度的利用方式」。真实 CPU 用流水线让多条指令重叠执行,取指、译码、执行、访存、写回五个阶段各占一套硬件,吞吐率接近每周期一条指令。而这份设计用状态机串行推进,一条指令占好几个周期,硬件利用率低得可怜。但这个「低效」恰恰是它的教学价值所在。
流水线的代价是冒险处理:数据冒险要旁路或停顿,控制冒险要预测或冲刷,结构冒险要复制硬件。这些机制在波形图上表现为各种「气泡」和「转发路径」,初学者很难把一条指令的完整生命周期从重叠的时序里剥离出来。状态机串行执行则把每条指令的每个阶段摊平在时间轴上,status从 0 到 5 的每一次跳变都对应一个明确的操作,IR、MAR、MDR、R0~R3 的变化都能逐拍追踪。这种「慢而可见」的设计,让你先建立「指令是分阶段执行的」这个底层认知,再去理解流水线为什么要重叠、重叠会带来什么问题,路径是顺的。
另一个取舍是存储器字宽。8 位字宽逼着取指分两次(虽然这份设计只取了高 8 位,低 8 位靠字段复用),地址线 12 位对 64 个单元是浪费。但如果换成 16 位字宽、6 位地址,取指一次完成,状态机可以少一个节拍,代码更简洁——代价是「指令进入 CPU 的过程」变得不可观察。教学设计里,可观察性往往比效率优先。
如果要验证自己对这套机制的理解是否到位,我建议做一件事:把status状态机的分支路径画成一张状态转移表,每一行写清楚「当前状态、指令类型、下一状态、该状态下的数据动作」,然后拿几条指令序列手动推演一遍,再和仿真波形对照。推演和波形对不上的地方,就是理解有偏差的地方。这个习惯我从第一次做这类设计保持到现在,每次改指令集或加新指令,都强制走一遍状态转移表,比直接看波形快得多。
从那以后我每次碰状态机驱动的设计,都先把状态转移表写出来再写代码,省下的调试时间远超制表的那点功夫。希望帮到你。
本文还有配套的精品资源,点击获取