单周期MIPS处理器Verilog实现:数据通路、控制器与仿真验证
简介华中科技大学HUST类MIPS单周期微处理器设计实验报告是一份面向计算机体系结构课程的实践教学资料适合正在学习MIPS架构与硬件描述语言数字逻辑设计的学生参考。报告围绕单周期微处理器的完整实现展开覆盖指令存储器、数据存储器、寄存器组、ALU、ALU控制译码、符号扩展及多路复用器等关键模块并结合框图和硬件描述语言源码详细说明了控制器信号、存储地址计算和条件跳转判断等要点有助于读者理解RISC处理器数据通路与控制逻辑的协同工作机制。压缩包内仅有1个PDF文件大小约901KB内容完整且便于离线阅读。该资料目前在CSDN已有2154人学习/下载实用性受到一定认可。对于正在完成类MIPS处理器实验或复习计算机组成原理的读者这份报告能提供模块设计思路、代码示例与排错参考。1. 单周期MIPS不是玩具是一张数据通路地图在互联网公司里做底层、做芯片验证的同学回头看大学里这门《计算机组成原理》课设最值钱的部分往往不是最后跑通的 demo而是那张把指令存储器、寄存器组、ALU、控制器、多路复用器全部串起来的数据通路图。这份华中科技大学 HUST 类 MIPS 单周期微处理器实验报告正好把这张图拆成了可以逐行对照的 Verilog 模块ROM 取指、RAM 访存、32 个寄存器读写、ALU 五类运算、主控制器九根控制信号、五个 MUX 的数据选择路径再加一条 QtSpim 汇编到 COE 文件的完整工具链。适合三类人正在做单周期 MIPS CPU 课设的学生、想把数据通路和控制信号彻底捋清楚的软硬件工程师、以及想从功能仿真走向可综合 RTL 的 Verilog 初学者。单周期的核心代价也很直白一条指令在一个时钟周期内走完取指、译码、执行、访存、写回全流程CPI 恒等于 1时钟频率上不去但它把所有控制逻辑摊平在桌面上是理解硬布线控制器的最佳标本。2. 数据通路先于代码ROM、RAM 与时钟相位2.1 指令存储器128 个字、32 位宽、地址寄存一拍指令存储器在单周期处理器里是只读的实验中用 Xilinx IP Wizard 配置 ROM分配 128 个字每个字 32 位宽对外接口只有三个时钟 clk、地址输入指令指针 PC、指令输出 inst。关键设置在“地址寄存”选项上——ROM 的地址输入端带一级寄存器意味着只有当时钟上升沿到来时地址才被锁存并输出对应指令。这带来一个直观后果PC 值的改变和 inst 的输出之间隔着半个到一个时钟周期仿真波形里 pc 和 inst 是对不齐的需要按时序错位去看。地址线的位宽选择也值得展开。ROM 分配的是 128 个字不是 128 字节所以字地址只需要 7 位。但实验代码里取的是pc[8:2]原因在于 PC 是一个 32 位寄存器bit[1:0] 是字节偏移对字寻址没有意义bit[8:2] 共 7 位正好覆盖 0x0 到 0x1FC 这 128 个字地址。这种「高位取址、低位丢弃」的写法在 Verilog 顶层里很常见但很多初学者会直接接pc[6:0]仿真时因为地址对齐方式不同读出来的指令完全错位属于第一个隐蔽的坑。2.2 数据存储器读写分离的 RAM 接口数据存储器是 RAM 类型需要独立的读写控制信号接口比 ROM 多一个写使能 we。实验里的接口定义是clk、we、datain、addr、dataout。对应到 Xilinx IP 核的端口映射一般是这样绑定的模块接口位宽方向连接说明clk1input接!clkin下降沿采样we1input接主控制器memWritedatain32input接寄存器组 RtDatasw 指令写入的数据addr7input接 ALU 计算结果aluRes[7:2]同样按字寻址dataout32output接 MUX 输入lw 指令读回的数据要注意addr接的是 ALU 输出而不是 PC因为数据存储器的地址来自指令中的偏移量加上基址寄存器的值这条路径在单周期里由 ALU 的加法操作完成。lw 和 sw 指令的 ALUOp 都是 00即 ALU 执行加法算出来的结果就是访存地址。2.3 时钟相位为什么 regFile 和 DRAM 接在 !clkin这是整份报告里最不起眼、但决定整个设计能不能跑通的一行代码顶层模块里regFile和dram的 clk 接的是!clkin而irom接的是clkin。原因在于 Xilinx 的 ROM/RAM IP 核默认在时钟上升沿采样若寄存器组也在同一上升沿写入那么 ROM 输出指令、寄存器组写回、DRAM 写数据三个动作全部挤在同一沿上组合逻辑路径上的数据根本来不及稳定。把寄存器组和 DRAM 接到反相时钟后数据写入发生在下降沿而指令在上升沿更新两者错开半个周期写后读的时序裕量就有了。这不是 MIPS 的硬性要求而是这套基于 Xilinx IP 核的实验框架下最省事的时序处理方式。如果自己写纯 RTL 的 regFile 和 RAM完全可以统一用正沿但在 ISE/Vivado 的 IP 配置下保持「指令存储器正沿、数据通路负沿」的接法仿真波形会干净很多。3. 寄存器组与 ALU同步写与五类运算的边界3.1 regFile同步写、异步读、$0 硬接线寄存器组是 MIPS 指令操作的主要对象32 个 32 位寄存器读端口 Rs、Rt 各一个写端口 RegWriteAddr 一个写使能 RegWriteEn 控制是否写入reset 负责复位。实验给出的代码如下我整理成了 ANSI 风格逻辑与原报告一致module regFile( input clk, input reset, input [31:0] regWriteData, input [4:0] regWriteAddr, input regWriteEn, input [4:0] RsAddr, RtAddr, output [31:0] RsData, RtData ); reg [31:0] regs [0:31]; integer i; assign RsData (RsAddr 5b0) ? 32b0 : regs[RsAddr]; assign RtData (RtAddr 5b0) ? 32b0 : regs[RtAddr]; always (posedge clk) begin if (!reset) begin for (i 0; i 31; i i 1) regs[i] 32b0; regs[31] 32hffffffff; end else if (regWriteEn) begin regs[regWriteAddr] regWriteData; end end endmodule读数据是纯组合逻辑通过三目运算符对地址 0 做特判RsAddr或RtAddr为 0 时直接输出 0而不是读取 regs[0]。这是因为 MIPS 规范里 $0 恒为零任何写入 $0 的操作都必须被忽略这里用硬接线的方式从行为上保证这一语义。写数据则是同步的regWriteEn有效时在时钟沿把数据写入指定寄存器。有一个细节需要单独解释复位逻辑里前 31 个寄存器清零但regs[31]被置成了全 1。严格按 MIPS 规范所有寄存器复位都应该清零这里单独处理 $31 是实验环境里的技巧。原报告后面的汇编程序用了andi $2,$31,85这种写法把 $31 当零寄存器用。如果 $31 是 0指令结果没问题如果 $31 是全 1andi只取低 16 位立即数结果依然是 0x55两条路径殊途同归。所以这份代码在仿真里能跑对但换到别的程序上就需要留意任何依赖 $31 高位数据的指令行为会和规范不一致。3.2 ALU5 种运算与 zero 标志的锁存器隐患ALU 需要支持 add、sub、and、or、slt 五种操作同时承担 lw/sw 的地址计算和 beq 的寄存器比较。实验用 4 位控制信号aluCtr译码决定执行哪种操作接口是 32 位输入 input1、input2输出 32 位结果 aluRes 和 1 位 zero 标志。原报告的代码里有一个典型的组合逻辑问题加法、与、或分支没有给 zero 赋值只有减法和 slt 分支处理了 zero这在综合时会产生锁存器。module ALU( input [31:0] input1, input2, input [3:0] aluCtr, output reg [31:0] aluRes, output reg zero ); always (*) begin case (aluCtr) 4b0010: aluRes input1 input2; 4b0110: begin aluRes input1 - input2; zero (aluRes 32b0); end 4b0000: aluRes input1 input2; 4b0001: aluRes input1 | input2; 4b0111: aluRes (input1 input2) ? 32d1 : 32d0; default: begin aluRes 32b0; zero 1b0; end endcase end endmodule这里我在每个分支里都补上了 zero 的赋值并在 default 中清零避免生成锁存器。zero 标志被 beq 指令使用如果 ALU 执行完加法后 zero 还保留着上一次减法留下的值那么 beq 的跳转判断就完全错误。仿真的隐蔽之处在于如果程序里 beq 之前恰好执行过减法仿真结果碰巧是对的一旦前面换成 lw 或 and立刻跳飞。排查这类问题要看综合报告里的 warning看到latch inferred基本就是 case 分支覆盖不全。3.3 ALU 控制译码ALUOp 与 funct 的组合逻辑ALU 控制译码是 mips 指令译码器设计的核心环节它把主控制器输出的 2 位 ALUOp 和 R 型指令的 6 位功能码 funct 组合成 4 位 ALUCtr。设计思路是非 R 型指令lw、sw、beq不关心 funct直接用 ALUOp 决定操作R 型指令则必须看 funct 才能区分 add、sub、and、or、slt。实验代码用的是 casex 通配符匹配module aluctr( input [1:0] ALUOp, input [5:0] funct, output reg [3:0] ALUCtr ); always (*) begin casex ({ALUOp, funct}) 8b00xxxxxx: ALUCtr 4b0010; // lw/sw: 加法算地址 8b01xxxxxx: ALUCtr 4b0110; // beq: 减法比较 8b11xxxxxx: ALUCtr 4b0000; // 预留容错 8b10xx0000: ALUCtr 4b0010; // add 8b10xx0010: ALUCtr 4b0110; // sub 8b10xx0100: ALUCtr 4b0000; // and 8b10xx0101: ALUCtr 4b0001; // or 8b10xx1010: ALUCtr 4b0111; // slt default: ALUCtr 4b0000; endcase end endmoduleALUCtr 的编码不是随便定的4b0000 对应与、4b0001 对应或、4b0010 对应加、4b0110 对应减、4b0111 对应小于设置。casex 里x表示不关心位比如8b00xxxxxx表示只要 ALUOp 是 00funct 任意一律输出加法控制号。这样 lw/sw 的地址计算不需要知道 functbeq 的比较也不需要。8b11xxxxxx这个分支原报告没有是容错保护实际五类指令用不到 ALUOp11但加上它能让 casex 覆盖完整综合时不产生锁存器。4. 主控制器与多路复用器九根控制信号从哪来4.1 控制器接口一条 opcode 引出九个输出主控制器的输入是指令的 [31:26] 共 6 位 opCode输出是九根控制信号regDst、aluSrc、memToReg、regWrite、memRead、memWrite、branch、aluop[1:0]、jmp。每一根信号负责数据通路上的一个选择开关regDst 决定写回寄存器用 Rd 还是 RtaluSrc 决定 ALU 第二操作数来自寄存器还是立即数memToReg 决定写回数据来自内存还是 ALU 结果branch 和 zero 一起控制 PC 是否跳转jmp 直接强制改写 PC。控制器本身没有计算逻辑它本质上是一张真值表module ctr( input [5:0] opCode, output reg regDst, aluSrc, memToReg, regWrite, output reg memRead, memWrite, branch, jmp, output reg [1:0] aluop ); always (*) begin {regDst, aluSrc, memToReg, regWrite, memRead, memWrite, branch, jmp} 8b0; aluop 2b00; case (opCode) 6b000000: begin // R型 regDst 1b1; regWrite 1b1; aluop 2b10; end 6b100011: begin // lw aluSrc 1b1; memToReg 1b1; regWrite 1b1; memRead 1b1; aluop 2b00; end 6b101011: begin // sw aluSrc 1b1; memWrite 1b1; aluop 2b00; end 6b000100: begin // beq branch 1b1; aluop 2b01; end 6b000010: begin // j jmp 1b1; end endcase end endmodule控制信号的真值表是硬布线控制器的核心也是所有 MIPS 单周期设计里必须背下来的矩阵。这个实验的指令集很小只有五类指令所以 case 只有五个分支每根信号直接在 always 块里赋值。default 分支保持全 0意味着未知 opcode 执行 NOP不会写寄存器也不会访存这是最安全的处理方式。4.2 R 型、lw、sw、beq、j 的控制信号矩阵把上面的 case 展开成表格每列对应一根控制信号能更清楚地看到不同指令对数据通路的差异化需求指令opCoderegDstaluSrcmemToRegregWritememReadmemWritebranchjmpALUOpR 型0000001001000010lw1000110111100000sw101011x1x0010000beq000100x0x0001001j000010xxx00001xx逐行看这张表R 型指令要写寄存器写地址来自 rd 字段所以 regDst1ALU 的两个操作数都来自寄存器所以 aluSrc0不访存ALUOp10 交给译码器根据 funct 细分运算。lw 要从内存读数据写回寄存器regWrite1、memRead1、memToReg1写入地址来自 rt 字段所以 regDst0ALU 第二操作数是符号扩展后的偏移量aluSrc1。sw 不写寄存器但写内存regWrite0、memWrite1regDst 和 memToReg 是无关项。beq 需要 ALU 做减法比较两个寄存器branch1 把零标志传到 PC 端不访存也不写寄存器。j 直接改写 PC其他信号全部无效。4.3 顶层数据通路上五个 MUX 的优先级顶层模块里的五个多路复用器把各模块串成完整通路它们的选择信号全部来自控制器。原报告里的 assign 语句整理如下assign mux1 reg_dst ? inst[15:11] : inst[20:16]; // 写回寄存器号: Rd / Rt assign mux2 alu_scr ? expand : RtData; // ALU第二操作数: 立即数 / 寄存器 assign mux3 memtoreg ? memreaddata : aluRes; // 写回数据: 内存 / ALU结果 assign mux4 choose4 ? address : add4; // 分支目标 / PC4 assign mux5 jmp ? jmpaddr : mux4; // 跳转目标 / 分支结果 assign choose4 branch zero; // beq成立条件 assign expand2 expand 2; // 立即数左移两位 assign jmpaddr {add4[31:28], inst[25:0], 2b00}; // j指令目标拼接 assign address pc expand2; // beq目标地址这里能看到两个容易搞混的优先级问题mux4 和 mux5 是两级串联mux5 的选择信号 jmp 优先级高于 mux4 的 branch 信号所以 j 指令会覆盖 beq 的分支结果这在硬件上体现为一个额外的 MUX 层级。另一个细节是 j 指令的目标地址拼接方式取 PC4 的高 4 位拼上指令低 26 位再补两个 0形成 28 位对齐地址。因为 MIPS 指令都是 4 字节对齐低 2 位恒为 0所以指令里只存高 28 位。5. 从 QtSpim 汇编到 COE 文件学号 ASCII 码的装载链路5.1 用 $31 当零寄存器用的小技巧实验的程序目标很朴素把学号每一位数字的 ASCII 码写入 RAM 连续地址。汇编代码用 UltraEdit 编辑保存内容如下main: andi $2, $31, 85 # U 的 ASCII 码 sw $2, 0($3) andi $2, $31, 50 # 2 sw $2, 4($3) andi $2, $31, 48 # 0 sw $2, 8($3) andi $2, $31, 49 # 1 sw $2, 12($3) andi $2, $31, 53 # 5 sw $2, 16($3) andi $2, $31, 49 sw $2, 20($3) andi $2, $31, 51 # 3 sw $2, 24($3) andi $2, $31, 51 sw $2, 28($3) andi $2, $31, 52 # 4 sw $2, 32($3) andi $2, $31, 51 sw $2, 36($3) j main这段代码反复用andi取立即数低 16 位再存入内存基址寄存器 $3 在复位后是 0正好从地址 0 开始连续存放。这里的$31在标准 MIPS 里是返回地址寄存器初始为 0把它当作零寄存器使用指令编码里的 rs 字段是 11111机器码完全合法。换成 $0 也一样能跑因为andi的结果不依赖 rs 的高位数据。5.2 QtSpim 装载与机器码提取接下来用 QtSpim 装载这个汇编文件。QtSpim 的用户代码段固定从 0x00400000 开始每条指令占 4 字节j main位于 0x00400024。装载成功后从 QtSpim 的 Text Segment 窗口逐条读取机器码提取出 20 条指令的十六进制编码。关键的一步是把j main原本的机器码0x08100009修改为0x08000000。为什么要改这个地址j指令的 26 位立即数字段是目标地址右移 2 位的结果。0x08100009对应的目标地址是0x00400024这是 QtSpim 加载程序时的内存布局地址而实验里的 ROM 从地址 0 开始映射复位后 PC0取到的第一条指令是 ROM[0]如果j main还指向 0x00400024执行完一遍就直接跳到不存在的地址空间。改成0x08000000后目标地址是 0x0程序回到开头形成死循环正好符合 RAM 里循环写入学号 ASCII 码的预期。5.3 COE 文件格式与 ROM 初始化把修正后的机器码整理成 Xilinx COE 文件格式如下MEMORY_INITIALIZATION_RADIX16; MEMORY_INITIALIZATION_VECTOR 33e20055, ac620000, 33e20032, ac620004, 33e20030, ac620008, 33e20031, ac62000c, 33e20035, ac620010, 33e20031, ac620014, 33e20033, ac620018, 33e20033, ac62001c, 33e20034, ac620020, 33e20033, ac620024, 08000000;每一行是一个 32 位机器码与汇编指令一一对应。前两条对照一下33e20055是andi $2,$31,85的编码opcode001100rs11111rt00010立即数 0x0055ac620000是sw $2,0($3)opcode101011rs00011rt00010偏移量为 0。中间的 18 条指令规律完全一致只是立即数和偏移量在变。COE 文件第一行声明进制为 16第二行开始按顺序列出 ROM 每个字的内容分号结尾。把这个文件导入 iROM 的 IP 核配置界面重新生成 ROM 模块指令存储器就带上了程序。6. 仿真验证与三个最容易翻车的时序点仿真这一步报告给的是寄存器组的独立 testbench。下面这个例子覆盖了三个关键场景复位清零、写使能写入、$0 恒零输出。激励代码可以直接套进 ISE/Vivado 的仿真流程module regsim; reg clk, reset, regWriteEn; reg [4:0] regWriteAddr, RsAddr, RtAddr; reg [31:0] regWriteData; wire [31:0] RsData, RtData; regFile uut( .clk(clk), .reset(reset), .regWriteData(regWriteData), .regWriteAddr(regWriteAddr), .regWriteEn(regWriteEn), .RsAddr(RsAddr), .RtAddr(RtAddr), .RsData(RsData), .RtData(RtData) ); initial begin clk 0; reset 1; regWriteEn 0; #10 reset 0; regWriteEn 1; regWriteAddr 5; regWriteData 32h12345678; #10 RsAddr 5; RtAddr 0; #10 $display(RsData%h, RtData%h, RsData, RtData); // 期望 RsData12345678, RtData0验证 $0 恒零 #10 reset 1; #10 $display(after reset RsData%h, RsData); // 期望复位后 RsData0验证异步复位 $finish; end always #5 clk ~clk; endmoduletestbench 里clk每 5 个时间单位翻转一次产生 10 个时间单位的时钟周期。reset先拉高再拉低确认复位路径生效regWriteEn拉高一个周期把 0x12345678 写入寄存器 5随后把 RsAddr 指向寄存器 5、RtAddr 指向 0观察两个读端口的输出。如果寄存器组实现正确RsData 输出写入的数据RtData 输出 0。实际跑完整 CPU 仿真时有三个点最容易翻车。第一个是时钟相位ROM 接正沿 clk寄存器组和 DRAM 接反沿!clkin如果不按这个接法lw 指令读回的数据会在写回阶段差半拍波形上看就是写进寄存器的值总是上一条指令的结果。第二个是 j 指令地址QtSpim 生成的机器码指向 0x00400024不改直接烧进 ROM程序执行到 jmp 后 PC 跳到空地址仿真直接跑飞。第三个是复位的优先级regFile 的 always 块里if (!reset)必须写在else if (regWriteEn)前面否则复位和写使能同时有效时复位逻辑被写使能覆盖寄存器组的初始状态不可控。把这三个点都验证通过再把复位释放时间挪到 clk 上升沿之后观察 MemToReg 路径波形会诚实地告诉你问题出在哪。本文还有配套的精品资源点击获取