ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

单周期CPU设计全解析:MIPS32指令集、数据通路与Verilog实现

单周期CPU设计全解析:MIPS32指令集、数据通路与Verilog实现

简介:面向计算机组成原理课程学习者与Verilog HDL入门者,这份实验报告完整记录MIPS32单周期CPU从指令分析、模块设计到仿真验证的全过程。资源以Verilog HDL为主语言,实现了十六条常用指令的取指、译码、执行与写回通路,并展示了寄存器堆、ALU、控制单元、数据存储器等核心模块的代码组织与连接方式,可帮助读者系统理解单周期处理器的工作原理与逻辑功能实现。包体共90个文件,以v源码、qpf/qsf工程配置、rpt编译报告、vwf仿真波形、docx实验报告及jpg运行截图为主,压缩包仅2.37MB,文件类型涵盖Quartus工程各阶段生成产物;其中docx报告详细记录了设计思路,仿真波形与测试截图则便于逐段对照调试。已有12292人学习下载,适合正在完成同类实验、复试备考或初次接触MIPS32架构的同学参考借鉴。

1. 单周期CPU设计:教学实验里最硬的骨头,也是理解MIPS32的钥匙

单周期CPU设计几乎是每个计算机组成原理实验里的终极大作业,也是我见过挂科率最高的一个实验。很多同学把数据通路图画了三个星期还没搭好,问题往往不在代码,而在动手前没把指令集和信号之间的关系想清楚。这份《计算机组成原理》实验报告正好覆盖了从MIPS32指令子集选定、数据通路设计、Verilog HDL代码实现到仿真验证的全过程,连实验报告怎么排版、波形图怎么贴、问题分析怎么写得像回事都有完整示例。它适合两类人:一类是被课程设计逼到墙角、需要一份能复现的完整参考的本科生;另一类是面试前想快速把单周期CPU整条链路串起来的求职者——这类资源不是让你抄,是让你在有限时间里看清一个完整CPU到底由哪些部件构成。

2. 指令集子集选定:先定8条指令,再谈数据通路映射

2.1 为什么选8条而不是全量MIPS32

做单周期CPU的第一步永远不是写代码,而是决定CPU支持哪些指令。全量MIPS32有几十条指令,单周期实验中全部实现既不现实也没有必要。这份实验报告选择的是一个经过剪裁的8条指令子集,这也是国内多数高校课程设计里的常规选择。8条指令刚好覆盖三种指令格式:R型、I型、J型,又能让控制信号的数量控制在十个左右,数据通路图也不会乱到看不清。

选指令有个原则:每条指令都要带来新的控制信号组合。比如lw和sw这对访存指令决定了需要MemRead、MemWrite、MemtoReg三个信号;beq引入了Branch信号和ALU减法比较逻辑;j引入了跳转地址拼接逻辑。如果只选算术逻辑指令,数据通路会简单一半,但你也学不到访存和分支的核心机制,评分通常也不会高。

2.2 指令格式拆解与译码信号的来源

MIPS32指令统一为32位,格式固定,这是它适合教学的根本原因。R型指令的31到26位是opcode,25到21位是rs,20到16位是rt,15到11位是rd,10到6位是shamt,5到0位是funct。I型指令没有rd和shamt,15到0位直接是16位立即数。J型指令的26到0位是跳转目标地址的低26位。

这个实验报告里最值得看的部分是它把每条指令分列成一张表,标记opcode、funct、需要的控制信号。我一般会让读者先自己做一遍这张表,因为后面写Verilog控制单元时,代码几乎就是这张表的逐行翻译。比如add指令的opcode是6'b000000,funct是6'b100000,RegDst为1,ALUSrc为0,RegWrite为1;而addi的opcode是6'b001000,RegDst为0,ALUSrc为1,RegWrite为1——两者差别集中在RegDst和ALUSrc上,这就是译码时区分R型和I型的关键。

2.3 每条指令需要的数据通路资源清单

确定指令集后,下一步是逐条列出数据通路资源。我习惯画一张表,横向是部件(指令存储器、寄存器堆、ALU、数据存储器、PC、立即数扩展单元),纵向是每条指令,交叉格子里打勾。这份报告里的设计覆盖了如下资源需求:

