单周期CPU设计实战:从指令集到Verilog仿真的完整流程

发布时间:2026/10/15 14:33:51
单周期CPU设计实战:从指令集到Verilog仿真的完整流程
简介这份资源是一套面向计算机组成原理与处理器设计课程教学的十六位单周期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那么多条指令所以设计目标应该是“覆盖算数运算、逻辑运算、访存、跳转、比较、停机”几类核心场景。我拿到这份资源时先看了它的指令编码表整理下来大约是这样指令类型opcode4bit功能说明ADD0001寄存器加目标寄存器 源1 源2SUB0010寄存器减目标寄存器 源1 - 源2AND0011按位与目标寄存器 源1 源2OR0100按位或目标寄存器 源1 | 源2XOR0101按位异或目标寄存器 源1 ^ 源2LW0110从内存读目标寄存器 MEM[基址立即数]SW0111写内存MEM[基址立即数] 源寄存器BEQ1000相等转移相等则跳转到目标地址JMP1001无条件跳转直接跳到目标地址十六位目标由低位补零得到HLT1111停机拉高停机信号仿真停止寄存器编号为什么选三位因为剩余位段比较宽裕。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 ! 3b000) regs[rd] wd; end assign rdata1 regs[rs]; assign rdata2 regs[rt]; initial begin regs[0] 16h0000; regs[1] 16h0000; regs[2] 16h0000; // ... 其余寄存器初始化为0 end endmodule写端口的条件里我加了rd ! 3b000也就是禁止向零号寄存器写入。这是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) 4b0001: result a b; 4b0010: result a - b; 4b0011: result a b; 4b0100: result a | b; 4b0101: result a ^ b; 4b0110: result a b ? 16h0001 : 16h0000; default: result a b; endcase zero (result 16h0000); end endmoduleALU的运算控制信号来自控制单元op的编码和指令格式里的功能位不是同一个东西这中间是控制单元在做翻译。比如指令是SUB时控制单元发出的alu_op是4b0010ALU看到这个值才知道做减法。比较指令的结果用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 地址10R8 7 地址11停机 地址12R8 9 地址13写内存 地址14读内存 地址15停机这个序列覆盖了五种ALU运算、两条带立即数的算数指令、条件跳转、无条件跳转、访存和停机。每条指令执行后你都可以从波形里看到寄存器堆写端口的数据和使能信号。有一个关键点是测试程序的前几条指令故意让寄存器值互不相同这样才能验证比较和跳转逻辑真的在干活而不是测试程序刚好走对了。4.2 波形观察点盯哪几根信号打开仿真波形后不要一上来就盯所有信号。十六位CPU的顶层信号加内部信号加起来少说几十根全铺在波形窗口里等于没铺。我一般按下面的顺序观察第一组是PC和指令字确认取指是否递增分支跳转时代是否跳到目标地址。第二组是寄存器堆的两个读数据线和写数据线确认读出的源操作数和写回的目标寄存器。第三组是ALU的两个输入和结果确认运算类型和数值是否正确。第四组是存储器写使能和地址线确认SW指令有没有误写、LW指令读的地址是否正确。具体的检查方法是暂停仿真到某个特定周期比如执行到地址2的加法指令时看alu_a是不是0x0005alu_b是不是0x0003alu_result是不是0x0008reg_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资源时少走弯路。本文还有配套的精品资源点击获取