2026/10/8 9:18:06

Verilog实现单周期MIPS处理器:计算机组成原理实验二设计指南

Verilog实现单周期MIPS处理器:计算机组成原理实验二设计指南 计算机组成原理**课程设计实验二对软件学院的同学来说是大学四年里少有的几次硬核体验。实验一可能还在教你怎么写一个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是pc4还是pc4偏移量。这条路径不画出来写代码的时候百分之百漏信号。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是1RegDst是0写入rt而不是rdALUSrc是1第二个操作数来自立即数而不是寄存器rtMemRead是1MemtoReg是1写回的数据来自内存读出的值。而add指令的RegWrite是1RegDst是1写入rdALUSrc是0MemtoReg是0。这两条指令的差别就能看出RegDst和MemtoReg的意义所在。控制信号真值表可以整理成如下表格以MIPS部分指令为例指令opcodeRegDstALUSrcMemtoRegRegWriteMemReadMemWriteBranchJumpadd00000010010000sub00000010010000and00000010010000or00000010010000slt00000010010000addi00100001010000lw10001101111000sw101011x1x00100beq000100x0x00010j000010xxx00001这张表要反复对照特别是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再和PC4相加。很多同学只做了符号扩展忘了左移结果跳转位置全错仿真里PC跑得远得离谱。我自己当年在这个问题上折腾了快一个下午最后在波形图里看到目标地址差了好几行才反应过来。j指令的地址计算方式则更特殊——它是把当前PC的高四位和指令中26位目标地址左移两位拼接起来组成32位地址。单周期实验里一般只实现附近的跳转所以直接用{pc[31:28], imm26, 2b00}来表示即可。但要注意这个计算是相对于当前指令地址的如果你用了别的寻址逻辑务必先确认实验文档允许的范围。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的目标地址是PC4符号扩展立即数左移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号寄存器zerort字段是16号寄存器s0立即数是5ALUSrc为1ALU的一个操作数是0另一个操作数是5ALU加出来的结果是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的变化根本测不出来。最好设计几组带对比的指令lwaddbeq组合、swsub组合、jump跳转组合这样能覆盖更多的控制信号分支。实验二验收最怕的不是代码跑通而是你只跑了一条路径其他路径全在隐藏状态下没验证过。第五合理利用$display打印寄存器值。在仿真器里通过打印关键寄存器在特定周期的值可以快速判断数据通路是否打通。打印内容不用多比如每条指令执行后打印目标寄存器的值即可。最后再分享一个小技巧我当年做这个实验二时最大的教训就是不要等代码全写完了再开始仿真而是每写一个模块就单独写一个小testbench去验证它。特别是寄存器堆和控制单元这两个模块的错误在整个CPU里最难排查因为它们的影响会扩散到后续所有指令。如果写的是MIPS指令集建议把指令机器的编码表打印出来贴在自己面前写控制器的时候对着表写一个一个case去对基本不会错。你甚至可以先用一个最简单的测试程序比如只执行addi $t0, $zero, 8和addi $t1, $zero, 2两条指令然后在波形图里看寄存器t0和t1是否正确赋值。如果这都不对其他都不用谈。做实验二心态比天赋重要。它不像软件工程课那样改一改就能运行硬件描述语言里编译通过离逻辑正确还差十万八千里。但只要数据通路图画清楚了、控制信号表列出来了、波形图会顺着时间轴查了这个实验拿高分完全不难。毕竟它能教给你的不只是一块FPGA板怎么转起来而是整个CPU设计的基本功——这个基本功以后学操作系统、编译原理甚至计算机体系结构都会反复用到。