指令指令存储器寄存器堆读寄存器堆写ALU运算数据存储器读数据存储器写PC更新方式
add是rs, rtrd加否否PC+4
sub是rs, rtrd减否否PC+4
and是rs, rtrd与否否PC+4
or是rs, rtrd或否否PC+4
slt是rs, rtrd比大小否否PC+4
lw是rsrt加读否PC+4
sw是rs, rt否加否写PC+4
beq是rs, rt否减否否条件跳转

这张表的好处是写Verilog前你就能发现:寄存器堆必须有两个读端口和一个写端口,ALU必须支持加、减、与、或、比较五种运算,数据存储器只有lw和sw用得到。如果你发现某个部件只有一条指令用到,就该想想这算不算浪费——单周期CPU里每个部件每个周期都在工作,哪怕当前指令用不到某个部件,它的输入输出也必须保持无害状态。这就是控制信号里为什么要有ALUOp、MemRead这类使能信号的原因。

3. 数据通路搭建:从PC到寄存器堆的连线,先画图再写码

3.1 经典单周期结构的三个核心部件

很多同学拿到实验报告第一步就看代码,这是本末倒置。单周期CPU的灵魂是数据通路图,代码只是数据通路的文字化表达。这份报告里有完整的数据通路图,核心可以拆成三块:取指部分、执行部分、访存回写部分。

取指部分由PC、指令存储器、加法器组成。PC输出当前指令地址,指令存储器读出32位指令,PC+4作为下一条指令的地址。关键点在于:PC更新和指令读取是同一周期完成的,beq跳转时需要把跳转地址通过MUX选择进入PC。

执行部分由寄存器堆、立即数扩展单元、ALU组成。寄存器堆读出rs和rt的值;立即数扩展把16位立即数符号扩展成32位;ALU根据控制信号决定做加法还是其他运算。beq和lw/sw都要经过ALU做加法,但beq加出来的是地址,lw/sw加出来的是访存地址,二者共用同一个ALU反而是单周期结构最优雅的地方——硬件资源不会因为指令不同而改变。

访存回写部分是单周期CPU相对复杂的一环。lw读出的数据要写回寄存器堆,而add的结果来自ALU,所以寄存器堆写数据端口前要放一个MUX,用MemtoReg信号选择写回的是内存读出的数据还是ALU结果。同时要注意:寄存器堆的写地址也需要MUX,R型指令写rd,I型指令写rt,这个选择信号是RegDst。

3.2 PC与指令存储器的读写时机

单周期CPU只有一个时钟周期,所有操作在一个周期内完成,因此PC的更新逻辑最关键。由于没有流水线寄存器,每个周期开始时PC输出当前指令地址,组合逻辑完成译码、执行、访存后,在时钟上升沿到来时把下一条指令地址写入PC。这个“边沿触发写、电平期间读”的机制是整个设计的基石。

写Verilog时,PC部分最常见的写法是这样的:

always @(posedge clk or posedge rst) begin if (rst) pc <= 32'h0000_0000; // 复位PC到0 else pc <= next_pc; // next_pc由组合逻辑计算 end

