简介:面向计算机组成原理课程设计学习者,哈尔滨工程大学模型机设计资源完整覆盖16条指令的CPU设计与实现流程,包含工程源码、实验报告与仿真验证文件,适合高校本科生对照完成同类模型机或指令系统实验。压缩包共340个文件,以bdf原理图、vhd/tdf硬件描述、vwf仿真波形、qpf/qsf工程配置及sof下载文件为核心,另有doc实验报告与hex测试程序,整体约7.06MB,结构清晰便于按模块复用。已有348人学习下载,适合需要借鉴完整设计方案、排错思路和报告写作范式的同学。资源内还包括大量Quartus中间文件与调试记录,可作为从原理图设计到功能仿真的过程性参考,帮助理解微程序控制、ALU运算及16条指令的译码执行通路。
1. 模型机设计(16条指令):课设的最后一关,到底考什么?
模型机设计(16条指令)这个题目,是很多院校计算机组成原理课在期末阶段布置的经典综合项目:不依赖现成处理器,而是用 Verilog 或 Logisim 从指令集、数据通路到控制器,亲手搭一台只认识 16 条指令的小 CPU,最后在仿真环境里跑通一段汇编小程序。放倒学生的通常不是“不会写 Verilog”,而是指令集没定清楚、控制器时序没理顺、分支偏移算错这类前期设计问题——代码只是把流程图翻译成硬件描述语言而已。这个课设适合正在准备验收的同学,也适合想补体系结构基本功、搞清楚取指译码执行背后到底发生什么的从业者。下面这套方案,就是按“先定指令集,再画数据通路,最后填控制器”的顺序展开的。
2. 先定指令集:16 条指令的取舍、编码表与汇编脚本
2.1 16 条指令为什么这样凑:寄存器个数和指令格式先定死
做模型机设计第一步不是打开编辑器,而是把指令集定死。16 条指令看似不多,但每加一种寻址方式,控制器状态就要多出两三个,数据通路的控制信号也会跟着膨胀。我一般会把指令分成四类:算术逻辑运算、访存、分支跳转、停机,保证每一种典型操作都有覆盖,又不会多到状态机写不完。
寄存器个数建议砍到 4 个:R0、R1、R2、R3,并且把 R0 固定为常数 0。这样寄存器编号只需要 2 位,16 位指令字里才能同时放下操作码、寄存器字段和 8 位立即数。如果一开始就按 MIPS 的 32 个寄存器设计,寄存器编号占 5 位,一条 16 位指令根本装不下,最后只能缩立即数或缩操作码,反而更别扭。
指令字长我定为 16 位。四种类型按字段区分:
- R 型:算术逻辑指令,操作码 4 位,rs、rt、rd 各 2 位,funct 3 位,移位量 3 位
- I 型:立即数运算、访存、条件分支,操作码 4 位,rs、rt 各 2 位,立即数 8 位
- J 型:无条件跳转,操作码 4 位,目标地址 12 位
- 特殊:jr、halt 用独立操作码编码
这样 16 条指令分别定为:add、sub、and、or、sll、srl、addi、lw、sw、beq、bne、lui、j、jal、jr、halt。前 6 条是 R 型,中间 6 条是 I 型,然后 3 条跳转指令,最后 1 条停机。
2.2 一张表把机器码编码定完:R型、I型、J型和停机指令
确定格式后,操作码分配要尽早固定,因为后面控制器入口地址、微指令分支都依赖这张表。我的分配方案如下:
| 指令 | 类型 | 操作码 / funct | 功能 |
|---|---|---|---|
| add | R | opcode=0000, funct=000 | rd = rs + rt |
| sub | R | opcode=0000, funct=001 | rd = rs - rt |
| and | R | opcode=0000, funct=010 | rd = rs & rt |
| or | R | opcode=0000, funct=011 | rd = rs | rt |
| sll | R | opcode=0000, funct=100 | rd = rt << shamt |
| srl | R | opcode=0000, funct=101 | rd = rt >> shamt |
| addi | I | opcode=0001 | rt = rs + 符号扩展(imm) |
| lw | I | opcode=0010 | rt = mem[rs + 符号扩展(imm)] |
| sw | I | opcode=0011 | mem[rs + 符号扩展(imm)] = rt |
| beq | I | opcode=0100 | 若 rs==rt,PC = PC + 1 + 符号扩展(imm) |
| bne | I | opcode=0101 | 若 rs!=rt,PC = PC + 1 + 符号扩展(imm) |
| lui | I | opcode=0110 | rt = {imm, 8’h00} |
| j | J | opcode=0111 | PC = {4’h0, addr12} |
| jal | J | opcode=1000 | R1 = PC + 1;PC = {4’h0, addr12} |
| jr | 特殊 | opcode=1001 | PC = rs |
| halt | 特殊 | opcode=1111 | 停机 |
R 型指令的统一编码为:[15:12] opcode、[11:10] rs、[9:8] rt、[7:6] rd、[5:3] funct、[2:0] shamt。sll 和 srl 的 shamt 就是移位位数,其他 R 型指令该字段固定为 0。I 型指令编码为:[15:12] opcode、[11:10] rs、[9:8] rt、[7:0] imm。J 型指令编码为:[15:12] opcode、[11:0] addr。
这里有一个容易翻车的点:beq 和 bne 的 imm 是相对下一条指令的偏移量,计算方式是“目标地址 − (当前 PC + 1)”,并且按符号扩展参与加法。很多同学在这里直接写目标地址,波形仿真时分支出错,后面第 5 章会专门讲。
2.3 用一个小脚本把助记符翻译成机器码
手工逐条翻译机器码很容易看错位,我习惯写一个几十行的 Python 小脚本,把助记符转成 16 进制机器码,再喂给 Verilog 的$readmemh。下面是一个面向本指令集的最小实现:
# mini_asm.py —— 16条指令模型机微型汇编器 import re regs = {'r0':0, 'r1':1, 'r2':2, 'r3':3} rtype = {'add':0, 'sub':1, 'and':2, 'or':3, 'sll':4, 'srl':5} itype = {'addi':0x1, 'lw':0x2, 'sw':0x3, 'beq':0x4, 'bne':0x5, 'lui':0x6} jtype = {'j':0x7, 'jal':0x8, 'jr':0x9, 'halt':0xF} def reg(x): return regs[x.lower()] def asm16(line): t = line.replace(',', ' ').split() op = t[0].lower() if op in rtype: if op in ('sll', 'srl'): rd, rt, sh = reg(t[1]), reg(t[2]), int(t[3]) return (0 << 12) | (rt << 8) | (rd << 6) | (rtype[op] << 3) | (sh & 0x7) rd, rs, rt = reg(t[1]), reg(t[2]), reg(t[3]) return (0 << 12) | (rs << 10) | (rt << 8) | (rd << 6) | (rtype[op] << 3) if op in itype: rt, rs = reg(t[1]), reg(t[2]) imm = int(t[3]) & 0xFF return (itype[op] << 12) | (rs << 10) | (rt << 8) | imm if op in jtype: if op in ('j', 'jal'): target = int(t[1], 0) & 0xFFF return (jtype[op] << 12) | target if op == 'jr': rs = reg(t[1]) return (0x9 << 12) | (rs << 10) if op == 'halt': return 0xFFFF raise ValueError('无法识别: ' + line) if __name__ == '__main__': for line in open('test.s'): line = line.split(';')[0].strip() if line: print('%04X' % asm16(line))逻辑说明:asm16根据助记符分发到 R 型、I 型、J 型三类编码函数,reg负责把 r0~r3 映射成 2 位寄存器编号。脚本只接收纯助记符文本,每行一条指令,分号后面可以写注释。使用%04X输出 4 位十六进制数,直接重定向到文件就能作为$readmemh的输入。
参数说明:R 型移位指令的语法是sll rd, rt, shamt,其他 R 型指令是add rd, rs, rt;I 型指令统一是op rt, rs, imm;分支指令的偏移直接写数字,比如 -3。脚本没有做标签解析,分支跳转地址需要自己算偏移,这是教学版本的有意取舍——能让你在算偏移的过程中把 PC 相对寻址彻底搞明白。
3. 数据通路:让 16 条指令能取指、运算、访存、写回
3.1 单周期还是多周期:这个选择决定后续所有代码结构
模型机设计有两种主流路线:单周期和多周期。单周期每条指令在一个时钟周期内完成,控制信号组合逻辑简单,Verilog 代码短,但在这个 16 条指令的规模下会发现一个麻烦:访存指令、分支指令和算术指令的延迟差异很大,周期长度只能按最慢的 lw 来定,整体效率低。多周期则把每条指令拆成取指、译码、执行、访存、写回几个微步骤,每个周期只做一个微操作,时钟可以跑得更“公平”,也更接近真实处理器的节奏。
但多周期有一个代价:控制器状态变多,每条指令至少 4 个周期,调试时波形数量多到眼花。作为课程设计,我建议选多周期,因为答辩时能讲清楚“为什么需要多个时钟周期”这个高频问题;如果项目时间只剩两天,单周期硬布线反而更容易跑通。
这个 16 条指令的规模,多周期的典型状态是:取指两个周期,译码一个周期,执行一至两个周期,额外写回一个周期。我的方案里取指用两个周期:第一拍把 PC 送到指令存储器地址端,第二拍把读出的指令锁存到 IR,同时 PC 完成加 1。这样即使指令存储器是同步读,也不会出现数据还没稳定就锁存的时序问题。
3.2 模块划分与关键接口:寄存器堆、ALU、存储器、PC和IR
模块划分我习惯按五块走:PC、指令存储器 IM、寄存器堆 RF、ALU、数据存储器 DM,外加一个集中控制所有使能信号的控制器。控制器单独占一个模块,其余模块只负责“被控制”,这样调试时只要盯着控制器的输出信号,就能定位是哪条指令在哪个周期出了岔子。
寄存器堆是第一个要写的模块。4 个寄存器中 R0 固定为 0,R1 同时作为 jal 的链接寄存器。模块接口如下:
module regfile ( input wire clk, input wire we, // 写使能 input wire [1:0] raddr_a, // 读口A地址 input wire [1:0] raddr_b, // 读口B地址 input wire [1:0] waddr, // 写地址 input wire [15:0] wdata, // 写数据 output reg [15:0] rdata_a, output reg [15:0] rdata_b ); reg [15:0] regs [1:3]; // R0 用常量0替代 always @(*) begin rdata_a = (raddr_a == 2'b00) ? 16'd0 : regs[raddr_a]; rdata_b = (raddr_b == 2'b00) ? 16'd0 : regs[raddr_b]; end always @(posedge clk) begin if (we && (waddr != 2'b00)) regs[waddr] <= wdata; end endmodule逻辑说明:读口是组合逻辑,只要地址给定,数据立刻出现在输出端;写口在时钟上升沿写入。R0 没有实际存储单元,读口用三目运算符直接返回 0,写口用waddr != 2'b00屏蔽写入。这种写法比单独维护一个寄存器数组更省逻辑,也不容易在复位时漏掉 R0 的清零。
参数说明:we是寄存器堆写使能,由控制器给出;raddr_a和raddr_b分别接指令的 rs、rt 字段;waddr接 rd 字段或 rt 字段,由控制器根据指令类型选择。这个模块是整机调试时最容易出问题的地方,尤其是“同一时钟沿读旧值”的问题,第 5 章会展开。
ALU 模块要同时支持算术、逻辑、移位和零标志输出:
module alu ( input wire [15:0] a, // 操作数A input wire [15:0] b, // 操作数B input wire [2:0] funct, // 运算选择 input wire [2:0] shamt, // 移位量 output reg [15:0] y, output wire z // 零标志 ); always @(*) begin case (funct) 3'd0: y = a + b; 3'd1: y = a - b; 3'd2: y = a & b; 3'd3: y = a | b; 3'd4: y = b << shamt; 3'd5: y = b >> shamt; default: y = 16'd0; endcase end assign z = (y == 16'd0); endmodule逻辑说明:a和b分别来自寄存器堆的读口 A、读口 B,funct直接复用指令的 funct 字段,这样控制器不需要额外解码 ALU 操作。z是零标志,供 beq、bne 使用——判断 rs 与 rt 是否相等,实际上就是做一次减法看结果是否为零。参数说明:shamt来自 R 型指令的低 3 位,非移位指令时该字段为 0,不影响运算结果。
访存部分,指令存储器和数据存储器我建议都用 Verilog 里的 reg 数组来建模,指令存储器用$readmemh初始化:
reg [15:0] imem [0:255]; reg [15:0] dmem [0:255]; initial begin $readmemh("imem.hex", imem); end // 取指:先给地址,下一拍锁存 IR wire [15:0] inst = imem[pc[7:0]]; // 数据写:时钟上升沿写入 always @(posedge clk) begin if (mem_we) dmem[addr[7:0]] <= data_in; end这里的pc[7:0]用了低 8 位做索引,意味着程序空间限制在 256 条指令以内,对这个课设完全够用。数据存储器同理,256 个字够存放测试数据。如果后续要做复杂一点的排序、求和程序,可以把地址位宽扩到 10 位或 12 位,但对应的寻址范围也要在答辩时讲清楚。
3.3 最小 Verilog 骨架:顶层连线与信号命名规范
数据通路各模块拼起来之后,顶层模块的核心信号如下:
| 信号 | 位宽 | 作用 |
|---|---|---|
| clk, rst_n | 1 | 时钟与异步复位 |
| pc | 16 | 程序计数器 |
| ir | 16 | 指令寄存器 |
| alu_a, alu_b | 16 | ALU 两个输入 |
| alu_out | 16 | ALU 输出,接寄存器堆写数据或数据存储器地址 |
| reg_we | 1 | 寄存器堆写使能 |
| mem_we | 1 | 数据存储器写使能 |
| mem_addr | 16 | 数据存储器地址,来自 ALU 输出 |
顶层连线我一般这样接:PC 输出到指令存储器地址端,指令存储器的输出在取指第二拍打入 IR;IR 的 rs、rt 字段接寄存器堆读地址,寄存器堆输出接 ALU 输入;I 型指令的立即数经过符号扩展后也接 ALU 的 B 口,通过一个选择信号在“寄存器堆 B 口输出”和“立即数”之间切换。这样一通,控制器只需决定每个周期哪些信号有效,数据通路本身不需要看不懂的状态。
4. 控制器怎么落地:微程序、硬布线还是状态机?
4.1 微程序控制器和硬布线的课设如何选择?
控制器是模型机设计里区分度最高的部分。硬布线控制器用组合逻辑和触发器直接生成控制信号,速度快,但每条指令都要单独画逻辑表达式,16 条指令的规模下表达式会写到崩溃。微程序控制器则把控制信号预先存成一条条微指令,执行指令变成“逐条读出微指令并执行”的过程,修改指令集只改微指令 ROM,不动硬件结构。课程设计层面,微程序方案更好讲、更好改,也更容易拿高分。
但真写微程序控制器时,很多同学会死在微指令编码上:字段怎么分配、下址怎么生成、条件分支怎么处理。我的建议是折中:先按“状态机式微程序”把整机跑通,再用微指令 ROM 的表驱动方式重构。下面给一个最小可用的状态机写法,它和微程序的本质是一样的——每个状态对应一条微操作,不同指令走不同状态路径。
4.2 一个简化微指令格式:控制信号、下址与条件分支
微指令至少要包含三类信息:要执行什么微操作、下一条微指令地址是多少、在什么条件下转移。对应到 Verilog 里,我习惯把“微操作”写成一组使能信号,包括:
| 控制信号 | 含义 |
|---|---|
| ld_ir | 将指令存储器输出打入 IR |
| ld_pc | PC 加 1 |
| reg_we | 寄存器堆写使能 |
| mem_we | 数据存储器写使能 |
| alu_src_b | ALU 的 B 口选立即数还是寄存器 |
| mem_to_reg | 寄存器堆写数据选 ALU 结果还是存储器读数据 |
| br_taken | 分支条件成立时改写 PC |
这些信号合起来就是一个微指令的控制字。真正的微程序控制器还会把下址字段编码进微指令 ROM,但在课设规模下,我用下面的case状态转移来生成下址,效果等同:
localparam S_FETCH0 = 0, S_FETCH1 = 1, S_DECODE = 2; localparam S_ADD = 3, S_SUB = 4, S_ADDI = 5, S_LW = 6, S_SW = 7, S_BRANCH = 8, S_JAL = 9, S_JUMP = 10, S_HALT = 11; always @(*) begin next_state = S_FETCH0; case (cur_state) S_FETCH0: next_state = S_FETCH1; S_FETCH1: next_state = S_DECODE; S_DECODE: begin case (opcode) 4'b0000: next_state = S_ADD; // R型,再按funct细分 4'b0001: next_state = S_ADDI; 4'b0010: next_state = S_LW; 4'b0011: next_state = S_SW; 4'b0100, 4'b0101: next_state = S_BRANCH; 4'b1000: next_state = S_JAL; 4'b1111: next_state = S_HALT; default: next_state = S_JUMP; endcase end ... endcase end逻辑说明:cur_state是当前微指令状态,next_state是下一条微指令地址。opcode来自 IR 的高 4 位,译码阶段根据 opcode 分发到各指令的执行入口。这个分发表相当于微程序控制器里的“入口地址映射”,把操作码翻译成微指令序列的起始地址。
参数说明:R 型指令在 S_ADD 状态里还需要再判断 funct 字段来生成不同 ALU 操作,所以状态名写 S_ADD,实际执行时根据 funct 等于 000 到 101 分别走加、减、与、或、左移、右移。可以在 S_ADD 状态里用case (funct)生成alu_funct信号,不需要为每条 R 型指令单独开状态。
4.3 OPCODE入口映射表与Verilog的case写法
各指令的执行状态按我的习惯做如下映射:
| opcode | 指令 | 入口状态 | 所需周期数 |
|---|---|---|---|
| 0000 | R型 | S_ADD | 4 |
| 0001 | addi | S_ADDI | 4 |
| 0010 | lw | S_LW | 5 |
| 0011 | sw | S_SW | 5 |
| 0100/0101 | beq/bne | S_BRANCH | 4 |
| 0110 | lui | S_ADDI | 4 |
| 0111 | j | S_JUMP | 4 |
| 1000 | jal | S_JAL | 4 |
| 1001 | jr | S_JUMP | 4 |
| 1111 | halt | S_HALT | 停住 |
控制信号在每个状态里怎么置位,是代码的核心部分。以 S_FETCH0 和 S_FETCH1 为例:
always @(*) begin {ld_ir, ld_pc, reg_we, mem_we, alu_src_b} = 5'b00000; case (cur_state) S_FETCH0: begin pc_addr_en = 1'b1; // 把PC送到指令存储器地址端 end S_FETCH1: begin ld_ir = 1'b1; // 锁存指令 ld_pc = 1'b1; // PC加1 end ... endcase end这里的赋值风格很关键:我给每个状态一个完整的控制字,默认全为 0,避免综合出意外的锁存器。ld_ir和ld_pc在同一时钟沿生效,指令被锁存的同时 PC 完成加 1,正好衔接下一拍的译码。
4.4 让HALT真正停下来:停机指令控制时序
halt 指令是课设里验收老师最常看的地方。很多同学写完 halt,发现波形里 PC 还在继续跳,原因在于控制器虽然在 HALT 状态停了,但取指周期的公共序列还会被触发。正确的做法是:halt 状态的下一条微指令指向 HALT 自身,同时关闭 PC 使能和指令存储器读使能。
S_HALT: begin next_state = S_HALT; // 停在原地 pc_en = 1'b0; // PC 不再更新 imem_en = 1'b0; // 指令存储器不再输出 end这样设计之后,PC 会停留在 halt 指令所在的地址上,uPC 也固定在 HALT 状态,波形上表现为所有关键信号都不再跳变。验收时看一眼这条信息,就知道停机逻辑是真正生效的,而不是靠仿真强制停止。
5. 模型机调试避坑:仿真过了不等于验收过了
5.1 复位后第一条指令不执行
现象:仿真开始后,PC 一直是 0,IR 始终没有锁存任何指令,程序完全不走。原因多半是复位信号释放的时机和时钟上升沿撞在一起,或者取指状态机的初始状态没复位干净。解决:在测试平台里把rst_n拉低至少两个时钟周期再释放,同时在控制器复位逻辑中把cur_state明确赋值为 S_FETCH0,IR 和 PC 也在复位时清零。还要检查$readmemh的路径是否写对,文件读不到时 imem 会保持全 X,IR 锁存出来全是未知值。
5.2 BNE/BEQ的偏移为什么总是跳不准
现象:程序单步执行没问题,一到分支指令就跳错地址,要么提前跳,要么跳完回不来。原因几乎都是相对偏移计算错误。beq/bne 的跳转目标是“当前 PC + 1 + 符号扩展(imm)”,不是“当前 PC + imm”。比如地址 5 的 bne,下一条指令地址是 6,目标地址是 3,偏移就是 3 − 6 = −3,用 8 位二进制表示是 11111101。很多同学把偏移写成 3 或者 −4,跳转就会偏一条。解决:把分支指令的地址、下一条指令地址、目标地址列成一张表,逐一核对偏移,再用第 2 章的 Python 脚本重新生成机器码。
5.3 寄存器堆同沿读写读出旧值
现象:add 指令执行后,下一个周期从寄存器堆读出的数据还是旧值,导致后续指令计算结果错。原因是寄存器堆的写操作和读操作发生在同一个时钟沿:如果控制信号把reg_we拉高,数据要等这个时钟沿结束后才写入寄存器,而同一时刻读口看到的还是旧数据。解决:把加法结果在写回周期写入,读发生在再下一个周期,因为模型机本来就是多周期设计,天然有缓冲;如果做成了单周期,就需要在寄存器堆内部做“写优先”处理,写地址等于读地址时直接旁路写数据到读输出。
5.4 HALT后波形还在往下走
现象:halt 指令执行完,PC 仍然加 1,程序计数器一路走到内存末尾。原因是没有在控制器里关闭取指序列,或者 PC 加 1 信号在译码阶段就被拉高。解决:按 4.4 的做法,让 HALT 状态的下址指向自身,同时把ld_pc强制为 0。另外要确认imem_en是否默认开启,如果指令存储器是组合读,HALT 之后地址变化虽然不影响程序运行,但波形上很难看,验收时容易被追问。
5.5 指令ROM数据RAM地址重叠
现象:lw/sw 指令明明写对了,但读写的数据地址总往程序区跑,导致内存内容被破坏。原因是没有对程序空间和数据空间做隔离。这个 16 条指令的模型机里,我习惯让指令存储器从地址 0 开始,数据存储器也从地址 0 开始,但两者在 Verilog 里是不同的 reg 数组,问题在于 ALU 输出的地址如果只有 16 位,程序很容易读写到同一索引。解决:在顶层把数据存储器和指令存储器的地址空间错开,比如数据存储器物理地址取alu_out[7:0],程序计数器取pc[7:0],二者互不干扰;或者给数据存储器加一个单独的高位偏移,从 0x100 开始编址。答辩时要说清楚“指令空间与数据空间是分开的”这个设计选择。
6. 用一个循环累加程序验证整机:从汇编到波形对比
6.1 验证程序选型:为什么选4+3+2+1
验证程序不必复杂,但必须覆盖 R 型运算、I 型立即数、条件分支、访存和停机五类操作。我用一个 4+3+2+1 的累加循环,从 4 递减到 1,逐次加到 R1 里,最后把结果写入数据存储器再读回。这个程序短小精悍,跑完大约 30 个时钟周期,波形一眼能看完。
addi r1, r0, 0 ; r1 = 0,累加器 addi r2, r0, 4 ; r2 = 4,循环计数器 addi r3, r0, 1 ; r3 = 1,步长 loop: add r1, r1, r2 ; r1 = r1 + r2 sub r2, r2, r3 ; r2 = r2 - 1 bne r2, r0, -3 ; r2 != 0 则回到 loop sw r1, r0, 0 ; 数据存储器[0] = 10 lw r3, r0, 0 ; r3 = 10 halt用第 2 章的脚本翻译后,得到机器码序列:
| 地址 | 助记符 | 机器码 |
|---|---|---|
| 0 | addi r1, r0, 0 | 0x1100 |
| 1 | addi r2, r0, 4 | 0x1204 |
| 2 | addi r3, r0, 1 | 0x1301 |
| 3 | add r1, r1, r2 | 0x0640 |
| 4 | sub r2, r2, r3 | 0x0B88 |
| 5 | bne r2, r0, -3 | 0x58FD |
| 6 | sw r1, r0, 0 | 0x3100 |
| 7 | lw r3, r0, 0 | 0x2300 |
| 8 | halt | 0xFFFF |
注意地址 5 的 bne 偏移:下一条指令地址是 6,目标地址是 3,偏移 = 3 − 6 = −3,所以机器码低 8 位是 0xFD。这就是符号扩展后参与 PC 运算的结果。
6.2 机器码对照表与ModelSim验证
在测试平台里把 imem 初始化成上面的机器码,然后启动仿真。验证顺序我按三步走:先看 R1 的值是否依次变成 4、7、9、10;再看 PC 是否在地址 3、4、5 之间循环了 4 次;最后等 halt 之后确认 PC 停在 8,uPC 停在 HALT。
ModelSim 里添加pc、ir、rdata_a、rdata_b、alu_out、cur_state这几个信号。循环第一次跳转时,cur_state应该从 S_BRANCH 回到 S_FETCH0,PC 从 5 跳到 3;循环结束时,R2 变为 0,z 标志为 1,bne 不跳转,顺序执行到地址 6。看到alu_out变成 10,同时mem_we拉高,数据存储器写入成功,整机的基本功能就算是验证通过了。
6.3 验收时的加分技巧
课设答辩时,不要只给总波形图,那是自己都看不清的黑匣子。我习惯准备三张截图:第一张是整个程序的完整波形,第二张是循环跳转处放大后的波形,第三张是 halt 之后 PC 和 uPC 稳定不动的波形。第三张对应 5.4 的避坑点,最容易被问到,答得清楚就是加分项。
另外可以准备一个简单的计数显示:在dmem[0]写入结果后,用一个 testbench 里的$display打印寄存器状态,仿真结束时自动输出结果对错。这样验收老师不用从波形里数二进制数,一眼就知道设计是否正确。我当年就是靠这个习惯省去了大量从波形里数数的麻烦。
模型机设计(16条指令)这个题目,做到最后你会发现,真正的难点从来不是 Verilog 语法,而是“把需求变成状态机”的拆解能力。指令集定得好,数据通路就是搭积木;控制器写得干净,调试就是看信号。希望这篇笔记能把你的课设从“能跑”推到“讲得清”,这是比验收分数更值的东西。
本文还有配套的精品资源,点击获取