简介:这份资源是一套面向计算机组成原理与处理器设计课程教学的十六位单周期CPU硬件工程,主要采用Verilog语言编写,适用于高校本科生、课程设计者以及希望在FPGA平台上验证处理器设计的硬件爱好者。压缩包内共有140个文件,整体大小约826KB,主要包含Verilog源码、C语言测试程序、ISE工程文件、综合仿真报告与日志等,文件类型覆盖.v、.c、.prj、.xise、.log、.xst,既有硬件描述代码,也有工程配置和验证记录。源码围绕指令寄存器、指令解码器、数据路径、控制单元、寄存器堆与ALU等核心模块展开,并提供测试向量与仿真模型,有助于从模块级理解单周期CPU在单个时钟周期内完成取指、译码、执行、写回的完整流程。目前已有604人学习,既可直接作为课程设计参考,也可在现有源码基础上修改扩展,适合入门级处理器设计实践。
1. 单周期CPU设计:为什么十六位比三十二位更适合入门
做过计算机组成原理课程设计的同学,几乎都会在“单周期CPU”这个题目上卡住一个星期。十六位单周期CPU是一个标准的处理器设计入口,它把取指、译码、执行、访存、写回全部压缩到同一个时钟周期完成,所有模块的状态更新都对齐在一个时钟边沿上。相比三十二位MIPS风格的实现,十六位版本的数据通路更短,指令格式可以用四位操作码加三位寄存器编号来组织,ALU和控制单元的逻辑更直观,波形调试时一眼能看穿每一条指令的生命周期。这份资源就是一个完整的可综合项目,适合正在做课程设计、准备考研复试上机、或者想补全处理器工作原理的从业者。
它的核心价值不在于“CPU能跑”这个结果,而在于把一条指令从取指到写回的数据流拆成了可仿真、可观测、可改错的模块链。你拿到手的是Verilog源码加仿真脚本加测试程序,不是一张概念图。
2. 先定指令集,再画数据通路:十六位格式的取舍
单周期CPU的设计顺序不是从代码开始,而是从指令集格式开始。指令宽度定了,操作码位数、寄存器数目、立即数范围都跟着定,后续所有模块的位宽和译码逻辑全部受制于这一张表格。
2.1 指令格式设计:四位操作码怎么安排
十六位指令宽度下,常见的分配方式是操作码占四位,十六种编码最多支持十六条基础指令。你不可能塞入三十二位CPU那么多条指令,所以设计目标应该是“覆盖算数运算、逻辑运算、访存、跳转、比较、停机”几类核心场景。
我拿到这份资源时先看了它的指令编码表,整理下来大约是这样:
| 指令类型 | opcode(4bit) | 功能 | 说明 |
|---|---|---|---|
| ADD | 0001 | 寄存器加 | 目标寄存器 = 源1 + 源2 |
| SUB | 0010 | 寄存器减 | 目标寄存器 = 源1 - 源2 |
| AND | 0011 | 按位与 | 目标寄存器 = 源1 & 源2 |
| OR | 0100 | 按位或 | 目标寄存器 = 源1 | 源2 |
| XOR | 0101 | 按位异或 | 目标寄存器 = 源1 ^ 源2 |
| LW | 0110 | 从内存读 | 目标寄存器 = MEM[基址+立即数] |
| SW | 0111 | 写内存 | MEM[基址+立即数] = 源寄存器 |
| BEQ | 1000 | 相等转移 | 相等则跳转到目标地址 |
| JMP | 1001 | 无条件跳转 | 直接跳到目标地址,十六位目标由低位补零得到 |
| HLT | 1111 | 停机 | 拉高停机信号,仿真停止 |
寄存器编号为什么选三位?因为剩余位段比较宽裕。R型指令里,十六位可以这样拆:操作码四位、rs三位、rt三位、rd三位、空余三位留给功能扩展或者直接不用。I型指令则是操作码四位、rs三位、rd三位、立即数六位。六位立即数的范围是-32到31,做小偏移量访存和短跳转足够用,也符合十六位CPU的教学定位。
2.2 数据通路结构:哪些模块必须存在
指令集定了,数据通路的骨架就出来了:程序计数器、指令存储器、寄存器堆、ALU、数据存储器、控制单元,这六个模块一个都不能少。单周期的意思是,所有模块在同一个时钟周期内完成整条指令的处理,所以组合逻辑路径上每多一个模块,关键路径就长一截。
这份资源的顶层模块划分很清晰:PC模块在时钟上升沿更新指令地址;指令存储器直接输出十六位指令字;译码单元从指令字里拆出rs、rt、rd和立即数;寄存器堆有两个读端口一个写端口;ALU根据控制信号执行加减、逻辑运算、比较;控制单元根据操作码生成全部控制线。数据通路的结构层级不算深,顶层例化七个模块,端口名直接对应总线走向,非常适合用来对照教材上的单周期电路图逐线核验。
我一般建议先画一张数据通路草图,把每一条控制信号的来源和去向标出来,再打开代码。因为后面看仿真波形时,你面对的是几十根信号,没有这张图,任何一根信号跳变都很难定位。
3. 模块级实现:顶层例化、寄存器堆、ALU与信号同步
代码的组织方式决定了你排查问题的效率。这份资源把每个模块单独放在一个文件里,顶层用纯wire连接,控制信号全部由控制单元统一发出。这种写法比把所有逻辑塞进一个always块更容易追踪数据流,也更接近真实芯片的分层设计习惯。
3.1 顶层RTL的端口与例化关系
module cpu_top( input wire clk, input wire rst_n, output wire [15:0] debug_pc, output wire [15:0] debug_inst, output wire debug_halt ); wire [15:0] pc, inst; wire [15:0] alu_result, alu_a, alu_b; wire [15:0] mem_data, reg_data_in; wire [15:0] imm_ext, branch_target; wire reg_we, mem_we, alu_src, mem_to_reg, branch, jump; wire [3:0] alu_op; wire [2:0] rs, rt, rd; wire zero_flag; pc_reg u_pc( .clk(clk), .rst_n(rst_n), .pc_next(pc_next), .pc(pc) ); inst_mem u_inst_mem( .addr(pc), .inst(inst) ); control_unit u_ctrl( .opcode(inst[15:12]), .reg_we(reg_we), .mem_we(mem_we), .alu_src(alu_src), .mem_to_reg(mem_to_reg), .branch(branch), .jump(jump), .alu_op(alu_op) ); regfile u_regfile( .clk(clk), .we(reg_we), .rs(inst[11:9]), .rt(inst[8:6]), .rd(inst[5:3]), .wd(reg_data_in), .rdata1(alu_a), .rdata2(alu_b) ); alu u_alu( .a(alu_a), .b(alu_b), .op(alu_op), .result(alu_result), .zero(zero_flag) ); // ... mem、pc_next逻辑省略 endmodule顶层连接的核心是让每一条数据线的名称表达它在数据通路中的角色。alu_a来自寄存器堆的第一个读端口,alu_b来自第二个读端口或立即数扩展器,mem_to_reg选择写回数据源是ALU结果还是数据存储器输出。这张端口表对应到单周期电路图时,每一个箭头都有明确的信号名,排查波形时你只需要追踪这条信号线的来源和目的。
3.2 寄存器堆:两个读端口一个写端口的行为级写法
寄存器堆是单周期CPU里最容易写错的地方。两个读端口必须是组合逻辑输出,写端口必须是时钟边沿触发。如果读端口也写成时钟触发,那么这条指令读到的就是上一个周期的旧值,整个流水都会错位。
module regfile( input wire clk, input wire we, input wire [2:0] rs, input wire [2:0] rt, input wire [2:0] rd, input wire [15:0] wd, output wire [15:0] rdata1, output wire [15:0] rdata2 ); reg [15:0] regs [0:7]; always @(posedge clk) begin if (we && rd != 3'b000) regs[rd] <= wd; end assign rdata1 = regs[rs]; assign rdata2 = regs[rt]; initial begin regs[0] = 16'h0000; regs[1] = 16'h0000; regs[2] = 16'h0000; // ... 其余寄存器初始化为0 end endmodule写端口的条件里我加了rd != 3'b000,也就是禁止向零号寄存器写入。这是RISC风格处理器的一个隐含约定,如果去掉这个判断,初始化为零的寄存器可能在程序运行过程中被改成非零值,后续依赖零寄存器的代码全部错乱。读端口用assign连续赋值,rs和rt变化后,读数据在同一周期就稳定输出,这样ALU在同一周期内就能拿到操作数。
3.3 ALU设计:加减逻辑比较一条路走到底
十六位CPU的ALU不需要复杂功能,支持加法、减法、按位与、或、异或、比较就可以覆盖指令集。减法用补码加法实现,比较直接减出一个负数或零值,用符号位和零标志位判断大小关系。
module alu( input wire [15:0] a, input wire [15:0] b, input wire [3:0] op, output reg [15:0] result, output reg zero ); always @(*) begin case (op) 4'b0001: result = a + b; 4'b0010: result = a - b; 4'b0011: result = a & b; 4'b0100: result = a | b; 4'b0101: result = a ^ b; 4'b0110: result = a < b ? 16'h0001 : 16'h0000; default: result = a + b; endcase zero = (result == 16'h0000); end endmoduleALU的运算控制信号来自控制单元,op的编码和指令格式里的功能位不是同一个东西,这中间是控制单元在做翻译。比如指令是SUB时,控制单元发出的alu_op是4'b0010,ALU看到这个值才知道做减法。比较指令的结果用0001或0000表示真伪,而不是直接用减法结果,这样语义更清晰。
3.4 控制信号的建立时间:为什么不能有延迟
单周期CPU的全部控制信号必须在同一周期内到达目标模块,不能有任何一级锁存。控制单元是纯组合逻辑,opcode进来后直接输出全部控制电平。实际的坑在于:控制信号通过不同的组合逻辑路径到达ALU、寄存器堆、数据存储器的时间不同,如果到达时间差太大,就会在一个周期内出现短暂的不稳定状态。
仿真时这个不稳定状态不一定爆出来,因为时钟边沿采样的瞬间数据已经是稳态了。但综合后跑时序分析时,关键路径上的延迟差异会直接影响最大时钟频率。所以控制单元的写法应该尽量扁平,不要嵌套太深的组合逻辑。我习惯用wire直接assign,不用always块,让综合器自由优化,路径延时会均匀很多。
4. 把验证做扎实:测试程序、波形观察与执行流追踪
单周期CPU的验证比流水线简单,因为每一条指令的执行互不重叠,PC从零开始递增,中间只有分支和跳转会打断顺序流。验证的核心是设计一段能覆盖全部指令类型的测试程序,然后在仿真波形里逐条核对。
4.1 测试程序的执行序列设计
这份资源里的测试程序从内存地址零开始存放,每条指令占一个十六位字。我把它读出来转成助记符后,大致是这样的执行序列:
地址0: R1 = 5 地址1: R2 = 3 地址2: R3 = R1 + R2 地址3: R4 = R1 - R2 地址4: R5 = R1 & R2 地址5: R6 = R1 | R2 地址6: R7 = R1 ^ R2 地址7: R3 = R3 + 1 地址8: R1 = R1 + R2 + 1 地址9: 相等跳转,目标地址12 地址10:R8 = 7 地址11:停机 地址12:R8 = 9 地址13:写内存 地址14:读内存 地址15:停机这个序列覆盖了五种ALU运算、两条带立即数的算数指令、条件跳转、无条件跳转、访存和停机。每条指令执行后,你都可以从波形里看到寄存器堆写端口的数据和使能信号。有一个关键点是测试程序的前几条指令故意让寄存器值互不相同,这样才能验证比较和跳转逻辑真的在干活,而不是测试程序刚好走对了。
4.2 波形观察点:盯哪几根信号
打开仿真波形后,不要一上来就盯所有信号。十六位CPU的顶层信号加内部信号加起来少说几十根,全铺在波形窗口里等于没铺。我一般按下面的顺序观察:
第一组是PC和指令字,确认取指是否递增,分支跳转时代是否跳到目标地址。第二组是寄存器堆的两个读数据线和写数据线,确认读出的源操作数和写回的目标寄存器。第三组是ALU的两个输入和结果,确认运算类型和数值是否正确。第四组是存储器写使能和地址线,确认SW指令有没有误写、LW指令读的地址是否正确。
具体的检查方法是:暂停仿真到某个特定周期,比如执行到地址2的加法指令时,看alu_a是不是0x0005,alu_b是不是0x0003,alu_result是不是0x0008,reg_we是不是高电平,写端口的地址是不是3号寄存器。一处一处核,任何一处不对,都能直接定位到具体模块。
4.3 常见误用:把VCD文件导出当成全部
很多同学跑完仿真看一眼波形图就收工了,但实际上单周期CPU要验证的边界条件比波形里展示的要复杂得多。波形只能展示你选中的几个周期,没法展示几十条指令连续执行的完整过程。我一般会写一段自动化检查的testbench,在每条指令执行完后用$display打印寄存器堆的关键寄存器和PC值,再手动跑一遍全内存的指令序列,避免只靠肉眼判断波形。
数据存储器的初始化也是个容易翻车的点。程序里用到的初始数据应该在仿真开始时通过$readmemh或$readmemb读入内存镜像,而不是在寄存器堆的initial块里逐个赋值。测试代码里有这部分内容,直接对照使用就可以。
5. 单周期CPU的避坑记录:从端口位宽到存储器时序
单周期CPU的代码量虽然只有几百行,但踩坑的点非常集中。我整理了几个自己调试时真实遇到的问题,每一条都对应仿真波形里的具体现象。
5.1 端口位宽不匹配,仿真报错但波形不崩
现象:编译通过,仿真跑了一段后寄存器值莫名其妙变成高阻态或者不定值。原因:顶层例化时把某个模块的输入端口位宽接窄了,比如ALU的b端口声明是十六位,但例化时只接了八位信号。高阻态出现的周期正好是该信号被采样的时刻。解决:检查所有跨模块连接的信号位宽声明,用$display打印关键信号的宽度,或者在顶层模块里人为加wire [15:0]再重新走一遍例化。
5.2 写使能信号早了半个周期
现象:加法指令执行完,目标寄存器的值变成正确结果,但下一条指令读同一个寄存器时读到的还是旧值。原因:寄存器堆是时钟上升沿写入的,但控制单元产生的reg_we信号在组合逻辑路径上比寄存器堆的写入边沿早到了半个周期。仿真时看起来好像下一秒才写入,实际是数据建立时间不足。解决:把寄存器堆的写入条件改成posedge clk且we高电平,确保数据在时钟边沿真正锁存,读端口永远读的是上一笔已提交的结果。
5.3 数据存储器的读端口在写操作时给出旧值
现象:SW指令执行后,紧接着一条LW指令读同一地址,读出来的不是刚写入的数据。原因:数据存储器的读端口是组合逻辑,写端口是时序逻辑。如果仿真模型里读写共用地址线,写操作时读端口返回旧的存储内容,下个周期才更新为刚写入的值。解决:检查数据存储器模型是不是真双口或伪双口,单周期设计里的访存指令无法在同一周期完成读后写,把读写指令分开编址,避免在测试程序里连续对同一地址做写后读。
5.4 分支指令的目标地址少算了一个字
现象:条件跳转生效后,PC跳到了目标指令的后一条,或者跳到了目标地址的正前方。原因:指令存储器按字节编址还是按字编址没分清楚。十六位CPU的指令字宽十六位,地址加一对应下一条指令,但如果程序计数器按字节计数,地址加二才是下一条指令。跳转目标地址的计算逻辑里没对齐就会翻车。解决:统一所有地址都按指令字计数,分支目标等于符号扩展立即数加当前PC值,跳转指令则把十六位目标左移一位后拼接PC高位,形成完整的十六位地址。
5.5 组合逻辑仿真里出现毛刺但时钟沿采样正常
现象:波形里ALU输出在周期中间闪了一下,有短暂的低电平毛刺,但时序检查时没有报错,最终仿真结果也是对的。原因:单周期CPU的ALU输入来自寄存器堆读端口和立即数扩展器,这两个信号到达ALU的时间不同,导致组合逻辑输出在短暂窗口内不稳定。这个毛刺如果被数据存储器写使能信号采样到,就可能触发误写。解决:在ALU输出和数据存储器写使能之间加一个寄存器隔离,或者把写使能信号设计成时钟下降沿有效,保证数据稳定后才锁存。对于教学设计和功能仿真,这个坑不一定爆出来,但做实际电路时一定要处理。
这里有一条从头到尾要提醒的重要事项:寄存器堆的零号寄存器约束只能手工写进代码,综合器不会自动帮你检查。任何把rd端口指向零号寄存器的指令在硬件上依然会写入,只是你的代码逻辑里用if拦了一下。出于严谨,应该在指令译码阶段就把写入零号寄存器的指令判定为非法,或者直接禁用这组编码,而不是只依赖寄存器的if判断。
6. 把设计做得再扎实:边界指令、最长路径延时与单周期模型的底线
单周期CPU做到能跑测试程序只是起点,真正能体现出对架构理解的是那些测试程序没覆盖到的边界情况。我拿到这份资源后做了一件事:把指令集里每个操作码都翻了一遍,找出那些语义模糊的编码——比如操作码为全零的未定义指令,比如除以零的运算指令,比如跳转目标越界的边界地址。这些未定义指令在真实处理器里必须有确定的处理方式,要么抛出异常,要么至少让PC保持静止不再执行。教学用的十六位CPU通常不实现异常机制,但你要在控制单元的default分支里让所有控制信号输出安全值,避免未定义指令产生意外的寄存器写入或存储器写操作。
第二个值得花时间的点是单周期模型的最长路径延时。单周期CPU的时钟周期 = 取指延迟 + 寄存器堆读延迟 + ALU运算延迟 + 数据存储器读延迟 + 寄存器堆写延迟,以及PC和MUX的延迟。这份资源里每条指令的路径长度不一样,LW指令的数据通路最长,因为它要经过取指、寄存器读、ALU算地址、数据存储器读、再写回寄存器堆,五段串起来。BEQ指令虽然没有访存,但PC的更新依赖于ALU的zero标志和加法器,也是一条不短的路径。
你可以用综合工具打开时序报告,查看这几条路径的负裕量。如果有裕量为负数,说明当前时钟约束太紧,要么放松时钟周期,要么在ALU和数据存储器之间插入一级缓冲。真实芯片设计里的常见做法是加流水线寄存器,把单周期变成两段或多段流水,但对于课程设计来说,你更需要理解的是这条最长路径的构成,而不是盲目切流水。
我在调这块资源时犯过一个典型错误:把寄存器堆的写端口地址和写数据线全部接到顶层信号上,结果一条写指令执行完后,波形里寄存器值在下一个周期又被还原成初始化值。查了很久才发现是寄存器堆模块的initial块里对每个寄存器做了循环初始化,但写入条件没排除初始化时段,仿真一开始所有寄存器被清零,测试程序的第二条指令就把第一条算好的结果冲掉了。从那以后我每次做处理器实验都会强制自己走一遍完整流程:先确定指令格式和操作码分配,然后核对数据通路图与信号名一一对应,再写测试程序覆盖所有指令类型,最后跑仿真时只看三个点——读取的源数据、ALU运算结果、写回的目标值。任何一步对不上,说明前面的某个模块设计有误,不要急于改波形,先把错位的地方找出来。这套检查习惯帮我节省了大量调试时间,希望也能帮到你在复现这份单周期CPU资源时少走弯路。
本文还有配套的精品资源,点击获取