简介这份课程设计文档面向电子信息类专业学生聚焦计算机组成原理实验中的简化计算机系统设计帮助读者理解从指令系统到处理器与存储器联调的完整实现路径。文档围绕简单指令集展开涵盖idle、load、move、addp、subp、mulp、logp、comp、sh、swap、jmp、jz、read、write、stop等指令的编码与执行流程并给出用Verilog HDL实现处理器模块的底层代码包括寄存器、指令寄存器、存储器地址寄存器等核心部件同时说明64×8存储器模块的调用方式与顶层原理图连接方案最后分析指令fetch、decode、execute、store的执行过程。资源包共1个doc文件约384KB内容为完整的实验报告式设计文档含顶层BLOCK图与Verilog源码节选适合作为课程设计参考与代码对照模板。目前已有382人学习可供需要完成同类实验或复习计算机核心部件设计的学习者查阅借鉴。1. 从一份“能跑起来的 CPU”说起这个 Verilog 课设到底在做什么很多人第一次看到“简化计算机系统的设计”这个题目会以为又是画几张框图、写几段报告就能交差的课程作业。真正打开源码才会发现它要求你用 Verilog HDL 从零写出一个能取指、译码、执行的处理器模块再配一块 64×8 的存储器最后把两者连成一台能跑指令序列的“迷你计算机”。换句话说这不是画图题是让你亲手把计算机组成原理课本里那张数据通路图变成可综合的硬件逻辑。这份资源适合电子信息类专业的同学做课程设计复现也适合已经工作、想补一补 RTL 级处理器设计基本功的开发者——它足够小小到一晚上能读懂全部代码又足够完整完整到指令周期、状态机、溢出判断这些真问题一个都没少。下面我按“它是什么 → 怎么跑起来 → 怎么改”的顺序把这份 Verilog 课设拆开讲。2. 架构拆解一个 16 位指令字如何驱动整台简化计算机2.1 顶层结构与模块划分这份设计的顶层非常干净只有两个核心模块处理器cpu和存储器。处理器对外暴露的端口就是它和存储器之间的全部“总线”理解这组端口等于理解了整台机器的通信契约。module cpu( reset, clock, Write_Read, // 读写控制1 写 0 读 M_address, // 12 位地址总线 M_data_in, // 从存储器读入的数据 M_data_out, // 写往存储器的数据 overflow // 算术溢出标志 ); input reset, clock; output Write_Read; output [11:0] M_address; input [7:0] M_data_in; output [7:0] M_data_out; output overflow;这组端口里藏着几个关键设计决策。地址总线是 12 位但存储器只有 64 个单元实际只用到低 6 位高 6 位留作扩展——这是课设里常见的“留余量”做法我一般也会这么干免得后面想加指令时地址不够用。数据总线 8 位决定了这是一台 8 位机所有寄存器、运算都是 8 位宽。Write_Read是单比特读写选择信号配合地址和数据线构成最简单的存储器接口没有握手、没有等待周期属于“理想存储器”模型。overflow是组合逻辑算出来的溢出标志供条件跳转或后续扩展使用。顶层用原理图方式连接意味着处理器和存储器之间没有额外的胶合逻辑地址、数据、控制三组线直连。这种结构的好处是信号流向一目了然坏处是所有时序问题都会直接暴露在模块边界上调试时得盯着波形一根线一根线看。2.2 指令格式与指令集设计整台机器的灵魂是那条 16 位指令字。高 4 位IR[15:12]是操作码剩下 12 位按指令类型分配操作数字段。源码里用parameter把 16 个操作码定义成了有名字的常量parameter idle 4b0000, load 4b0001, move 4b0010, addp 4b0011, subp 4b0100, mulp 4b0101, logp 4b0110, comp 4b0111, sh 4b1000, meth1 4b1001, swap 4b1010, jmp 4b1011, jz 4b1100, read 4b1101, write 4b1110, stop 4b1111;这套指令集覆盖了教学处理器该有的几大类数据传送load、move、swap、算术运算addp、subp、mulp、meth1、逻辑运算logp、比较comp、移位sh、控制流jmp、jz、访存read、write以及停机stop。meth1这个名字有点抽象从代码看它是自增自减和加减常数的复合指令用IR[11:10]选操作类型、IR[9:8]选目标寄存器。addp、subp里的p大概率指“pair”或“pointer”实际语义是“寄存器与累加器 A 做运算”第二个操作数隐含在 A 里这是典型的单地址指令风格能有效压缩指令字长。提示读这套指令集时别急着背操作码先把每条指令的“源操作数从哪来、结果写到哪去”列成一张表后面看状态机时会顺很多。2.3 两段式状态机控制通路与数据通路的分工处理器内部用了两个always块一个管状态迁移一个管数据操作这是教学处理器里非常经典的两段式写法。状态机部分always (negedge reset or posedge clock) begin: status_change if (reset 1b0) 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; 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、译码与操作数准备状态 1、执行状态 2、访存相关状态 36几个阶段。注意状态 1 里对stop的特殊处理遇到停机指令就原地打转不再推进这是最朴素的“停机”实现。状态 2 只把访存类和控制流类指令导向状态 3其余算术逻辑指令直接回 0说明大部分运算在状态 2 就完成了。状态 3 到 6 专门服务read、write、swap、jmp、jz这些需要多拍才能完成的指令。数据通路部分挂在时钟下降沿和状态机的上升沿错开形成“半拍控制、半拍执行”的节奏always (negedge reset or negedge clock) begin: seq reg [11:0] PC; reg [7:0] R0, R1, R2, R3, A; reg [8:0] temp; reg [15:0] temp2; if (reset 1b0) begin IR 16b0; PC 12b0; R0 8b0; R1 8b0; R2 8b0; R3 8b0; A 8b0; MAR 12b0; MDR 8b0; end else begin overflow 1b0; case (status) 0: begin IR {M_data_in, 8b00000000}; // 取指高 8 位来自存储器 PC PC 1b1; end // ... 后续状态处理 endcase end end这里有个细节值得注意取指时IR {M_data_in, 8b00000000}只把存储器读出的 8 位放进指令寄存器高半部低 8 位清零。这意味着指令的低 8 位操作数需要后续再从存储器读一次或者由指令本身携带——从状态机看状态 1 会把PC送进MAR再读一次凑齐完整的 16 位指令。这种“两次读”的取指方式在 8 位数据总线上很常见代价是多一个时钟周期换来的是数据总线宽度减半。PC、R0R3、A这些寄存器声明在always块内部用的是阻塞赋值而IR、MAR、MDR声明在模块级、用非阻塞赋值。这种混用是这份代码的一个风格特征块内局部变量当组合逻辑用模块级寄存器当时序逻辑用。能跑但读的时候要分清哪些是“真寄存器”、哪些是“块内临时存储”。3. 跑通这份设计从 Verilog 源码到波形验证的完整步骤3.1 环境准备与工程搭建这份设计不依赖任何厂商 IP 核纯 Verilog-2001 语法主流的仿真和综合工具都能吃下。我一般用两种环境验证一是 Icarus Verilog GTKWave 这套开源组合轻量、启动快适合反复改代码看波形二是 Vivado 或 Quartus 的仿真器适合后面要上板的时候顺手做综合检查。开源环境在 Linux 或 macOS 下一条命令就能装好# Ubuntu/Debian 系 sudo apt install iverilog gtkwave # 验证安装 iverilog -V gtkwave --versionWindows 下可以用 MSYS2 或 WSL 装同样的包也可以直接用带图形界面的仿真工具。工程目录我习惯这样组织把处理器、存储器、测试平台分开后面改哪个模块都不会互相干扰simple_cpu/ ├── rtl/ │ ├── cpu.v # 处理器模块 │ └── mem64x8.v # 64×8 存储器模块 ├── sim/ │ ├── tb_cpu.v # 测试平台 │ └── program.mif # 指令序列初始化文件 └── top_sch/ # 原理图顶层工具相关存储器模块要单独写一个行为级描述即可重点是支持mif文件初始化module mem64x8( input clk, input we, // 写使能 input [11:0] addr, input [7:0] din, output [7:0] dout ); reg [7:0] mem [0:63]; initial begin $readmemh(program.mif, mem); // 从 mif 加载指令序列 end assign dout mem[addr[5:0]]; // 读操作组合输出 always (posedge clk) if (we) mem[addr[5:0]] din; // 写操作同步 endmoduleaddr[5:0]只取低 6 位正好对应 64 个存储单元高位地址被忽略——这和处理器侧 12 位地址总线的“留余量”设计对上了。$readmemh读的是十六进制格式的mif每行一个字节写指令序列时按“高字节在前、低字节在后”的顺序排因为取指是分两次读的。3.2 编写测试平台与指令序列测试平台的核心任务是产生复位和时钟并把处理器和存储器连起来。时钟周期我一般设 10ns够慢波形看得清module tb_cpu; reg clk 0; reg reset 0; wire we; wire [11:0] addr; wire [7:0] dout_cpu, din_mem; wire ovf; always #5 clk ~clk; // 10ns 周期 cpu u_cpu( .reset(reset), .clock(clk), .Write_Read(we), .M_address(addr), .M_data_in(din_mem), .M_data_out(dout_cpu), .overflow(ovf) ); mem64x8 u_mem( .clk(clk), .we(we), .addr(addr), .din(dout_cpu), .dout(din_mem) ); initial begin reset 0; #20 reset 1; // 释放复位 #2000 $finish; // 跑够时间自动结束 end initial begin $dumpfile(cpu.vcd); $dumpvars(0, tb_cpu); end endmodule指令序列要按指令格式手工编码。比如想验证“把立即数 5 装入 R0再和 A 相加”就得先查操作码表把load0001、目标寄存器编码、立即数拼成 16 位再拆成两个字节写进mif。这一步最容易翻车因为高低字节顺序、操作码位置、寄存器编码任何一处对不上波形上看到的就是一条莫名其妙的指令。// program.mif 示例load R0, #5 然后 stop 15 F0上面两行对应load指令高字节0x15操作码 0001 寄存器字段 0101和stop指令高字节0xF0。实际写的时候每条指令占两个存储单元地址要连续排。3.3 用波形验证指令执行流程跑仿真iverilog -o sim.out rtl/cpu.v rtl/mem64x8.v sim/tb_cpu.v vvp sim.out gtkwave cpu.vcd打开波形后重点盯四组信号status看状态迁移是否符合预期IR看取到的指令对不对PC看是否按序递增或跳转R0R3和A看运算结果。一条load指令正常应该看到状态 0 取指、IR高字节更新、PC加一状态 1 再读低字节、IR补全状态 2 把立即数写进目标寄存器然后回状态 0。如果status卡在某个值不动先查IR[15:12]是不是落进了case没覆盖的分支——default分支虽然写了但有些指令在特定状态下没有对应处理会悄悄回到 0波形上表现为“指令被跳过”。注意reset是低电平有效测试平台里先拉低再释放别一上来就给高否则初始状态不确定波形第一拍就是乱的。4. 核心代码精读取指、译码、执行三段链路逐行拆4.1 取指阶段两次读存储器凑齐 16 位指令取指逻辑集中在状态 0 和状态 1。状态 0 做的是“读高字节”0: begin IR {M_data_in, 8b00000000}; PC PC 1b1; endM_data_in是存储器在当前MAR地址上的输出。但这里有个隐含前提进入状态 0 之前MAR必须已经指向PC。翻代码会发现MAR PC是在状态 1 里做的也就是说第一次取指时MAR还是复位值 0恰好等于PC初值能对上后续循环里状态 1 把下一条指令地址送进MAR状态 0 再读形成流水。这种“上一状态准备地址、下一状态读数据”的写法省了一个状态但要求MAR的更新和M_data_in的到达时序严格对齐仿真时如果看到IR高字节比预期晚一拍多半是这里的问题。状态 1 补低字节并做译码前的准备1: begin if (IR[15:12] ! stop) MAR PC; case (IR[15:12]) load: R0 {4b0000, IR[11:8]}; move: case (IR[11:8]) 4b0001: R0 R1; 4b0010: R0 R2; // ... 其余寄存器组合 endcase // ... 其他指令的操作数准备 endcase endload指令直接把IR[11:8]作为立即数写进R0说明这条指令的立即数藏在指令字里不需要再访存。move用IR[11:8]编码源寄存器和目标寄存器是一套 4 位编码的寄存器堆选择逻辑。这里用阻塞赋值而不是效果是同一拍内R0立刻更新后续状态能马上用到新值——教学代码里这么写能简化时序分析但综合成硬件时要注意它和IR的非阻塞更新不在同一拍生效。4.2 算术与逻辑运算溢出判断和移位实现addp的加法用 9 位临时变量temp接住进位addp: case (IR[11:10]) 2b00: begin temp ({R0[7], R0[7:0]}) ({A[7], A[7:0]}); R0 temp[7:0]; overflow temp[8] ^ temp[7]; end // ... R1、R2、R3 同理 endcase{R0[7], R0[7:0]}是符号扩展把 8 位有符号数扩成 9 位再相加temp[8]是进位、temp[7]是结果符号位两者异或就是有符号溢出的标准判据。这个写法比“看两个正数相加得负数”那种条件判断干净得多一行搞定。subp用补码加法实现减法({R0[7], R0[7:0]}) (~({A[7], A[7:0]})) 1b1取反加一溢出判断逻辑和加法一致。mulp是唯一产生 16 位结果的指令mulp: case (IR[11:10]) 2b00: begin temp2 R0 * A; R0 temp2[7:0]; // 低字节回 R0 R1 temp2[15:8]; // 高字节进 R1 end // ... 其他寄存器对 endcase结果拆成高低字节分别存进一对寄存器这是 8 位机上做乘法的常规操作。注意temp2是 16 位、R0 * A是两个 8 位无符号数相乘如果要做有符号乘法得先做符号扩展再乘这份代码按无符号处理改的时候要留意。sh移位指令用IR[11:10]选移位类型、IR[9:8]选目标寄存器四种类型分别是带符号右移、循环右移、逻辑右移、逻辑左移2b00: case (IR[9:8]) // 算术右移保留符号位 2b00: R0 {R0[7], R0[7:1]}; // ... endcase 2b01: case (IR[9:8]) // 循环右移 2b00: R0 {R0[6:0], R0[0]}; // ... endcase算术右移把最高位复制到次高位循环右移把最低位绕回最高位逻辑移位补零。四种移位共用一套选择逻辑代码密度很高但读的时候容易看串行建议对着IR[11:10]和IR[9:8]的取值画个 4×4 表格再逐格核对。4.3 访存与控制流read/write 和 jmp/jz 的多拍实现read和write是仅有的两条真正访问存储器的指令状态机给它们单独开了状态 3 到 6。read的流程是状态 2 把地址送进MAR状态 3 发读信号状态 5 判断是read就进状态 6状态 6 把M_data_in收进MDR再写回寄存器。write则在状态 3 把数据送上M_data_out、拉高Write_Read状态 4 完成写操作。这种多拍设计是因为存储器接口没有握手信号只能靠状态机“等一拍”保证数据稳定。jmp和jz的跳转目标来自指令低 12 位直接写进PC// 状态 3 中处理 jmp/jz if (IR[15:12] jmp) PC IR[11:0]; else if (IR[15:12] jz A 8b0) PC IR[11:0];jz的判断条件是累加器A为零这要求前面的comp指令把比较结果放进A。comp的实现是“大于写 1、小于写 0xFF、等于写 0”配合jz就能实现“相等跳转”。这套条件跳转只有零判断没有进位、溢出判断跳转是教学处理器的简化二次开发时加一条jc进位跳转是很自然的扩展。5. 避坑与排查仿真跑不通时先看这五处5.1 波形里 IR 一直是 0PC 也不动现象仿真跑起来IR始终为 0PC停在初值status在 0 和 1 之间来回跳。原因多半是reset时序不对——reset低电平有效如果测试平台里reset一直为低状态机永远停在复位分支或者reset释放得太早第一拍时钟上升沿时status还没稳定。解决确保reset至少保持两个时钟周期的低电平再在时钟下降沿附近释放避开上升沿的建立保持窗口。5.2 指令执行到一半跳回状态 0后续指令被吞现象某条指令执行后status没有按预期进入状态 2 或 3而是直接回 0PC却已经加了。原因通常是IR[15:12]落进了状态机case的default分支。状态 2 只对swap、jmp、jz、read、write导向状态 3其余指令回 0 是正常的但如果一条本该在状态 2 完成运算的指令比如addp在状态 1 的case里没有对应分支操作数没准备好状态 2 算出来的就是垃圾值。解决对照指令集表逐条检查状态 1 和状态 2 的case是否覆盖了所有操作码缺的分支补上。5.3 存储器读出的数据和 mif 对不上现象M_data_in的值和program.mif里写的字节不一致或者高低字节颠倒。原因有两个常见来源一是$readmemh的地址从 0 开始按行递增但指令是 16 位、占两个单元写mif时如果按“一条指令一行”写就会错位二是取指时先读高字节、后读低字节mif里必须高字节在前。解决mif严格按“每行一个字节、高字节在前”排写完用文本编辑器数一下行号确保第 0 行是第一条指令的高字节。5.4 乘法结果高字节丢失现象mulp执行后目标寄存器对里只有低字节对高字节是 0 或旧值。原因temp2声明为reg [15:0]但赋值时如果两个操作数都被综合工具推断成 8 位乘积可能只保留低 8 位。解决显式做位宽扩展比如temp2 {8b0, R0} * {8b0, A}确保乘法在 16 位宽度上进行。仿真时如果看到temp2[15:8]恒为 0基本就是这个原因。5.5 上板综合后行为仿真对、实际跑飞现象行为仿真波形完全正确综合下载到 FPGA 后指令执行错乱。原因通常是代码里混用了阻塞和非阻塞赋值行为仿真按顺序执行看不出问题综合成并行硬件后时序就变了。这份代码里PC、R0R3、A用阻塞赋值、IR、MAR、MDR用非阻塞赋值综合工具对块内阻塞赋值的处理依赖具体工具版本。解决上板前把所有时序逻辑统一改成非阻塞赋值块内临时变量如果只是组合中间结果单独拆成always (*)块别和时序逻辑混在一个always里。6. 二次开发给这台迷你计算机加一条新指令改造这台处理器最直接的练手方式是加一条原指令集没有的指令比如“带进位加法”adc。整个改动分四步每一步都能在波形上验证适合拿来练手。第一步在parameter列表里加操作码。选一个没被占用的 4 位编码比如4b1111已经被stop占了那就得重新分配或者干脆把idle挪走。我一般会先画一张操作码占用表确认空位再动手操作码指令是否占用0000idle是0001load是.........1111stop是如果 16 个编码全满就得扩展指令字到 20 位或压缩现有指令字段那是另一个量级的改动。教学场景下更现实的做法是复用meth1的某个未用子编码或者把idle让出来。第二步在状态 1 的操作数准备case里加分支把源寄存器和目标寄存器选出来逻辑和addp一致。第三步在状态 2 的运算case里加adc分支把进位标志overflow或单独的carry寄存器加进运算adc: case (IR[11:10]) 2b00: begin temp ({R0[7], R0[7:0]}) ({A[7], A[7:0]}) carry; R0 temp[7:0]; carry temp[8]; overflow temp[8] ^ temp[7]; end // ... endcase第四步在状态机的状态 2 分支里确认adc走default回 0 的路径不需要额外状态。改完跑一遍原有测试序列确认没破坏旧指令再加一条专门测adc的指令序列看进位是否正确传递。验证方法上我习惯“先单元后系统”先写一个只测adc的短序列波形上盯carry和结果再跑完整程序确认新旧指令混跑没问题。改 RTL 最怕的就是“新功能对了、旧功能坏了”所以每次改完都强制回归一遍原始指令序列这个习惯帮我省过不少返工。从那以后我每次动这种教学处理器的状态机都先把指令集表和状态迁移图画在纸上改完对着图逐条核对case分支再跑波形——玄学问题少了一大半。希望这份拆解能帮你把这份 Verilog 课设顺利跑起来也跑出自己的第一条自定义指令。本文还有配套的精品资源点击获取