☰
Verilog实现单周期MIPS处理器:计算机组成原理实验二设计指南
2026/10/8 9:17:39 网站建设 项目流程

计算机组成原理**课程设计实验二,对软件学院的同学来说,是大学四年里少有的几次"硬核"体验。实验一可能还在教你怎么写一个ALU、搭一个寄存器堆,到了实验二,就要把这些零散的部件组合成一个能真正跑指令的CPU核心。很多同学就是从这个实验开始,才真正明白"指令是怎么一步步变成数据的流动"这件事。

这篇内容我按山东大学软件学院课程设计的常见要求来展开,核心场景是:用Verilog在FPGA平台上,实现一个支持基础指令集的单周期MIPS处理器。不管你的实验板是不是同一块、指令集选MIPS还是RISC-V,里面涉及的模块划分、数据通路搭建、控制信号设计、仿真调试思路,都是通用的。这篇内容适合两类人:一类是正准备动手做实验二、想提前把思路理顺的同学;另一类是已经写了一堆代码但波形永远不对、想来找排查思路的同学。

1. 实验二整体设计与思路拆解

1.1 先定指令集:别让"做什么"拖到写代码那天才想

我说句实在话,实验二最容易翻车的地方,不是代码本身,而是动手之前没把指令集定清楚。山东大学软件学院这门课的实验二,通常要求实现一个能执行整数运算、访存和分支跳转指令的小型CPU。如果你选MIPS指令集,那最常见的要求是支持这几类指令:

  • 算数运算:add、sub、addi
  • 逻辑运算:and、or、slt
  • 访存:lw、sw
  • 分支:beq
  • 跳转:j

为什么优先选这套?因为它们是单周期CPU最经典的指令集子集,覆盖了寄存器操作、立即数扩展、内存读写、程序跳转所有关键路径。最关键的在于,这些指令的控制信号差异足够大,能逼着你去把控制器的每一根控制线想明白,而不是抄一份现成的大同表就完事。

实验报告里如果指令集写不清楚,后面全乱。我见过太多同学,代码里支持了七八条指令,但控制信号没做全,仿真的时候跑到第三条指令CPU就飞了。所以第一步一定是:把指令集列成表格,每条指令的操作码、功能、要修改哪些寄存器、要不要写回寄存器、要不要访存,全部写清楚。这一步花费的时间不会超过40分钟,但它决定了你后面调试时是"对着波形猜"还是"看着表格查"。

1.2 为什么单周期CPU是实验二的主流选择

实验二的核心是搭一个完整的数据通路,但数据通路的实现方式有好几种:单周期、多周期、流水线。课程设计通常选单周期,原因很直接——单周期是理解CPU工作原理的最佳载体。

单周期CPU的基本思想是:每条指令在一个时钟周期内完成取指、译码、执行、访存、写回全过程。这意味着数据通路里所有操作都在同一个时钟沿下同步推进,控制信号由组合逻辑根据指令解析出来。好处是控制逻辑简单、时序关系清晰,写代码的时候心理负担小;坏处是时钟频率很低,每条指令长短不齐只能按最慢的那条来定周期,但这对教学实验完全不是问题。

有人会问,流水线不是更能体现现代CPU设计思路吗?没错,但流水线涉及冒险处理、转发、停顿等一堆问题,复杂度呈指数级上升。实验二的重点是让你建立"指令是怎么通过硬件执行的"这个基本认识,单周期把这个目标完成得最干净。等以后学了体系结构,再去追加流水线结构也不迟。

1.3 数据通路的整体框架:先画图再写代码

单周期CPU的数据通路,说白了就是五步:取指、译码、执行、访存、写回。这五步不是五个独立模块,而是同一个时钟周期内完成的不同阶段。实际电路里,PC先从指令存储器取出一条指令,指令送进寄存器堆读出操作数,然后由ALU执行运算,最后根据控制信号决定是写回寄存器还是从数据存储器读数据。整体数据流是单向的,只有写回寄存器的时候会通过一个多路选择器把数据送回去。

