☰
MIPS多周期CPU设计:54条指令Verilog资源与状态机调试指南
2026/10/14 18:25:06 网站建设 项目流程

简介:面向计算机组成原理课程的多周期CPU设计项目,基于MIPS指令集实现54条指令,覆盖算术逻辑运算、分支跳转、内存访问等核心功能,并支持在Xilinx N4开发板上完成综合与验证,适合本科生与硬件爱好者学习处理器工作原理及数字逻辑设计流程。压缩包共包含539个文件,大小约26.94MB,其中以Verilog和VHDL硬件描述源码为主,同时收录Vivado工程配置、引脚约束、存储器初始化文件、仿真测试脚本和说明文档,目录结构完整,便于按模块阅读和复用。已有908人浏览学习。资源内容涵盖指令格式分析、控制单元设计、数据通路搭建、时序仿真及板上调试,并配有测试程序与激励向量,可逐条验证54条指令;其中的工程日志和调试记录也有助于快速定位问题,适合课程设计、项目复现与自学参考。

1. MIPS 54条多周期CPU:课程设计难度线上一套能跑通全流程的资源

某开发者拆过几套计组课设资源后发现,真正卡住人的往往不是算法原理,而是配套不齐:有图没代码,有代码没约束文件,有测试程序但没人讲清楚状态机怎么走。这套MIPS 54条多周期CPU设计资源把缺口补上了,覆盖Verilog源码、数据通路图、控制信号表、测试汇编指令和N4开发板约束文件,指令集规模比网上常见的32条版多了乘除、寄存器转移和系统调用,难度正好压在计算机组成原理课程设计的合理上限。适合正在做计组课设的本科生,也适合想把手写多周期CPU改造成流水线的开发者用来对照和复现。

2. 多周期CPU设计框架:为什么是54条指令,为什么分5个周期

2.1 多周期和单周期怎么选

单周期CPU的一条指令在一个时钟周期内完成,时钟周期必须能容纳最慢指令。比如LW要依次访问寄存器堆、ALU、数据存储器,全部组合逻辑在一个周期内跑完,路径长、频率上不去。多周期CPU把一条指令切成取指、译码、执行、访存、写回五个步骤,每步占一个时钟周期,硬件模块在每个周期只做一件事:取指周期只算PC+4和读指令存储器,执行周期才用ALU。于是时钟周期可以压低,同一个加法器在取指周期算PC+4,在执行周期算地址,硬件复用率也上来了。

选择多周期而不是单周期,直接原因有两个。一是硬件复用,比如ALU既承担PC+4的增量运算,又承担R型指令的算术逻辑运算,单周期架构则要额外加法器。二是控制逻辑的可观测性好,状态机每到一个状态,对应的控制信号是固定的,这对逐条指令仿真和调试很友好。代价是每条指令周期数不同,R型4个周期、LW 5个周期、SW 4个周期、分支3个周期,吞吐率不如流水线,但课程设计的核心目标是把原理讲清楚,逻辑清晰比吞吐优先。

2.2 54条指令的构成与译码要点

这套资源里的54条指令,按编码格式分成四类,具体分布如下表。

类别指令数量
R型算术逻辑ADD ADDU SUB SUBU AND OR XOR NOR SLT SLTU10
R型移位SLL SRL SRA SLLV SRLV SRAV6
R型乘除与转移MULT MULTU DIV DIVU MFHI MFLO MTHI MTLO JR9
I型访存LW SW LB LBU LH LHU SB SH8
I型立即数ADDI ADDIU ANDI ORI XORI LUI SLTI SLTIU8
I型分支BEQ BNE BLEZ BGTZ BLTZ BGEZ6
J型跳转J JAL2
特殊指令SYSCALL BREAK BGEZAL BLTZAL NOP5

R型译码要看opcode和funct两个字段:opcode等于6’b000000时进入R型译码,再由funct[5:0]决定具体操作。I型指令直接由opcode区分,访存、立即数算术和分支三类分得开;分支里BLTZ和BGEZ共用一个opcode(6’b000001),靠rt字段[4:0]是5’b00000还是5’b00001区分,这个细节在写控制状态机时最容易漏。

乘除类指令的译码比较特殊:MULT和DIV只负责把结果写进内部HI/LO寄存器,不写通用寄存器;MFHI/MFLO负责把HI/LO读出来写回通用寄存器。这意味着控制状态机的写回使能RegWrite信号,对MFHI/MFLO是1,对MULT/DIV是0,不能一刀切按R型处理。JR指令同样特殊,它的跳转目标在rs寄存器里,和普通分支用ALU算偏移的做法不同,PCSource需要单独给一路寄存器跳转。

