
简介这是一份面向计算机组成原理课程实验的单周期CPU设计完整资料围绕MIPS指令集和硬件描述语言实现展开适合正在学习处理器设计、需要完成同类实验或期末大作业的本科生参考。内容涵盖十六条指令的功能定义与数据通路设计包含完整的Verilog硬件描述语言源码、Quartus工程、仿真波形与编译报告配套的实验报告详细记录了从指令分析、模块划分到波形验证的每一步并附有寄存器传输级结构截图便于对照检查。压缩包共90个文件以工程文件、仿真波形文件、报告文本与文档为主整体仅2.37MB目录层次清晰可直接导入Quartus查看运行结果。目前已有12292人浏览学习无论用于梳理实验思路、排查代码错误还是撰写实验报告都能提供可复用的参考。1. 单周期CPU设计一个周期内走完指令的一生“单周期CPU设计”能成为《计算机组成原理》实验里的压轴题不是因为它难而是因为它把一条指令的一生压缩进了一个时钟周期取指、译码、执行、访存、写回五个阶段全部在同一个周期内完成。你以为只要会写 Verilog 就没问题真正动手时的第一道坎是——PC更新、寄存器写入、存储器读写全都挤在这个时钟沿前后组合逻辑路径上任何一环没安定下一个周期抓到的就是错数据。适不适合做这个实验不看你会不会写代码而看你能不能把“一个周期内所有信号稳定”这条时序约束装进自己脑子里。跑通它多周期和流水线的很多概念会自然接上。2. 先定指令集六条指令搭出完整数据通路2.1 为什么选这六条而不是整个MIPS单周期CPU设计的第一步永远不是写代码而是确定指令集。MIPS是课程实验里最常被选用的指令集原因很朴实定长32位、格式规整R型和I型两种格式几乎覆盖实验要用的全部场景。做这种实验我一般建议从六条指令起步不要贪多add寄存器加法、addi立即数加法、lw读内存、sw写内存、beq条件分支、jump无条件跳转。这六条指令把数据通路的三种形态占满了运算结果回写寄存器、内存数据回写寄存器、分支跳转改变PC。相比再加十条指令这六条对应的控制信号组合足够让你理解控制器的工作方式。真把整个MIPS子集塞进单周期CPU控制器里 case 分支多出几十条综合时间和调试难度翻倍但对核心原理的认知并不会因此增加半分。指令集选型的原则就一句话能覆盖实验要求就别追求大而全。2.2 R型与I型的字段边界在哪MIPS指令集中R型指令的32位被切成六个字段opcode占第31到26位rs占第25到21位rt占第20到16位rd占第15到11位shamt占第10到6位funct占第5到0位。I型指令则是opcode、rs、rt加一个16位立即数。J型指令是opcode加一个26位地址。字段边界之所以要背得滚瓜烂熟是因为写Verilog时你要从inst信号里精确切出这些位段接错一位整个CPU的行为就完全错乱。六条指令的编码如下这个表格是后面控制器真值表的基础。指令类型opcodefunct功能addR000000100000rd rs rtaddiI001000-rt rs 符号扩展(imm)lwI100011-rt mem[rs 符号扩展(imm)]swI101011-mem[rs 符号扩展(imm)] rtbeqI000100-若 rs rt则跳转jumpJ000010-无条件跳转注意beq的跳转目标不是简单的PC加立即数而是先取符号扩展后的16位立即数左移两位再加到PC4上。左移两位是因为所有MIPS指令地址按4字节对齐低两位恒为00。这一点在第四章的PC更新逻辑里会有对应的Verilog实现。2.3 立即数扩展同一个字段、三种用法16位立即数在不同指令里的用法不同。addi、lw、sw里它是有符号偏移量需要做符号扩展成32位把第15位的符号位复制到高16位。beq里除了符号扩展还要左移两位。课程实验里最常犯的错就是扩展和移位顺序搞反或者直接把16位立即数当32位用导致负偏移全部变成正偏移。我写扩展模块时一般单独建一个imm_ext把符号扩展和左移两个动作分开便于复用和排查module imm_ext( input wire [15:0] imm, // 指令的低16位 output wire [31:0] imm_ext, // 符号扩展后的32位数 output wire [31:0] imm_shift // 符号扩展后再左移2位 ); assign imm_ext { {16{imm[15]}}, imm }; assign imm_shift imm_ext 2; endmodule逻辑说明第一条assign把16位立即数的符号位复制16份拼在原值高16位得到有符号扩展的32位数。第二条assign在扩展结果上左移两位供beq计算目标地址用。参数说明imm是inst[15:0]若指令是addi且立即数为负数imm_ext会得到一个很大的正数表示的有符号数ALU做加法时按补码自然得到正确结果。3. 数据通路搭建从PC到写回的六个模块3.1 PC与取指同步更新、异步读指令单周期CPU的数据通路按“取指→读寄存器→运算→访存→写回”串起来。PC寄存器在每个时钟上升沿把next_pc更新为当前值指令存储器则是纯组合逻辑异步读只要PC地址稳定当前周期的组合逻辑就能立刻把指令输出不需要等下一个时钟沿。这里一定要用异步读而不是同步读否则指令会比PC晚一个周期到达整个CPU的行为会整体向后平移。module pc_reg( input wire clk, input wire rst_n, input wire [31:0] next_pc, output reg [31:0] pc ); always (posedge clk or negedge rst_n) begin if (!rst_n) pc 32h00000000; // 复位后从地址0开始 else pc next_pc; // 每个周期更新一次 end endmodulemodule inst_mem( input wire [31:0] addr, output wire [31:0] inst ); reg [31:0] rom [0:255]; // 256条指令的只读存储器 initial begin $readmemh(inst.hex, rom); // 从hex文件加载指令 end assign inst rom[addr[31:2]]; // 字地址低2位丢弃 endmodule逻辑说明pc_reg是标准的同步复位寄存器复位信号rst_n低有效。inst_mem用assign连续赋值实现异步读addr[31:2]作为数组下标因为每条指令占4字节、以字为单位存储。参数说明rom容量按实验需要的指令条数调整inst.hex的每一行是一个32位十六进制数对应一条指令的机器码。3.2 寄存器堆读是异步的写是同步的寄存器堆是单周期CPU里最容易写错的模块。读端口必须用组合逻辑因为读出的寄存器值要立刻参与这周期内的运算写端口则必须用时钟沿触发因为写回动作发生在周期末尾。$0寄存器恒为0任何对它的写入都要忽略。module regfile( input wire clk, input wire we, // 写使能 input wire [4:0] raddr1, // 读端口1地址 input wire [4:0] raddr2, // 读端口2地址 input wire [4:0] waddr, // 写地址 input wire [31:0] wdata, // 写数据 output wire [31:0] rdata1, output wire [31:0] rdata2 ); reg [31:0] regs [0:31]; integer i; initial for (i 0; i 32; i i 1) regs[i] 32h0; // 读端口是组合逻辑$0固定输出0 assign rdata1 (raddr1 0) ? 32h0 : regs[raddr1]; assign rdata2 (raddr2 0) ? 32h0 : regs[raddr2]; // 写端口是时钟同步触发 always (posedge clk) begin if (we waddr ! 0) regs[waddr] wdata; end endmodule逻辑说明读操作不依赖时钟只要地址稳定rdata立刻变化。写操作在时钟上升沿完成waddr非零才写保证$0永远不变。参数说明raddr1对应rs字段inst[25:21]raddr2对应rt字段inst[20:16]写地址在R型指令中来自rd字段inst[15:11]在I型指令中来自rt字段inst[20:16]具体选哪一路由后面的控制器决定。3.3 ALU与数据存储器加法在组合逻辑里完成ALU是纯组合逻辑支持and、or、add、sub、slt五种操作zero输出用于beq判断。数据存储器同样采用“异步读、同步写”的结构这样做的好处是一个周期内lw的数据能直接流回寄存器堆不需要额外的等待周期。module alu( input wire [31:0] a, input wire [31:0] b, input wire [3:0] alu_ctrl, // ALU控制信号 output reg [31:0] result, output wire zero ); always (*) begin case (alu_ctrl) 4b0000: result a b; 4b0001: result a | b; 4b0010: result a b; 4b0110: result a - b; 4b0111: result (a b) ? 32h1 : 32h0; default: result 32h0; endcase end assign zero (result 32h0); endmodule逻辑说明alu_ctrl由第四章的ALU控制译码器生成b端口既可以是寄存器堆输出的rt也可以是立即数扩展结果由ALUSrc多路选择器决定。zero信号完全是结果的衍生不需要寄存器记忆。参数说明4位alu_ctrl的低四位编码正好对应常见ALU控制表写case时保持编码连续便于后续扩展比较指令slt用了三目运算符综合后通常会变成减法器加符号位判断。module data_mem( input wire clk, input wire we, // 写使能 input wire [31:0] addr, // 地址 input wire [31:0] wdata, // 写入数据 output wire [31:0] rdata ); reg [31:0] mem [0:255]; assign rdata mem[addr[31:2]]; // 异步读 always (posedge clk) begin if (we) mem[addr[31:2]] wdata; // 同步写 end endmodule逻辑说明data_mem与inst_mem结构相似区别在于多了一个写使能。读操作不需要时钟写操作在时钟上升沿完成。参数说明容量按实验数据段大小调整地址同样按字对齐addr低两位丢弃。3.4 六个模块如何串成一条完整的路把以上模块在顶层连起来时数据流的方向是PC给出地址inst_mem输出指令指令字段分别送到regfile的读地址和控制器的输入regfile的rdata1直接进ALU的a端口rdata2经过ALUSrc多路选择后进ALU的b端口ALU结果一路作为sw的写地址和数据存储器的地址另一路作为lw场景下回写寄存器的候选数据数据存储器读出的rdata与ALU结果经过MemtoReg多路选择最终成为寄存器堆的写数据。信号来源去向说明next_pc控制器与分支逻辑pc_reg分支、跳转或PC4instinst_mem控制器与各模块提供opcode和字段rdata1regfileALU a端口rs寄存器内容alu_resultALUdata_mem地址、回写运算或地址计算结果mem_rdatadata_mem回写多路选择器lw读出的内存数据wb_data回写多路选择器regfile wdata最终写回值这条通路里的每个模块都必须在组合逻辑路径上安定下来。ALU结果、data_mem读数据、多路选择器的输出全部要在下一个时钟上升沿前完成这就是单周期CPU为什么叫“单周期”的原因。4. 控制器与ALU译码九个控制信号串起一张真值表4.1 每个控制信号是干什么的数据通路搭好以后控制器的任务就是根据opcode给出九路控制信号指挥多路选择器和写使能。每个信号对应一个明确动作RegDst选择写寄存器地址来自rd还是rtALUSrc选择ALU第二操作数来自寄存器还是立即数MemtoReg选择写回数据来自ALU还是内存RegWrite是寄存器堆写使能MemRead是数据存储器读使能MemWrite是数据存储器写使能Branch表示当前是beq指令若zero有效则跳转Jump表示无条件跳转ALUOp是两位的操作码交给ALU控制译码器进一步生成精确的4位alu_ctrl。这九路信号在单周期CPU里全部由组合逻辑生成没有任何一个需要跨周期记忆。这一步最容易犯的错是漏掉某个信号的默认值综合工具会因此推断出锁存器导致控制器产生不可预测的行为。4.2 主控制器用case语句列出六种指令的组合主控制器按opcode做分支判断所有输出信号都要在always块开头赋默认值case里只覆盖需要改变的信号。这样做能避免锁存器也让真值表一目了然。module main_control( input wire [5:0] opcode, output reg reg_dst, alu_src, mem_to_reg, output reg reg_write, mem_read, mem_write, output reg branch, jump, output reg [1:0] alu_op ); always (*) begin // 默认值所有信号关断 reg_dst 0; alu_src 0; mem_to_reg 0; reg_write 0; mem_read 0; mem_write 0; branch 0; jump 0; alu_op 2b00; case (opcode) 6b000000: begin // R型: add等 reg_dst 1; reg_write 1; alu_op 2b10; end 6b001000: begin // addi alu_src 1; reg_write 1; alu_op 2b00; end 6b100011: begin // lw alu_src 1; mem_to_reg 1; reg_write 1; mem_read 1; alu_op 2b00; end 6b101011: begin // sw alu_src 1; mem_write 1; alu_op 2b00; end 6b000100: begin // beq branch 1; alu_op 2b01; end 6b000010: begin // jump jump 1; end default: ; endcase end endmodule逻辑说明always块开头先把所有信号拉低确保case没有覆盖的信号在综合时不会生成锁存器。参数说明addi、lw、sw的ALUOp都是00表示ALU只需要做加法因为这三条指令都要计算rs加立即数的地址beq的ALUOp是01表示ALU做减法比较两个寄存器R型的ALUOp是10具体做什么运算由funct字段在下一级译码器里决定。4.3 ALU控制译码器与PC更新逻辑ALU控制译码器接收主控制器的两位alu_op和R型指令的funct字段输出精确的4位alu_ctrl。r-type指令根据funct选运算lw/sw用加法beq用减法。module alu_ctrl_decoder( input wire [1:0] alu_op, input wire [5:0] funct, output reg [3:0] alu_ctrl ); always (*) begin case (alu_op) 2b00: alu_ctrl 4b0010; // 加法给lw/sw用 2b01: alu_ctrl 4b0110; // 减法给beq用 2b10: case (funct) 6b100000: alu_ctrl 4b0010; // add 6b100010: alu_ctrl 4b0110; // sub 6b100100: alu_ctrl 4b0000; // and 6b100101: alu_ctrl 4b0001; // or 6b101010: alu_ctrl 4b0111; // slt default: alu_ctrl 4b0010; endcase default: alu_ctrl 4b0010; endcase end endmodule逻辑说明这是两级译码结构主控制器只看opcodeALU控制器只看funct职责边界清楚。参数说明如果实验里还要加slti等指令只需在alu_op00的分支里加一条case。PC更新逻辑要把beq和jump都算进来。next_pc的优先级是jump最高其次是分支命中最后才是顺序执行。beq的目标是PC4加符号扩展左移两位后的偏移量jump的目标是PC高四位拼接指令低26位再左移两位。wire [31:0] pc_plus4 pc 32d4; wire [31:0] branch_target pc_plus4 imm_shift; assign next_pc jump ? {pc[31:28], inst[25:0], 2b00} : (branch zero) ? branch_target : pc_plus4;逻辑说明jump指令里的26位地址是字地址左移两位后与PC高四位拼接形成32位绝对地址。参数说明branch信号来自主控制器zero信号来自ALU输出只要beq且zero有效PC就改成branch_target。以这种写法仿真波形里jump和beq的行为能直接对齐MIPS规范。5. 单周期CPU调试避坑指南五个高频故障现场5.1 仿真波形明明对上板却全灭现象仿真工具里PC、寄存器、内存波形全部正确下载到开发板后LED不亮或者乱跳像完全没在跑。原因时钟没有通过专用时钟引脚进入设计综合工具把时钟信号当成了普通IO布线时钟到达各寄存器的时间差过大组合逻辑在同一个时钟沿前来不及稳定。这类问题在仿真里完全看不出来因为仿真工具的时钟是理想时钟。解决检查约束文件确认时钟输入绑定到开发板的晶振引脚并创建时钟约束。如果实验平台有时钟使能引脚也要一并检查。上板调试第一步永远是确认时钟进了全局时钟网络而不是先怀疑逻辑代码。5.2 分支跳转总是偏一个PC现象beq执行后应该跳到目标地址仿真里PC也确实变成了目标值但下一条执行的指令还是PC4那一条好像指令永远慢了一拍。原因如果把指令存储器做成了同步读即always(posedge clk) inst rom[addr]那么PC更新和指令读取发生在同一个时钟沿PC在沿后才能拿到新值当前周期看到的指令依然是旧PC对应的指令。这等于给取指环节凭空加了一级流水线延迟和单周期的设计目标冲突。解决把指令存储器改成异步读用assign直接把rom[addr]输出去。单周期CPU里的取指必须是组合逻辑行为PC一稳定指令立刻有效。5.3 lw读出的数据永远慢一拍现象lw把内存值写回寄存器后下一条指令读同寄存器得到的还是旧值再下一条指令才读到新值。原因数据存储器使用了同步读而单周期设计要求“访存”和“写回”在同一个周期内完成。同步读会让mem_rdata在时钟沿后才变化寄存器堆写回又依赖这个数据两个同步环节叠加后数据被延迟了一个周期。解决把数据存储器的读端口改成纯组合逻辑异步读。只要记住这个工程经验——单周期CPU中存储器的读永远用连续赋值写永远用时钟沿——就不会再犯这类错。5.4 寄存器写入像抽风RegDst选错了位现象add指令执行后目标寄存器不是预期的rd而是另一个编号用ModelSim单步跟踪时会发现寄存器堆写地址完全不对。原因R型指令的写地址应取rd字段也就是inst[15:11]但在顶层例化寄存器堆时把waddr接到了inst[20:16]即rt字段。add的rd和rt若不同结果就是写进了完全无关的寄存器。解决回到指令格式表逐位核对。rs是inst[25:21]rt是inst[20:16]rd是inst[15:11]shamt是inst[10:6]。建议在Verilog里用parameter给每个字段命名避免在接口例化时反复裸写数字位段。5.5 综合报告里出现锁存器上板行为玄学现象仿真一切正常但综合报告里出现latch上板后偶发错误每次下载结果还可能不同。原因主控制器的case语句没有覆盖default或者always块里某个信号没有被无条件赋值。比如RegWrite在case里只在高电平分支赋值另一条路径保持旧值综合工具就推断出锁存器。控制信号本应是纯组合逻辑一旦有记忆行为出来的行为就变成“玄学”。解决在always(*)块的开头为所有输出信号赋默认值case每条分支只写差异项。上面的main_control模块已经展示了这个写法这是综合不会出锁存器的标准范式。6. 从仿真到上板测例设计与流水线化起步6.1 测试程序怎么组织才靠谱验证单周期CPU时最直接的做法是写一段能覆盖全部六条指令的测试序列然后观察PC和寄存器值的变化。我常用的序列是addi $t0, $zero, 3 addi $t1, $zero, 4 add $s0, $t0, $t1 sw $s0, 0($zero) lw $t2, 0($zero) beq $t0, $t1, 8 jump 0这段程序先做加法再写入内存再读回来最后做分支和跳转。由于addi的立即数和lw/sw的偏移都是常数值手工转成十六进制机器码并不难但条数一多就很容易算错。常见做法是先写汇编文本再用交叉汇编器转成机器码没有工具时用一个脚本按字段拼装是最快的。# 伪代码示意按字段拼接指令 printf 0x%08X\n $(( (opcode 26) | (rs 21) | (rt 16) | (imm 0xFFFF) ))把生成的机器码按地址写入inst.hex仿真时观察点在PC上每一条指令执行后PC的跳变是否符合预期这就验证了控制器和PC更新逻辑。上板验证时把PC的低位接到LED上手动单步复位观察LED变化比看逻辑分析仪更直观。6.2 从单周期到流水线先把关键路径找出来单周期设计跑通后它的局限也很清楚关键路径从PC出发穿过指令存储器、寄存器堆、ALU、数据存储器再到寄存器堆写回所有环节都在一个周期内串行完成。主频上不去瓶颈就在这里。多周期设计的思路是把这条长路径切分到多个时钟周期每段结果存入中间寄存器流水线则是让多段路径并行工作每个时钟周期都有不同指令在不同阶段推进。我的习惯是把单周期CPU的六条指令测例先跑通再在顶层加入IF/ID、ID/EX、EX/MEM、MEM/WB四级中间寄存器观察每条指令经过各级时控制信号是否还保持一致。这一步能直观感受到为什么流水线的控制信号不再是一张真值表能搞定的事情。不要只把综合工具当黑匣子单周期CPU本身就是一根组合逻辑链你要做的是把链上的每个环节用自己的代码显式表达清楚。希望帮到你。本文还有配套的精品资源点击获取