资讯动态

MIPS多周期CPU设计实战:从数据通路到Verilog控制器的完整实现

发布时间:2026/10/14 0:49:36 来源:尧图企业网站定制
简介面向计算机组成原理课程的MIPS 54条多周期CPU设计项目是一份以Xilinx N4开发板为目标的FPGA工程资源涵盖MIPS指令格式、数据通路、控制单元、ALU、寄存器堆等核心模块适合本科教学实践或相关课程设计参考。压缩包内共539个文件、约26.94MB以66个Verilog源文件、54个do仿真脚本和13个MIF初始化文件为主体辅以55个txt说明、60个xml工程配置、12个COE系数及17个udo调试脚本能够支撑从RTL设计到仿真验证的完整流程。工程中同时带有基于VHDL/Verilog的源文件、XDC引脚约束与时序报告可帮助读者梳理取指、译码、执行、访存、写回等周期的控制信号变化。资源上线以来已有908人学习浏览适合正在设计多周期CPU或准备FPGA综合实现的计算机组成学习者对照使用。通过完整工程代码和脚本配置读者可快速掌握多周期CPU的硬件描述思路参照其中的测试与调试方法减少排错时间提升对CPU工作原理和数字逻辑设计的系统认识。1. MIPS 54条多周期CPU为什么这套设计比单周期更值得亲手实现“MIPS 54条多周期CPU”在体系结构入门里几乎是一个标准剧本但真正的价值不在“能跑通54条指令”而在于把取指、译码、执行、访存、写回拆成独立步骤后你被迫去理解每一步之间的数据依赖。单周期方案里一条 lw 的存储器访问延迟拖累所有指令的时钟周期而多周期方案每个时钟周期只做一件事时钟频率可以明显提高代价是每条指令的周期数增加。我最早在模拟项目X里从单周期改到多周期时最明显的感受是代码量没有增加多少但调试难度上了一个台阶因为“状态”成为新的变量。这套设计适合三类人正在做计算机组成课程设计的学生、想在 FPGA 上跑通一个小型处理器的爱好者、以及准备进入流水线设计但想先打牢控制信号基础的人。下边按指令集、数据通路、控制器、Verilog实现、排障、验证的顺序展开把每次选择的理由和坑都说明白。2. 动手前的指令集梳理与数据通路设计2.1 54条指令按功能分成五类格式与用途一次看清“54条”并不是某个官方文档里的固定列表而是课程设计和教学项目中对 MIPS-I 整数指令集的常见裁剪与扩展。常见做法是按指令格式分成 R 型、I 型、J 型再按功能分成五类算术逻辑指令、乘除指令、移位指令、访存指令、分支跳转指令。这样分类的好处是数据通路可以按类型复用R 型指令都走“读寄存器 → ALU 运算 → 写寄存器”的同一条路径访存指令则统一走“计算地址 → 访问存储器 → 回写”。类别代表指令在数据通路上的行为算术逻辑add, addu, sub, subu, and, or, xor, nor, slt, sltu, addi, addiu, slti, andi, ori, xori, lui取指 → 译码 → 执行 → 写回乘除mult, multu, div, divu, mfhi, mflo执行阶段需要额外周期结果放在 HI/LO移位sll, srl, sra, sllv, srlv, srav执行阶段在 ALU 内完成路径与算术指令相同访存lw, sw, lb, lbu, lh, lhu, sb, sh取指 → 译码 → 地址计算 → 访存 → 回写/完成分支跳转beq, bne, bgtz, blez, bltz, bgez, j, jal, jr取指 → 译码 → 比较/跳转 → 更新 PC不写寄存器堆这里要特别强调一点分支跳转指令在多周期设计里不写寄存器堆所以 RegWrite 始终为 0但 jr 和 jal 需要从寄存器堆读出目标地址读操作本身不影响 RegWrite。很多初学者在画数据通路时习惯性给所有指令都连上寄存器堆写入端口后面写控制信号真值表时就会发现自己多了一堆无意义的 1。2.2 多周期数据通路谁在什么时候干活寄存器划分是核心多周期 CPU 与单周期最大的区别不是指令集而是数据通路里多了一批临时寄存器IR指令寄存器、MDR存储器数据寄存器、A/B寄存器堆读出锁存、ALUOutALU 输出锁存。为什么需要它们因为一个时钟周期内只做一步当前步骤的结果必须被锁存供下一步使用。我一般按执行步骤画数据通路而不是按指令类型画这样画完加法指令和访存指令的区别只是“跳过哪几步”。取指阶段PC 作为地址访问指令存储器读出的指令写入 IR同时 PC 自身加 4。译码阶段寄存器堆读出两个操作数到 A 和 B立即数完成符号扩展或零扩展。执行阶段ALU 根据 ALUSrcA 和 ALUSrcB 选择输入来源运算结果写入 ALUOut。访存阶段ALUOut 中的地址访问数据存储器lw 读数据到 MDRsw 写 B 中的数据。写回阶段ALUOut 或 MDR 写回寄存器堆。画到这里你会发现R 型算术指令只是跳过了访存阶段把 ALUOut 直接写回。module mips54_multicycle_top ( input wire clk, input wire rst_n, input wire [31:0] instr, // 指令存储器读出的当前指令 output wire [31:0] pc, // 当前 PC用于外部调试 output wire mem_write, // 数据存储器写使能 output wire mem_read // 数据存储器读使能lw 时有效 );这个顶层模块只暴露了最小端口内部连接留给数据通路。把 pc 和 mem_write 引出来是为了仿真时快速确认取指和访存行为而不是为了对外提供完整总线接口。你会发现多周期 CPU 的顶层比单周期多了一组控制信号因为 PC 不再是每个周期无条件更新它由 PCWrite 和 PCWriteCond 两个信号共同决定这是多周期数据通路上最容易画错的一根线。2.3 端口与位宽设计寄存器堆、存储器与 ALU 的参数选型寄存器堆做 32×32两个读端口rs、rt一个写端口写时钟同步读组合输出。这里不需要为多周期做特殊处理因为 A/B 寄存器会在译码阶段锁存读出值后续阶段不再直接访问寄存器堆。指令存储器按字节寻址但取指按字对齐PC 低两位不参与地址计算。教学场景下常见做法是例化一个深度 1024 的 32 位 ROM容量 4KB足以放下几千条测试指令。数据存储器用同步写、组合读的 reg 数组即可深度也是 1024按字访问。ALU 位宽 32 bit操作码建议用 4 位从 0000 到 1111 映射 AND、OR、ADD、SUB、SLT、NOR、XOR、左移、右移逻辑、右移算术等。多周期方案里同一组合逻辑不需要在一拍内完成太多事所以 ALU 组合路径可以直接用 Verilog 的运算符描述不需要像流水线 CPU 那样做查表优化。时钟周期先按 10ns 设定综合后如果时序紧张再调整不要一开始就追求高频。提示寄存器堆的写端口必须处理 0 号寄存器。最常见的做法是在写逻辑里判断地址是否为 0是则丢弃写入保证 $0 永远是 0。这个细节不做随机指令压测时会出现“所有结果都偏大一个立即数”的玄学错误。3. 控制器为核心多周期状态机的状态流转与控制信号真值表3.1 两级控制主控制器管时序ALU控制器管运算多周期 CPU 的控制器拆成两级主控制器管状态流转和全局控制信号ALU 控制器只负责把 ALUOp 和 funct 字段映射成具体的 alu_ctrl。为什么必须拆开54 条指令的 opcode 和 funct 组合数量很多如果全部揉进主控制器的状态机里状态数会变成指令数乘以步骤数真值表根本没法维护。拆开之后主控制器只关心“当前处于哪个步骤”和“这条指令属于哪一类”ALU 控制器则是一张纯查表输入是 2 位 ALUOp 和 6 位 funct输出是 4 位 alu_ctrl。两级控制的另一个好处是分支指令和访存指令可以共用大部分控制信号。beq 在执行阶段做减法并检测 zerolw 在执行阶段做加法计算地址两者在 ALU 层面的区别只是 ALUOp 不同。主控制器看到的分支跳转类指令只有两类需要比较的和不需要比较的。这样设计后状态机的分支数量保持在 10 个以内代码可读性和可调试性都明显提升。3.2 状态机设计从取指到写回分支跳转走什么路径状态机建议用符号名定义不搞二进制编码优化。状态总数不到 10 个二进制编码省不了多少触发器反而让仿真波形难读。常见状态集合如下S_FETCH、S_DECODE、S_EXECUTE、S_MEM_READ、S_MEM_WRITE、S_WRITEBACK、S_BRANCH、S_JUMP、S_MULT。每个状态只做一件事这条原则必须贯彻到底否则“某个状态里又读寄存器又写 PC”会让控制信号重叠真值表冲突。lw 指令走 S_FETCH → S_DECODE → S_EXECUTE → S_MEM_READ → S_WRITEBACK一共 5 个周期。sw 走 S_FETCH → S_DECODE → S_EXECUTE → S_MEM_WRITE访存阶段后直接回到 S_FETCH。R 型算术指令走 S_FETCH → S_DECODE → S_EXECUTE → S_WRITEBACK4 个周期。beq 走 S_FETCH → S_DECODE → S_BRANCH在 S_BRANCH 里用 ALU 做减法产生 zero 信号然后决定下一周期 PC 是 PC4 还是分支目标地址。j/jal 在 S_DECODE 之后直接跳 S_JUMP把 PC 的高 4 位和指令立即数拼接成目标地址。乘除指令需要额外小心。mult/div 的执行阶段不能和普通 ALU 指令共用一个周期因为乘法和除法的组合逻辑延迟远大于加法和逻辑运算。常见做法是在 S_EXECUTE 后插入 S_MULT 状态在这个状态里完成乘除运算下一个周期再进入写回mfhi/mflo 则把 HI/LO 寄存器的值通过 ALU 旁路写到寄存器堆。状态机的 next_state 逻辑里遇到 mult/div 就多走一跳是相对安全的设计。3.3 控制信号真值表一张表搞定全部决策控制信号的核心是 PCWrite、PCWriteCond、IorD、MemRead、MemWrite、IRWrite、RegWrite、RegDst、MemtoReg、ALUSrcA、ALUSrcB、PCSource。它们的含义在实现前必须逐一定义清楚我这里给一张我在项目里常用的定义表。控制信号含义取值说明PCWrite无条件写 PC取指和跳转状态为 1PCWriteCond条件写 PC分支状态为 1与 zero 相与IorD存储器地址来源0 选 PC1 选 ALUOutMemRead存储器读使能取指和 lw 访存阶段为 1MemWrite存储器写使能sw 访存阶段为 1IRWrite写入 IR取指状态为 1RegWrite寄存器堆写使能R 型、lw、jal 写回时为 1RegDst写寄存器地址来源0 选 rt1 选 rdjal 固定选 31MemtoReg写回数据来源0 选 ALUOut1 选 MDRALUSrcAALU 输入 A 来源0 选 PC1 选 A 寄存器ALUSrcBALU 输入 B 来源0 选 B1 选常数 42 选立即数3 选偏移量PCSourcePC 数据来源0 选 PC41 选分支目标2 选跳转目标S_FETCH 状态下的取值是很好的起点PCWrite1IorD0MemRead1IRWrite1ALUSrcA0ALUSrcB1PCSource0其余信号为 0。这个状态同时在取指令和计算 PC4两个操作互不冲突是状态机里唯一一个“同时做两件小事”的状态。S_DECODE 状态则把 ALUSrcA 置 1、ALUSrcB 置 2为两种可能的下一个状态准备 ALU 输入。4. 用 Verilog 实现多周期 CPU关键模块代码与调试参数4.1 顶层模块与数据通路搭建顶层数据通路建议分成寄存器堆、ALU、临时寄存器、控制器四个部分。临时寄存器包括 IR、A、B、ALUOut、MDR它们都接在公共时钟和复位上。为了调试方便我通常把状态、PC、ALUOut 全部引出到顶层端口仿真时一眼就能看到当前执行进度。module mips54_datapath ( input wire clk, input wire rst_n, input wire [31:0] instr, // 指令存储器输出 input wire [31:0] mem_data_in, // 数据存储器读出的数据 output wire [31:0] mem_addr, // 送往数据存储器的地址 output wire [31:0] mem_data_out, output wire [31:0] pc_out, output wire [ 3:0] state_out, // 控制器输出到数据通路的使能信号 input wire reg_write_en, input wire [ 1:0] reg_dst_sel, input wire [ 1:0] mem_to_reg_sel, input wire [ 1:0] alu_src_a_sel, input wire [ 2:0] alu_src_b_sel, input wire [ 1:0] pc_source_sel );这个接口把控制器和运算部分分开控制信号全部从外部输入。这样测试时可以用手写波形单独验证数据通路也可以在控制器写错时直接断言数据通路的输出缩小定位范围。mem_addr 来自 IorD 选中的地址源mem_data_out 来自 B 寄存器这是多周期接线的两个关键点。4.2 状态机作为控制器核心状态寄存器的翻转逻辑控制器模块用参数定义状态状态切换和输出逻辑分开写。状态切换是一个简洁的时序块输出逻辑是组合块。一个常见错误是在同一个 always 块里既做状态切换又做输出赋值导致输出晚一拍仿真波形里控制信号总是和状态错位。module mips54_controller ( input wire clk, input wire rst_n, input wire [ 5:0] opcode, input wire [ 5:0] funct, input wire zero, output reg pc_write, output reg pc_write_cond, output reg ior_d, output reg mem_read, output reg mem_write, output reg ir_write, output reg reg_write, output reg [ 1:0] reg_dst, output reg [ 1:0] mem_to_reg, output reg [ 1:0] alu_op, output reg [ 1:0] alu_src_a, output reg [ 2:0] alu_src_b, output reg [ 1:0] pc_source, output reg [ 3:0] state_out ); localparam S_FETCH 4d0; localparam S_DECODE 4d1; localparam S_EXECUTE 4d2; localparam S_MEM_READ 4d3; localparam S_MEM_WRITE 4d4; localparam S_WRITEBACK 4d5; localparam S_BRANCH 4d6; localparam S_JUMP 4d7; localparam S_MULT 4d8; reg [3:0] state, next_state; always (posedge clk or negedge rst_n) begin if (!rst_n) state S_FETCH; else state next_state; end状态切换块里复位拉回 S_FETCH正常工作时把 next_state 拍进当前状态。注意复位是异步复位低电平有效这是 FPGA 上最常见的复位风格。如果在这里写同步复位仿真时第一拍状态仍是 X后面的控制信号全部被 X 污染是典型的第一个翻车点。4.3 ALU 与 ALU 控制器两条查表路径ALU 控制器的输入是 2 位 ALUOp 和 6 位 funct输出是 4 位 alu_ctrl。ALUOp 由主控制器决定R 型指令用 2b10分支指令用 2b01其他非 R 型指令用 2b00。这样设计后ALU 控制器的查表逻辑很小addi、lw、sw 这类指令不需要 funct 字段就能确定运算类型。module alu_controller ( input wire [1:0] alu_op, input wire [5:0] funct, output reg [3:0] alu_ctrl ); always (*) begin if (alu_op 2b00) alu_ctrl 4b0010; // 加法 else if (alu_op 2b01) alu_ctrl 4b0110; // 减法分支比较用 else 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 6b100111: alu_ctrl 4b1100; // nor default: alu_ctrl 4b0010; endcase end end endmodule这段代码里的 default 分支需要特别重视。如果 funct 不是预期值把 alu_ctrl 默认成加法虽然功能不一定正确但至少不会产生锁存器。ALU 本体是组合逻辑输入 a、b 和 alu_ctrl输出 result 与 zero。zero 的判定必须基于 result 是否为全 0不能在 ALU 内部单独比较 a 和 b 是否相等因为 beq 使用的是减法结果只有减法的结果全 0 才代表两个数相等。4.4 寄存器堆与存储器读写同步写、异步读的边界寄存器堆的设计直接决定整个 CPU 的正确性。读端口用组合逻辑写端口用时钟逻辑读写地址来自 IR 的不同字段段。写使能由控制器控制写回阶段才会拉高其他时间保持 0。module regfile ( input wire clk, input wire reg_write_en, input wire [4:0] read_addr1, input wire [4:0] read_addr2, input wire [4:0] write_addr, input wire [31:0] write_data, output wire [31:0] read_data1, output wire [31:0] read_data2 ); reg [31:0] regs [0:31]; integer i; always (posedge clk) begin if (reg_write_en (write_addr ! 5d0)) regs[write_addr] write_data; end assign read_data1 regs[read_addr1]; assign read_data2 regs[read_addr2]; endmodule0 号寄存器在这里通过写地址判断丢弃写入比在数据通路层面对 write_data 做 mux 更干净。另一个边界是复位寄存器堆里除 0 号以外的寄存器可以不复位因为它们每个周期都会被写但如果有测试用例在复位后立刻读取某个未初始化寄存器拿到 X 也是正常的。数据存储器用 reg [31:0] mem [0:1023]同步写、组合读地址按字对齐后访问不要忘记用mem[addr[11:2]]做位宽适配。5. 多周期 CPU 调试的 5 个高频翻车点与排查方法5.1 仿真输出全是 X复位没做干净现象仿真一开始PC、IR、ALUOut、MDR 全部显示 X状态机停在 X 状态控制信号一团糟。 原因多周期 CPU 内部锁存器太多PC 和临时寄存器都没有初值。如果复位没有接到所有时序逻辑上或者复位只持续了半个时钟周期状态机一开始就是 X后面的每一步计算都被 X 污染整个仿真没有参考价值。这个问题几乎每个第一次写多周期 CPU 的人都会遇到。 解决所有 always (posedge clk) 的寄存器都接异步复位复位时把 PC 清零、状态置为 S_FETCH、IR 和 MDR 清零。testbench 里复位信号至少保持 2 到 3 个完整时钟周期并且在第一条指令生效前先看到 PC 稳定到 0、状态稳定到 S_FETCH。做这一步只需要花十分钟但能避免后面半小时的无意义排查。5.2 分支指令跳飞PC 更新时序和零检测信号冲突现象beq 条件明明相等PC 却多加了 4或者跳转后执行的是目标地址后一条指令而不是目标指令。 原因分支在 S_BRANCH 状态用 ALU 减法产生 zero然后由 PCWriteCond 与 zero 相与决定是否更新 PC。如果 PCWriteCond 没有在 S_BRANCH 状态拉高或者 PCSource 在分支状态选错了来源PC 就会按默认的 PC4 更新。还有一种隐蔽情况zero 信号在 ALU 输出后没有及时稳定状态机在同一个时钟沿采集到了旧值。 解决先查控制信号真值表确认 S_BRANCH 状态里 PCWriteCond1、PCSource2b01再查 ALU 控制器里分支指令的 alu_op 是否为 2b01减法。最后在波形里找到状态为 S_BRANCH 的那个时钟上升沿确认 PC 的新值是在该沿写入的目标地址而不是稍后一拍才出现。如果新值出现在正确沿但内容不对问题在数据通路接线的问题如果沿不对问题在控制器。5.3 寄存器堆读写冲突写使能和写数据的边界现象连续两条指令写同一个目的寄存器时第二条指令写回后的值总是比预期少一拍用 read_data1 做组合逻辑时偶尔拿到旧值。 原因寄存器堆是同步写、异步读同一时钟周期内读写同一个地址时读端口拿到的是旧值。多周期设计里读寄存器只在译码阶段发生写寄存器只在写回阶段发生两个阶段不会重叠所以理论上不该冲突。但一旦你在调试时加了“把寄存器堆读端口接出来转发”的逻辑或者把写回阶段压缩到和下一个取指阶段同一个周期就会踩到这个边界。 解决严格保持多周期的阶段划分不要把寄存器堆的读和写放在同一个周期。如果实在需要在写回后立刻读出结果使用非阻塞赋值让写入在时钟沿之后生效保证读端口在下一个周期才能看到新值。依赖这条规则可以省去在数据通路里做前馈网络的复杂设计。5.4 乘除多周期指令的握手问题现象mult 执行完mfhi 读出的结果高 32 位和低 32 位颠倒或者结果完全不对div 的结果没有在预期状态下稳定。 原因乘除法在组合逻辑里完成需要很长的延迟尤其除法的组合逻辑面积大、路径长。如果直接把 mult 放在 S_EXECUTE 一个周期内完成仿真可能看起来是对的但综合到 FPGA 上时序收敛不了如果仿真里结果不对则可能是 HI/LO 寄存器的写使能和控制状态没有对齐。 解决在教学验证里常见的做法是把 mult/div 拆成两步S_EXECUTE 状态里启动乘除下一个 S_MULT 状态里取结果。状态机的 next_state 逻辑单独判断 opcode 是否为 0x00 且 funct 属于 0x18mult、0x19multu、0x1adiv、0x1bdivu是则跳到 S_MULT 而不是 S_WRITEBACK。HI/LO 在 S_MULT 状态写入mfhi/mflo 在 S_EXECUTE 状态把对应寄存器作为 ALU 输入然后走 S_WRITEBACK。这样每个周期只处理一小段组合逻辑时序压力小很多。5.5 控制信号错位case 缺 default 导致状态机迷路现象某条不常用指令执行后状态跳到未知值控制信号变成全 1 或全 0后续指令全部乱掉波形里状态变成一条直线冲向 32 位全 1。 原因状态机的 case 语句没写 default或者 opcode 匹配有漏洞综合工具为了填补未覆盖分支生成了锁存器仿真时状态直接变 X。另一个常见原因是 opcode 使用了不完整的位宽比如只匹配了高 4 位导致两条低 4 位不同的指令进了同一条路径。 解决在状态机 case 的 default 分支里把 next_state 拉回 S_FETCH所有输出控制信号赋默认 0。这个习惯同时消除了锁存器和 X 传播两个问题。opcode 一律用完整的 6 位进行匹配不要只匹配部分位。我在模拟项目X里遇到过几次状态机迷路最终定位都是某个指令的 opcode 少了一位判断分支条件不够完整default 又没兜底。6. 用参考模型与随机指令压测验证 CPU 正确性验证多周期 CPU 的最直接方法是找一个已经确认正确的参考模拟器对比寄存器快照。先把 54 条指令逐条编写最小测试用例每条用例执行后把寄存器堆、PC、HI/LO 全部 dump 到文件和参考模拟器运行同样的程序后的结果做 diff。全对不代表 CPU 没问题但第一条指令 begin 开始就错则可以直接定位到具体状态。定向用例通过后再上随机指令压测。构造随机指令流时注意约束合法性内存访问地址要落在已定义的 4KB 数据存储器范围内除法指令要避免除数为 0跳转目标要对齐到字地址。我一般生成 200 条随机指令跑完整个指令流后对比寄存器堆和内存快照。压测的价值在于它会随机激活状态机的路径组合定向用例里没覆盖到的“R 型写回后立即分支”这类交错场景会被抓出来。验证结束后可以顺势做两个进阶扩展一是给状态机加计数器统计每条指令的平均周期数验证 lw 确实比 add 多一个周期二是把数据通路里的临时寄存器接口清理干净准备改造成五级流水线。多周期 CPU 的价值在于它把控制信号和状态的关系讲透了改成流水线时你会发现流水线和多周期的核心冲突就是“同一份控制信号被不同指令同时使用”理解了这一点才能体会为什么要加流水寄存器。提示随机压测脚本最好带上种子参数失败时可以复现同一条指令流。没有复现能力的压测基本等于白做。最后说一个我自己的习惯每次改完数据通路先跑一条 addi 和一条 lw 的定向用例确认状态序号按照预期逐拍递增再跑随机压测。这个习惯帮我挡掉过至少三次“看起来跑了很久但结果全错”的尴尬场面。多周期 CPU 的调试是一场耐心的游戏把状态机、控制信号、数据通路三者的边界划清楚剩下的问题都只是时间问题。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