简介:这份西南交通大学计算机组成原理课程设计文档,面向大二下阶段正在完成计组实验与课程设计的学生,重点提供一套可对照学习的简单CPU模块实现方案。压缩包内共1个docx文件,约64KB,正文以Verilog HDL代码与设计说明为主,围绕取指、译码、执行等指令周期展开,组织方式便于按状态机流程查阅。文档完整呈现了CPU模块的寄存器组成,包括指令寄存器IR、程序计数器PC、内存地址寄存器MAR、内存数据寄存器MDR、通用寄存器R0~R3、操作数寄存器A及状态寄存器等,并给出加法、减法、传送、逻辑与或异或、移位、跳转、读写和停机等指令的处理逻辑,其中加减运算还包含溢出判断。通过状态0取指令、状态1分析操作码的分段写法,读者可以理解多状态控制与时钟边沿触发更新的基本方法。该资源已有1569人学习,适合作为课程设计参考、实验报告整理和Verilog实现思路对照。
1. 8 位内存口塞 16 位指令:一颗教学 CPU 为什么被逼出五状态机
把这份 CPU 模块从头读到尾,会发现它缺了教科书里几乎所有“标准件”:没有独立的 ALU,没有指令译码器,没有寄存器堆,四个通用寄存器 R0~R3 直接摊在模块顶部,加减与或异或全塞进同一个 always 块的 case 分支里。唯一像“控制器”的东西,是一个 4 位宽的 state。
反直觉的地方在于:撑起整套逻辑的关键因素是位宽,而不是运算本身。存储器数据口只有 8 位,指令却是 16 位,地址又是 12 位——一条指令要分两次搬进来,跳转地址得先拼后跳,写内存时数据还得提前一拍挂到输出口上。五个状态真正要做的就是切出 8 位总线装不下的那些时间片,并非教学装饰。看懂这条约束,后面每个 state 分支为什么长那样,就不再是玄学。
2. 指令格式:16 位 IR 怎么切出操作码、寄存器号和 12 位地址
2.1 IR[15:12] 的十六个操作码:一张表看全指令集
指令寄存器 IR 是 16 位,模块里所有译码动作都围绕它做位段切片。最高 4 位 IR[15:12] 固定作操作码 OP,取值 0~15,恰好把整个指令集占满。中间 4 位 IR[11:8] 承担了双重职责,低 8 位 IR[7:0] 则是立即数或地址的低半部分。
| OP | 助记 | 关键字段 | 语义 |
|---|---|---|---|
| 0 | NOP | IR[9:8] | A←Rs 后直接回 state 0,PC 已在取指拍自增 |
| 1 | LDI | IR[11:8] | R0←{4'b0000, IR[11:8]},4 位立即数 |
| 2 | MOV | IR[11:10]、A | Rd←A,即 Rd←Rs |
| 3 | ADD | 同上 | Rd←Rd+A,同时更新 overflow |
| 4 | SUB | 同上 | Rd←Rd−A,同时更新 overflow |
| 5 | AND | 同上 | Rd←Rd&A |
| 6 | OR | 同上 | Rd←Rd|A |
| 7 | XOR | 同上 | Rd←Rd^A |
| 8 | SHR | 同上 | 逻辑右移一位,高位补 0 |
| 9 | SHL | 同上 | 逻辑左移一位,低位补 0 |
| 10 | SWAP | IR[11:8]、IR[11:10] | 两张查表完成寄存器间搬运 |
| 11 | JMP | IR[11:0] | PC←12 位地址 |
| 12 | JZ | IR[11:0] | R0==0 时跳转,否则顺序执行 |
| 13 | READ | IR[11:0] | 内存单元→R0 |
| 14 | WRITE | IR[11:0] | R0→内存单元 |
| 15 | STOP | 无 | state 钉死在 1,形成停机自锁 |
这张表里最值得看的是 2 到 9 这八个算逻指令:它们的目标寄存器不是从 IR 里现场译出来的,而是早在取指那一拍就已经确定——state 0 把 IR[9:8] 指向的寄存器装进 A,执行拍再比对 IR[11:10],把 A 与 Rd 运算后写回 Rd。换句话讲,这套 ISA 是隐式的双操作数结构:源操作数永远走 A 这条通道,目标操作数永远由 IR[11:10] 指定。
2.2 IR[11:10] 与 IR[9:8]:两个 2 位字段怎么撑起四寄存器寻址
为什么寄存器号是 2 位而不是 3 位?算一笔字段预算就明白:16 位指令里,操作码吃掉 4 位,地址或立即数至少要留 8 位,剩下 4 位刚好够放两个 2 位寄存器号,各自编址四个寄存器。想扩到八个寄存器,要么砍地址位宽,要么把指令拉长到 24 位,两条路都会推翻现在的两拍取指流程。
字段复用的代价在 LDI 上暴露得最明显。写R0 = {4'b0000, IR[11:8]}的时候,整个 4 位字段被当成立即数用,不再区分 Rd 和 Rs,所以这条指令只能写 R0,且立即数只有 0~15 这十六个值。这不是实现者偷懒,是 4 位字段在“两个寄存器号”和“一个立即数”之间二选一的必然结果。常见的实现思路是给 LDI 单独划一条指令格式,或者干脆用 IR[7:0] 的低 8 位做立即数,但那样就得再占一个取指拍。
SWAP 的 16 项查表也是同一个思路的延伸。case(IR[11:8])把 Rd、Rs 的 16 种组合全部展开写死,本质是用面积换译码逻辑——不写译码器,直接把 16 种情况摊平。这种写法在教学代码里很常见,好处是波形上一眼能看出走了哪条分支,坏处是每次改寄存器数量都要重写整张表。
2.3 12 位地址的拼装:为什么 JMP 的地址必须跨两拍才凑齐
12 位地址装不进 8 位数据口,这是整个设计最要命的一条约束。这版实现的做法是把地址劈成两半:高 4 位藏在指令高字节的 IR[11:8] 里,随操作码一起取进来;低 8 位等到第二个字节再补。
// state 2:为访存/跳转类指令取地址低字节 2: begin Write_Read = 1'b0; MDR = R0; // 顺带把 R0 挂到数据输出口,WRITE 指令依赖它 PC = PC + 1; // 指令占连续两个字节,PC 再前进一格 state = 3; case (OP) 11: begin // JMP IR[7:0] = M_data_in; // 只改低 8 位,IR[15:8] 保持取指拍的内容 MAR = IR; // 12 位地址在此刻才成形 end 12: begin // JZ if (R0 == 0) begin IR[7:0] = M_data_in; MAR[11:0] = IR[11:0]; // 命中:用拼出来的地址 end else MAR[11:0] = PC; // 未命中:顺序取下一条 end endcase endIR[7:0] = M_data_in这种写法叫部分赋值,Verilog 允许只对寄存器的某几位赋值,其余位保持不变。这一行是整个地址拼装的关键:IR[15:8] 还是上一拍取指时留下的内容,IR[7:0] 被新数据覆盖,拼完正好是 12 位有效地址加 4 位操作码。参数说明上要留意两点:M_data_in 是 8 位,赋值时高位自动补零,不会污染 IR[15:8];MAR 是 12 位而 IR 是 16 位,赋值时取低 12 位,高 4 位被截掉,所以拼出来的地址上限是 4K 字节,正好对上 PC 和 M_address 的位宽。
这也解释了为什么 READ 和 WRITE 只能操作 R0:MDR 在 state 2 里被固定赋成 R0,写入内存的数据源只有这一个;READ 在 state 4 里把 M_data_in 落回 R0,接收端也只有这一个。R0 在这套指令集里因此同时扮演累加器、数据门户和条件位三个角色,JZ 判断的也是它。这种“一个寄存器打天下”的收敛设计,让 8 位总线的往返路径变得极其短,代价是写代码的人必须时刻记着“先搬进 R0 再说”。
3. 五状态机:一条指令周期是怎么被切成 state 0 到 state 4 的
3.1 state 0:取指拍里塞进了三件事
state 0 是整个状态机的入口,也是信息密度最高的一拍。它要在同一个时钟沿完成三件事:推进 PC、更新地址寄存器、把内存返回的字节塞进 IR 的高半部分,同时顺手切出操作码和源寄存器。
// state 0:取指令高字节 + 切操作码 + 预装源操作数 0: begin Write_Read = 1'b0; // 存储器方向为读 PC = PC + 1; // PC 语义是"已消费字节数",先自增 MAR = PC; // 用自增后的 PC 驱动地址寄存器 IR = {M_data_in, 8'b00000000}; // 本拍只有高 8 位有效,低 8 位先补零占位 OP = IR[15:12]; // 切出 4 位操作码 case (IR[9:8]) // 源寄存器号,直接装进 A 0: A = R0; 1: A = R1; 2: A = R2; 3: A = R3; endcase state = 1; // 进入译码/执行拍 end这段代码里有三个顺序依赖值得逐行看。PC = PC + 1写在MAR = PC之前,用的是阻塞赋值,所以 MAR 拿到的是自增后的值,而不是当前值——这一行顺序决定了取指地址比 PC 初值大 1,也决定了 MIF 文件里第一个字节的摆放位置。IR = {M_data_in, 8'b00000000}用拼接把 8 位数据顶到高半部分,低 8 位先填零,等第二拍再覆盖;之所以能这么写,是因为后面所有译码只依赖 IR[15:12] 和 IR[9:8],全在高字节里。OP = IR[15:12]必须写在 IR 赋值之后,同样是阻塞赋值的顺序语义在起作用,换个顺序拿到的就是上一条指令的操作码。
A 的装载放在取指拍,是一处很省的设计。此刻 M_data_in 上挂着指令高字节,IR[9:8] 的两位恰好可以就地选通四个寄存器之一,等 state 1 做运算时源操作数已经在 A 里备好了,不需要额外的读寄存器拍。血泪经验是:这种“顺手做掉”的优化在波形上极不明显,一旦把 state 0 拆成两拍,所有算逻指令的时序都会跟着变。
3.2 state 1:算逻指令为什么能一拍收工
state 1 是译码执行拍,OP 的 0 到 10 全部在这一拍里结束,执行完直接把 state 写回 0,构成单拍闭环。以 ADD 为例,它的全部动作是:按 IR[11:10] 选出目标寄存器,把 Rd 和 A 符号扩展成 9 位相加,取低 8 位写回,再用第 9 位和第 7 位判断溢出。没有写回级的流水,没有旁路网络,一拍之内读写同一个寄存器,靠的是阻塞赋值让写回结果立刻对后续语句可见。
MOV 更能说明这套 ISA 的收敛程度:case(IR[11:10])四个分支,每支就是一句Rd = A。因为源操作数在取指拍已经进了 A,目标寄存器号在 IR[11:10] 里,译码器退化成两个 2-4 译码器的组合,根本不需要单独的逻辑块。反过来说,如果哪天要给 MOV 加一个“支持内存寻址”的变体,就必须拆成两拍,因为地址得跨字节拼,这就掉进 state 2 到 4 那条更长的路径里去了。
OP=15 的 STOP 是个特例:它把 state 写回 1 而不是 0,于是下一拍又进 state 1、又匹配到 15、又写回 1,形成自锁死循环。这版实现里没有真正的 halt 信号输出,停机是靠状态机自己卡住实现的。仿真时看到波形不再变化,不用怀疑挂死,那是设计意图。真要做成可中断的停机,得加一个 running 标志位,让 state 1 之外的地方也能感知到停机状态。
3.3 state 2 到 state 4:访存与跳转的三拍半流程
OP 从 11 到 14 的四条指令(JMP、JZ、READ、WRITE)走的是同一条长路径:state 2 取地址低字节并拼装,state 3 决定是改 PC 还是动内存,state 4 收尾。这条路径之所以要三拍,全是为了给 12 位地址和 8 位数据口留出拼装时间。
// state 3:地址已成形,开始真正动内存或改 PC 3: begin Write_Read = 1'b0; case (OP) 11: begin PC = IR[11:0]; state = 0; end // JMP:地址直接落进 PC 12: begin // JZ:只在命中时改 PC if (R0 == 0) PC = IR[11:0]; state = 0; end 13: begin MAR = PC; state = 4; end // READ:把 PC 当数据地址 14: begin // WRITE Write_Read = 1'b1; // 写使能拉高 MAR = PC; // 写地址同样用 PC state = 4; end endcase end // state 4:撤销写使能,或者把读回的数据落进 R0 4: begin Write_Read = 1'b0; if (OP == 13) R0 = M_data_in; // 只有 READ 需要收回数据 state = 0; end这里有个容易被忽略的设计:READ 和 WRITE 用的地址是 PC,而不是 IR 里拼出来的那个 12 位地址。也就是说这两条指令实际访问的,是紧跟在指令后面的那个字节位置,相当于把“指令流当前指针”当成了数据指针。这种把代码段和数据段混在一起的简化做法,省掉了一整套地址生成逻辑,代价是没法自由指定数据区,程序里想读写哪个单元,就得让 PC 正好停在那儿。
WRITE 的写脉冲宽度也值得算一下:state 3 里Write_Read = 1'b1,state 4 第一行就把它拉回 0,所以高电平只维持一个状态周期,正好一个时钟宽度。如果外部存储器模型是在时钟上升沿采样写使能,这一拍刚好被采到;如果存储器是电平敏感的异步模型,那就要确认地址在写使能有效期间已经稳定,否则会写错单元。这是后面排查章节要展开的一个坑。
4. 一个 always 块扛下所有:阻塞赋值、输出镜像与溢出判断
4.1 阻塞赋值的执行顺序:Temp 与 overflow 的先后依赖
整颗 CPU 的主状态机写在一个always @(posedge clock)里,内部全部使用阻塞赋值=。这种写法在可综合风格里通常被劝阻,但在这类单块状态机里反而成了表达顺序逻辑的利器:同一个时钟沿内,语句按书写顺序立即生效,后一句能看到前一句的结果。
// state 1 中的 ADD 分支 3: begin case (IR[11:10]) 0: begin Temp = {R0[7], R0} + {A[7], A}; // 9 位符号扩展加法 R0 = Temp[7:0]; // 只取低 8 位写回 end 1: begin Temp = {R1[7], R1} + {A[7], A}; R1 = Temp[7:0]; end // R2、R3 分支同理 endcase if (Temp[8] ^ Temp[7]) // 依赖上一行的 Temp,顺序不能颠倒 overflow = 1; else overflow = 0; state = 0; end{R0[7], R0}是符号扩展拼接:把 8 位数的最高位复制一份放到第 9 位,得到一个 9 位的补码数。加上{A[7], A}之后,Temp[8] 是真实的符号位,Temp[7] 是截断成 8 位后的符号位。Temp[8] ^ Temp[7]为 1 就说明两个符号位不一致,结果溢出。
这段代码能跑通,完全依赖阻塞赋值的顺序语义:Temp 必须先算完,R0 才能取低 8 位,overflow 才能读 Temp 的高位。如果把这三行换成非阻塞赋值<=,同一个时钟沿里 Temp 的新值要等这一拍结束才可见,Temp[8] ^ Temp[7]拿到的是上一拍的残留值,溢出标志就会整体滞后一条指令。这不是理论推演,是这类代码最常见的翻车点之一。
4.2 三个 always 块的分工与敏感列表的隐患
模块里其实有三个 always:一个组合块负责把内部寄存器映射到输出端口,一个时钟块负责把 R0~R3 镜像到观察端口,最后一个才是主状态机。
// 组合块:把内部 MAR/MDR 映射到存储器接口 always @ (reset or state) begin M_address = MAR; M_data_out = MDR; end // 时钟块:把内部通用寄存器镜像到输出端口,供仿真观察 always @ (posedge clock) begin R_0 = R0; R_1 = R1; R_2 = R2; R_3 = R3; end第一个块的敏感列表是reset or state,而不是MAR or MDR。它之所以能工作,靠的是一个巧合:MAR 和 MDR 几乎只在状态切换时被改写,所以用 state 当触发条件基本能覆盖所有变化。但 state 2 的 JMP 分支里恰恰有一句MAR = IR,这一句发生在同一个 state 内,不会触发组合块重新求值,M_address 要等到 state 变成 3 之后才更新,整整晚了一拍。
常见的实现思路是把敏感列表补全成always @(*),或者干脆改成assign M_address = MAR;,让工具自己推导依赖关系。第二种更彻底,也不会有漏信号的隐患。教学代码里保留@(reset or state)的写法,大概是为了让波形上每个输出变化都对齐状态边界,但这就把时序正确性押在了“没人会在状态内部改 MAR”这个约定上。
第二个块把 R0~R3 赋给 R_0~R_3,纯粹是给仿真看的内窥窗口。麻烦在于它和主状态机是同一时钟沿触发的两个 always 块,主块改了 R1,镜像块在同一拍读 R1,两个块的调度顺序在 Verilog 里没有保证,读到新值还是旧值取决于仿真器的内部顺序。所以波形上 R_1 常常比 R1 慢一拍,这不是设计错误,是并发块之间没有同步的必然结果。
4.3 移位与寄存器搬运:拼接运算符和查表能走多远
移位指令的实现几乎是把拼接运算符用到极致的例子。右移一句R0 = {1'b0, R0[7:1]},把最高位补 0、低 7 位右挪一格,拼成新的 8 位;左移反过来R0 = {R0[6:0], 1'b0}。两行代码、零个控制信号,逻辑移位就完成了。
真正的短板在算术移位上。这对拼接写法只补 0,不保留符号位,所以负数右移一位会变成正数——如果要支持算术右移,得写成{R0[7], R0[7:1]},用原符号位填充高位,这需要在译码阶段区分逻辑移位和算术移位,也就意味着要再占一个操作码。4 位操作码已经用满,想在现有格式里塞新指令,只能复用那些语义上可以合并的槽位,或者借用 IR[11:8] 的取值做二级译码。
5. 常见问题与排查:仿真波形里最容易翻车的五个位置
5.1 取指与跳转上的三个坑
现象一:复位后第一条指令像是被跳过了,PC 一上来就停在 1。原因在 state 0 的语句顺序:PC = PC + 1写在MAR = PC之前,阻塞赋值让 MAR 拿到的是自增后的值
本文还有配套的精品资源,点击获取