
简介一份华中科技大学类MIPS单周期微处理器设计实验报告面向计算机组成原理与体系结构课程学习者帮助读者从零构建并理解单周期CPU的完整数据通路与控制逻辑。报告覆盖指令存储器ROM、数据存储器RAM、32×32寄存器组、ALU及ALU控制译码等核心模块给出各模块接口定义与关键Verilog代码并针对$0寄存器恒零、复位清空、beq比较等细节展开说明。压缩包内为1个PDF文件大小901KB结构完整、可直接对照实验任务逐步实现。目前已有2154人学习下载适合正在完成同类MIPS实验、需要参考模块划分与代码思路的学生使用。1. 先把一条指令跑通才算摸到单周期MIPS的门在华中科技大学计算机组成原理的实验里和“类MIPS单周期微处理器”打交道的那个学期你大概率见过这样的场景Testbench里时钟已经跑了十几个周期PC也在一拍一拍地变可寄存器堆里被你设置了初始值的那几个寄存器就是纹丝不动。不是代码写错了是你根本没分清“这条指令什么时候被译码、什么时候被写回”这正是单周期CPU最容易懵的地方——它是组合逻辑和时序逻辑的一次大合流每一条MIPS指令在同一个时钟周期内完成取指、译码、执行、访存和写回。这篇博文把这类实验的标准做法拆开怎么设计指令子集、怎么搭数据通路、Verilog模块怎么写、仿真怎么验、上板怎么调按一套能直接照做的流程走一遍。适合正在写这个实验报告的你也适合想快速上手MIPS单周期CPU设计的新手工程师。2. 数据通路与MIPS指令子集先画图再写代码2.1 MIPS指令子集怎么选三类格式各挑两组单周期CPU不要求你实现全部MIPS指令集实验报告里通常只让选一小部分用它们把三类指令格式都覆盖到。常见做法是选8条R型的add、sub、and、or、sltI型的lw、sw、beq再加上J型的j。这个组合不是随便挑的它能让RegDst、ALUSrc、MemtoReg、Branch、Jump这几根控制线都有实际用武之地而且覆盖了寄存器写回、内存读写、分支跳转三条主要数据路径。指令子集对应的格式如下指令格式opcodefunct行为addR000000100000rd rs rtsubR000000100010rd rs - rtandR000000100100rd rs rtorR000000100101rd rs | rtsltR000000101010rd (rs rt) ? 1 : 0lwI100011-rt MEM[rs imm]swI101011-MEM[rs imm] rtbeqI000100-if (rs rt) PC 4 (imm 2)jJ000010-PC (PC4)[31:28] : (addr 2)注意beq的跳转目标不是PC imm 2而是PC 4 (imm 2)。这是MIPS特有的“PC相对寻址”因为beq在译码阶段拿到的是已经完成加4的PC值这个偏移量以字为单位所以在Verilog里要左移两位再参与加法。写实验报告时这个公式值得单独标注出来它是RTL实现和底层电路之间的一个关键映射。J型指令的目标地址是{(PC4)[31:28], address, 2b00}也就是取当前PC4的高4位拼接26位地址和两个0。这个拼接方式看起来奇怪但它是固定长度的不需要ALU参与计算单周期里只需要一个多路选择器就能完成。2.2 单周期数据通路把8根关键连线走一遍数据通路不画图很难讲清楚但画图之前先把主干连线的数据流向用文字说透PC → 指令存储器PC的输出作为地址指令存储器异步读出当前指令。单周期CPU里PC在时钟上升沿更新所以一个新的指令字在下一个上升沿到来之前一直保持稳定。指令字拆分拿到32位指令后按MIPS规则直连[25:21]是rs[20:16]是rt[15:11]是rd[15:0]是立即数。这里不用做任何逻辑就是总线宽度不同的连线分配。rs/rt → 寄存器堆rs和rt作为读地址读出两个32位数据。寄存器堆的读端口是组合逻辑只要地址稳定输出立即跟上不依赖时钟。立即数扩展 → ALUlw/sw的立即数是符号扩展beq也是符号扩展j指令的26位地址不走这条路径。扩展后的32位值和rt寄存器的输出进ALU输入端的MUX由ALUSrc控制选哪一路。ALU → 数据存储器/写回lw/sw的访存地址是ALU的加法结果rs 扩展立即数R型指令的结果则直接送到寄存器堆写数据口。这里注意MemtoReg控制的是写回寄存器堆的数据到底来自ALU还是来自数据存储器读出的值。分支比较 → PC选择beq不需要ALU输出作结果但它要用ALU的减法结果判断是否相等。Zero信号拉高且Branch使能时PC的输入切换到分支目标地址。写寄存器地址选择R型指令写rdlw写rt所以寄存器堆写地址端口前有一个RegDst控制的二选一MUX。这一步容易漏少了它R型指令和lw会互相踩地址。j指令旁路j指令不走ALU也不走分支逻辑它直接通过Jump控制的多路选择器把PC输入切到拼接后的跳转地址优先级高于Branch。这八条链路里最容易犯错的是第5条和第7条的配合lw的写寄存器是rt而R型是rd如果你在做lw时把RegDst置为1那么写入的寄存器地址会变成rd——但lw指令里rd字段其实和opcode的低位重叠取值完全不相关实验结果就是错得莫名其妙。这类问题在波形上很好认但前提是你先知道正确答案应该是什么。2.3 控制单元真值表9根控制线的完整映射控制单元是单周期CPU里最像“查表”的模块。把9根控制信号按指令列出就是一张真值表指令RegDstALUSrcMemtoRegRegWriteMemReadMemWriteBranchJumpALUOpR型1001000010lw0111100000sw01x0010000beq00x0001001jxxx00001xxALUOp是两位编码ALU控制单元会根据它和funct字段算出真正的ALU选择信号。R型指令置10ALU再根据funct区分add/sub/and/or/sltlw/sw置00表示ALU做加法计算访存地址beq置01表示ALU做减法比较两个寄存器。这张表建议背下来。不背也行但你在调试时每次都要重新翻指令手册效率会低很多。我一般会把这张表直接作为注释贴在控制单元的Verilog代码上方仿真对波形时对照看比看代码更直观。注意MemRead和MemWrite不会同时为1sw的MemtoReg虽然是无所谓的但为了仿真波形干净我建议给它赋0而不是x省得仿真器里出现红色的不定态。3. 用Verilog搭建单周期CPU五个模块从零拼装3.1 顶层模块与PC一个时钟沿干一件事单周期CPU的顶层模块不负责具体计算它只做一件事把PC、指令存储器、寄存器堆、ALU、数据存储器和控制单元的端口全部例化并连起来。下面是一段可以直接用的顶层例化框架module mips_single_cycle ( input wire clk, input wire rst_n, output wire [31:0] pc_debug, // 供仿真/ILA观测 output wire [31:0] instr_debug, // 当前指令字 output wire [31:0] alu_result_debug ); wire [31:0] pc_next, pc_current; wire [31:0] instr; wire [31:0] reg_data1, reg_data2; wire [31:0] alu_result, mem_data; wire [31:0] write_data; wire [4:0] write_reg; wire zero, reg_write, mem_read, mem_write; wire alu_src, mem_to_reg, reg_dst, branch, jump; wire [1:0] alu_op; // PC寄存器同步复位复位后从0x00000000开始取指 always (posedge clk or negedge rst_n) begin if (!rst_n) pc_current 32h00000000; else pc_current pc_next; end // 指令存储器异步读 inst_mem u_imem ( .addr(pc_current), .dout(instr) ); // 控制单元 control u_ctrl ( .opcode(instr[31:26]), .reg_dst(reg_dst), .alu_src(alu_src), .mem_to_reg(mem_to_reg), .reg_write(reg_write), .mem_read(mem_read), .mem_write(mem_write), .branch(branch), .jump(jump), .alu_op(alu_op) ); // 寄存器堆异步读同步写 regfile u_regfile ( .clk(clk), .rst_n(rst_n), .addr1(instr[25:21]), .addr2(instr[20:16]), .addr3(write_reg), .wdata(write_data), .wen(reg_write), .rdata1(reg_data1), .rdata2(reg_data2) ); // ALU及输入选择 alu u_alu ( .a(reg_data1), .b(alu_src ? imm_ext : reg_data2), .alu_op(alu_op), .funct(instr[5:0]), .result(alu_result), .zero(zero) ); // 数据存储器 data_mem u_dmem ( .clk(clk), .addr(alu_result), .wdata(reg_data2), .we(mem_write), .re(mem_read), .rdata(mem_data) ); // 写回寄存器地址与写回数据选择 assign write_reg reg_dst ? instr[15:11] : instr[20:16]; assign write_data mem_to_reg ? mem_data : alu_result; // PC_next生成jump优先级最高branch次之默认PC4 wire [31:0] pc_plus4 pc_current 32d4; wire [31:0] branch_target pc_plus4 ({{16{instr[15]}}, instr[15:0]} 2); wire [31:0] jump_target {pc_plus4[31:28], instr[25:0], 2b00}; assign pc_next jump ? jump_target : (branch zero) ? branch_target : pc_plus4; assign pc_debug pc_current; assign instr_debug instr; assign alu_result_debug alu_result; endmodule这段代码里有几个参数值得特意说明。符号扩展部分{{16{instr[15]}}, instr[15:0]}把16位立即数的最高位复制16份拼在高16位这是保证负数立即数在加法运算时语义正确的前提如果你写成{16b0, instr[15:0]}lw sw访问负偏移地址时就会寻址出错。分支目标里 2是字对齐beq的立即数表示的是“偏移几条指令”不是偏移几个字节。PC的复位值是0x00000000如果你的指令存储器用$readmemh加载数据文件里第一行对应的就是0号地址两者要匹配。3.2 寄存器堆与数据存储器异步读是单周期的灵魂寄存器堆在单周期CPU里必须做异步读否则一个周期内完不成“读出rs/rt → ALU算 → 写回”的完整链路。它的Verilog实现通常是这样的module regfile ( input wire clk, input wire rst_n, input wire [4:0] addr1, // rs input wire [4:0] addr2, // rt input wire [4:0] addr3, // rd/rt input wire [31:0] wdata, input wire wen, output reg [31:0] rdata1, output reg [31:0] rdata2 ); reg [31:0] regs [31:0]; integer i; // 复位时清空寄存器堆$0始终为0 always (posedge clk or negedge rst_n) begin if (!rst_n) begin for (i 0; i 32; i i 1) regs[i] 32b0; end else if (wen (addr3 ! 5b0)) begin regs[addr3] wdata; end end // 异步读不依赖时钟地址变化立即更新输出 assign rdata1 regs[addr1]; assign rdata2 regs[addr2]; endmodule为什么写端口要加addr3 ! 5b0的判断MIPS规范里0号寄存器恒为0无论是R型指令还是lw只要目标寄存器是$0写使能都不该真正改变它的值。有的实现里不做这个防护仿真时倒是看不出问题但如果写成一个不回写的设计等到上板调试时0号寄存器被污染程序跑飞了很难排查。寄存器堆的复位不是必须的但加上能让仿真从头开始的状态完全可控代价只是多一个for循环和几十个寄存器不亏。数据存储器的做法同样关键。单周期里常见的是同步写、异步读module data_mem ( input wire clk, input wire [31:0] addr, input wire [31:0] wdata, input wire we, input wire re, output reg [31:0] rdata ); reg [31:0] mem [0:255]; // 256字数据存储按字节寻址需要地址右移两位 always (posedge clk) begin if (we) mem[addr[31:2]] wdata; end // 异步读仿真时直接看到当前地址的数据 always (*) begin if (re) rdata mem[addr[31:2]]; else rdata 32b0; end endmodule注意addr[31:2]的处理。CPU的访存地址是字节地址而存储阵列按字组织所以访问内部数组前要把低两位去掉。很多初学调试半天发现lw读回来的数据总是乱的不是存储器写坏了是地址没有对齐到字边界。这里we和re同时为1时不写保护但上面的控制信号真值表保证了lw和sw不会同时触发读写所以实际不需要担心。3.3 ALU与指令译码器从opcode和funct算出真正的运算ALU本身不复杂关键在它接收的alu_op是两位的粗粒度控制信号必须再结合funct字段才能确定具体运算。这是一段紧凑的ALU实现module alu ( input wire [31:0] a, b, input wire [1:0] alu_op, // 来自控制单元 input wire [5:0] funct, // 来自指令的低6位 output reg [31:0] result, output reg zero ); reg [3:0] alu_ctrl; // 二级译码ALUOp为10时根据funct决定否则直接映射 always (*) begin case (alu_op) 2b00: alu_ctrl 4b0010; // lw/sw加法 2b01: alu_ctrl 4b0110; // beq减法 2b10: begin 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 4bxxxx; endcase end default: alu_ctrl 4bxxxx; endcase end 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) ? 32b1 : 32b0; default: result 32b0; endcase zero (result 32b0); end endmodule二级译码是这个模块设计的核心思想控制单元不直接输出4位ALU选择信号而是输出2位ALUOp由ALU再基于funct展开。这样做的好处是控制真值表更紧凑也符合教材里的经典做法。default: alu_ctrl 4bxxxx看起来像是偷懒但它能让仿真时未定义指令直接显示为X态方便你一眼看出指令子集里是否混入了不支持的操作码。slt的result只赋值32位的0或1但注意比较是有符号还是无符号这里按有符号处理才是最贴近MIPS语义的做法。如果你的实验要求sltu那a b要改成无符号比较也就是{1b0, a} {1b0, b}别把符号扩展的负数立即数和无符号比较混在一起用。3.4 控制单元用case语句把真值表翻译成硬件控制单元的Verilog实现就是2.3节真值表的直接翻译。用一个always块根据opcode输出全部控制信号module control ( input wire [5:0] opcode, output reg reg_dst, output reg alu_src, output reg mem_to_reg, output reg reg_write, output reg mem_read, output reg mem_write, output reg branch, output reg jump, output reg [1:0] alu_op ); always (*) begin // 先给默认值避免case分支遗漏时产生锁存器 {reg_dst, alu_src, mem_to_reg, reg_write, mem_read, mem_write, branch, jump, alu_op} 11b0; case (opcode) 6b000000: begin // R型 reg_dst 1b1; reg_write 1b1; alu_op 2b10; end 6b100011: begin // lw alu_src 1b1; mem_to_reg 1b1; reg_write 1b1; mem_read 1b1; alu_op 2b00; end 6b101011: begin // sw alu_src 1b1; mem_write 1b1; alu_op 2b00; end 6b000100: begin // beq branch 1b1; alu_op 2b01; end 6b000010: begin // j jump 1b1; end endcase end endmodule这段代码的关键点是给所有信号先赋默认值0再在具体case分支里覆盖。如果你漏掉这一步case没有覆盖到的指令会让某些信号保持原值而组合逻辑的always块在没有else的情况下会被综合成锁存器Latch这在单周期CPU里是灾难——锁存器的输出既不是0也不是1而是“保持”导致某条指令执行完后下一跳指令的控制信号残留。这个坑在仿真里往往不报错上板后行为随机特别难查。4. 仿真验证从“波形在动”到“结果是对的”4.1 手工构造指令序列用有限指令覆盖全部数据通路仿真一个单周期CPU最忌讳一上来就跑一大段排序程序那样跑挂了也定位不了是哪条指令的问题。我习惯先用手工构造的8~10条指令每一条都保证能验证一条独立的数据通路。下面这一段是典型的覆盖性测试序列可以直接放到指令存储器的初始化文件里# 地址 指令(hex) 含义 # 0x00 8fa80004 lw $t0, 4($sp) # 从内存地址sp4读数据到t0 # 0x04 010a4020 add $t0, $t0, $t2 # t0 t0 t2验证R型写回 # 0x08 0008402a slt $t0, $zero, $t0 # t0 (0 t0) ? 1 : 0 # 0x0c 110a0002 beq $t0, $t2, 8 # 若t0t2则跳转到0x14 # 0x10 00000000 nop # 0x14 afa80000 sw $t0, 0($sp) # 把t0写回内存 # 0x18 08000007 j 0x1c # 无条件跳转到0x1c对应Verilog仿真里的指令存储器初始化可以这样写reg [31:0] imem_core [0:31]; initial begin // 用PC的高位取模作为地址便于观察 imem_core[6h00] 32h8fa80004; imem_core[6h01] 32h010a4020; imem_core[6h02] 32h0008402a; imem_core[6h03] 32h110a0002; imem_core[6h04] 32h00000000; imem_core[6h05] 32hafa80000; imem_core[6h06] 32h08000007; end这套序列覆盖了lw的访存与写回、R型加法、slt设置比较结果、beq分支命中与不命中两种路径、sw的写内存以及j跳转。跑完一遍以后你能确认的事PC递增是否正常、分支和跳转目标计算是否正确、寄存器写使能与写地址选择是否对、数据存储器读写时序是否和预期一致。测试之前先在纸上把每个寄存器的期望值写下来仿真完了逐项对照这一步省不了。4.2 仿真时必看的四个检查点波形拉出来以后不要只盯着pc_current看。按时间顺序检查这几个位置第1个上升沿之前复位信号有效时寄存器堆全部为0PC为0control模块所有输出为0。如果复位还没拉高指令存储器地址就开始跳变说明复位逻辑或PC的异步复位没接对。lw执行的那个周期在时钟上升沿来之前alu_result应该已经是sp 4的加法和mem_data上是寄存器指定地址的内容。上升沿之后regs[8]即t0被写入新值。如果regs[8]要等到下一个周期才变化说明寄存器堆的异步读被写成了同步读。beq命中周期看到branch 1且zero 1时pc_next必须在当前周期就变成branch_target而不是等这个周期结束后的下个沿才计算。如果pc_next变成了PC4检查分支目标计算里的pc_plus4是不是用了旧PC。j指令周期jump 1时pc_next直接是跳转目标不经过ALU也不关注zero。如果pc_next被Branch优先级盖过检查顶层模块的assign优先级。四个检查点全部通过说明你的单周期CPU在仿真层面已经能完整执行指令序列了。但注意“能跑指令”不等于“字面上正确”比如lw的mem_read为1时数据存储器的输出如果一直没有变化需要回到3.2节的实现里确认re信号的接法。4.3 波形里的三类典型错误“错一拍”“错一值”“错一地址”现象直接原因排查手段所有R型指令结果都晚一个周期写入寄存器堆的写端口接了非阻塞赋值但仿真里读到了旧值确认寄存器堆是否同步写在写使能有效周期拉高观察rdata是否立即更新lw写入寄存器的值总是差4数据存储器按字节地址索引内部数组却按字索引检查mem[addr[31:2]]的地址切片sw/lw的低两位必须去掉beq永远不跳转或永远跳转Zero信号没有做32位比较或PC4的偏移方向搞反在ALU输出端加$display观察a、b、result再对照imem里的指令字程序跑着跑着PC乱跳j指令的拼接位pc_plus4[31:28]取了旧值检查jump_target拼接的到底是pc_current还是pc_plus4错一拍的根源大多是寄存器堆的写时序。单周期里所有写操作都发生在时钟上升沿但读操作是异步组合逻辑。你如果图省事把读端口也写成always (posedge clk)那仿真的波形会晚一个周期才反映真实值。错一值的典型是slt的无符号/有符号没分清。错一地址则几乎都出在立即数扩展和字节寻址上把{{16{instr[15]}}, instr[15:0]}写成{16b0, instr[15:0]}负偏移的lw/sw就会取到奇奇怪怪的位置。5. 上板调试用ILA抓单周期CPU的真实周期仿真通过后把设计烧到FPGA开发板上才算真正结束实验。单周期CPU在板上的运行速度远远超过人眼能观察的尺度直接看数码管或者LED基本只能确认“它在动”无法确认“它动对了”。常见做法是先用ILAIntegrated Logic Analyzer抓内部信号把FPGA内部的真实波形拉出来和仿真对照。在Vivado里使用ILA核需要先例化一个ILA的IP把要观测的信号接进去。最简单的方式是在综合后的网表上直接Mark Debug把pc_debug、instr_debug、alu_result_debug、reg_data1、reg_data2这几个信号标记为debug信号然后在硬件管理器里设置触发条件为pc_debug 32h00000008这样就能在PC到达某条指定指令时把前后若干个周期的波形抓下来。抓到的波形应该和仿真的波形形状一致只是可能因为时钟频率不同而显得更密。如果上板波形和仿真对不上优先检查时钟是否经过BUFG、复位是否是异步复位且极性正确。另一个实用技巧是用拨码开关做单步控制。给PC的时钟使能加一个门控信号拨一次开关产生一个单脉冲PC才走一步再配合数码管显示当前PC或ALU结果就能逐条指令查看运行状态。这个验证方法特别适合排查beq和j跳转指令因为逐条看时你能确定跳转发生后PC落点是否和汇编程序一致。注意门控时钟在高频设计里不推荐但实验课的单周期CPU工作频率很低这么做可接受的范围内。最后一个建议实验报告里放三张图就够——一张数据通路框图、一张仿真波形图标注lw写回那一个沿、一张ILA抓的板上波形图。数据通路框图要标注清楚每条多路选择器的选择端来自哪个控制信号这是评分老师最想看到的东西波形图要圈出关键时间点而不是整个屏幕丢上去ILA波形图和仿真波形放在一起对照着看能直观证明“上板行为和仿真一致”。做到这三步报告的信息量已经超过了大多数只贴代码和数据通路的作业而你从“仿真能跑”到“板上能跑”的这个过程也正是单周期MIPS设计里最有含金量的那一部分。本文还有配套的精品资源点击获取