☰
单周期CPU设计实验方案:MIPS32指令集与Verilog HDL实现
2026/10/19 3:18:48 网站建设 项目流程

简介:这份资源是《计算机组成原理》实验课的单周期CPU设计完整作业包,面向正在学习MIPS32指令系统与处理器实现的高校学生及自学者。内容围绕Verilog HDL编写的单周期CPU展开,覆盖十六条指令的实现过程,帮助读者理解单周期CPU的工作原理与逻辑功能,并通过运行观察加深对指令执行流程的认识。压缩包共90个文件,约2.37MB,包含Verilog源码、Quartus工程配置与综合报告文件、仿真波形与RTL截图、实验报告文档及说明文件,可完整还原从编码、综合到仿真的实验链路。目前已有12292人学习下载,热度较高。读者可借助其中的完整代码、详细实验报告与RTL视图,对照梳理数据通路与控制信号设计思路,排查仿真与综合中的常见问题,适合作为课程作业参考或单周期CPU设计的入门实践材料。

1. 单周期CPU设计:从指令集到数据通路的完整实验方案

做计算机组成原理实验,最怕的不是写代码,而是拿到题目后不知道从哪下手。单周期CPU设计这个题目,几乎每届学生都会遇到,但真正能把MIPS32指令集、Verilog HDL实现、数据通路搭建、时序验证串成一条线的人并不多。我见过太多人卡在“指令译码写完了但寄存器堆读不出数”或者“仿真波形全是红色不定态”这种问题上。这套实验方案要解决的就是这件事:给你一条从指令集定义到最终上板验证的完整路径,每一步都有可复现的代码和检查点。适合正在做组成原理课设的本科生,也适合想重新梳理CPU流水线基础的在职开发者。关键词里的单周期CPU设计、MIPS32、Verilog HDL,这套方案全部覆盖,而且不是泛泛而谈,是能直接对着写的程度。

2. 指令集与总体架构:MIPS32子集怎么选、模块怎么切

2.1 指令子集裁剪:为什么只选这12条指令

MIPS32指令集本身很庞大,但单周期CPU实验不需要全实现。通常的做法是选一个最小可用子集,覆盖R型、I型、J型三类格式,能跑通一个完整的程序即可。我一般会选这12条:add、sub、and、or、slt、lw、sw、beq、bne、addi、j、jal。这组指令的好处是,它们能构成一个完整的冒泡排序程序,同时覆盖了寄存器堆读写、ALU运算、立即数扩展、分支跳转、内存访问这几个核心路径。

选指令的时候有个容易翻车的地方:有人为了“显得完整”把乘除法也加进去,结果ALU要额外做乘法器,时序直接崩掉。单周期CPU的时钟周期取决于最长路径,乘法器一加,关键路径延迟翻倍,仿真能过,上板必挂。所以我的建议是,实验阶段就老老实实做这12条,把数据通路吃透比堆指令数量重要得多。

指令格式上,R型指令的opcode固定为6‘b000000,funct字段区分具体操作;I型指令的opcode直接决定操作类型;J型指令只有opcode和26位跳转地址。这些在译码模块里要严格对齐,不然后面控制信号全是乱的。

2.2 总体架构:五个模块的职责边界

单周期CPU的经典架构分五个模块:取指单元(IF)、译码单元(ID)、执行单元(EX)、访存单元(MEM)、写回单元(WB)。但在单周期设计里,这五个阶段是在一个时钟周期内完成的,所以模块划分更多是逻辑上的,物理上就是组合逻辑加寄存器堆和存储器。

我通常会把代码分成这几个文件:cpu_top.v(顶层连线)、pc_reg.v(程序计数器)、imem.v(指令存储器)、decoder.v(译码器)、regfile.v(寄存器堆)、alu.v(算术逻辑单元)、dmem.v(数据存储器)、extend.v(立即数扩展)。每个模块的接口信号要提前定好,不然后面连线会非常痛苦。

顶层模块的连线逻辑是这样的:PC输出地址给指令存储器,指令存储器读出指令给译码器,译码器产生控制信号和寄存器地址,寄存器堆读出操作数给ALU,ALU计算结果一路给数据存储器、一路给写回多路器,数据存储器读出的数据也给写回多路器,最后写回寄存器堆。分支跳转的地址由PC加偏移计算,跳转指令的地址由指令中的26位字段拼接PC高4位得到。

