简介这份资源面向电子信息类专业学生及计算机组成原理课程设计的学习者聚焦用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人学习适合需要快速搭建简化处理器系统、对照状态机与模块接口查漏补缺的读者参考。1. 简化计算机系统为什么一个 16 位指令字能撑起整台机器的时序很多人第一次看到「简化计算机系统设计」这个题目会以为它只是把 CPU 画小一点、寄存器少放几个。真正动手写 Verilog 才会发现难点根本不在规模而在时序一条指令从存储器里被取出来到它真正改变某个寄存器的值中间要跨过多少个时钟沿、经过几个状态、哪些信号在哪个沿上稳定这些如果没想清楚仿真波形就是一团乱麻。这份课程设计资源给出的答案很直接——用 16 位指令字、8 位数据通路、12 位地址总线配一个 64×8 的存储器再用一个显式的状态机把取指、译码、执行、访存切成固定节拍。它解决的不是「造一台真机器」而是把计算机组成原理里最容易被黑匣子化的那部分——指令周期——摊开成可观测、可单步、可改参数的硬件行为。搞懂它你再看任何真实处理器的流水线都能对上号原来那些花哨的优化都是在给这套最朴素的节拍做减法或并行化。2. 指令字与状态机16 位编码如何决定一条指令的命运2.1 指令格式的字段切分与设计动机这套设计的指令字固定 16 位高 4 位IR[15:12]是操作码剩下 12 位按指令类型再切。这个切法不是随便定的它直接决定了译码逻辑能写多简单。看源码里的操作码参数表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;16 个操作码刚好填满 4 位空间没有浪费也没有歧义。这种「操作码定长、操作数字段按需复用」的做法是教学型指令集的典型取舍牺牲编码密度换取译码时一个case就能穷举。常见的实现思路是把IR[11:10]当作目标寄存器选择、IR[9:8]当作源寄存器或子操作选择这样算术类指令addp/subp/mulp/logp/comp和移位类指令sh能共用同一套字段布局译码器不用为每条指令单独写一套位提取逻辑。为什么值得这么设计因为简化系统的核心矛盾是「可读性 vs 真实性」。真实 ISA 会有变长指令、会有立即数扩展、会有寻址模式位但那些会让一个本科课程设计的代码量翻三倍而理解收益并不成比例。固定 16 位、字段复用让读者能把注意力放在「状态怎么流转」而不是「位怎么拼」上。我一般会这么理解这是一份刻意做了减法的 ISA减掉的每一处复杂度都对应一个你以后在真实架构里要重新加回来的设计点。2.2 状态机的节拍划分为什么用 negedge clock 驱动执行整个 CPU 的骨架是一个status寄存器加一个大的case状态机。复位时status 0之后每个posedge clock推进状态。关键在第二个always块——它用的是negedge clockalways (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而真正干活的数据通路块用的是negedge clockalways (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; ... end这个「状态机上升沿推进、数据通路下降沿执行」的错拍设计是整份代码里最容易被忽略、也最值得琢磨的一处。为什么不让两者都用上升沿因为如果状态推进和数据修改在同一个沿发生status刚变成新值数据块里的case (status)可能读到的是旧值还是新值取决于仿真器的调度顺序这就是典型的竞争冒险。把执行放到下降沿等于给状态一个半周期的稳定窗口上升沿状态变了下降沿数据块看到的status一定是已经稳定的新值。血泪经验是很多同学把两个块都写成posedge仿真出来 PC 偶尔跳错、寄存器值偶尔差一拍查半天以为是复位问题其实是沿冲突。提示这种半周期错拍在真实处理器里对应的是「建立/保持时间」的简化模型。教学代码用时钟沿错开规避时序问题真实设计靠时序约束和静态时序分析保证但背后的「让信号在采样前稳定」的思想是一致的。2.3 取指与 PC 自增状态 0 里藏着的第一个陷阱状态 0 是取指阶段代码只有两行0 : begin IR {M_data_in, 8b00000000}; PC PC 1b1; endM_data_in是存储器送回来的 8 位数据拼上低 8 位零组成 16 位指令字。这里有个细节存储器是 64×8也就是按字节编址一条 16 位指令要占两个字节。但代码里PC每次只加 1而MAR PC在状态 1 才赋值。这意味着取指时M_address指向的是指令的高字节还是低字节取决于顶层连线时地址怎么接。常见的实现思路是让存储器输出直接对应指令高字节低字节在下一个周期补取或者干脆把 64×8 当成 64 条 8 位「微指令」来用。这份设计的取舍是不追求字节对齐的严谨性而是把「一条指令一次取回」简化成「高字节有效、低字节占位」这样状态机少一个取低字节的节拍。这个简化带来的直接后果是指令的低 8 位如果被用作操作数比如load指令的立即数在IR[11:8]那它其实来自取指时拼进去的零而不是存储器里的真实数据。所以load : R0 {4b0000, IR[11:8]}实际上加载的是 0。这不是 bug而是这套教学 ISA 的约定立即数类指令在这个版本里只验证通路不验证数值语义。理解这一点才不会在仿真时对着「R0 怎么老是 0」抓狂。3. 数据通路与运算实现8 位 ALU 怎么用 9 位临时变量判溢出3.1 加法与减法的溢出判定temp[8] ^ temp[7] 的来历算术指令的核心在状态 2。以addp为例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 ... endcasetemp声明为 9 位reg [8:0]两个 8 位数相加前都做了符号扩展把最高位复制一份放到第 8 位所以结果最多 9 位。temp[8]是真实的进位输出temp[7]是结果的符号位。temp[8] ^ temp[7]这个异或就是经典的「进位与符号位不一致即溢出」判据两个同号数相加如果结果的符号位和操作数符号不同说明发生了溢出。用 9 位临时变量而不是直接截断是为了让进位不丢失——如果temp只声明 8 位temp[8]根本不存在溢出就无从判断。减法用的是补码加法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~A 1就是-A的补码。这里同样做了符号扩展再取反加一保证 9 位运算的进位语义正确。踩坑点在于如果对 8 位的A直接取反再加一结果还是 8 位第 8 位的进位就丢了溢出判定会失效。所以符号扩展必须发生在取反之前顺序不能反。3.2 乘法的高低字节拆分temp2 为什么是 16 位mulp指令用了一个 16 位临时变量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两个 8 位数相乘结果最多 16 位所以temp2必须是 16 位。低 8 位放回目标寄存器高 8 位放到相邻寄存器。这个「结果跨两个寄存器」的约定是教学 ISA 在没有专用乘法器、没有多周期乘法的情况下用组合逻辑乘法加寄存器对来模拟真实乘法指令的常见做法。真实处理器里乘法往往是多周期迭代或者专用 DSP 单元这里一个*号综合出来就是一大片组合逻辑面积换简单。注意mulp的四个分支里目标寄存器对并不统一2b00和2b01都是R0/R12b10和2b11是R2/R3。这种不对称是 ISA 设计里的一个取舍——寄存器对固定译码简单但灵活性差。如果让你重写可能会改成由IR[9:8]显式指定结果对代价是多一层译码。3.3 移位与逻辑运算字段复用带来的边界情况移位指令sh用IR[11:10]选移位类型算术右移、循环右移、逻辑右移、逻辑左移用IR[9:8]选目标寄存器。看其中一段2b01 : case (IR[9:8]) 2b00 : R0 {R0[6:0], R0[0]}; 2b01 : R1 {R0[6:0], R1[0]}; 2b10 : R2 {R0[6:0], R2[0]}; default : R3 {R0[6:0], R3[0]}; endcase这里有个明显的笔误R1、R2、R3的循环右移拼接的高 7 位用的是R0[6:0]而不是各自的R1[6:0]、R2[6:0]、R3[6:0]。这会导致对 R1 做循环右移时移进去的位来自 R0。仿真时如果只测 R0完全发现不了一旦测 R1 就会翻车。这类字段复用加复制粘贴的代码是教学资源里最常见的坑也正好提醒读者读别人的 HDL 代码不能只看分支结构要逐位核对每个拼接表达式的源寄存器。逻辑运算logp也有类似的不对称2b00和2b01两个分支都写成R0 R0 A2b10是R1 R1 R02b11是R1 R1 R1。后两个分支的源操作数选择明显偏离了「A 作为第二操作数」的约定。这些地方不是设计意图而是实现时的疏漏但恰恰是这份资源值得深挖的原因——它是一份真实的、带瑕疵的学生实现你能从中学到「规格和代码不一致时怎么定位」。4. 存储器与顶层连接64×8 存储模块如何与 CPU 握手4.1 存储器接口信号的方向与语义CPU 模块的端口定义里存储器相关信号有四个output Write_Read; output [11:0] M_address; input [7:0] M_data_in; output [7:0] M_data_out;Write_Read是读写控制M_address是 12 位地址虽然存储器只有 64 个单元6 位就够但地址总线留了 12 位给 PC 和 MAR 复用M_data_in是从存储器读回的数据M_data_out是写往存储器的数据。注意命名方向M_data_in对 CPU 是输入对存储器是输出M_data_out反过来。这种「以 CPU 为视角」的命名在顶层连线时最容易接反常见的排查方法是看仿真波形里M_data_in在取指周期是否有非零值——如果一直是 0多半是顶层把存储器的输出接到了 CPU 的输出端口上。存储器模块本身是 64×8地址 6 位有效。CPU 送出的 12 位地址里低 6 位真正选中单元高 6 位被忽略。这个「地址总线宽于存储容量」的设计是为了让 PC 和 MAR 能共用同一套 12 位寄存器不用为存储器单独裁位。代价是地址译码时要做截断如果顶层忘了截综合工具可能会报位宽不匹配的警告。4.2 读写时序状态 3 到状态 6 的访存节拍访存类指令read/write的状态流转是状态 2 判断是访存指令后进状态 3状态 3 如果不是swap就进状态 4状态 4 无条件进状态 5状态 5 如果是read进状态 6否则回 0。这条链看起来绕其实是在给存储器留出足够的响应时间状态 2把地址装进MAR同时把要写的数据装进MDR状态 3Write_Read拉低或拉高启动访存状态 4等待存储器数据稳定状态 5如果是读进状态 6 把M_data_in锁进MDR状态 6把MDR写回寄存器堆这种「一个动作一个状态」的写法在真实处理器里会被流水线折叠掉但在教学系统里它让每个节拍的行为都能在波形上单独观察。我一般会这么理解这不是性能设计而是可观测性设计。你想验证「地址是不是在正确的周期送出」就把波形光标停在状态 2 的下降沿看MAR和M_address是否一致想验证「数据是不是在正确的周期锁存」就看状态 6 的MDR。4.3 顶层原理图连接模块边界上的信号对齐顶层用原理图方式连接 CPU 和存储器这一步在文本资源里看不到图形但可以从端口对应关系反推连接规则。CPU 的M_address、Write_Read、M_data_out接存储器的地址、写使能、数据输入存储器的数据输出接 CPU 的M_data_in。时钟和复位共用。容易出错的是Write_Read的极性如果存储器模块约定高电平写、低电平读而 CPU 在write指令时输出高、read时输出低那直接连就对如果约定相反就得加反相器。这类极性问题在原理图里看不出来只能靠读两个模块的代码确认或者仿真时看写操作有没有真正改变存储内容。注意顶层连线完成后先把一段只有load和stop的指令序列存进存储器跑一遍确认 PC 能正常自增、stop能停住状态机再逐步加算术和访存指令。一次性把所有指令都塞进去出问题时根本分不清是取指错了还是执行错了。5. 避坑与排查仿真波形对不上时先看这五处5.1 现象状态机卡在状态 1 不动原因stop指令的判断写成了if (IR[15:12] stop) status 1;本意是停机保持但如果存储器里第一条指令就是stop或者取指时M_data_in为 0 导致IR高 4 位是 0对应idle状态机会在 1 和 2 之间反复。更隐蔽的情况是复位释放的时机不对reset在时钟沿附近变化导致status被异步复位拉回 0 后又立刻被时钟推进。解决先单独仿真复位序列确认reset拉低至少一个完整时钟周期再释放然后在状态 1 加一个$display打印IR看取回的指令字是否符合预期。5.2 现象算术指令结果正确但overflow恒为 0原因overflow在seq块开头被无条件清零overflow 1b0;而各算术分支里的赋值也是非阻塞赋值同一周期内后写的覆盖先写的取决于代码顺序。如果清零语句在case之后就会把刚算出的溢出标志冲掉。解决把清零放到case之前或者只在非算术状态下清零。更稳妥的写法是用一个组合逻辑单独算溢出寄存器只负责锁存。5.3 现象read指令读回的数据总是 0原因状态 5 判断read进状态 6但状态 6 的代码在给出的片段里没有完整展示常见实现是在状态 6 才把M_data_in锁进MDR而MDR到寄存器的写回又依赖另一个节拍。如果状态机在状态 5 之后直接回 0读回的数据就丢了。解决在波形上确认状态 6 是否真的出现如果出现看MDR在该状态的下降沿是否等于M_data_in如果不等检查M_data_in在状态 6 时是否已经被存储器撤掉存储器可能在Write_Read变化后一个周期就释放数据。5.4 现象移位指令对 R1/R2/R3 的结果异常原因第 3.3 节提到的拼接源寄存器写错循环右移分支里R1、R2、R3的高位拼接用了R0[6:0]。这是代码级 bug不是时序问题。解决逐条核对每个拼接表达式的两个操作数是否都指向目标寄存器写一个只对 R1 做循环右移的测试序列对比期望值和仿真值。5.5 现象综合后资源占用异常高或报组合环原因seq块里用了阻塞赋值给PC、R0等寄存器赋值同时又用非阻塞赋值给IR、MAR、MDR赋值。混合赋值风格在仿真时可能碰巧对但综合工具对阻塞赋值的寄存器推断可能产生意外组合逻辑尤其是temp、temp2这类在case分支里声明又使用的变量。解决统一改成非阻塞赋值临时变量提到always块外部声明为reg综合前跑一遍 lint 检查重点看「blocking assignment in sequential block」的警告。6. 设计取舍为什么教学处理器宁可「不优雅」也要把节拍摊开这份设计的每一处「不优雅」几乎都能对应一个教学意图。状态机用 6 个状态做一次访存而不是像真实处理器那样一个周期发地址、下一个周期收数据是因为多出来的状态让每个动作在波形上独占一个节拍学生能指着屏幕说「这一拍地址出去了这一拍数据回来了」。指令字固定 16 位、字段复用牺牲了编码效率换来译码逻辑一个case穷举。算术用 9 位临时变量判溢出而不是用专门的溢出标志逻辑是因为temp[8] ^ temp[7]这一行把「进位」和「符号位」两个概念直接摆在你面前。如果让我给这份资源提一个改进方向我会把seq块里的阻塞赋值全部改成非阻塞并把PC、R0~R3、A从块内局部变量提升为模块级reg。这样做的代价是代码行数变多收益是综合结果可预测、波形和代码一一对应。另一个值得动的地方是移位和逻辑运算的分支把源寄存器选择统一成由IR[9:8]显式指定消除复制粘贴带来的不对称。这些改动不会让系统更「高级」但会让它在被当作参考实现时更少坑人。验证这套系统是否真的跑通我习惯用一个最小指令序列先load把 R0 置一个非零值再addp让 R0 自加然后write把结果写回存储器某个地址最后stop。跑完后在存储器对应地址上看到那个值就说明取指、译码、执行、访存四条通路全通了。这个序列只有四条指令但覆盖了状态机的 0、1、2、3、4、5 全部节拍比一上来就测乘法靠谱得多。从那以后我每次读这类教学 HDL 代码都强制自己先画一张状态-节拍表把每个状态对应的MAR、MDR、IR、PC变化列出来再对着代码逐行核对。这张表花十分钟能省掉后面两小时的波形猜谜。希望帮到你。本文还有配套的精品资源点击获取