动手写代码之前,我强烈建议你手动画一遍数据通路图。不要求画得多专业,但PC、指令存储器、寄存器堆、ALU、数据存储器、控制单元这几个核心模块之间怎么相连,控制信号的来源和去向必须自己画清楚。你会发现,真正难的不是模块本身,而是模块之间的连接线。比如beq指令,ALU计算结果作为零标志位,零标志位送到控制器,再决定下一个PC是pc+4还是pc+4+偏移量。这条路径不画出来,写代码的时候百分之百漏信号。

2. 核心模块拆解与实现要点

2.1 PC、指令存储器和寄存器堆:三个基础件不能写错

PC是整个CPU的"调度中心"。单周期CPU里,每个时钟上升沿PC更新一次,默认加4(因为一条指令占4字节)。遇到beq跳转时,PC改为当前地址加4再加上立即数偏移左移两位的结果;遇到j指令时,PC直接改成目标地址。实现时用一个带同步复位的寄存器即可,外加一个多路选择器来选择下一个PC的值。

需要注意的是PC的复位值。上电瞬间PC必须为0(对应第一条指令的地址),否则CPU一启动就跑飞。在Verilog里,always块中的复位逻辑要写成异步复位或者同步复位都可以,但一定要保证复位时PC等于0。这个问题看起来小,实际调试时如果波形里PC一上来是个不定值x,后面全部信号都会是红的,很多人卡在这一看就是半小时以上。

指令存储器用只读方式实现,实验里一般直接用reg数组加initial块来初始化一段机器码,或者用一个只读存储模型。关键点是存储器地址要用字节地址,而数组下标通常按字计算,所以取指令时IM[PC >> 2]这个操作,很多第一次写的人会理解不了。简单说,PC的值是8,对应第2条指令(8除以4等于2),所以要从数组中取第2个元素。

寄存器堆则是写一个双读单写的同步写、异步读存储阵列。两个读端口分别接rs和rt字段,一个写端口接rd或rt字段(取决于寄存器写入控制信号)。要特别注意的是写使能信号RegWrite和时钟的配合——在单周期里,寄存器堆的写入发生在时钟上升沿,但ALU算出的结果是在同一周期内组合到达写端口的。也就是说,写数据在时钟沿到来前必须稳定,否则会写入错误的值。

2.2 ALU与控制单元:真值表是调试时的救命稻草

ALU的实现相对直接,输入是A、B两个操作数和一个ALU控制信号ALUOp,输出是运算结果Result和一个零标志位Zero。支持add、sub、and、or、slt这五类运算就够用。slt比较特殊,它做的是A小于B置1,实现时可以先用减法,然后判断结果的符号位来做,但要注意MIPS里slt对无符号数和有符号数有不同的处理方式,实验里默认有符号即可。

真正需要花心思的是控制单元。控制单元是一个组合逻辑模块,输入是指令的opcode和funct字段,输出是一大堆控制信号。这些控制信号包括RegWrite、RegDst、ALUSrc、MemWrite、MemRead、MemtoReg、Branch、Jump等。每条指令对应的控制信号组合必须一一列清楚,写代码之前最好先做一张真值表,然后照着真值表写case语句,不要凭空硬写。

举个例子,lw指令的RegWrite是1,RegDst是0(写入rt而不是rd),ALUSrc是1(第二个操作数来自立即数而不是寄存器rt),MemRead是1,MemtoReg是1(写回的数据来自内存读出的值)。而add指令的RegWrite是1,RegDst是1(写入rd),ALUSrc是0,MemtoReg是0。这两条指令的差别就能看出RegDst和MemtoReg的意义所在。

控制信号真值表可以整理成如下表格(以MIPS部分指令为例):

指令opcodeRegDstALUSrcMemtoRegRegWriteMemReadMemWriteBranchJump
add00000010010000
sub00000010010000
and00000010010000
or00000010010000
slt00000010010000
addi00100001010000
lw10001101111000
sw101011x1x00100
beq000100x0x00010
j000010xxx00001

这张表要反复对照,特别是sw指令,它不写寄存器但要从寄存器读两个操作数,所以RegWrite必须是0但rs和rt两个读口都要工作。写代码时,控制器里经常会出现某几个信号在这个指令下置1、那个指令下置0,漏掉一个case分支,整条指令就废了。

2.3 立即数扩展与跳转地址计算:细节决定成败