这里有个设计决策点:数据存储器和指令存储器是分开的还是统一的。MIPS架构本身是统一编址的,但实验里通常分开实现,因为指令存储器只读、数据存储器读写,分开做时序更简单。如果要做统一存储器,读写冲突的处理会复杂很多,单周期里不推荐。

2.3 数据通路关键路径:时钟周期怎么算

单周期CPU的时钟周期必须大于等于最长组合逻辑延迟。最长路径通常出现在lw指令:PC更新→指令存储器读出→译码→寄存器堆读出→ALU计算地址→数据存储器读出→写回多路器→寄存器堆写入。这条路径上每一级都有延迟,加起来就是关键路径。

实际做的时候,不需要精确计算每个门的延迟,但要有这个概念。如果仿真时发现数据在时钟上升沿还没稳定,那就是关键路径太长了。解决办法通常是简化ALU、减少多路器级数、或者把寄存器堆的读操作改成组合逻辑输出(写操作仍然是时序的)。我一般会在ALU里把移位操作单独拿出来,因为桶形移位器的延迟比加减法大很多,如果指令子集里没有移位指令,就直接不实现,省一大块延迟。

3. Verilog核心模块实现:译码、ALU与寄存器堆的代码细节

3.1 译码器:控制信号的生成逻辑

译码器是整个CPU的指挥中心,它的输入是指令的opcode和funct字段,输出是一组控制信号:RegDst、ALUSrc、MemtoReg、RegWrite、MemRead、MemWrite、Branch、Jump、ALUOp。这些信号的取值直接决定了数据通路的走向。

// decoder.v - 指令译码器 module decoder( input [5:0] opcode, // 指令高6位 input [5:0] funct, // R型指令功能码 output reg RegDst, // 写寄存器选择:0=rt, 1=rd output reg ALUSrc, // ALU第二操作数选择:0=寄存器, 1=立即数 output reg MemtoReg, // 写回数据选择:0=ALU, 1=存储器 output reg RegWrite, // 寄存器堆写使能 output reg MemRead, // 数据存储器读使能 output reg MemWrite, // 数据存储器写使能 output reg Branch, // 分支指令标志 output reg Jump, // 跳转指令标志 output reg [2:0] ALUOp // ALU操作码 ); always @(*) begin // 默认值:防止锁存器推断 RegDst = 0; ALUSrc = 0; MemtoReg = 0; RegWrite = 0; MemRead = 0; MemWrite = 0; Branch = 0; Jump = 0; ALUOp = 3'b000; case(opcode) 6'b000000: begin // R型指令 RegDst = 1; RegWrite = 1; case(funct) 6'b100000: ALUOp = 3'b010; // add 6'b100010: ALUOp = 3'b110; // sub 6'b100100: ALUOp = 3'b000; // and 6'b100101: ALUOp = 3'b001; // or 6'b101010: ALUOp = 3'b111; // slt endcase end 6'b001000: begin // addi ALUSrc = 1; RegWrite = 1; ALUOp = 3'b010; end 6'b100011: begin // lw ALUSrc = 1; RegWrite = 1; MemtoReg = 1; MemRead = 1; ALUOp = 3'b010; end 6'b101011: begin // sw ALUSrc = 1; MemWrite = 1; ALUOp = 3'b010; end 6'b000100: begin // beq Branch = 1; ALUOp = 3'b110; end 6'b000101: begin // bne Branch = 1; ALUOp = 3'b110; end 6'b000010: begin // j Jump = 1; end 6'b000011: begin // jal Jump = 1; RegWrite = 1; end endcase end endmodule

这段代码的关键点在于默认值赋值。always块里如果不给所有输出赋默认值,综合工具会推断出锁存器,这是新手最容易踩的坑。锁存器在仿真时可能看不出问题,但上板后时序完全不可控。另外,ALUOp的编码要和ALU模块里的case严格对应,我一般用3'b010表示加法、3'b110表示减法、3'b000表示与、3'b001表示或、3'b111表示小于则置位。

3.2 ALU设计:运算与标志位生成

ALU负责所有算术逻辑运算,同时产生零标志位供分支指令使用。输入是两个32位操作数和一个3位操作码,输出是32位结果和零标志。