这里的next_pc是在组合逻辑里计算的。默认情况下是pc + 4;当Branch信号为1且ALU输出的Zero信号为1时,next_pc应该变成pc + 4 + (sign_extended_offset << 2);当Jump信号为1时,next_pc是{pc[31:28], instruction[25:0], 2'b00}。注意J型指令的跳转地址是绝对地址拼接,不是相对偏移,容易和beq搞混。

3.3 寄存器堆、ALU与数据存储器的信号关系

寄存器堆是异步读、同步写的典型部件。异步读意味着读地址一变化,读数据马上跟着变,不依赖时钟;同步写意味着只有在时钟上升沿,且RegWrite为1时,写地址对应的寄存器才会被更新。这个特性要求写代码时把读逻辑放在组合逻辑的always块里,把写逻辑放在时序逻辑的always块里,二者不能混在同一个块里。

ALU的控制需要单独理解。ALUOp是两级信号,由控制单元根据opcode生成,再交给ALU控制单元结合funct字段生成最终的ALU控制信号。实验中比较通用的映射表是:ALUOp为2'b00时,ALU做加法,对应lw和sw;ALUOp为2'b01时,ALU做减法,对应beq;ALUOp为2'b10时,需要查看funct字段决定加法、减法、与、或、比较中的哪一种。ALU控制信号一般用4位,比如0010代表加,0110代表减,0000代表与,0001代表或,0111代表小于比较。

数据存储器的读和寄存器堆类似,读是组合逻辑,写是同步逻辑。特别要注意的是:数据存储器的读地址是ALU的运算结果,而不是PC。lw和sw的访存地址都是寄存器值加立即数偏移,所以数据存储器的地址线直接接ALU输出,MemRead和MemWrite分别控制读使能和写使能。

4. Verilog实现:控制信号真值表与可综合代码的五个边界

4.1 控制单元真值表先写Excel再写代码

控制单元是单周期CPU最容易写乱的部分。常见误区分两类:一类是把控制逻辑写成if_opcode_equals...的瀑布结构,代码冗长且容易漏信号;另一类是想用case一把梭,但没有整理过真值表,写到一半发现某个指令的控制信号对不上。我见过最稳的流程是先建一个Excel表格,把8条指令对应9个控制信号全部列出,再照着表格写case语句。这份实验报告里也有同样的痕迹——它的控制信号表做得非常规整,甚至可以直接拿来当测试基准。

以这份报告的8条指令为例,控制信号真值表的关键几行如下:

指令RegDstALUSrcMemtoRegRegWriteMemReadMemWriteBranchJumpALUOp
R型1001000010
lw0111100000
swX1X0010000
beqX0X0001001
jXXX00001XX

注意sw的RegDst和MemtoReg是X(无关项),因为这条指令不写寄存器堆,这两个信号是什么无所谓。写代码时X可以直接填0,不影响正确性。j的ALUOp同理可能是X,因为跳转指令不需要ALU运算,填00也不会出错。

有了这张表,控制单元的代码只是翻译工作:

always @(*) begin // 默认所有控制信号为0,逐条指令覆盖 RegDst = 1'b0; ALUSrc = 1'b0; MemtoReg = 1'b0; RegWrite = 1'b0; MemRead = 1'b0; MemWrite = 1'b0; Branch = 1'b0; Jump = 1'b0; ALUOp = 2'b00; case (opcode) 6'b000000: begin // R型指令按funct子译码 RegDst = 1'b1; RegWrite = 1'b1; ALUOp = 2'b10; end 6'b100011: begin // lw ALUSrc = 1'b1; MemtoReg = 1'b1; RegWrite = 1'b1; MemRead = 1'b1; ALUOp = 2'b00; end 6'b101011: begin // sw ALUSrc = 1'b1; MemWrite = 1'b1; ALUOp = 2'b00; end 6'b000100: begin // beq Branch = 1'b1; ALUOp = 2'b01; end 6'b000010: begin // j Jump = 1'b1; end default: begin // 预留:未定义指令保持默认值 end endcase end

这段代码的默认赋值习惯值得学习。很多人喜欢在每个case分支里把9个信号全部重新赋值一遍,代码既长又容易漏。把默认值放在case之前,case里只需要写“与默认值不同”的信号,可读性和可维护性都高一个档次。这里lw的MemtoReg和ALUSrc设成了1,而RegDst用的是默认值0——因为lw写回寄存器堆时写的是rt,不是rd。

4.2 可综合代码写法:避免仿真与上板不一致

仿真通过但上板不工作的案例在单周期CPU实验里极其常见,绝大多数根因出在代码风格上。报告里最典型的可综合代码问题有两个:一是initial块里的初始化语句,二是clk和rst在多个always块里被重复使用。

针对仿真和上板行为不一致的问题,我一般坚持这些习惯:

// 全局通用参数定义 `define OP_WIDTH 6 `define FUNC_WIDTH 6 `define DATA_WIDTH 32 module cpu( input wire clk, input wire rst, output wire [31:0] pc_out, // 对外可观察PC,便于调试 output wire [31:0] alu_out // 对外可观察ALU结果 );
// 寄存器堆:读采用组合逻辑,写采用时序逻辑 reg [31:0] regfile [0:31]; integer i; always @(posedge clk or posedge rst) begin if (rst) begin for (i = 0; i < 32; i = i + 1) begin regfile[i] <= 32'b0; // 不是用initial,而是用复位清零 end pc <= 32'b0; end else if (RegWrite && (write_reg != 5'b0)) begin regfile[write_reg] <= write_data; end end

很多人喜欢用initial begin ... end对寄存器堆和PC做初始赋值。仿真时initial确实有效,但上板时initial块经常被综合工具跳过,导致上电后寄存器堆是随机值,仿真里永远复现不出来的现象随之出现。正确做法是在rst信号的时序块里做初始化,上电后复位一次,所有状态回到已知值。

另外要留意写寄存器堆的if里多了个write_reg != 5'b0的条件。MIPS32的$0寄存器恒为0,即使RegWrite为1也不应该被写入。这个边界在处理lw $0这类指令时能避免莫名其妙的数值改乱。

5. 仿真与避坑:波形检查、初始化问题与最常翻车的四个细节

5.1 仿真波形先检查这三个信号

写完代码第一件事不是急着上板,而是跑仿真。仿真时只看最终结果是不行的,必须看波形。我建议按顺序检查三个信号:PC是否按预期递增、控制信号是否在正确周期拉高、寄存器堆写数据是否与指令语义一致。这个顺序的依据是:PC错了说明取指链路存在问题,控制信号错了说明译码链路存在问题,写回错了说明回写链路存在问题,逐层排查才能定位根因。

仿真testbench里可以对照指令序列写一份期望输出。比如程序第一条指令是addi $t0, $zero, 5,那么第一个周期结束后$t0应该变成5;第二条指令是add $t1, $t0, $t1,那么第二个周期结束后$t1应该变成5加初值。写期望输出时不要自己算,要用MIPS模拟器或手工按规则算清楚,避免“仿真结果和手算结果一致但手算本来就是错的”这种尴尬局面。

5.2 初始化与复位:黑匣子启动的玄学

单周期CPU上板后的“玄学”问题,九成是复位和初始化造成的。很多同学在testbench里用initial给所有信号赋了个初值,仿真正常,一上板就乱跑。原因在上章已经提过:initial块不可综合。另一个常见玄学是复位信号的异步复位和同步释放没有处理好。实验报告里常用的做法是让rst信号在仿真文件里先拉高几个时钟周期再拉低:

initial begin rst = 1; #100; rst = 0; // 之后开始执行程序 end

这行代码的意义是让CPU进入一个已知状态。单独看它很简单,但很多人会犯一个低级错误:rst只拉高了1个时间单位,还没等到第一个时钟上升沿就拉低,复位没起到作用。如果testbench里同时有rst和clk,我习惯让rst保持至少两个完整时钟周期,保证第一个上升沿到来时CPU处于复位状态。

5.3 常见翻车现象与解决记录

这一节是文章里最值得反复看的部分,我在做这个实验时踩过的坑和帮别人调试时见的坑,集中记录如下:

现象一:程序前几条指令执行正确,从某条指令开始寄存器值全部错乱原因:寄存器堆的写地址MUX接错。很多人在做R型指令时明明RegDst应该为1选rd,结果选成了rt;或反过来。lw写rt、add写rd,如果两个地址混了,后续所有依赖该寄存器的指令都会算错。 解决:把每条指令的写寄存器地址打印到波形里,和真值表逐条对照。R型看instruction[15:11],I型看instruction[20:16],确认MUX选择信号和指令类型对齐。

现象二:beq跳转不生效,但控制信号明明是Branch=1原因:Zero信号没有接对。beq比较两个操作数是否相等,ALU做减法后输出Zero为1表示相等。如果ALU减法结果位宽和Zero信号位宽不一致,或者Zero信号取自ALU结果的最低位而不是所有位做或非,跳转条件判定就会失效。 解决:Zero必须是ALU结果32位按位或后取非,不是某一位。检查ALU结果最大位宽是32位,Zero信号应该用assign Zero = ~|alu_result;,这个写法在报告里出现过,确认自己没写反。

现象三:j指令跳转地址错了,跳到一个奇怪的位置原因:J型指令的目标地址拼接错误。MIPS32的j指令目标地址是{pc[31:28], instruction[25:0], 2'b00},为什么要拼PC的高4位?因为32位地址空间里跳转目标不能脱离当前256MB区域。很多人直接把26位指令字段左移两位,没有拼上PC高4位,地址自然错乱。 解决:检查拼接表达式,一定是四个部分拼接,顺序是pc[31:28]在最前面,指令字段中间,末尾补两个0。不要忘了2'b00。

现象四:lw读出的数据永远不变,或者读出来的数不对原因:把数据存储器的读端口写成了时序逻辑。MIPS32单周期里数据存储器读是组合逻辑,地址一变数据马上变;如果读数据也在always @(posedge clk)里更新,读结果会晚一拍,出现“当前指令读的是上一条指令地址的数据”。 解决:数据存储器要么用wire和assign做读端口,要么用两个always块,读端口全组合逻辑,只有写端口用时钟。用reg声明存储数组时,读数据用assign mem_data = mem[mem_addr];写数据用always @(posedge clk)。

现象五:上板后板子无输出,仿真完全正常原因:寄存器堆或PC没有复位逻辑。上电后FPGA内部寄存器是随机值,没有复位端口的状态机或寄存器阵列开始乱跑。 解决:确认所有状态相关模块都有rst分支。检查PC和寄存器堆的时序块里有没有if (rst) begin ... end,没有的情况下上板翻车概率极高。

6. 上板验证与调试:信号观察、板上自检程序与时间预算

6.1 板上调试的环境准备

仿真全过之后,真正的考验才开始。上板调试时你面对的是一个没有波形的黑匣子,只能通过有限的LED或数码管观察结果。这时候我的一套流程已经被验证过多次,先说结论:先用最基础的指令自检程序,再跑复杂程序;先观察关键信号到外设,再做全功能验证。

代码里把pc_out和alu_out引到板子上的LED,是性价比最高的调试手段。LED显示PC是否按预期递增,或ALU结果是否符合预期,比JTAG和逻辑分析仪更快。如果板子LED足够多,可以同时接入PC低8位和ALU结果低8位,两个信号一起看能迅速判断卡死在哪个周期。

6.2 自检程序的设计路线

自检程序是上板前的最后一道防线,也是这份实验报告里比较出彩的部分。我通常建议按难度分三层设计:

第一层:基础运算。执行addi、add、sub各几条,把结果写进不同的寄存器,通过这些寄存器的值判断ALU通路和控制单元通路是否正确。第二层:访存指令。执行sw把一组已知数据写入内存连续地址,再执行lw读回来,比较读回值和写入值是否一致。第三层:跳转指令。先设计一个简单循环,用一个寄存器做计数器,循环若干次后跳出,观察PC跳转过程中是否有异常停顿。

6.3 我保留到现在的检查习惯

做完单周期CPU实验后,我养成了一个习惯:每次写带状态机的Verilog模块,都会先整理一张与本章第4节类似的“控制信号真值表”,再把它转化成注释写进代码顶部,最后才开始写逻辑。从那以后,凡是按这个流程走的模块,Debug时间至少缩短了三分之二。复现这份实验报告的过程也证实了同一件事:单周期CPU的设计没有玄学,只有逻辑没理清就动手的仓促。按指令集、数据通路、控制真值表、代码、仿真、上板这个顺序推进,每一步都是上一步的验证,翻车概率自然大幅下降。

希望这份实验报告能帮你把单片机的“单周期”三个字彻底看清楚——有限指令集、固定数据通路、组合逻辑译码、边沿触发更新,理解了这四件事,单周期CPU也就没有什么秘密了。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进