2.3 顶层模块划分

顶层把一个CPU拆成七个模块,和经典多周期数据通路一一对应。

模块职责关键输出
pc程序计数器,保存当前指令地址pc_out
imem指令存储器,按PC读指令instruction
ir指令寄存器,锁存当前指令opcode rt rs rd funct imm
regfile32个通用寄存器read_a read_b
alu算术逻辑运算alu_out zero
dmem数据存储器read_data
controller主状态机与控制信号生成所有控制信号

数据通路连接起来就是一条主线:PC送imem取指,指令存入ir,译码后regfile读操作数,ALU算结果,访存指令走dmem,最后按MemToReg选择写回数据,经RegWrite写回regfile;PC的新值由PCSource选择,取PC+4、分支目标还是J型跳转地址。

module mips_cpu_top( input wire clk, rst_n, output wire [31:0] pc_out ); wire [31:0] inst, ir_out; wire pc_write, ir_write, reg_write, mem_write; wire [1:0] alu_sel_a, alu_sel_b, pc_source, mem_to_reg; // 例化 pc、imem、ir、regfile、alu、dmem、controller // 控制信号由 controller 输出,逐根连到数据通路 endmodule

这个层级划分是后面改动的基础:想增加指令,只需要动controller状态机;想改成流水线,把数据通路中间节点换成流水线寄存器。第2章代码块里的参数是32位数据和5位寄存器地址,对应MIPS32基础架构;imem的深度根据程序大小调整,常见配置是1024条指令,宽度固定32位。

3. 数据通路实现:寄存器堆、ALU与存储器的读写时序

3.1 寄存器堆RegFile

MIPS寄存器堆共32个寄存器,每个32位。关键点是读端口作为纯组合逻辑不依赖时钟,写端口在时钟上升沿更新。这样设计的意义在于:EXECUTE周期算出的结果,在WRITEBACK周期末尾的时钟沿写入寄存器,下一个FETCH周期又能组合读出,恰好满足多周期CPU的数据流节奏。

