简介:面向计算机组成原理课程的多周期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 SLTU | 10 |
| R型移位 | SLL SRL SRA SLLV SRLV SRAV | 6 |
| R型乘除与转移 | MULT MULTU DIV DIVU MFHI MFLO MTHI MTLO JR | 9 |
| I型访存 | LW SW LB LBU LH LHU SB SH | 8 |
| I型立即数 | ADDI ADDIU ANDI ORI XORI LUI SLTI SLTIU | 8 |
| I型分支 | BEQ BNE BLEZ BGTZ BLTZ BGEZ | 6 |
| J型跳转 | J JAL | 2 |
| 特殊指令 | SYSCALL BREAK BGEZAL BLTZAL NOP | 5 |
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 |
| regfile | 32个通用寄存器 | 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型状态机:状态寄存器保存当前状态,组合逻辑根据当前状态和指令字段决定控制信号。资源包把流程分成五个状态,每种指令只走完整路径中需要的部分。
| 状态 | 编码 | 该周期做的事 |
|---|---|---|
| FETCH | 3'b000 | 取指,PC+4 |
| DECODE | 3'b001 | 读寄存器堆,立即数扩展 |
| EXECUTE | 3'b010 | ALU计算,地址计算,分支判断 |
| MEM_ACCESS | 3'b011 | 读写数据存储器 |
| WRITEBACK | 3'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_a | 2'b00 | A输入端为PC |
| alu_sel_a | 2'b01 | A输入端为寄存器A读出 |
| alu_sel_b | 2'b00 | B输入端为寄存器B读出 |
| alu_sel_b | 2'b01 | B输入端为常量4 |
| alu_sel_b | 2'b10 | B输入端为立即数扩展值 |
| pc_source | 2'b00 | PC <- PC+4 |
| pc_source | 2'b01 | PC <- 分支目标地址 |
| pc_source | 2'b10 | PC <- 寄存器rs值 |
| mem_to_reg | 2'b00 | 写回数据来自ALU |
| mem_to_reg | 2'b01 | 写回数据来自数据存储器 |
| mem_to_reg | 2'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)); endmoduletestbench的关键是做初始化:复位保持至少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设计资源,都强制先走一遍自检序列,再谈其他功能,这一步能过滤掉八成莫名其妙的玄学问题。希望帮到你。
本文还有配套的精品资源,点击获取