简介:西南交通大学计算机组成原理实验课程设计文档,面向大二下阶段学习计组原理、需要完成CPU设计实验的学生,也可作为Verilog HDL入门到状态机实践的参考。文档围绕一个简化CPU模块展开,含完整Verilog代码与原理图:以指令寄存器、程序计数器、内存地址与数据寄存器、操作数寄存器、状态寄存器搭建数据通路,用状态机划分取指、译码、执行阶段,覆盖立即数加载、move、add/sub、and/or/xor、移位、swap、跳转、读写与停机等指令,并在加减运算中给出溢出检测和时钟沿更新寄存器的写法,便于对照理解指令周期与各寄存器作用。压缩包共1个docx文件,约64KB,篇幅精炼,适合直接查阅代码与结构。已有1569人学习,可作为课程设计复现、实验报告撰写与调试排错的参照。
1. 一个 8 位 CPU 的交付清单:计组原理实验课程设计要跑通什么
计算机组成原理课程设计的任务书通常只有一句话:用 Verilog HDL 实现一个 CPU 模块。真正坐下来做才会发现,卡住进度的一般不是语法,而是三张表没对齐——指令格式表、寄存器职责表、状态转移表。这份计组原理实验课程设计给出的方案,是一个 8 位数据通路、12 位地址、16 位指令字、五状态状态机的完整 CPU,配套顶层原理图和 MIF 存储器初始化文件,指令周期拆得清清楚楚。它适合两类人:一是大二下学期要交课程设计、需要一个能跑通且讲得清结构的参考实现;二是带实验课、想找一套指令集不复杂但覆盖了取指、译码、运算、访存、跳转全流程的演示代码。下面按「指令系统怎么定 → 状态机怎么转 → 模块怎么写 → 哪里会翻车 → 怎么验收」的顺序拆开讲。
2. 指令系统与数据通路:16 位指令字怎么切出操作码、寄存器和地址
动手写第一行 Verilog 之前必须先把指令格式定死,否则后面每改一次编码,状态机、MIF 文件和测试程序都要跟着返工。这套设计把指令字固定为 16 位,高 8 位从存储器一次读出,低 8 位在需要的时候再读一次,属于典型的变长取指思路。
2.1 指令字段划分:4 位操作码 + 2 位目的寄存器 + 2 位源寄存器
16 位指令字的切法直接决定了译码电路长什么样。这套方案里,IR[15:12] 是 4 位操作码,最多支持 16 条指令;IR[11:10] 是目的寄存器编号,IR[9:8] 是源寄存器编号,两位刚好覆盖 R0~R3 四个通用寄存器;IR[7:0] 在算术逻辑类指令里不用,在跳转和访存类指令里作为地址的低 8 位。
| 位段 | 宽度 | 名称 | 用途 |
|---|---|---|---|
| IR[15:12] | 4 | OP | 操作码,决定执行哪条指令 |
| IR[11:10] | 2 | Rd | 目的寄存器编号,00~11 对应 R0~R3 |
| IR[9:8] | 2 | Rs | 源寄存器编号,取指拍就把它的内容送进 A |
| IR[11:8] | 4 | Imm / Sel | 立即数,或 SWAP 指令的寄存器选择码 |
| IR[7:0] | 8 | Addr | 第二字节,跳转与访存指令的地址低 8 位 |
这张表里最值得说的是字段复用。IR[11:8] 这个 4 位段,在立即数加载指令里是操作数,在 SWAP 指令里是寄存器选择码,在 JMP 指令里又是地址的高 4 位——因为 MAR 是 12 位,地址字段只有 8 位,剩下 4 位只能从指令字的高段借。省位宽是省下来了,代价是可读性变差。我一般会在代码里对着每个 case 分支写一句注释说明当前用的是哪套解释,不然过两天自己都看不懂。
寄存器编号只用 2 位也是同样的道理。整个设计只有 R0~R3 四个通用寄存器,编号范围 0~3,两位足够。如果哪天要扩到 8 个寄存器,指令格式就得重排,所以这个取舍要在设计文档里写清楚,别等到答辩被问到「为什么不是 3 位寄存器号」才现想。
2.2 寄存器组与数据通路:A、Temp、MDR、MAR 各自的岗位
数据通路的清晰程度,取决于每个寄存器是否有唯一且明确的职责。这套 CPU 内部一共 10 组寄存器,位宽和分工如下。
| 寄存器 | 位宽 | 职责 |
|---|---|---|
| R0~R3 | 8 | 通用寄存器,程序可见 |
| IR | 16 | 指令寄存器,存放当前指令 |
| PC | 12 | 程序计数器,指向下一条待取指令 |
| MAR | 12 | 内存地址寄存器,驱动 M_address |
| MDR | 8 | 内存数据寄存器,承接 M_data_out |
| A | 8 | 源操作数暂存,取指拍装入 |
| OP | 4 | 操作码锁存,从 IR[15:12] 提取 |
| state | 4 | 状态机当前状态 |
| Temp | 9 | 加减法临时结果,多出的 1 位是进位/借位 |
为什么源操作数要单独用一个 A 寄存器,而不是每次运算时现从 R0~R3 里选?因为一条指令里目的寄存器和源寄存器可能指向同一个物理寄存器,比如「ADD R1, R1」,如果在执行拍才去读源寄存器,目的寄存器一旦先被写回,源操作数就变了。取指拍先把 Rs 的内容锁进 A,执行拍无论怎么写 Rd 都不会影响源操作数,这是最省事也最不容易错的做法。
Temp 用 9 位是另一个关键点。8 位加法要判溢出,必须保留第 9 位进位,否则溢出判断无从谈起。加减法都先把两个操作数做符号扩展再运算,结果的低 8 位写回寄存器,最高位留给溢出逻辑用。
MDR 和 MAR 是一对面向存储器的缓冲。MDR 负责把要写出的数据挂到 M_data_out 上,MAR 负责把地址挂到 M_address 上。它们不参与运算,只负责和外部存储器握手,这样 CPU 内部的数据流动和外部总线就分开了。
2.3 指令编码表:16 条操作码的语义与执行拍数
指令集的覆盖面决定了这个 CPU 能不能算「完整」。这套设计给了 16 条指令,算术、逻辑、移位、传送、跳转、访存、停机都有,作为课程设计已经够用。
| OP | 助记符 | 语义 | 执行完回到 |
|---|---|---|---|
| 0 | NOP | 空操作,PC 已自增 | state 0 |
| 1 | LDI | R0 ← 零扩展(IR[11:8]) | state 0 |
| 2 | MOV | Rd ← A | state 0 |
| 3 | ADD | Rd ← Rd + A,更新 overflow | state 0 |
| 4 | SUB | Rd ← Rd − A,更新 overflow | state 0 |
| 5 | AND | Rd ← Rd & A | state 0 |
| 6 | OR | Rd ← Rd | A | state 0 |
| 7 | XOR | Rd ← Rd ^ A | state 0 |
| 8 | SHR | Rd ← Rd 逻辑右移一位 | state 0 |
| 9 | SHL | Rd ← Rd 逻辑左移一位 | state 0 |
| 10 | SWAP | 寄存器之间交换内容 | state 0 |
| 11 | JMP | PC ← IR[11:0] | state 0 |
| 12 | JZ | R0 为 0 时跳转 | state 0 |
| 13 | READ | R0 ← Mem[IR[11:0]] | state 0 |
| 14 | WRITE | Mem[IR[11:0]] ← R0 | state 0 |
| 15 | STOP | 停机 | state 1 |
拍数上要分清两类。算术逻辑类指令是单字节指令,取指拍 PC 自增一次就够了,整个指令周期占两拍(state 0 取指、state 1 执行)。跳转和访存类指令是双字节指令,第二字节要单独取一次,PC 会在 state 2 再自增一次,所以它们在内存里占两个单元,指令周期占四到五拍。
这个「单字节 + 可选第二字节」的变长设计,是这套方案里最容易被忽略的细节。写测试程序往 MIF 里填机器码时,如果按「每条指令两字节」去排地址,跳转目标就会整体错位,波形上表现为 PC 跳到一个全是 0 的区域然后一直执行 NOP。我见过不止一个人在这里耗掉一整个下午。
3. 五状态状态机:取指、译码、执行、访存的状态流转怎么写才不打架
状态机是 CPU 的骨架。这套设计用 state 的 0~4 五个值把指令周期切成取指、译码执行、取第二字节、访存准备、访存完成五段,结构简单但足够表达所有指令。
3.1 状态划分与跳转条件:五个状态各管一段
先把状态和跳转关系列清楚,写代码时照着填 case 就不会漏。
| 状态 | 名称 | 主要动作 | 去向 |
|---|---|---|---|
| 0 | 取指 | PC 自增、MAR 装载、IR 高字节拼接、A 装入源操作数 | 一律去 1 |
| 1 | 译码执行 | 按 OP 分支,算术逻辑指令在此完成 | 算术逻辑回 0;跳转访存去 2 |
| 2 | 取第二字节 | 把 M_data_in 装入 IR[7:0],形成完整地址 | 去 3 |
| 3 | 访存准备 | 区分 JMP、JZ、READ、WRITE,设置读写信号与 MAR | 去 0 或 4 |
| 4 | 访存完成 | READ 把 M_data_in 写回 R0,WRITE 结束写周期 | 回 0 |
五个状态里,state 0 和 state 1 是必经之路,state 2~4 只有跳转和访存指令才会走进去。这种「主干短、支路长」的结构,好处是算术指令执行效率高,坏处是所有涉及地址的指令都要多走两三拍,时序上必须算清楚。
一个常被问到的点是:为什么取指拍不直接把整条指令读进来,非要分两次?原因是存储器数据总线只有 8 位,一次只能出一个字节。指令字 16 位,物理上就得分两次读。这套设计把第二次读延后到真正需要地址字段的时候,算术指令就不必为用不到的地址字节多付一拍,算是用控制复杂度换了一点效率。
3.2 取指拍的三件事:PC 自增、MAR 装载、IR 高字节拼接
取指拍是每个指令周期里唯一必走的一段,写错了后面全错。核心代码就下面这几行。
// state 0:取指拍,PC 先自增,MAR 指向下一个单元 0: begin Write_Read = 1'b0; // 取指阶段只读不写 PC = PC + 1; // 指向下一条指令 MAR = PC; // 地址送上地址总线 IR = {M_data_in, 8'b0000_0000}; // 高字节先装入,低字节留空 OP = IR[15:12]; // 锁存操作码,后续分支都用它 case (IR[9:8]) // 源寄存器内容装进 A 2'd0: A = R0; 2'd1: A = R1; 2'd2: A = R2; 2'd3: A = R3; endcase state = 1; end这里有三件事在同一个时钟沿完成。第一,PC 自增,注意是先自增再把值给 MAR,所以 MAR 指向的其实是「下一条指令」,而当前指令的内容来自于上一个周期就已经挂好的 M_data_in。这套设计的取指依赖一个隐含前提:MAR 在上一个周期末尾已经指向当前指令地址。理解这一点,才能解释为什么复位后第一次取指需要额外一拍来稳定地址,也才能解释为什么跳转之后 PC 要直接赋成目标地址而不是目标地址加一。
第二,IR 的高字节拼接。{M_data_in, 8'b0000_0000}把刚读到的字节放到 IR 的高 8 位,低 8 位清零等后续填充。这样 OP 和寄存器号立刻可用,译码分支不用等第二字节。
第三,源操作数装入 A。这一步放在取指拍而不是执行拍,前面说过是为了防止目的寄存器先写回污染源操作数。同时它让 state 1 的代码能直接引用 A,每个 case 分支少写一层嵌套 case,可读性提升明显。
3.3 访存指令为什么要多走两拍:READ 和 WRITE 的时序账
READ 和 WRITE 是这套状态机里最长的指令,要走到 state 4 才结束。多出来的拍数花在哪,值得单独算一算。
READ 的完整路径是:state 0 取指令高字节,state 1 识别出 OP=13 转到 state 2,state 2 读第二字节拿到地址低 8 位并拼成 12 位地址,state 3 设置 MAR 和读写信号,state 4 把 M_data_in 写回 R0。整条指令五个状态,其中 state 2 和 state 3 是纯粹为地址传输服务的。
WRITE 类似,区别在于 state 2 要先把 R0 的内容锁进 MDR,因为写入期间 R0 可能被后续逻辑改动;state 3 才把 Write_Read 拉高,真正启动写周期。这个「先备数据、再拉信号」的顺序不能反,反了就会出现地址还没稳定、写使能已经有效的情况,存储器写进去的是错误数据。
这里有个容易被忽略的时序约束:存储器是同步读还是异步读,会直接影响 state 3 和 state 4 之间要不要留空拍。如果用的是输出寄存的同步 RAM,地址在 state 3 打进去,数据要到下一个时钟沿才出来,state 4 刚好接住;如果用的是组合输出的异步 RAM,数据在 state 3 就已经有效,state 4 再读也不冲突,只是多了一拍。做顶层联调时先确认存储器模型的读出延迟,再决定状态机的拍数,比事后在波形上猜要省事得多。
3.4 组合逻辑与时钟逻辑的分工:那段 always @(reset or state) 在干什么
代码开头有一段容易被当成「凑数」的组合逻辑:
// 组合逻辑:把内部寄存器持续驱动到输出端口 always @ (reset or state) begin M_address = MAR; // 地址总线始终跟随 MAR M_data_out = MDR; // 数据输出总线始终跟随 MDR end它的作用是把内部的 MAR 和 MDR 实时映射到模块的地址、数据输出端口上。之所以敏感列表里写reset or state,是因为这两个信号变化时端口值一定需要更新;而 MAR、MDR 的赋值都发生在时钟沿,综合工具会据此推断出正确的组合通路。严格来说,把 MAR 和 MDR 也加进敏感列表更稳妥,写成always @(*)是最不容易出错的写法。
与它相对的是另一个时钟块,专门把 R0~R3 复制到 R_0~R_3 这组输出端口上:
// 观测输出:把通用寄存器内容引出,方便接数码管或看波形 always @ (posedge clock) begin R_0 = R0; R_1 = R1; R_2 = R2; R_3 = R3; end这组端口不是 CPU 功能的一部分,纯粹是为了观测。课程设计里通常要把寄存器值接到数码管或者仿真波形上,直接观测内部 reg 变量在不同工具里支持程度不一样,多引一组端口是最保险的做法。写的时候注意它是时钟驱动的,比 R0 的实际更新晚一个沿,看波形的时候别把它当成组合直通。
分工原则可以总结成一句:凡是描述「当前值是多少」的,用组合逻辑;凡是描述「下一个时钟沿变成什么」的,用时序逻辑。这条线划清楚,状态机就不会出现莫名其妙的竞争。
4. 关键模块实现:ALU 溢出判断、移位拼接与 MIF 存储器初始化
状态机搭好骨架之后,真正决定这个 CPU 能不能算对题的,是运算单元和存储器初始化这几块细节。
4.1 加减法单元与溢出判断:Temp[8] ^ Temp[7] 为什么能判溢出
溢出判断是这套代码里最精巧的一行。ADD 分支的实现如下。
// ADD:符号扩展后相加,低 8 位写回,最高位用于溢出判断 3: begin case (IR[11:10]) 2'd0: begin Temp = {R0[7], R0} + {A[7], A}; R0 = Temp[7:0]; end 2'd1: begin Temp = {R1[7], R1} + {A[7], A}; R1 = Temp[7:0]; end 2'd2: begin Temp = {R2[7], R2} + {A[7], A}; R2 = Temp[7:0]; end 2'd3: begin Temp = {R3[7], R3} + {A[7], A}; R3 = Temp[7:0]; end endcase overflow = Temp[8] ^ Temp[7]; // 进位与符号位不一致即溢出 state = 0; end{R0[7], R0}是符号扩展,把 8 位数扩展成 9 位,最高位复制符号位。两个 9 位数相加,结果还是 9 位,Temp[8] 是真实的进位输出,Temp[7] 是结果的符号位。有符号数加法的溢出条件是「两个同号数相加,结果符号变了」,等价于「进入符号位的进位和从符号位出去的进位不一致」,也就是 Temp[8] 和 Temp[7] 异或为 1。这一行同时覆盖了正溢出和负溢出,比分别判断要简洁。
SUB 分支结构完全一样,只是把加号换成减号:
// SUB:减法同样用 9 位临时结果,借位体现在 Temp[8] 4: begin case (IR[11:10]) 2'd0: begin Temp = {R0[7], R0} - {A[7], A}; R0 = Temp[7:0]; end 2'd1: begin Temp = {R1[7], R1} - {A[7], A}; R1 = Temp[7:0]; end 2'd2: begin Temp = {R2[7], R2} - {A[7], A}; R2 = Temp[7:0]; end 2'd3: begin Temp = {R3[7], R3} - {A[7], A}; R3 = Temp[7:0]; end endcase overflow = Temp[8] ^ Temp[7]; state = 0; end参数上要注意 Temp 必须声明成reg [8:0],9 位。如果图省事声明成 8 位,最高位进位直接丢掉,溢出信号永远是 0,仿真时看起来一切正常,只有专门构造溢出用例才会暴露。这是典型的「不测就发现不了」的问题。
另外 overflow 是寄存器型输出,只在 ADD 和 SUB 执行时更新,其他指令执行期间它保持上一次的值。如果测试程序需要连续观察溢出,记得在两次运算之间读一次,别等到几条指令之后再看,那时候值早被覆盖了。
4.2 逻辑运算与移位:位拼接写法与位宽陷阱
逻辑运算和移位指令实现都不长,但位宽处理上有几个固定套路值得记住。
// AND / OR / XOR:按位运算,直接对寄存器整体赋值 5: begin case (IR[11:10]) 2'd0: R0 = R0 & A; 2'd1: R1 = R1 & A; 2'd2: R2 = R2 & A; 2'd3: R3 = R3 & A; endcase state = 0; end 8: begin // SHR:逻辑右移,高位补 0 case (IR[11:10]) 2'd0: R0 = {1'b0, R0[7:1]}; 2'd1: R1 = {1'b0, R1[7:1]}; 2'd2: R2 = {1'b0, R2[7:1]}; 2'd3: R3 = {1'b0, R3[7:1]}; endcase state = 0; end 9: begin // SHL:逻辑左移,低位补 0 case (IR[11:10]) 2'd0: R0 = {R0[6:0], 1'b0}; 2'd1: R1 = {R1[6:0], 1'b0}; 2'd2: R2 = {R2[6:0], 1'b0}; 2'd3: R3 = {R3[6:0], 1'b0}; endcase state = 0; end移位用位拼接而不是用>>运算符,是为了明确指定补什么。Verilog 里>>是逻辑右移,补 0;>>>是算术右移,补符号位。课程设计里两个都见过,写{1'b0, R0[7:1]}的好处是意图零歧义,任何人一眼就能看出补的是 0,不用去猜运算符的语义。
还有一个小细节:拼接表达式的总位宽必须和左值一致。{1'b0, R0[7:1]}是 1+7=8 位,赋给 8 位的 R0 刚好;如果写成{2'b00, R0[7:2]},虽然也是 8 位,但语义变成了右移两位,改参数的时候很容易算错。我一般写完会在旁边注释一句「右移 1 位」,避免后续改动时手滑。
4.3 立即数加载与 SWAP:字段复用最密集的两条指令
LDI 和 SWAP 是字段复用最集中的地方,也是容易写错的地方。
// LDI:IR[11:8] 是 4 位立即数,零扩展到 8 位后写入 R0 1: begin R0 = {4'b0000, IR[11:8]}; state = 0; end立即数只有 4 位,能表示 0~15。想加载更大的数,只能先 LDI 一个小值再左移拼,或者从内存里 READ。这个限制在课程设计里可以接受,但要在设计文档里写明,否则答辩时被问「怎么加载 200」会答不上来。
SWAP 指令的写法比较特殊,它先按 IR[11:8] 做一组寄存器之间的传送,再按 IR[11:10] 把 A 写回目的寄存器。从代码结构看,前一组 case 的结果会被后一组 case 覆盖,实际生效的是后一组。写的时候如果不能确定哪段生效,最稳的办法是把两段拆开仿真,单独观察每条分支执行后 R0~R3 的终值,用波形说话。这也是我建议所有寄存器传送类指令都配一条单独的测试用例的原因——指令一多,靠读代码推演终值,翻车概率远高于靠波形。
4.4 MIF 文件与顶层原理图:存储器怎么和 CPU 端口对上
CPU 写完之后要跑起来,还需要外部存储器。这套设计用 MIF 文件初始化存储器内容,格式如下。
DEPTH = 256; % 存储深度:256 个 8 位单元 WIDTH = 8; % 每个单元 8 位 ADDRESS_RADIX = HEX; % 地址用十六进制 DATA_RADIX = HEX; % 数据用十六进制 CONTENT BEGIN 00 : 15; % LDI R0, 5 -> R0 = 5 01 : 24; % MOV R1, R0 -> R1 = 5 02 : 34; % ADD R1, R0 -> R1 = 10 03 : F0; % STOP [04..FF] : 00; % 其余单元填 0 END;机器码是这么拼出来的:LDI 的 OP=1,立即数 5 放 IR[11:8],高字节就是0001_0101,即 0x15;MOV R1, R0 的 OP=2,Rd=01,Rs=00,拼成0010_0100,即 0x24;ADD R1, R0 的 OP=3,Rd=01,Rs=00,拼成0011_0100,即 0x34;STOP 的 OP=15,拼成 0xF0。四条指令都是单字节指令,地址连续递增。
顶层原理图上要连的线不多,但每一根都不能接错。CPU 的 M_address 接存储器的地址输入,M_data_in 接存储器的数据输出,M_data_out 接存储器的数据输入,Write_Read 接存储器的写使能(高有效),clock 和 reset 分别接全局时钟和复位按钮。R_0~R_3 引出接数码管或者 LED,overflow 接一个指示灯。存储器一般用 ROM 装指令、RAM 存数据,或者用一块双端口 RAM 分区域,具体看实验箱提供的 IP 核。
接线时有两点要留意。第一,复位极性。代码里是if (reset == 0)清零,属于低电平有效,顶层如果接的是高有效按钮,中间要加一级反相,否则上电后 PC 一直是随机值。第二,时钟。CPU 和存储器必须用同一个时钟源,中间不要插分频器,否则地址和数据会在不同的沿上更新,波形看起来像随机跳变。
5. 避坑与排查:波形对不上时先查这 5 个地方
这套代码结构清楚,但有几处实现细节在原始版本里留着隐患,仿真跑不通的时候,八成是下面五个问题之一。每一条我都按「现象 → 原因 → 解决」写,照着顺序查能省不少时间。
5.1 跳转之后 PC 飞进一片全 0 区域
现象:执行 JMP 之后,波形上 PC 跳到一个明显不是目标地址的位置,接着连续执行 NOP,程序再也不回来。原因通常是地址字段拼接错了。JMP 的目标地址由 IR[11:8] 和 IR[7:0] 拼成 12 位,而 IR[7:0] 是 state 2 从 M_data_in 取回来的第二字节。如果 MIF 文件里把跳转指令按单字节排列,第二字节其实是下一条指令的编码,拼出来的地址当然不对。解决办法是把所有跳转和访存指令都按双字节排布,第二字节单独占一个地址,并在 MIF 注释里标明哪一行是地址字节、哪一行是指令。
5.2 READ 读回来的数据其实是下一条指令
现象:READ 执行完,R0 的值等于紧跟在 READ 后面的那条指令编码,而不是目标内存单元里的数据。原因是 state 3 里把 MAR 重新赋成了 PC,之前 state 2 算好的目标地址被覆盖掉,state 4 读到的自然是 PC 指向的那一格。解决方式是 state 3 只负责拉高读信号和保持 MAR 不变,把设置 MAR 的动作留在 state 2;或者干脆把 READ 的地址计算和读信号放在同一拍,用 state 4 只做数据回写。改完之后重新跑一遍,R0 应该等于目标地址里预置的值。
5.3 SWAP 执行完两个寄存器都没变
现象:SWAP 指令执行后,观察 R0~R3 的波形,发现值完全没动,或者只有一个寄存器被改成了 A 的值。原因是这条指令在同一个 state 1 里写了两组 case,前一组按 IR[11:8] 做寄存器间传送,后一组按 IR[11:10] 把 A 写回,后一组的结果覆盖了前一组。解决办法是先明确 SWAP 到底要交换哪两个寄存器,把选择码的定义固定下来,然后只保留一组 case;如果确实需要两组,就用两个临时寄存器做中转,按Temp1 = Ra; Ra = Rb; Rb = Temp1的顺序写,别指望后写的覆盖前写的还能得到交换效果。
5.4 STOP 之后仿真停不下来
现象:程序最后一条是 STOP,但仿真波形一直在跑,state 一直停在 1,PC 也不再变化。原因是 STOP 分支把 state 赋成了 1,而 state 1 的 case(OP) 里根本没有 OP=15 的分支,状态机就卡在那里空转。解决方式有两种:一是给 STOP 单独加一个终止状态,比如 state = 5,并在状态机里让这个状态不再跳转;二是在 state 1 的 case 里补上4'd15: state = 0;,让 PC 停在原地但状态机继续走,配合一个 halt 标志位控制仿真结束。课程设计里推荐第一种,语义更清楚。
5.5 综合报告里冒出意料之外的锁存器
现象:功能仿真正常,但综合之后报告里提示推断出了 latch,或者资源占用比预期高很多。原因通常有两个。一是组合 always 块的敏感列表不完整,比如只写了always @(reset or state),而块里用到了 MAR 和 MDR,工具就会认为这些信号变化时输出不需要更新,从而推断出锁存行为。二是 case 语句没有写全所有分支,也没有 default,未覆盖的取值下输出保持原值,同样会推断出锁存器。解决办法是把组合块统一写成always @(*),并给每个 case 补上 default 分支赋默认值;时序块里如果 case 不完整,也要补 default 把状态复位到 0,避免状态机跑飞。
6. 验收与答辩:用一组能对答案的测试程序自证 CPU 跑通
课程设计的最后一步不是把代码交上去,而是能拿出证据说明它真的跑对了。我的习惯是准备三组测试程序,每组只验证一类能力,跑完把波形截图和预期值一起存进报告。
第一组验证数据通路,程序就四条指令:LDI 装 5、MOV 复制到 R1、ADD 得到 10、STOP 停机。这一组看的是 R0 和 R1 的终值,以及 PC 是不是停在 3。第二组验证溢出,装 0x7F 再加 1,看 overflow 是不是拉高,同时 R0 变成 0x80。第三组验证访存和跳转,先用 WRITE 把 R0 写到某个地址,再用 READ 读回来比对,中间插一条 JZ 制造一次条件跳转,观察 PC 在跳与不跳两种情况下分别落在哪。
波形检查我一般固定看五个点,做成表格贴在报告里:
| 检查点 | 观察信号 | 合格判据 |
|---|---|---|
| 取指拍 | PC、MAR | MAR 比 PC 小 1,且逐条递增 |
| 译码拍 | OP、state | OP 与指令高 4 位一致,state 在 0 和 1 之间切换 |
| 运算拍 | R0~R3、overflow | 终值与手算一致,溢出标志符合预期 |
| 访存拍 | M_address、Write_Read、MDR | 写周期地址稳定、写使能为高 |
| 停机 | state、PC | state 不再跳转,PC 保持不变 |
答辩时最容易被追问的是「你怎么证明状态机没死锁」。我的答法是当场打开波形,把 state 的所有取值在时间轴上的出现顺序指出来,说明每个状态都有明确的出口条件,并且 STOP 之后状态停在终止态不再接受新指令。评委一般不会再往深里问。
还有一点属于血泪经验:答辩前一定要用全新的工程目录重新综合一遍。我遇到过在原工程里改了 MIF 文件但综合工具读的是缓存副本,仿真跑的是新程序、板子上跑的是旧程序,现场演示直接对不上。从那以后我每次上板前都强制走一遍「新建工程 → 导入源码和 MIF → 全编译 → 看资源报告」的流程,多花十分钟,能省掉一场尴尬。希望帮到你。
本文还有配套的精品资源,点击获取