资讯详情 Verilog实战:从零构建16指令CPU与存储器系统
📅 2026/10/28 5:10:25
简介这份资源是面向电子信息类专业学生的《计算机组成实验》课程设计文档聚焦用Verilog HDL实现简化计算机核心部件适合正在学习计算机组成原理、需要完成处理器与存储器联调实验的读者参考。压缩包内共1个doc文件约384KB内容涵盖实验目的、实验内容、预习要求与实验报告框架并给出顶层BLOCK图与底层Verilog设计代码涉及idle、load、move、addp、subp、mulp、logp、comp、sh、swap、jmp、jz、read、write、stop等指令的状态机实现。读者可从中获得处理器模块的寄存器、指令寄存器、存储器地址寄存器设计思路64×8存储器模块的调用方式以及将指令序列存入存储器后分析fetch、decode、execute、store执行流程的完整方法。目前已有382人学习适合作为课程设计参考与Verilog实现对照材料。1. 从一份“能跑起来的 CPU”说起这个 Verilog 课设到底在做什么很多人第一次看到“简化计算机系统的设计”这个题目会以为又是画几张框图、写几段报告就能交差的课程设计。真正打开源码才会发现它要求你用 Verilog HDL 从零写出一个能取指、译码、执行的处理器模块再配一块 64×8 的存储器最后把两者连成一个最小可运行的计算机核心部件。换句话说这不是画图题是要让一段指令序列在仿真波形里真正跑完的工程活。这份资源适合两类人一类是正在做计算机组成实验、需要一份可复现参考的电子信息类专业学生另一类是想搞明白“CPU 内部到底怎么一步步动起来”的开发者。它把指令系统、状态机、寄存器组、存储器接口这些平时藏在黑匣子里的东西全部摊开成可读、可改的 Verilog 代码。下面按“先看懂结构、再跑通仿真、最后动手改指令”的顺序拆。2. 系统架构拆解16 条指令、一个状态机、两块存储2.1 顶层结构CPU 与存储器怎么连这个设计的顶层非常干净只有两个核心模块处理器cpu和存储器memory。处理器对外暴露一组信号存储器则通过地址、数据、读写控制三组线与之对接。常见做法是在顶层用原理图或一个薄的 Verilog 包装文件把两者例化在一起形成top模块。// 顶层连接示意cpu 与 memory 的端口对接 module top( input wire reset, input wire clock, output wire Write_Read, // 1 写0 读 output wire [11:0] M_address, // 存储器地址 output wire [7:0] M_data_out, // CPU 写出的数据 input wire [7:0] M_data_in, // CPU 读入的数据 output wire overflow // 运算溢出标志 ); cpu u_cpu( .reset (reset), .clock (clock), .Write_Read (Write_Read), .M_address (M_address), .M_data_in (M_data_in), .M_data_out (M_data_out), .overflow (overflow) ); memory u_mem( .clock (clock), .Write_Read (Write_Read), .address (M_address), .data_in (M_data_out), .data_out (M_data_in) ); endmodule这段代码的关键在于方向M_data_out是 CPU 输出给存储器的写数据M_data_in是 CPU 从存储器读回的数据。很多新手第一次连线时会把这两个名字搞反结果仿真里读出来的永远是 X。参数上地址位宽 12 位对应 4096 个字节空间但实际存储器只实现了 64×8所以有效地址范围是 0 到 63高位地址在译码时会被忽略或用于片选。2.2 指令系统16 个操作码覆盖了哪些能力处理器用IR[15:12]这 4 位作为操作码正好对应 16 条指令。从源码里的parameter定义可以看出完整指令集操作码指令功能简述0000idle空操作状态机复位用0001load把立即数装入 R00010move寄存器之间搬数据0011addp加法结果写回目标寄存器0100subp减法用补码实现0101mulp乘法结果拆到两个寄存器0110logp与、或、异或、取反0111comp比较结果写 0 或 11000sh移位含算术右移和逻辑移位1001meth1自增、自减、加二、减二1010swap交换类操作1011jmp无条件跳转1100jz零标志跳转1101read从存储器读数据1110write向存储器写数据1111stop停机这套指令集虽然精简但已经覆盖了数据传送、算术运算、逻辑运算、移位、比较、控制转移和访存七大类。对于课程设计来说能把这 16 条指令的流程全部跑通基本就理解了冯·诺依曼结构里“取指—译码—执行—写回”的完整闭环。2.3 状态机设计一条指令分几步走处理器内部用一个 3 位status寄存器驱动整个执行流程。源码里的状态转移逻辑写得比较紧凑但拆开看就是一条指令的生命周期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 负责把存储器输出的高 8 位拼上低 8 位零装入IR同时PC加一这是典型的取指动作。第二运算类指令在状态 1 就完成译码和执行然后直接回状态 0所以一条加法指令只占两个时钟周期。第三访存类指令要走状态 3 到 5其中状态 4 是一个等待周期给存储器留出建立时间。这种“运算快、访存慢”的节奏和真实处理器里访存需要多周期是一致的。提示状态机用negedge reset异步复位、posedge clock同步推进仿真时如果复位信号只拉低一个 delta 周期可能抓不到复位沿建议复位至少保持一个完整时钟周期。3. 核心模块代码逐段讲从取指到写回的完整链路3.1 寄存器组与取指逻辑处理器内部维护了PC、R0到R3、累加器A、指令寄存器IR、存储器地址寄存器MAR和数据寄存器MDR。这些寄存器在seq这个always块里统一管理复位时全部清零。always (negedge reset or negedge clock) begin: seq reg [11:0] PC; reg [7:0] R0, R1, R2, R3, A; reg [8:0] temp; // 9 位用于捕获进位 reg [15:0] temp2; // 16 位用于乘法结果 if (reset 1b0) begin IR {16{1b0}}; PC {12{1b0}}; R0 {8{1b0}}; // ... 其余寄存器清零 end else begin overflow 1b0; case (status) 0: begin IR {M_data_in, 8b00000000}; // 高 8 位来自存储器 PC PC 1b1; end // ... endcase end end取指阶段把M_data_in放到IR的高 8 位低 8 位补零。这意味着指令的高字节存操作码和部分操作数低字节留给后续扩展。PC在取指时自增指向下一条指令地址。这里用阻塞赋值给PC和通用寄存器赋值用非阻塞给IR、MAR、MDR赋值混用是有意为之前者是组合逻辑风格的中间变量后者是需要时序对齐的跨周期信号。3.2 算术运算加法、减法与溢出判断加法指令addp在状态 2 执行核心是把源寄存器和累加器A做带符号扩展的加法结果截低 8 位写回同时用第 9 位和符号位异或产生溢出标志。2: case (IR[15:12]) 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 subp: case (IR[11:10]) 2b00: begin temp ({R0[7], R0[7:0]}) (~({A[7], A[7:0]})) 1b1; R0 temp[7:0]; overflow temp[8] ^ temp[7]; end // ... endcase endcase{R0[7], R0[7:0]}是符号扩展把 8 位有符号数扩成 9 位这样第 9 位就是真实的进位。溢出判断用temp[8] ^ temp[7]即进位输出和符号位不一致时置位这是标准的补码溢出检测方法。减法用“加补码”实现~A 1就是-A所以R0 - A变成R0 (~A) 1。参数上IR[11:10]选择目标寄存器IR[9:8]在部分指令里选择源寄存器这种两位字段的编码方式让一条指令能覆盖四个寄存器的组合。3.3 乘法与移位结果拆分和位操作乘法mulp的结果是 16 位源码把它拆成低 8 位和高 8 位分别写入两个寄存器。以IR[11:10] 2b00为例R0 * A的低字节进R0高字节进R1。mulp: case (IR[11:10]) 2b00: begin temp2 R0 * A; R0 temp2[7:0]; R1 temp2[15:8]; end 2b01: begin temp2 R1 * A; R0 temp2[7:0]; R1 temp2[15:8]; end // ... endcase移位指令sh用IR[11:10]区分四种模式算术右移、循环右移、逻辑右移、逻辑左移再用IR[9:8]选择目标寄存器。以算术右移为例R0 {R0[7], R0[7:1]}把符号位复制到最高位保持有符号数的符号不变。这里有个容易翻车的点循环右移的写法在源码里对 R1、R2、R3 用了R0[6:0]作为高位来源看起来像是复制粘贴留下的笔误实际综合后行为可能和预期不符。二次开发时如果用到循环移位建议先单独写测试台验证这一条。3.4 访存指令read 与 write 的时序配合read和write是唯二需要和存储器打交道的指令它们要走满状态 3 到 5。状态 3 把PC或寄存器里的地址送到MAR状态 4 等待状态 5 根据读写方向决定是否进入状态 6 回写数据。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; endcaseWrite_Read信号在组合逻辑里根据当前指令拉高或拉低write时置 1read时置 0。存储器模块在时钟上升沿采样这个信号写操作把data_in写入address指向的单元读操作把对应单元的值送到data_out。这里的关键是地址和数据必须在状态 3 就稳定下来否则状态 4 的等待周期就白费了。常见做法是在状态 3 用非阻塞赋值更新MAR和MDR保证它们在下一个时钟沿之前保持稳定。4. 跑通仿真从 mif 初始化到波形验证4.1 存储器初始化mif 文件怎么写存储器是 64×8 的结构意味着有 64 个字节单元每个单元 8 位。指令序列需要提前写进一个.mif文件仿真时由$readmemh或存储器 IP 的初始化接口加载。一个最小的测试程序可以这样组织// test.mif先 load 立即数再 addp最后 stop 01 05 31 00 F0 00第一行01 05对应load指令操作码 0001把立即数 5 装入 R0。第二行31 00对应addp操作码 0011目标寄存器由IR[11:10]决定。第三行F0 00是stop操作码 1111。注意每条指令占两个字节所以PC每次加一指向的是字节地址取指时一次读一个字节拼成 16 位指令的高半部分低半部分在源码里固定补零这意味着实际指令编码只用了高字节低字节可以留作扩展。注意mif 文件的地址增长方向和存储器读写的字节序必须一致如果仿真读出来的操作码和预期对不上先检查 mif 是不是按字节顺序排列而不是按 16 位字排列。4.2 测试台搭建时钟、复位和波形观察一个能跑通的最小测试台需要产生时钟、拉复位、例化顶层并把关键信号打到波形窗口。timescale 1ns/1ps module tb_top; reg reset, clock; wire Write_Read, overflow; wire [11:0] M_address; wire [7:0] M_data_in, M_data_out; top u_top( .reset(reset), .clock(clock), .Write_Read(Write_Read), .M_address(M_address), .M_data_in(M_data_in), .M_data_out(M_data_out), .overflow(overflow) ); initial begin clock 0; forever #5 clock ~clock; // 100MHz end initial begin reset 0; #20 reset 1; // 复位保持 20ns #500 $stop; // 跑够时间后暂停 end endmodule时钟周期设成 10ns 只是仿真习惯实际综合到 FPGA 上要看板级时钟。复位保持 20ns 是为了确保异步复位沿被状态机捕获。波形窗口里重点看三组信号status的状态跳转是否符合预期、IR在取指后是否等于 mif 里的操作码、R0到R3在运算后是否变成正确结果。如果status卡在 0 不动先查时钟有没有接上如果IR一直是 X查存储器输出和M_data_in的连接。4.3 指令执行流程验证一条 addp 的完整波形以load 5后接addp为例预期波形是这样的第一个时钟沿后status从 0 跳到 1IR高 8 位变成01PC从 0 变 1第二个时钟沿status到 2R0被赋值为 5第三个时钟沿status回 0开始取下一条第四个沿IR变成31PC变 2第五个沿status到 2R0加上A的值。如果A在addp之前没有被正确加载结果就会是R0 0看起来像加法没生效。这也是为什么源码在状态 1 里对addp、subp、mulp这些指令先做了一步A R?的源操作数准备。5. 避坑与排查仿真跑不通时先看这几处5.1 现象波形里所有信号都是 X原因通常是复位没有生效或者存储器没有初始化。异步复位用negedge reset触发如果测试台里reset初始值是 X 而不是 0状态机永远进不了复位分支。解决方法是把reset声明为reg并在initial块里先赋 0保持至少一个时钟周期再拉高。存储器侧则要确认 mif 文件路径正确、$readmemh的地址范围没有越界。5.2 现象status 在 0 和 1 之间反复跳不进入执行这多半是IR[15:12]译码出了偏差。源码里stop指令在状态 1 会让status停在 1如果 mif 里第一条指令的高字节恰好是F0处理器会直接停机。排查时先把IR加到波形里看取指后的值是不是和 mif 第一行一致。如果不一致检查存储器读出的字节是不是被拼到了正确的高 8 位。5.3 现象加法结果总是差一或者溢出标志乱跳先看temp的位宽。源码里temp是 9 位{R0[7], R0[7:0]}是 9 位符号扩展两者相加不会丢进位。如果自己改代码时把temp写成 8 位进位就被截掉了溢出判断自然失效。另外overflow在seq块开头每个周期都被清零只有运算指令那一拍才置位所以波形上它只闪一个周期不要以为它没工作。5.4 现象read 指令读回的数据和写入的不一致访存类指令对时序最敏感。MAR和MDR必须在状态 3 就稳定状态 4 是给存储器的等待周期。如果存储器模型是同步读时钟沿采样地址、下一沿出数据而处理器在状态 5 就采样M_data_in中间可能差一拍。解决办法是在存储器模块里确认读延迟或者把状态 4 的等待周期拉长。这类问题在仿真里表现为“写进去的值读出来是上一次的旧值”本质是地址和数据没有对齐到同一个时钟沿。5.5 现象综合后资源占用异常高如果只是课程设计仿真这个问题不致命但想上 FPGA 板子就要注意。源码里大量使用case嵌套和阻塞赋值给寄存器综合工具可能会推断出大量锁存器。常见做法是把R0到R3的赋值统一改成非阻塞或者在always块里给所有分支都赋默认值避免综合出意料之外的锁存器。另外temp2 R0 * A会推断出乘法器如果目标器件没有硬核乘法器资源会涨得比较明显。6. 二次开发把 16 条指令扩成你自己的指令集6.1 加一条新指令的完整步骤假设要加一条inc指令操作码用0000原idle位功能是把指定寄存器加一。改动分三步先在parameter里把idle改名或挪位给inc分配一个新编码然后在状态 1 的译码case里加分支把源寄存器送到A最后在状态 2 的执行case里写R? R? 1。改完后必须同步更新 mif 测试程序否则旧程序里的0000会被当成新指令执行。// 在状态 2 的执行分支里追加 inc: case (IR[11:10]) 2b00: R0 R0 1b1; 2b01: R1 R1 1b1; 2b10: R2 R2 1b1; default: R3 R3 1b1; endcase参数上IR[11:10]选目标寄存器IR[9:8]可以留作立即数或源寄存器选择。如果新指令需要访存还要在状态机的状态 2 分支里把它加进swap, jmp, jz, read, write那一组让它走状态 3 到 5 的访存路径。6.2 验证方法用自检测试台替代肉眼盯波形手工看波形只能覆盖几条指令指令一多就容易漏。更稳的做法是写一个自检测试台在仿真结束时自动比对寄存器值。initial begin // 等待程序跑完 wait(u_top.u_cpu.status 1 u_top.u_cpu.IR[15:12] 4b1111); #10; if (u_top.u_cpu.R0 ! 8d10) $display(FAIL: R0 %0d, expect 10, u_top.u_cpu.R0); else $display(PASS: R0 %0d, u_top.u_cpu.R0); $finish; end这段代码在stop指令生效后检查R0是否等于预期值。每加一条指令就加一个断言回归时跑一遍就能知道有没有改坏旧功能。层次化路径u_top.u_cpu.R0要求仿真器支持跨层次引用主流工具都支持。6.3 从仿真到上板几个容易忽略的细节想把设计烧到 FPGA 上除了综合和引脚约束还要注意存储器不能用仿真里的$readmemh得换成块 RAM IP 并预先把 mif 转成 IP 要求的初始化格式。时钟方面板级时钟频率通常比仿真高状态机的多周期流程要重新算时序余量。另外overflow这类单周期脉冲信号上板后如果直接接 LED肉眼根本看不到常见做法是加一个计数器把它拉宽到毫秒级再输出。我自己的习惯是每改一次指令集先把 mif 换成只含load和stop的最小程序跑一遍确认取指和停机没问题再逐步加addp、read、write。有一次偷懒跳过了最小程序直接上完整测试序列结果一个MAR赋值写错导致所有访存指令读回旧值查了大半天才定位到状态 3 的那一行。从那以后我每次动状态机都强制先跑最小程序再跑全量这个习惯省下的时间比写测试台本身多得多。希望帮到你。本文还有配套的精品资源点击获取