// alu.v - 算术逻辑单元 module alu( input [31:0] a, // 操作数A(来自寄存器堆) input [31:0] b, // 操作数B(寄存器或立即数) input [2:0] alu_op, // 操作码 output reg [31:0] result,// 运算结果 output zero // 零标志 ); assign zero = (result == 32'b0); always @(*) begin case(alu_op) 3'b000: result = a & b; // and 3'b001: result = a | b; // or 3'b010: result = a + b; // add 3'b110: result = a - b; // sub 3'b111: result = ($signed(a) < $signed(b)) ? 32'b1 : 32'b0; // slt default: result = 32'b0; endcase end endmodule

slt指令这里用了$signed做有符号比较,因为MIPS的slt默认是有符号比较。如果实验要求无符号比较,改成a < b即可。零标志位是组合逻辑输出,分支指令在译码阶段会用到它。注意zero信号在result变化后会有毛刺,但在单周期里因为时钟周期足够长,毛刺会在下一个时钟沿前稳定下来,不影响功能。

3.3 寄存器堆:双读单写与写优先策略

寄存器堆是CPU里最频繁访问的部件,MIPS32有32个32位通用寄存器,$0恒为零。寄存器堆需要支持两个读端口和一个写端口,读操作是组合逻辑,写操作是时序逻辑。

// regfile.v - 寄存器堆 module regfile( input clk, input rst, input [4:0] raddr1, // 读地址1 input [4:0] raddr2, // 读地址2 input [4:0] waddr, // 写地址 input [31:0] wdata, // 写数据 input we, // 写使能 output [31:0] rdata1, // 读数据1 output [31:0] rdata2 // 读数据2 ); reg [31:0] regs[0:31]; integer i; // 写操作:时钟上升沿写入 always @(posedge clk or posedge rst) begin if (rst) begin for (i = 0; i < 32; i = i + 1) regs[i] <= 32'b0; end else if (we && waddr != 5'b0) begin regs[waddr] <= wdata; end end // 读操作:组合逻辑输出,$0恒为零 assign rdata1 = (raddr1 == 5'b0) ? 32'b0 : regs[raddr1]; assign rdata2 = (raddr2 == 5'b0) ? 32'b0 : regs[raddr2]; endmodule

写地址为0时禁止写入,这是MIPS架构的硬性规定。读操作里也做了$0的判断,双保险。写优先策略的意思是,如果同一个时钟周期内对同一个寄存器既读又写,读出的应该是旧值还是新值?在单周期CPU里,因为读是组合逻辑、写是时序逻辑,读出的自然是旧值,这符合MIPS规范。如果实验要求写优先,需要在读数据路径上加旁路逻辑,但单周期里通常不需要。

4. 数据通路联调与仿真验证:从波形到指令级追踪

4.1 顶层连线:模块例化与信号命名规范

顶层模块的工作是把所有子模块例化并连线。信号命名我习惯用_if、_id、_ex、_mem、_wb后缀区分阶段,这样在波形里看信号一目了然。

// cpu_top.v - 顶层模块(核心连线部分) module cpu_top( input clk, input rst, output [31:0] pc_out, output [31:0] alu_result ); wire [31:0] pc, pc_next, pc_plus4; wire [31:0] instr; wire [31:0] rdata1, rdata2, wdata; wire [31:0] ext_imm, alu_b, alu_out, mem_rdata; wire [4:0] waddr; wire [2:0] alu_op; wire RegDst, ALUSrc, MemtoReg, RegWrite, MemRead, MemWrite, Branch, Jump, zero; // 取指阶段 pc_reg u_pc(.clk(clk), .rst(rst), .pc_next(pc_next), .pc(pc)); assign pc_plus4 = pc + 4; imem u_imem(.addr(pc), .instr(instr)); // 译码阶段 decoder u_dec( .opcode(instr[31:26]), .funct(instr[5:0]), .RegDst(RegDst), .ALUSrc(ALUSrc), .MemtoReg(MemtoReg), .RegWrite(RegWrite), .MemRead(MemRead), .MemWrite(MemWrite), .Branch(Branch), .Jump(Jump), .ALUOp(alu_op) ); regfile u_regfile( .clk(clk), .rst(rst), .raddr1(instr[25:21]), .raddr2(instr[20:16]), .waddr(waddr), .wdata(wdata), .we(RegWrite), .rdata1(rdata1), .rdata2(rdata2) ); extend u_ext(.imm16(instr[15:0]), .imm32(ext_imm)); // 执行阶段 assign alu_b = ALUSrc ? ext_imm : rdata2; alu u_alu(.a(rdata1), .b(alu_b), .alu_op(alu_op), .result(alu_out), .zero(zero)); // 访存阶段 dmem u_dmem(.clk(clk), .addr(alu_out), .wdata(rdata2), .we(MemWrite), .re(MemRead), .rdata(mem_rdata)); // 写回阶段 assign waddr = Jump ? 5'd31 : (RegDst ? instr[15:11] : instr[20:16]); assign wdata = MemtoReg ? mem_rdata : alu_out; // 下一条PC计算 assign pc_next = Jump ? {pc_plus4[31:28], instr[25:0], 2'b00} : (Branch && zero) ? pc_plus4 + (ext_imm << 2) : pc_plus4; assign pc_out = pc; assign alu_result = alu_out; endmodule