module regfile( input wire clk, input wire reg_write, input wire [4:0] addr_a, // rs input wire [4:0] addr_b, // rt input wire [4:0] addr_w, // rd或rt input wire [31:0] write_data, output wire [31:0] read_a, output wire [31:0] read_b ); reg [31:0] regs [0:31]; assign read_a = regs[addr_a]; assign read_b = regs[addr_b]; always @(posedge clk) begin if (reg_write) regs[addr_w] <= write_data; end endmodule

容易踩的点有三个。第一,addr_a和addr_b是组合逻辑读,不需要在时钟边沿采样,仿真时可以随时看信号变化;第二,写使能reg_write只在WRITEBACK状态是1,其他状态都是0;第三,寄存器0固定为0,初始化时可以在regs[0]里写0,也可以在写地址等于0时强制不写。设计里addr_w的选择靠RegDst信号:R型指令选rd,I型指令选rt,JAL固定选31。

3.2 ALU模块

ALU是多周期CPU的核心执行单元,控制信号alu_op取4位比较稳,既能覆盖常见算术逻辑操作,也给扩展比较指令留空间。这里需要说明一个常见误解:指令的funct字段不能直接接到ALU的alu_op,因为I型指令没有funct,ADDI要复用加法编码。实际方案是controller输出2位的ALUOp,再经过一个ALU控制解码器,结合funct转成4位alu_op。

module alu( input wire [31:0] a, b, input wire [3:0] alu_op, output reg [31:0] alu_out, output reg zero ); always @(*) begin case (alu_op) 4'b0010: alu_out = a + b; 4'b0110: alu_out = a - b; 4'b0000: alu_out = a & b; 4'b0001: alu_out = a | b; 4'b1100: alu_out = ~(a | b); 4'b1110: alu_out = (a < b) ? 32'b1 : 32'b0; 4'b0111: alu_out = a ^ b; default: alu_out = 32'b0; endcase end always @(*) zero = (alu_out == 32'b0); endmodule

写R型指令时,ADD、ADDU都映射到4’b0010,SUB、SUBU映射到4’b0110,SLT用4’b1110,zero信号只在分支比较时用。I型指令里的ADDI、ADDIU也走4’b0010,ANDI走4’b0000,ORI走4’b0001,XORI走4’b0111,但要注意这些指令的立即数要先做零扩展或符号扩展,扩展逻辑放在ID阶段的立即数扩展单元里,不放在ALU内部。

移位指令不走ALU的算术通道。SLL、SRL、SRA在数据通路上单独接一个桶形移位器,移位量来自shamt字段,这样ALU专心做算术和逻辑比较,避免控制信号位宽越写越乱。移位结果同样在EXECUTE周期算好,WRITEBACK周期写回寄存器,时序和R型算术指令完全一致,只是数据来源换成移位器输出。

3.3 数据存储器与字节访存

数据存储器按字组织,32位宽。写操作在时钟上升沿生效,读操作是组合逻辑输出,访存指令在MEM_ACCESS周期拿到数据。注意SW的写数据来自rt寄存器,不是ALU结果,这一步接错会导致内存数据全部错乱。

module dmem( input wire clk, input wire mem_write, input wire [31:0] addr, input wire [31:0] write_data, output wire [31:0] read_data ); reg [31:0] mem [0:1023]; wire [9:0] word_index = addr[11:2]; // 字对齐,低2位是字节偏移 assign read_data = mem[word_index]; always @(posedge clk) begin if (mem_write) mem[word_index] <= write_data; end endmodule

访存指令里LW、SW按字对齐,addr[1:0]为0;LB、LBU要取字节,常见做法是读出整个32位字后,由一个字节选择器根据addr[1:0]提取对应字节,再做符号扩展或零扩展。LH、LHU同理取半字。这个选择逻辑放在dmem输出和寄存器堆写数据之间,控制信号由opcode的访存类型决定。

指令存储器和数据存储器分开,程序和数据互不干扰。imem的读地址接PC,输出直连ir寄存器的输入,取指周期结束时在时钟上升沿把指令锁存进ir。如果程序量超过1024条,把imem的深度改成4096,PC位宽和地址索引需要同步扩展。还有一个资源包里的细节:dmem的word_index用addr[11:2]意味着地址空间被限制到4KB,对课程设计足够,但如果你想跑更大的测试程序,把索引位宽改成addr[31:2]并扩大mem数组即可,控制逻辑不用动。

4. 控制单元状态机:把每条指令变成微操作序列

4.1 状态划分与周期归属

多周期CPU的控制核心是Moore型状态机:状态寄存器保存当前状态,组合逻辑根据当前状态和指令字段决定控制信号。资源包把流程分成五个状态,每种指令只走完整路径中需要的部分。

状态编码该周期做的事
FETCH3'b000取指,PC+4
DECODE3'b001读寄存器堆,立即数扩展
EXECUTE3'b010ALU计算,地址计算,分支判断
MEM_ACCESS3'b011读写数据存储器
WRITEBACK3'b100写回结果到寄存器堆

R型指令走FETCH、DECODE、EXECUTE、WRITEBACK,共4个周期;SW走FETCH、DECODE、EXECUTE、MEM_ACCESS,不写回,4个周期;LW全走5个周期。分支BEQ、BNE等在EXECUTE阶段比较完就直接回FETCH,3个周期。J在EXECUTE周期算好目标地址直接回FETCH,JAL需要多一个WRITEBACK周期把PC+4写入ra寄存器。这些周期差异就是多周期相对单周期的核心优势,不会被最慢指令拖垮整体时钟频率。

4.2 主状态机Verilog实现

module controller( input wire clk, rst_n, input wire [5:0] opcode, funct, output reg pc_write, ir_write, reg_write, mem_write, output reg [1:0] alu_sel_a, alu_sel_b, output reg [1:0] pc_source, mem_to_reg, output reg [1:0] alu_op, output reg reg_dst ); localparam FETCH=3'd0, DECODE=3'd1, EXECUTE=3'd2; localparam MEM_ACCESS=3'd3, WRITEBACK=3'd4; reg [2:0] state, next_state; wire is_lw = (opcode == 6'b100011); wire is_sw = (opcode == 6'b101011); wire is_branch = (opcode == 6'b000100) || (opcode == 6'b000101) || (opcode == 6'b000110) || (opcode == 6'b000111) || (opcode == 6'b000001); // 含BLTZ/BGEZ族 wire is_rtype = (opcode == 6'b000000); always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= FETCH; else state <= next_state; end always @(*) begin next_state = FETCH; case (state) FETCH: next_state = DECODE; DECODE: next_state = EXECUTE; EXECUTE: begin if (is_lw || is_sw) next_state = MEM_ACCESS; else if (is_branch || opcode == 6'b000010) // J next_state = FETCH; else next_state = WRITEBACK; // R型、JAL、SYSCALL等 end MEM_ACCESS: next_state = (is_lw) ? WRITEBACK : FETCH; WRITEBACK: next_state = FETCH; endcase end endmodule

状态机里最危险的是默认分支next_state = FETCH。如果新加一条指令但忘记在EXECUTE分支里处理,CPU不会报错,而是直接回FETCH,仿真看起来像指令被吞掉。写完状态机后,要对着指令清单逐条核对每条指令走了哪个路径。示例代码把SYSCALL归到WRITEBACK,完整资源包里SYSCALL一般是停机指令,会进入一个HALT状态不再取指,教学版简化后直接回FETCH也能跑通。

控制信号里还有一个关键点:pc_write在FETCH周期为1,跳转指令在EXECUTE周期也要为1。因为分支目标在EXECUTE才计算出来,如果PCWrite只有FETCH有效,分支永远跳不出去。JAL的RegWrite在WRITEBACK有效,且MemToReg要选PC+4这一路,这两个信号在4.3的连接表里落实到具体位。

4.3 控制信号与数据通路的连接

控制信号生成后,和数据通路的连接决定了处理器行为。比如PC新地址的PCSource有三路选择:PC+4、分支目标(ALU算出的地址)、寄存器跳转目标(JR的rs值)。ALU的输入选择也要细分。

控制信号值含义
alu_sel_a2'b00A输入端为PC
alu_sel_a2'b01A输入端为寄存器A读出
alu_sel_b2'b00B输入端为寄存器B读出
alu_sel_b2'b01B输入端为常量4
alu_sel_b2'b10B输入端为立即数扩展值
pc_source2'b00PC <- PC+4
pc_source2'b01PC <- 分支目标地址
pc_source2'b10PC <- 寄存器rs值
mem_to_reg2'b00写回数据来自ALU
mem_to_reg2'b01写回数据来自数据存储器
mem_to_reg2'b10写回数据来自PC+4(JAL)

把控制信号映射到数据通路时注意:分支指令的ALU输入A来自寄存器rs,B来自立即数扩展,所以alu_sel_a和alu_sel_b要和取指状态区分开。取指状态A端选PC、B端选常量4,ALU做加法得出PC+4;执行状态A端选寄存器、B端选立即数,ALU做加法得出访存地址或分支目标;R型执行时A、B都选寄存器。同一块ALU在不同周期做不同操作,这就是多周期复用的精髓。

5. 仿真验证与上板调试:常见问题与避坑清单

5.1 仿真环境怎么搭

先在仿真器里跑testbench。用时钟和复位激励把顶层CPU例化进去,跑完一段测试程序后观察regfile、dmem、PC的信号变化。

`timescale 1ns / 1ps module tb_mips; reg clk, rst_n; wire [31:0] pc; always #5 clk = ~clk; initial begin clk = 0; rst_n = 0; #20 rst_n = 1; #1000 $finish; end mips_cpu_top u0(.clk(clk), .rst_n(rst_n), .pc_out(pc)); endmodule

testbench的关键是做初始化:复位保持至少20ns,让状态机回到FETCH且PC清零;时钟周期10ns对应100MHz,如果上板时分频了,仿真时要同步改clk_period参数。汇编测试程序先翻译成hex或coe格式,再通过$readmemh加载到imem。比较稳妥的做法是用绝对路径加载文件,相对路径在仿真工具的工程迁移里经常失效。

5.2 现象:仿真波形全是X态

新导入工程第一次跑仿真,整个数据通路上一片X是高频翻车现场。

现象:pc_out和指令总线都是X,regfile输出也是X,state状态显示xxx。 原因:多数情况是复位信号没拉到有效电平,或时钟没接到CPU顶层;少数情况是imem初始化文件路径写错,读出来的内存数据是X。 解决:先看复位和时钟波形是否正常;再确认imem的readmemh文件路径写全;最后在testbench里把顶层模块的每个输出接到probe信号,逐级追到第一个出现X的模块。

5.3 现象:上板后开发板无反应

仿真正确不代表板子一定跑,上板失败是另一个黑匣子。

现象:bitstream下载成功,但LED不亮、数码管不显示,按复位也没反应。 原因:约束文件管脚映射没写或写错,比如时钟pin没接板载时钟源;复位按键极性反了,开发板按键按下是低电平,而CPU复位需要高电平。 解决:先核对约束文件里clk引脚是否来自板载时钟,rst按键极性是否匹配。把PC的低8位接到八个LED,跑一段每周期加一的指令,观察LED是否跳变。还不行就做分频,用计数器把100MHz降到10MHz再跑,排除频率过高的时序问题。

5.4 现象:分支指令和跳转指令PC错乱

这是一个很容易被忽略的时序问题:BEQ跳转目标总是偏了一个周期,或者直接顺序执行。

现象:仿真里PC应该在分支满足时跳到目标地址,实际却多加了4,或者停在原地。 原因:分支比较结果在EXECUTE周期才计算出来,但PCWrite只在FETCH周期有效,EXECUTE周期算出的分支目标没有在时钟沿写进PC。另一个常见原因是PCSource在EXECUTE周期没有切到分支目标那一路。 解决:把PCWrite的控制改成组合逻辑条件,在EXECUTE周期若分支满足或JR执行则拉高pc_write,同时把pc_source选到分支或寄存器跳转路径。仿真里对比PC变化波形,重点观察EXECUTE周期末的时钟上升沿PC是否按预期更新。

5.5 现象:MULT/DIV结果读出来是0

乘除法指令在课设里出问题最多,典型现象是MFHI/MFLO读回全0。

现象:执行完MULT后MFLO读出0,测试程序里乘法结果全是0。 原因:MULT结果没有在EXECUTE周期锁存到HI/LO寄存器;或者controller把MULT当成普通R型指令,RegWrite在WRITEBACK周期把ALU结果写回寄存器,而ALU又不做乘法。 解决:在数据通路上增加HI、LO两个内部寄存器,乘法器输出在EXECUTE周期结束时写入HI/LO。MFHI/MFLO执行时,RegWrite在WRITEBACK周期有效,写回数据来自HI/LO而不是ALU。对照顶层模块的mem_to_reg选择器,给HI/LO单独分一个数据来源编码。

5.6 板级验证的约束与时钟

上板前最后一关是约束文件。N4开发板外设引脚映射要按板卡原理图核对,下面是通用写法:

set_property PACKAGE_PIN E3 [get_ports clk] set_property IOSTANDARD LVCMOS33 [get_ports clk] set_property PACKAGE_PIN C4 [get_ports rst_n] set_property IOSTANDARD LVCMOS33 [get_ports rst_n]

注意引脚号因开发板批次和型号而异,别直接照抄到其他板子。时钟用板载100MHz晶振,多周期CPU的组合逻辑路径不长,100MHz一般能过时序,但仿真过了上板却随机出错时,先用计数器分频到10MHz做稳定性测试。复位按键的极性也因板卡而异,有的按键按下拉低,有的按下拉高,测试程序里可以用LED显示复位状态来确认极性。

6. 进阶用法:把多周期资源改造成流水线,以及自检序列验证

6.1 从多周期到5级流水线

多周期CPU的状态机和流水线CPU的五个阶段几乎一一对应,因此它是最合适的流水线跳板。改成流水线时,把状态机的五个状态替换成五个流水级,中间插入IF/ID、ID/EX、EX/MEM、MEM/WB四级流水寄存器。第一版流水线不用做转发,先让所有冲突用stall解决,数据通路和控制逻辑基本不用动,只需要把PCWrite、IRWrite这些使能信号改成每周期有效,并把算好的结果写进流水寄存器。

跑通后再做数据转发。一个控制细节:多周期里各状态的控制信号在对应周期生效,改流水后要把控制信号打进流水寄存器,和指令一起流到后级,不能直接用当前周期的state决定RegWrite。这一步改完,多周期资源就算吃透了。

6.2 自检指令序列的写法

无论多周期还是流水线,验证都要靠一段能覆盖全部54条指令的汇编程序。写一个初始化寄存器到特定值、算出已知结果并用BEQ检查的子程序循环。

# 自检核心片段 addi $t0, $zero, 0x10 addi $t1, $zero, 0x20 add $t2, $t0, $t1 beq $t2, $t1, next1 nop next1: sw $t2, 0($zero) lw $t3, 0($zero) sub $t4, $t3, $t0 beq $t4, $t1, next2 nop next2: mult $t0, $t1 mfhi $s0 mflo $s1

代码逻辑是逐段构造已知结果:先验证ADD的结果等于预期,再通过SW/LW验证存储器和访存通路,再用SUB结果反推验证ALU减法。mult后面接mfhi/mflo,确认HI/LO寄存器锁存了乘法结果。BGEZ、BLEZ、JAL这些指令可以分别在后续段里加入,每加一条把仿真波形里PC的变化和预期对照一次。

自检序列跑通后,把PC低位接到LED,数码管显示状态值,按一次复位从头跑,把LED变化或者数码管显示和仿真波形对照。从那以后我每次拿到CPU设计资源,都强制先走一遍自检序列,再谈其他功能,这一步能过滤掉八成莫名其妙的玄学问题。希望帮到你。

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

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

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

立即咨询