16条指令模型机设计全攻略:从指令集到控制器与调试
简介面向计算机组成原理课程设计学习者哈尔滨工程大学模型机设计资源完整覆盖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功能addRopcode0000, funct000rd rs rtsubRopcode0000, funct001rd rs - rtandRopcode0000, funct010rd rs rtorRopcode0000, funct011rd rs | rtsllRopcode0000, funct100rd rt shamtsrlRopcode0000, funct101rd rt shamtaddiIopcode0001rt rs 符号扩展(imm)lwIopcode0010rt mem[rs 符号扩展(imm)]swIopcode0011mem[rs 符号扩展(imm)] rtbeqIopcode0100若 rsrtPC PC 1 符号扩展(imm)bneIopcode0101若 rs!rtPC PC 1 符号扩展(imm)luiIopcode0110rt {imm, 8’h00}jJopcode0111PC {4’h0, addr12}jalJopcode1000R1 PC 1PC {4’h0, addr12}jr特殊opcode1001PC rshalt特殊opcode1111停机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, rtI 型指令统一是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 固定为 0R1 同时作为 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 2b00) ? 16d0 : regs[raddr_a]; rdata_b (raddr_b 2b00) ? 16d0 : regs[raddr_b]; end always (posedge clk) begin if (we (waddr ! 2b00)) regs[waddr] wdata; end endmodule逻辑说明读口是组合逻辑只要地址给定数据立刻出现在输出端写口在时钟上升沿写入。R0 没有实际存储单元读口用三目运算符直接返回 0写口用waddr ! 2b00屏蔽写入。这种写法比单独维护一个寄存器数组更省逻辑也不容易在复位时漏掉 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) 3d0: y a b; 3d1: y a - b; 3d2: y a b; 3d3: y a | b; 3d4: y b shamt; 3d5: y b shamt; default: y 16d0; endcase end assign z (y 16d0); endmodule逻辑说明a和b分别来自寄存器堆的读口 A、读口 Bfunct直接复用指令的 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_n1时钟与异步复位pc16程序计数器ir16指令寄存器alu_a, alu_b16ALU 两个输入alu_out16ALU 输出接寄存器堆写数据或数据存储器地址reg_we1寄存器堆写使能mem_we1数据存储器写使能mem_addr16数据存储器地址来自 ALU 输出顶层连线我一般这样接PC 输出到指令存储器地址端指令存储器的输出在取指第二拍打入 IRIR 的 rs、rt 字段接寄存器堆读地址寄存器堆输出接 ALU 输入I 型指令的立即数经过符号扩展后也接 ALU 的 B 口通过一个选择信号在“寄存器堆 B 口输出”和“立即数”之间切换。这样一通控制器只需决定每个周期哪些信号有效数据通路本身不需要看不懂的状态。4. 控制器怎么落地微程序、硬布线还是状态机4.1 微程序控制器和硬布线的课设如何选择控制器是模型机设计里区分度最高的部分。硬布线控制器用组合逻辑和触发器直接生成控制信号速度快但每条指令都要单独画逻辑表达式16 条指令的规模下表达式会写到崩溃。微程序控制器则把控制信号预先存成一条条微指令执行指令变成“逐条读出微指令并执行”的过程修改指令集只改微指令 ROM不动硬件结构。课程设计层面微程序方案更好讲、更好改也更容易拿高分。但真写微程序控制器时很多同学会死在微指令编码上字段怎么分配、下址怎么生成、条件分支怎么处理。我的建议是折中先按“状态机式微程序”把整机跑通再用微指令 ROM 的表驱动方式重构。下面给一个最小可用的状态机写法它和微程序的本质是一样的——每个状态对应一条微操作不同指令走不同状态路径。4.2 一个简化微指令格式控制信号、下址与条件分支微指令至少要包含三类信息要执行什么微操作、下一条微指令地址是多少、在什么条件下转移。对应到 Verilog 里我习惯把“微操作”写成一组使能信号包括控制信号含义ld_ir将指令存储器输出打入 IRld_pcPC 加 1reg_we寄存器堆写使能mem_we数据存储器写使能alu_src_bALU 的 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) 4b0000: next_state S_ADD; // R型再按funct细分 4b0001: next_state S_ADDI; 4b0010: next_state S_LW; 4b0011: next_state S_SW; 4b0100, 4b0101: next_state S_BRANCH; 4b1000: next_state S_JAL; 4b1111: 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指令入口状态所需周期数0000R型S_ADD40001addiS_ADDI40010lwS_LW50011swS_SW50100/0101beq/bneS_BRANCH40110luiS_ADDI40111jS_JUMP41000jalS_JAL41001jrS_JUMP41111haltS_HALT停住控制信号在每个状态里怎么置位是代码的核心部分。以 S_FETCH0 和 S_FETCH1 为例always (*) begin {ld_ir, ld_pc, reg_we, mem_we, alu_src_b} 5b00000; case (cur_state) S_FETCH0: begin pc_addr_en 1b1; // 把PC送到指令存储器地址端 end S_FETCH1: begin ld_ir 1b1; // 锁存指令 ld_pc 1b1; // 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 1b0; // PC 不再更新 imem_en 1b0; // 指令存储器不再输出 end这样设计之后PC 会停留在 halt 指令所在的地址上uPC 也固定在 HALT 状态波形上表现为所有关键信号都不再跳变。验收时看一眼这条信息就知道停机逻辑是真正生效的而不是靠仿真强制停止。5. 模型机调试避坑仿真过了不等于验收过了5.1 复位后第一条指令不执行现象仿真开始后PC 一直是 0IR 始终没有锁存任何指令程序完全不走。原因多半是复位信号释放的时机和时钟上升沿撞在一起或者取指状态机的初始状态没复位干净。解决在测试平台里把rst_n拉低至少两个时钟周期再释放同时在控制器复位逻辑中把cur_state明确赋值为 S_FETCH0IR 和 PC 也在复位时清零。还要检查$readmemh的路径是否写对文件读不到时 imem 会保持全 XIR 锁存出来全是未知值。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 验证程序选型为什么选4321验证程序不必复杂但必须覆盖 R 型运算、I 型立即数、条件分支、访存和停机五类操作。我用一个 4321 的累加循环从 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 章的脚本翻译后得到机器码序列地址助记符机器码0addi r1, r0, 00x11001addi r2, r0, 40x12042addi r3, r0, 10x13013add r1, r1, r20x06404sub r2, r2, r30x0B885bne r2, r0, -30x58FD6sw r1, r0, 00x31007lw r3, r0, 00x23008halt0xFFFF注意地址 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 停在 8uPC 停在 HALT。ModelSim 里添加pc、ir、rdata_a、rdata_b、alu_out、cur_state这几个信号。循环第一次跳转时cur_state应该从 S_BRANCH 回到 S_FETCH0PC 从 5 跳到 3循环结束时R2 变为 0z 标志为 1bne 不跳转顺序执行到地址 6。看到alu_out变成 10同时mem_we拉高数据存储器写入成功整机的基本功能就算是验证通过了。6.3 验收时的加分技巧课设答辩时不要只给总波形图那是自己都看不清的黑匣子。我习惯准备三张截图第一张是整个程序的完整波形第二张是循环跳转处放大后的波形第三张是 halt 之后 PC 和 uPC 稳定不动的波形。第三张对应 5.4 的避坑点最容易被问到答得清楚就是加分项。另外可以准备一个简单的计数显示在dmem[0]写入结果后用一个 testbench 里的$display打印寄存器状态仿真结束时自动输出结果对错。这样验收老师不用从波形里数二进制数一眼就知道设计是否正确。我当年就是靠这个习惯省去了大量从波形里数数的麻烦。模型机设计16条指令这个题目做到最后你会发现真正的难点从来不是 Verilog 语法而是“把需求变成状态机”的拆解能力。指令集定得好数据通路就是搭积木控制器写得干净调试就是看信号。希望这篇笔记能把你的课设从“能跑”推到“讲得清”这是比验收分数更值的东西。本文还有配套的精品资源点击获取