这段连线里有两个细节值得说。第一,jal指令的写回地址是$31,所以waddr在Jump有效时强制为31。第二,跳转地址的拼接是{pc_plus4[31:28], instr[25:0], 2'b00},这是MIPS的伪直接寻址方式,26位字段左移2位后与PC高4位拼接。分支地址是pc_plus4 + (ext_imm << 2),立即数左移2位是因为指令地址是4字节对齐的。

4.2 仿真测试:用冒泡排序程序验证指令覆盖

仿真平台我一般用Verilog testbench加$readmemh加载指令和数据。测试程序用冒泡排序,因为它覆盖了add、sub、slt、lw、sw、beq、bne、addi、j、jal全部12条指令。

// tb_cpu.v - 测试平台 module tb_cpu; reg clk, rst; wire [31:0] pc_out, alu_result; cpu_top u_cpu(.clk(clk), .rst(rst), .pc_out(pc_out), .alu_result(alu_result)); // 时钟生成:周期10ns initial begin clk = 0; forever #5 clk = ~clk; end // 复位与运行 initial begin rst = 1; #20 rst = 0; #2000 $finish; // 运行2000ns后结束 end // 波形记录 initial begin $dumpfile("cpu_wave.vcd"); $dumpvars(0, tb_cpu); end // 监控关键信号 always @(posedge clk) begin if (!rst) begin $display("PC=%h Instr=%h ALU=%h", pc_out, u_cpu.instr, alu_result); end end endmodule

仿真时重点看几个东西:PC是否按预期递增或跳转、指令存储器读出的指令是否和预加载的一致、ALU结果是否符合预期、寄存器堆的写回是否在正确的时钟沿发生。如果PC卡在某个值不动,通常是分支条件判断有问题;如果ALU结果全是x,检查操作数是否有未初始化的情况。

4.3 指令级追踪:逐条比对参考模型

光看波形不够,还要做指令级追踪。我的做法是写一个简单的参考模型,用Python或C把同样的程序跑一遍,记录每条指令执行后的寄存器状态,然后和Verilog仿真的结果逐条比对。

# ref_model.py - 指令级参考模型(片段) regs = [0] * 32 pc = 0 imem = load_hex('program.hex') dmem = [0] * 1024 def execute(instr): op = (instr >> 26) & 0x3F rs = (instr >> 21) & 0x1F rt = (instr >> 16) & 0x1F rd = (instr >> 11) & 0x1F imm = instr & 0xFFFF if imm & 0x8000: imm -= 0x10000 # 符号扩展 funct = instr & 0x3F if op == 0: # R型 if funct == 0x20: regs[rd] = (regs[rs] + regs[rt]) & 0xFFFFFFFF elif funct == 0x22: regs[rd] = (regs[rs] - regs[rt]) & 0xFFFFFFFF # ... 其他R型指令 elif op == 0x23: # lw addr = (regs[rs] + imm) & 0xFFFFFFFF regs[rt] = dmem[addr // 4] # ... 其他I型、J型指令 regs[0] = 0 # $0恒为零

参考模型不需要综合,用Python写最快。每执行一条指令,就把32个寄存器的值打印出来,和Verilog仿真里$display的输出对比。如果某条指令后寄存器值不一致,就定位到那条指令,检查译码或ALU逻辑。这个方法比盯着波形看效率高得多,尤其是调试分支跳转的时候。

5. 避坑与常见问题:仿真过了上板挂掉的五个原因

5.1 现象:仿真波形全红,信号全是x

原因通常是寄存器堆或存储器未初始化。Verilog里reg类型默认是x,如果复位逻辑没覆盖到所有寄存器,仿真一开始就是不定态。解决方法是确保复位信号连接到所有时序模块,并且在testbench里给足够长的复位时间。另外,指令存储器用$readmemh加载时,如果文件路径不对或格式有误,读出来也是x。检查方法是仿真开始后先看指令存储器的输出,如果是x,就是加载失败。

5.2 现象:PC跳转后指令执行错乱

原因多半是分支地址计算错误。MIPS的分支地址是PC+4 + (offset << 2),不是PC + (offset << 2)。很多人在这里少加了一个4,导致跳转偏移了4个字节,指令就全乱了。另一个常见错误是跳转指令的地址拼接,{PC+4[31:28], target[25:0], 2'b00}里的PC+4高4位,不是PC的高4位。这两个细节在写代码时容易忽略,仿真时表现为跳转后执行了错误的指令。

5.3 现象:lw指令读出的数据不对

先检查数据存储器的地址对齐。MIPS的lw要求地址4字节对齐,如果ALU算出的地址不是4的倍数,行为未定义。实验里通常假设程序是正确的,但调试时如果发现lw读出奇怪的值,可以打印ALU输出的地址看看。另一个原因是数据存储器的写使能和读使能同时有效,导致读写冲突。单周期里lw和sw不会同时出现,但译码逻辑如果写错了,可能会同时拉高MemRead和MemWrite,这时候数据存储器输出就是不确定的。

5.4 现象:上板后时钟频率上不去

仿真通过但上板跑不到预期频率,说明关键路径太长。单周期CPU的关键路径通常是lw指令的执行路径。优化方法有几个:把ALU里的减法用加法器加补码实现,减少一级逻辑;把寄存器堆的读操作改成寄存器输出(加一级流水),但这会改变单周期的语义,需要谨慎;把指令存储器和数据存储器用Block RAM实现,减少布线延迟。如果实验不要求高频,通常50MHz以下都能跑,不用过度优化。

5.5 现象:jal指令写回的地址不对

jal指令要把下一条指令的地址(PC+4)写入$31,但很多实现里写回的是ALU结果或存储器数据。检查wdata的多路选择逻辑,jal应该单独走一条路径,把PC+4送到写回数据端。另外,waddr在jal时应该是31,这个在顶层连线里要单独处理。如果jal和j共用Jump信号,需要在写回逻辑里区分这两条指令,通常用opcode或funct来判断。

6. 验收与答辩:怎么证明你的CPU真的跑对了

验收的时候,老师不会只看波形,通常会让你现场跑一个程序,然后检查寄存器或内存的最终状态。我的建议是提前准备三个测试程序:第一个是简单的算术运算,验证R型和I型指令;第二个是内存读写,验证lw和sw;第三个是冒泡排序,验证分支和跳转。每个程序都要有预期的最终状态,答辩时直接展示比对结果。

具体操作上,我会在testbench里加一段自动检查逻辑,程序跑完后把关键寄存器的值和预期值比对,一致就打印“PASS”,不一致就打印“FAIL”并指出哪个寄存器不对。这样答辩时一目了然,不用手动翻波形。

// 自动检查逻辑(加在testbench末尾) initial begin #2000; // 等待程序跑完 if (u_cpu.u_regfile.regs[8] == 32'h0000000A && u_cpu.u_regfile.regs[9] == 32'h00000014) begin $display("TEST PASS: reg[8]=%h reg[9]=%h", u_cpu.u_regfile.regs[8], u_cpu.u_regfile.regs[9]); end else begin $display("TEST FAIL: reg[8]=%h reg[9]=%h", u_cpu.u_regfile.regs[8], u_cpu.u_regfile.regs[9]); end $finish; end

答辩时还有一个加分项:展示指令级追踪的对比表。把参考模型和Verilog仿真的每条指令执行结果列成表格,逐条打勾。这个表能直接证明你的CPU在指令级别是正确的,比看波形有说服力得多。

指令序号PC值指令参考模型reg[8]仿真reg[8]一致
10x00000000addi $8,$0,100x0000000A0x0000000A是
20x00000004addi $9,$0,200x0000000A0x0000000A是
30x00000008add $10,$8,$90x0000000A0x0000000A是
..................

从那以后我每次做CPU实验,都强制自己先写参考模型再写Verilog,仿真通过后必须跑一遍指令级比对,最后才上板。这个习惯帮我省了至少三次通宵调试。希望这套方案能帮到你,把单周期CPU这个实验一次跑通。

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

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

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

立即咨询