单周期MIPS CPU设计指南:从数据通路到Verilog实现
2026/9/19 11:25:42 网站建设 项目流程

简介:一份华中科技大学类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、slt,I型的lw、sw、beq,再加上J型的j。这个组合不是随便挑的,它能让RegDst、ALUSrc、MemtoReg、Branch、Jump这几根控制线都有实际用武之地,而且覆盖了寄存器写回、内存读写、分支跳转三条主要数据路径。

指令子集对应的格式如下:

指令格式opcodefunct行为
addR000000100000rd = rs + rt
subR000000100010rd = rs - rt
andR000000100100rd = rs & rt
orR000000100101rd = rs | rt
sltR000000101010rd = (rs < rt) ? 1 : 0
lwI100011-rt = MEM[rs + imm]
swI101011-MEM[rs + imm] = rt
beqI000100-if (rs == rt) PC += 4 + (imm << 2)
jJ000010-PC = (PC+4)[31:28] : (addr << 2)

注意beq的跳转目标不是PC + imm << 2,而是PC + 4 + (imm << 2)。这是MIPS特有的“PC相对寻址”,因为beq在译码阶段拿到的是已经完成加4的PC值,这个偏移量以字为单位,所以在Verilog里要左移两位再参与加法。写实验报告时,这个公式值得单独标注出来,它是RTL实现和底层电路之间的一个关键映射。

J型指令的目标地址是{(PC+4)[31:28], address, 2'b00},也就是取当前PC+4的高4位拼接26位地址和两个0。这个拼接方式看起来奇怪,但它是固定长度的,不需要ALU参与计算,单周期里只需要一个多路选择器就能完成。

2.2 单周期数据通路:把8根关键连线走一遍

数据通路不画图很难讲清楚,但画图之前,先把主干连线的数据流向用文字说透:

  1. PC → 指令存储器:PC的输出作为地址,指令存储器异步读出当前指令。单周期CPU里PC在时钟上升沿更新,所以一个新的指令字在下一个上升沿到来之前一直保持稳定。

  2. 指令字拆分:拿到32位指令后,按MIPS规则直连:[25:21]是rs,[20:16]是rt,[15:11]是rd,[15:0]是立即数。这里不用做任何逻辑,就是总线宽度不同的连线分配。

  3. rs/rt → 寄存器堆:rs和rt作为读地址,读出两个32位数据。寄存器堆的读端口是组合逻辑,只要地址稳定,输出立即跟上,不依赖时钟。

  4. 立即数扩展 → ALU:lw/sw的立即数是符号扩展,beq也是符号扩展,j指令的26位地址不走这条路径。扩展后的32位值和rt寄存器的输出进ALU输入端的MUX,由ALUSrc控制选哪一路。

  5. ALU → 数据存储器/写回:lw/sw的访存地址是ALU的加法结果(rs + 扩展立即数),R型指令的结果则直接送到寄存器堆写数据口。这里注意MemtoReg控制的是写回寄存器堆的数据到底来自ALU还是来自数据存储器读出的值。

  6. 分支比较 → PC选择:beq不需要ALU输出作结果,但它要用ALU的减法结果判断是否相等。Zero信号拉高且Branch使能时,PC的输入切换到分支目标地址。

  7. 写寄存器地址选择:R型指令写rd,lw写rt,所以寄存器堆写地址端口前有一个RegDst控制的二选一MUX。这一步容易漏,少了它R型指令和lw会互相踩地址。

  8. j指令旁路:j指令不走ALU也不走分支逻辑,它直接通过Jump控制的多路选择器把PC输入切到拼接后的跳转地址,优先级高于Branch。

这八条链路里,最容易犯错的是第5条和第7条的配合:lw的写寄存器是rt,而R型是rd,如果你在做lw时把RegDst置为1,那么写入的寄存器地址会变成rd——但lw指令里rd字段其实和opcode的低位重叠,取值完全不相关,实验结果就是错得莫名其妙。这类问题在波形上很好认,但前提是你先知道正确答案应该是什么。

2.3 控制单元真值表:9根控制线的完整映射

控制单元是单周期CPU里最像“查表”的模块。把9根控制信号按指令列出,就是一张真值表:

指令RegDstALUSrcMemtoRegRegWriteMemReadMemWriteBranchJumpALUOp
R型1001000010
lw0111100000
sw01x0010000
beq00x0001001
jxxx00001xx

ALUOp是两位编码,ALU控制单元会根据它和funct字段算出真正的ALU选择信号。R型指令置10,ALU再根据funct区分add/sub/and/or/slt;lw/sw置00表示ALU做加法(计算访存地址);beq置01表示ALU做减法(比较两个寄存器)。

这张表建议背下来。不背也行,但你在调试时每次都要重新翻指令手册,效率会低很多。我一般会把这张表直接作为注释贴在控制单元的Verilog代码上方,仿真对波形时对照看,比看代码更直观。注意MemRead和MemWrite不会同时为1,sw的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 <= 32'h00000000; 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次之,默认PC+4 wire [31:0] pc_plus4 = pc_current + 32'd4; 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], 2'b00}; 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位,这是保证负数立即数在加法运算时语义正确的前提,如果你写成{16'b0, 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] <= 32'b0; end else if (wen && (addr3 != 5'b0)) begin regs[addr3] <= wdata; end end // 异步读:不依赖时钟,地址变化立即更新输出 assign rdata1 = regs[addr1]; assign rdata2 = regs[addr2]; endmodule

