8位CPU课程设计实战:Verilog状态机、指令周期与FPGA验证
简介:这份面向西南交通大学信息科学与技术学院大二下计算机组成原理课程设计的文档,适合计算机、电子信息类专业学生用作实验参考与课设复盘。内容围绕一个用Verilog HDL实现的简单CPU模块展开,涵盖指令寄存器、程序计数器、内存数据/地址寄存器、操作数寄存器、状态寄存器及R0~R3通用寄存器,并以状态机组织取指、译码与执行流程,涉及立即数加载、Move、ADD/SUB溢出检测、AND/OR/XOR、移位、Swap、Jmp、Jz、Read、Write、Stop等指令的实现思路。压缩包共1个docx文件,约64KB,以设计文档形式集中呈现源码与原理说明,便于对照理解指令周期与数据通路。目前已有1569人学习,可作为课程设计报告撰写、仿真调试和知识点查漏补缺的参考材料。
1. 从一张“跑不通的波形图”说起:8 位 CPU 课程设计到底要交付什么
每年做计算机组成原理课程设计,总有同学在仿真波形前卡住:代码编译通过,功能仿真却全是 X;或者波形跑出来了,PC 却在第一条指令后乱跳。这类问题的根子往往不在 Verilog 语法,而在于对“指令周期—状态机—寄存器组”三者关系的理解没落到位。这份资源给的是一条完整链路:Verilog HDL 写的 8 位 CPU 模块、顶层原理图、配套 MIF 文件,把取指、译码、执行、访存、跳转这一整套流程做成了可综合、可仿真、可上板验证的工程。它适合正在交计组课设、需要一份能跑通且讲得清的设计参考的读者,也适合想把状态机写法从“照抄模板”变成“自己会推”的读者。下面按需求、架构、模块实现、避坑、验收的顺序,把这套方案拆开讲透。
2. 总体架构设计:一条 12 位地址总线撑起的单周期状态机
2.1 为什么选“多状态机 + 单总线”而不是纯组合逻辑
这个 CPU 的核心思路很朴素:用有限状态机把一条指令拆成若干节拍,每个节拍只做一件事。这样写出来的代码虽然比纯组合逻辑长,但每一步都对应一个明确的时序阶段,仿真波形上能一眼看出“现在走到哪一步了”,出了问题也好定位。整个模块对外只有时钟、复位、内存数据输入三路输入,以及内存数据输出、地址输出、读写信号、溢出标志和四个通用寄存器输出。地址总线宽 12 位,意味着寻址空间是 4K;数据通路 8 位,寄存器组 R0~R3 各 8 位,加减法用 9 位临时寄存器 Temp 承接进位和符号位。
| 信号名 | 方向 | 位宽 | 作用 |
|---|---|---|---|
| clock | 输入 | 1 | 上升沿驱动状态推进 |
| reset | 输入 | 1 | 低电平有效,清零全部寄存器 |
| M_data_in | 输入 | 8 | 从内存读回的指令或数据 |
| M_data_out | 输出 | 8 | 写入内存的数据 |
| M_address | 输出 | 12 | 送往内存的地址 |
| Write_Read | 输出 | 1 | 0 读、1 写 |
| overflow | 输出 | 1 | 有符号运算溢出标志 |
选择单总线结构的好处是连线简单、原理图清爽,顶层只需要把 CPU 模块和存储器、时钟分频、数码管显示几块拼起来;代价是每个节拍只能有一个数据流动方向,所以读写内存必须拆成“送地址—等数据—取数据”三个状态,这也是后面状态 2、3、4 存在的原因。我一般会建议先把这个表画在纸上,再动手写代码,寄存器名字和位宽定死了,后面几乎不会返工。
2.2 状态划分:0~4 五个状态各自管什么
状态机的骨架是state这个 4 位寄存器,实际用到 0 到 4:
- 状态 0:取指令。PC 自增,MAR 载入 PC,IR 高 8 位接收内存数据,同时把 IR[9:8] 选中的通用寄存器内容送进 A 寄存器,操作码 OP 取 IR[15:12]。
- 状态 1:译码执行。按 OP 分支,处理立即数装载、Move、ADD、SUB、AND、OR、XOR、移位、Swap 等寄存器类指令;遇到 Jmp、Jz、Read、Write 这类需要访存的指令,转状态 2。
- 状态 2:地址准备。把 PC 或 R0 的值送上 MAR,同时把 MDR 预置为 R0,为写内存做准备。
- 状态 3:访存发起。Jmp 和 Jz 在这里直接改 PC 回到状态 0;Read 和 Write 则把 MAR 设为 PC 后进状态 4。
- 状态 4:访存完成。读指令把 M_data_in 写回 R0,随后回状态 0。
这里有个容易被忽略的设计点:状态 2 里Write_Read被固定拉低,而真正的写信号要到状态 3 的 Write 分支才置 1。这是为了让存储器在地址稳定之前保持读模式,避免误写。很多同学仿真时发现内存内容被莫名改写,追下去都是这一步时序没对齐。
2.3 指令格式与操作码分配
指令定长 16 位,高 4 位是操作码,中间若干位选寄存器或给寻址方式,低 8 位放立即数或跳转地址。操作码从 0 排到 15,覆盖了课设要求的基本指令集:
| OP | 指令 | 功能说明 |
|---|---|---|
| 0 | NOP | 空操作,PC 已自增,直接回状态 0 |
| 1 | Load IMM | 把 IR[11:8] 立即数装入 R0 |
| 2 | Move | 把 A 寄存器内容写入 IR[11:10] 指定的寄存器 |
| 3 | ADD | 目标寄存器与 A 相加,判溢出 |
| 4 | SUB | 目标寄存器减 A,判溢出 |
| 5 | AND | 按位与 |
| 6 | OR | 按位或 |
| 7 | XOR | 按位异或 |
| 8 | SHR | 逻辑右移一位 |
| 9 | SHL | 逻辑左移一位 |
| 10 | Swap | 源寄存器与目标寄存器交换 |
| 11 | Jmp | 无条件跳转到 IR[11:0] |
| 12 | Jz | R0 为零时跳转 |
| 13 | Read | 从内存读一个字节到 R0 |
| 14 | Write | 把 R0 写回内存 |
| 15 | Stop | 停机,停在状态 1 不再推进 |
把这张表记住,后面看代码基本就是查表。需要提醒的是,本设计里 Swap 的实现依赖 IR[11:8] 和 IR[11:10] 两段字段分别编码源和目的,编码表在状态 1 的 case 里铺开成 16 个分支,看着冗长,但它把“寄存器交换”这种在单总线上做不了并行操作的事拆成了两拍完成,逻辑上是自洽的。
3. 核心模块实现:从取指到溢出的代码落地
3.1 寄存器组与复位逻辑
所有寄存器都声明为 8 位或 12 位宽,复位采用同步清零而不是异步复位,这样在 FPGA 上综合出来的电路更规整,也避免了复位释放时的亚稳态。复位分支里把 PC、IR、MAR、MDR、R0~R3、state、OP 全部写 0,等于把整台机器拉回初始态。
always @ (posedge clock) begin if (reset == 0) begin // 同步清零,低电平有效 PC = 0; IR = 0; MAR = 0; MDR = 0; R0 = 0; R1 = 0; R2 = 0; R3 = 0; state = 0; OP = 0; end else begin case (state) // 状态机主体 // ... endcase end end这段代码里if (reset == 0)写在时钟沿内部,意味着复位必须等时钟上升沿才生效。上板时如果发现按键复位没反应,先查时钟有没有真正跑起来,再看复位按键的电平极性对不对。另外要注意R_0到R_3这组输出寄存器是在另一个 always 块里跟随 R0~R3 更新的,两个块之间用阻塞赋值还是非阻塞赋值要统一,本设计统一用阻塞赋值,方便在状态机里按顺序推导。
3.2 取指节拍:PC 自增、MAR 装载与 IR 拼接
取指阶段最考验对时序的理解。PC 先加 1,再把新 PC 送 MAR,这个顺序看起来有点反直觉——为什么不是先用当前 PC 取指再自增?因为存储器是组合读出的,MAR 一旦变化,M_data_in 立刻反映该地址的内容,如果先自增再送地址,读到的就是下一条指令。本设计把 PC 初值设为 0,第一条执行的其实是地址 1 上的指令,配合 MIF 文件的第一行填无效指令,正好绕开了这个坑。
0: begin // 状态0:取指令 Write_Read = 1'b0; // 读模式 PC = PC + 1; // 先自增 MAR = PC; // 新地址送 MAR IR = {M_data_in, 8'b00000000}; // 高8位装指令 OP = IR[15:12]; // 提取操作码 case (IR[9:8]) // 预取源操作数到 A 0: A = R0; 1: A = R1; 2: A = R2; 3: A = R3; endcase state = 1; endIR = {M_data_in, 8'b00000000}这一句把 8 位内存数据拼到高半部,低半部补零。这么做的原因是内存位宽只有 8 位,而指令需要 16 位,所以一条指令要占两个内存单元,高字节先读、低字节后读。状态 2 里那些IR[7:0] = M_data_in就是在补低字节。理解了这个拼接规则,MIF 文件怎么写就清楚了:每两条连续地址拼成一条指令,高地址放操作码那半边。
3.3 运算类指令与溢出判断
加减法用 9 位 Temp 承接结果,最高位是扩展出来的符号位。溢出判断用的是Temp[8] ^ Temp[7],也就是结果的第 8 位和第 7 位不同则溢出。这个判据对有符号数加减是成立的:两个同号数相加得到异号结果,或者两个异号数相减得到与被减数异号的结果,都属于溢出。
3: begin // ADD 指令 case (IR[11:10]) 0: begin Temp = {R0[7], R0} + {A[7], A}; R0 = Temp[7:0]; end 1: begin Temp = {R1[7], R1} + {A[7], A}; R1 = Temp[7:0]; end 2: begin Temp = {R2[7], R2} + {A[7], A}; R2 = Temp[7:0]; end 3: begin Temp = {R3[7], R3} + {A[7], A}; R3 = Temp[7:0]; end endcase if (Temp[8] ^ Temp[7]) // 异或为1表示溢出 overflow = 1; else overflow = 0; state = 0; end{R0[7], R0}是符号扩展,把 8 位数补成 9 位有符号数。SUB 的写法完全对称,只是把加号换减号。这里有个血泪经验:如果综合工具报“multiple drivers on overflow”,多半是你在别的状态里也赋了值,或者 overflow 在复位分支里漏了初值。溢出信号只在 ADD 和 SUB 两个分支里更新,其余指令执行时它保持上一次的值,这在答辩时可能被问到,标准回答是“溢出标志是粘滞的,由软件在需要时读取并清除”,如果你希望它每条指令都刷新,那就在状态 1 开头统一清一次。
3.4 访问内存的三拍:地址、数据、完成
Read 和 Write 是全设计里状态跳转最长的路径,一共经过状态 1、2、3、4 四个节拍。以 Read 为例:状态 1 收到 OP=13,转状态 2;状态 2 把 R0 送 MDR、PC 自增、从 M_data_in 取低 8 位地址装进 IR,MAR 设为该地址,转状态 3;状态 3 把 MAR 设为 PC 后转状态 4;状态 4 把 M_data_in 写回 R0,回状态 0。
4: begin // 状态4:读完成后写回 R0 Write_Read = 1'b0; if (OP == 13) R0 = M_data_in; // 仅 Read 指令回写 state = 0; end这段代码看似简单,但它是整个设计里最容易翻车的地方。原因在于状态 2 和状态 3 里MAR的来源不同,状态 2 用的是指令里带的地址,状态 3 用的是 PC,如果不画一张状态转移图,很容易把两者搞混,结果读写全打到错误地址上。我的建议是:在仿真时把 state、PC、MAR、MDR、Write_Read 五个信号全拖到波形窗口,逐拍比对,比盯代码快得多。
4. 避坑与排查:五个最容易翻车的点
4.1 现象:功能仿真全 X,时钟沿不触发
原因通常是测试平台里时钟没有正确翻转,或者复位信号一直为 0 导致状态机永远停在清零分支。解决方式是先写一个最小激励:时钟周期 10ns、复位前 20ns 拉低再拉高,跑 200ns 看 PC 是否从 0 变成 1。如果 PC 不动,检查 always 块的敏感列表是不是写成了posedge clock or negedge reset,而代码里又用同步复位——两者不匹配会让综合和仿真行为不一致。
4.2 现象:PC 跳转后指令执行错位
多半是 IR 的高低字节拼接顺序搞反了。本设计是内存高地址放指令高字节,状态 0 读高字节,状态 2 读低字节。如果 MIF 文件按“低地址低字节”的习惯写,程序就会把操作码和操作数颠倒,表现为 Jmp 跳到奇怪地址、ADD 算出离谱结果。排查方法很简单:把 IR 打印出来,对着指令表逐位核对,16 位里高 4 位必须等于你预期的操作码。
4.3 现象:写内存后读回数据不对
问题出在Write_Read的时序上。写操作在状态 3 才把信号置 1,而状态 2 和状态 4 都是 0,如果存储器模型是“写信号有效即写入”,那么状态 3 那一拍地址和数据必须同时稳定。常见的错误是在状态 2 就置写信号,结果地址还没切到目标就写进去了。修复方式是把写信号严格限制在状态 3 的 Write 分支,并在状态 4 立刻拉回 0。
4.4 现象:overflow 一直是高电平
检查复位分支里有没有给 overflow 赋初值,以及是否在不需要判溢出的指令分支里误写了overflow = 1。另一个隐蔽原因是 Temp 声明成 9 位但赋值时用了 8 位常数,高位被隐式补零,导致Temp[8] ^ Temp[7]恒为 1。解决办法是显式写9'b0参与运算,别依赖隐式位宽推导。
4.5 现象:上板后数码管显示乱跳
仿真通过但上板异常,先怀疑时钟。FPGA 板载晶振通常是几十 MHz,直接驱动状态机会导致每个节拍只有几十纳秒,数码管扫描根本来不及。需要在顶层加一个分频器,把时钟降到 1Hz 左右再喂给 CPU,同时确认 MIF 文件已经正确初始化到存储器 IP 核里。这一条我在第一次上板时踩过,波形全对、板子全错,查了两天才发现是分频器输出接错了引脚。
5. 验收与答辩:用一张状态转移表把设计讲清楚
验收环节最容易被问倒的不是代码,而是“你为什么这么设计”。我的做法是提前准备一张状态转移表,把每个状态下 PC、MAR、MDR、IR、Write_Read 的取值列出来,答辩时直接投屏。老师顺着表问“状态 2 为什么 PC 要自增”,你能答出“为下一条指令预取地址,同时把当前地址保留给读写指令用”,基本就稳了。
| 状态 | PC 变化 | MAR 来源 | Write_Read | 去向 |
|---|---|---|---|---|
| 0 | +1 | PC | 0 | 1 |
| 1 | 不变 | 不变 | 0 | 0 或 2 |
| 2 | +1 | 指令地址 | 0 | 3 |
| 3 | 跳转时改写 | PC | Write 时为 1 | 0 或 4 |
| 4 | 不变 | 不变 | 0 | 0 |
配套的验证步骤建议按这个顺序走:先跑复位,确认所有寄存器归零;再单步跑一条 Load IMM,看 R0 是否等于立即数;然后跑 ADD,看 overflow 在溢出用例下是否拉高;接着跑 Jmp,看 PC 是否跳到目标地址;最后跑一段包含 Read 和 Write 的小程序,把内存里的数据读出来写到数码管能观察的位置。每一步都在波形上留截图,答辩时按顺序放,比口头描述有说服力得多。
还有一个容易被忽略的细节:Stop 指令的实现是state = 1原地打转,而不是回到 0。这样停机后 PC 不再变化,程序不会继续往下跑。如果老师问“停机后怎么恢复”,回答“拉低 reset 重新初始化”即可,这符合课设的验收预期。
从那以后我每次带人做这类课设,都强制要求先画状态转移表再写代码,表画不出来就说明设计还没想清楚,写下去也是白写。这套资源里的 Verilog 源码、顶层原理图和 MIF 文件是一套完整可复现的工程,拿去对着表跑一遍,比自己从零试错省下的时间不止一点。希望帮到你。
本文还有配套的精品资源,点击获取