单周期MIPS里有两个地方需要立即数参与运算,一个是addi、lw、sw里的符号扩展,一个是beq里的偏移量左移两位再符号扩展。符号扩展的规则是:16位立即数的最高位(bit15)复制到高16位,形成32位。很多人会忘记,lw和addi的立即数都是16位有符号数,不做符号扩展,地址计算必错。

beq的跳转地址计算是实验里比较容易绕晕的地方。MIPS的beq指令格式中,立即数是以指令数为单位的偏移量,所以计算目标地址时要把立即数左移两位(乘以4)再和PC+4相加。很多同学只做了符号扩展忘了左移,结果跳转位置全错,仿真里PC跑得远得离谱。我自己当年在这个问题上折腾了快一个下午,最后在波形图里看到目标地址差了好几行才反应过来。

j指令的地址计算方式则更特殊——它是把当前PC的高四位和指令中26位目标地址左移两位拼接起来,组成32位地址。单周期实验里一般只实现附近的跳转,所以直接用{pc[31:28], imm26, 2'b00}来表示即可。但要注意,这个计算是相对于当前指令地址的,如果你用了别的寻址逻辑,务必先确认实验文档允许的范围。

3. 实操过程:从写代码到仿真验证

3.1 模块划分与代码组织:顶层连线和子模块分离

写Verilog时我建议把实验二拆成这几个文件:PC模块、指令存储器模块、寄存器堆模块、ALU模块、控制单元模块、数据存储器模块、顶层模块。这样每个文件职责单一,调试时可以直接对某个模块单独做仿真,不用每次都在顶层连带测试。

顶层模块负责把所有子模块像拼乐高一样连起来。连接的时候最容易出问题的是信号宽度不一致。比如PC是32位,但指令存储器读出的指令信号也是32位,而寄存器堆的rs、rt是5位,来自于指令[25:21]和[20:16],这些切片必须做对。我见过不少同学把instr[25:21]写成了instr[24:20],导致读的寄存器完全不对,仿真空跑了好久才发现。

一个简单的顶层Verilog框架可以参考下面这样(节选):

module cpu_top ( input clk, input rst_n ); wire [31:0] pc; wire [31:0] pc_next; wire [31:0] instr; wire [31:0] alu_result; wire [31:0] data_mem_read_data; wire [31:0] regfile_data_out1; wire [31:0] regfile_data_out2; wire [4:0] write_reg_addr; wire [31:0] write_reg_data; wire regwrite, memtoreg, memread, memwrite, branch, jump, alusrc, regdst; wire [3:0] alu_op; wire zero; instruction_memory im ( .addr(pc), .instr(instr) ); // ... 其他模块连接省略 endmodule

顶层文件不用写逻辑,只做连线,所以反而是整个工程里最容易检查的。写完之后要逐条信号对一遍:控制信号有没有漏连、多路选择器的选择端有没有反、每一个子模块的时钟和复位有没有都接对。这一步检查得越细,仿真时的崩溃次数就越少。

3.2 写自己的testbench:别直接上来就跑上板

仿真验证是整个实验二里占时间比重最大的环节。一个建议是:先写testbench,在电脑上把指令跑通,再上板。上板能看到的只有LED、数码管和串口,调试起来远不如波形图直观。

testbench的核心任务有两个:一是给时钟和复位信号,二是加载一段合理的机器码到指令存储器里。时钟信号用always #50 clk = ~clk;这种方式生成即可,周期100ns,单周期CPU跑得动。复位信号上电后拉低一串时间再释放,保证PC能回到0。

加载指令时建议写一小段有意义、但又不复杂的程序,比如先给寄存器赋初值,再做一次加法,再存内存,再条件跳转。不要一上来就加载几十条指令,那样波形图一片花花绿绿,反而不知道哪里对哪里错。仿真和排错是渐进的过程,先跑通3条指令,再扩展到10条,最后再看完整程序的执行结果。

testbench里我习惯在关键节点打印寄存器的值,比如执行完add指令后,打印寄存器2的值是不是预期的5。在仿真器里用$display或$monitor输出到控制台,比盯波形图更直观。有些同学只会看波形,遇到信号太多人就麻了,打印一句"reg[2] = 5"瞬间定位问题。

3.3 波形检查的优先级顺序:PC、控制信号、寄存器写入

仿真波形乱成一团的时候,不要瞎点信号。我的经验是按下述顺序来查:

第一看PC,确认它在每个时钟沿按预期递增或跳转。如果PC都不对,后面的数据流全是错的。第二看取出来的指令instr,是不是PC对应地址的那条指令。第三看控制信号,比如RegWrite在add指令时是不是1,在sw指令时是不是0。控制信号错了,后面全盘皆错。第四看ALU的两个输入操作数对不对,这个能定位是寄存器堆读错还是立即数扩展错。最后看写入寄存器的数据和地址。

这个顺序的核心逻辑是:CPU是层层流水(准确说是层层组合)的,前面的信号错了后面一定错。从源头查起,比在结果端乱猜要高效得多。我自己调试时,经常把波形里每个阶段的信号按时间对齐,从PC到最终写回数据一路顺下来,99%的bug都能在“顺着走一遍”的过程里暴露出来。

3.4 上板调试:时钟分频、按键复位和LED显示

如果实验要求上板验证,那就会遇到一个新的问题:板载时钟太快了。单周期CPU在仿真里用100ns的时钟没问题,但实验板的晶振往往是50MHz或100MHz,跑起单周期CPU直接把FPGA烧得内存满天飞也看不见。所以需要先分频,把时钟降到几赫兹到几十赫兹,用肉眼能观察到的速度执行指令。

分频最简单的做法是一个计数器,计数到某个值就翻转一次时钟信号,实现占空比接近50%的分频时钟。但要注意,这种分频时钟不是由PLL产生的,时序上会有一点抖动,但在教学实验里完全够用。另一种更稳妥的做法是使用FPGA厂商提供的PLL IP核,但配置相对复杂,教学实验不一定需要。

上板的另一个重点是复位按键和显示输出。复位键用来回到程序起点,显示输出则可以用LED灯来观察寄存器值或PC的低位。如果实验要求把计算结果用数码管显示,那还要自己写显示驱动模块,这部分与CPU本身无关,但容易在最终验收时出问题。我提两个建议:第一,显示模块和CPU模块分开写,不要混在顶层里,否则接线复杂到你自己都找不到信号;第二,上板前先用仿真确认CPU本身功能正确,再专门调显示,否则两个问题叠在一起,很难分清是CPU算错了还是显示模块译码错了。

4. 常见问题与排查技巧实录

4.1 典型Bug:寄存器写入时序、beq跳转和lw/sw地址计算

这个实验里,有三类错误出现的频率最高。

第一类:寄存器写入时序错误。单周期CPU中,寄存器的写入发生在时钟上升沿,写地址和写数据在沿前必须稳定。不少同学是控制信号已经正确了,但写数据线的多路选择器选错了输入,导致lw指令写回的不是内存读出的数据,而是ALU的计算结果。排查方法很简单,在仿真波形里看lw指令执行的那个周期,写数据口的数值来源是不是内存读出的信号。

第二类:beq跳转地址错误。beq的目标地址是PC+4+符号扩展立即数左移2位,很多人漏了左移2位这个操作,或者符号扩展处理错了,导致跳转落到错误的位置。还有一种更隐蔽的情况:beq在数据通路里判断是否跳转用的是ALU产生的Zero信号,而Zero信号在比较寄存器相等时为1。如果ALU在减法模式下输出的是128位之类的错误位宽,Zero信号也可能不对。所以调试beq时,要同时检查ALU的运算结果和Zero信号,二者要配合正确。

第三类:lw/sw地址计算错误。lw和sw在译码阶段使用的是rs寄存器的值和16位有符号立即数做加法。如果立即数符号扩展不对,地址就偏移。如果寄存器堆读错端口,比如把rt当成了rs,那地址就从错误寄存器算起。一个经典场景是执行lw $t0, 8($t1)时,CPU实际去的是$t0 + 8而不是$t1 + 8,最终数据完全乱掉。

我把这三类问题的表现、原因和排查方向整理成一张速查表:

现象可能原因排查方向
寄存器写入的值不对写数据选择错误、写地址选择错误、RegWrite时序问题检查MemtoReg信号、RegDst信号,检查寄存器堆数据输入是否稳定
beq跳转位置错误立即数没左移2位、符号扩展错误、Zero信号错误检查ALU输出和Zero信号,检查PC计算逻辑
lw读出的是垃圾值地址计算错、立即数扩展错、读使能未拉高检查ALU输入B是否来自符号扩展立即数,检查MemRead信号
sw写不进内存MemWrite未拉高、地址错、数据错检查控制信号真值表,检查ALU地址结果
j指令跳飞目标地址拼接错误检查PC[31:28]和imm26的拼接逻辑
PC是一堆x复位没拉低、复位没同步检查复位信号和initial块中的PC初值

4.2 波形图怎么看:按时间轴一步步把数据流走一遍

看波形图不是看到一堆绿线就以为万事大吉。我的习惯是,从第一条指令开始,在波形图上找到当前的PC值,然后顺着这个时间点依次观察取出的指令、读出的寄存器、ALU输出、访存结果和寄存器写入。如果哪一步和预期不一致,就停在那里,对比指令语义,找出是哪个信号断了。

举个例子,假设第一条指令是addi $s0, $zero, 5,那么在波形图上应当能看到rs字段是0号寄存器(zero),rt字段是16号寄存器(s0),立即数是5,ALUSrc为1,ALU的一个操作数是0,另一个操作数是5,ALU加出来的结果是5,最后RegWrite在时钟沿前是1,写地址是16。如果写地址不是16而是0,那问题就出在RegDst信号或多路选择器上。这种逐层检查的方式,训练的是你对数据通路的理解,比盲目改代码高效得多。

4.3 避坑清单与经验贴士

最后给几个我踩过坑之后总结出来的建议。

第一,不要在一份代码里同时修改多个地方。实验二调试时很容易陷入"随手改一处重新仿真"的循环。正确做法是每次只改一个信号,看完波形确认结果,再动下一个。否则两个错误叠加,你根本分不清哪个改对了哪个改错了。

第二,模块例化时端口一定要用名字连接,不要用位置连接。比如regfile u_regfile(.clk(clk), .addr1(instr[25:21]), ...)这种方式,比regfile u_regfile(clk, instr[25:21], ...)要清晰得多,也防止你少写一个端口导致编译器默默补零。

第三,仿真之前先检查编译日志里的Warning。很多Warning不是致命的,比如“信号未声明”或者“有信号没连接”,但这类警告往往是后期玄学bug的根源。我建议把编译日志里的Warning过一遍,该修的修,该忽略的心里有数。

第四,测试指令别用太"顺"的程序。比如全是加法指令,那控制信号里RegDst、ALUSrc的变化根本测不出来。最好设计几组带对比的指令:lw+add+beq组合、sw+sub组合、jump跳转组合,这样能覆盖更多的控制信号分支。实验二验收最怕的不是代码跑通,而是你只跑了一条路径,其他路径全在隐藏状态下没验证过。

第五,合理利用$display打印寄存器值。在仿真器里,通过打印关键寄存器在特定周期的值,可以快速判断数据通路是否打通。打印内容不用多,比如每条指令执行后打印目标寄存器的值即可。

最后再分享一个小技巧

我当年做这个实验二时,最大的教训就是:不要等代码全写完了再开始仿真,而是每写一个模块,就单独写一个小testbench去验证它。特别是寄存器堆和控制单元,这两个模块的错误在整个CPU里最难排查,因为它们的影响会扩散到后续所有指令。

如果写的是MIPS指令集,建议把指令机器的编码表打印出来贴在自己面前,写控制器的时候对着表写,一个一个case去对,基本不会错。你甚至可以先用一个最简单的测试程序,比如只执行addi $t0, $zero, 8和addi $t1, $zero, 2两条指令,然后在波形图里看寄存器t0和t1是否正确赋值。如果这都不对,其他都不用谈。

做实验二,心态比天赋重要。它不像软件工程课那样改一改就能运行,硬件描述语言里"编译通过"离"逻辑正确"还差十万八千里。但只要数据通路图画清楚了、控制信号表列出来了、波形图会顺着时间轴查了,这个实验拿高分完全不难。毕竟它能教给你的,不只是一块FPGA板怎么转起来,而是整个CPU设计的基本功——这个基本功,以后学操作系统、编译原理甚至计算机体系结构,都会反复用到。

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

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

立即咨询