为什么写端口要加addr3 != 5'b0的判断?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 = 32'b0; end endmodule

注意addr[31:2]的处理。CPU的访存地址是字节地址,而存储阵列按字组织,所以访问内部数组前要把低两位去掉。很多初学调试半天发现lw读回来的数据总是乱的,不是存储器写坏了,是地址没有对齐到字边界。这里were同时为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) 2'b00: alu_ctrl = 4'b0010; // lw/sw:加法 2'b01: alu_ctrl = 4'b0110; // beq:减法 2'b10: begin case (funct) 6'b100000: alu_ctrl = 4'b0010; // add 6'b100010: alu_ctrl = 4'b0110; // sub 6'b100100: alu_ctrl = 4'b0000; // and 6'b100101: alu_ctrl = 4'b0001; // or 6'b101010: alu_ctrl = 4'b0111; // slt default: alu_ctrl = 4'bxxxx; endcase end default: alu_ctrl = 4'bxxxx; endcase end always @(*) begin case (alu_ctrl) 4'b0000: result = a & b; 4'b0001: result = a | b; 4'b0010: result = a + b; 4'b0110: result = a - b; 4'b0111: result = (a < b) ? 32'b1 : 32'b0; default: result = 32'b0; endcase zero = (result == 32'b0); end endmodule

二级译码是这个模块设计的核心思想:控制单元不直接输出4位ALU选择信号,而是输出2位ALUOp,由ALU再基于funct展开。这样做的好处是控制真值表更紧凑,也符合教材里的经典做法。default: alu_ctrl = 4'bxxxx看起来像是偷懒,但它能让仿真时未定义指令直接显示为X态,方便你一眼看出指令子集里是否混入了不支持的操作码。

slt的result只赋值32位的0或1,但注意比较是有符号还是无符号,这里按有符号处理才是最贴近MIPS语义的做法。如果你的实验要求sltu,那a < b要改成无符号比较,也就是{1'b0, a} < {1'b0, 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} = 11'b0; case (opcode) 6'b000000: begin // R型 reg_dst = 1'b1; reg_write = 1'b1; alu_op = 2'b10; end 6'b100011: begin // lw alu_src = 1'b1; mem_to_reg = 1'b1; reg_write = 1'b1; mem_read = 1'b1; alu_op = 2'b00; end 6'b101011: begin // sw alu_src = 1'b1; mem_write = 1'b1; alu_op = 2'b00; end 6'b000100: begin // beq branch = 1'b1; alu_op = 2'b01; end 6'b000010: begin // j jump = 1'b1; 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) # 从内存地址sp+4读数据到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 # 若t0==t2则跳转到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[6'h00] = 32'h8fa80004; imem_core[6'h01] = 32'h010a4020; imem_core[6'h02] = 32'h0008402a; imem_core[6'h03] = 32'h110a0002; imem_core[6'h04] = 32'h00000000; imem_core[6'h05] = 32'hafa80000; imem_core[6'h06] = 32'h08000007; end

这套序列覆盖了lw的访存与写回、R型加法、slt设置比较结果、beq分支命中与不命中两种路径、sw的写内存以及j跳转。跑完一遍以后你能确认的事:PC递增是否正常、分支和跳转目标计算是否正确、寄存器写使能与写地址选择是否对、数据存储器读写时序是否和预期一致。测试之前,先在纸上把每个寄存器的期望值写下来,仿真完了逐项对照,这一步省不了。

4.2 仿真时必看的四个检查点

波形拉出来以后,不要只盯着pc_current看。按时间顺序检查这几个位置:

  1. 第1个上升沿之前:复位信号有效时,寄存器堆全部为0,PC为0,control模块所有输出为0。如果复位还没拉高,指令存储器地址就开始跳变,说明复位逻辑或PC的异步复位没接对。

  2. lw执行的那个周期:在时钟上升沿来之前,alu_result应该已经是sp + 4的加法和,mem_data上是寄存器指定地址的内容。上升沿之后,regs[8](即t0)被写入新值。如果regs[8]要等到下一个周期才变化,说明寄存器堆的异步读被写成了同步读。

  3. beq命中周期:看到branch = 1zero = 1时,pc_next必须在当前周期就变成branch_target,而不是等这个周期结束后的下个沿才计算。如果pc_next变成了PC+4,检查分支目标计算里的pc_plus4是不是用了旧PC。

  4. j指令周期jump = 1pc_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位比较,或PC+4的偏移方向搞反在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]}写成{16'b0, instr[15:0]},负偏移的lw/sw就会取到奇奇怪怪的位置。

5. 上板调试:用ILA抓单周期CPU的真实周期

仿真通过后,把设计烧到FPGA开发板上才算真正结束实验。单周期CPU在板上的运行速度远远超过人眼能观察的尺度,直接看数码管或者LED基本只能确认“它在动”,无法确认“它动对了”。常见做法是先用ILA(Integrated Logic Analyzer)抓内部信号,把FPGA内部的真实波形拉出来和仿真对照。

在Vivado里使用ILA核,需要先例化一个ILA的IP,把要观测的信号接进去。最简单的方式是在综合后的网表上直接Mark Debug,把pc_debuginstr_debugalu_result_debugreg_data1reg_data2这几个信号标记为debug信号,然后在硬件管理器里设置触发条件为pc_debug == 32'h00000008,这样就能在PC到达某条指定指令时把前后若干个周期的波形抓下来。抓到的波形应该和仿真的波形形状一致,只是可能因为时钟频率不同而显得更密。如果上板波形和仿真对不上,优先检查时钟是否经过BUFG、复位是否是异步复位且极性正确。

另一个实用技巧是用拨码开关做单步控制。给PC的时钟使能加一个门控信号,拨一次开关产生一个单脉冲,PC才走一步,再配合数码管显示当前PC或ALU结果,就能逐条指令查看运行状态。这个验证方法特别适合排查beq和j跳转指令,因为逐条看时你能确定跳转发生后PC落点是否和汇编程序一致。注意门控时钟在高频设计里不推荐,但实验课的单周期CPU工作频率很低,这么做可接受的范围内。

最后一个建议:实验报告里放三张图就够——一张数据通路框图、一张仿真波形图(标注lw写回那一个沿)、一张ILA抓的板上波形图。数据通路框图要标注清楚每条多路选择器的选择端来自哪个控制信号,这是评分老师最想看到的东西;波形图要圈出关键时间点,而不是整个屏幕丢上去;ILA波形图和仿真波形放在一起对照着看,能直观证明“上板行为和仿真一致”。做到这三步,报告的信息量已经超过了大多数只贴代码和数据通路的作业,而你从“仿真能跑”到“板上能跑”的这个过程,也正是单周期MIPS设计里最有含金量的那一部分。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询