
1. 项目概述这门课设到底在做什么如果你也是软件工程或者计算机相关专业的学生大概率对“计算机组成原理”这门课又爱又恨。爱的是它终于让你摸到了计算机的底裤——从晶体管到寄存器从指令到流水线一层层揭开CPU的神秘面纱恨的是它的课程设计往往让人头皮发麻尤其是像山东大学软件学院这种级别的课设不是随便写个冒泡排序就能糊弄过去的。我当年做实验二的时候第一反应是这不就是拿Logisim或者Verilog搭一个简单的CPU吗网上肯定有大把现成代码可以抄。但真正动手之后才发现课设的坑远比你想象的多而且每个坑都正好踩在你对计算机组成原理理解最薄弱的地方。这篇文章就是想把我在实验二里踩过的坑、总结的经验、以及最终的实现思路完整地梳理一遍给正在做或者准备做这门课设的同学一个“虽然不能直接抄但至少知道路该怎么走”的参考。先交代一下背景实验二的核心内容通常围绕“数据通路”和“控制器”展开要求你基于给定的指令集设计并实现一个能够执行若干条机器指令的最小处理器。可能有的学期要求用Logisim画电路图有的学期要求用Verilog写代码有的学期甚至要求你在FPGA板上跑起来。无论形式怎么变底层考察的知识点是一模一样的寄存器堆、ALU、存储器、控制器、数据通路、指令周期这六个东西搞通了实验二就拿下了一大半。这篇文章适合正在做课设的学生、准备考研复试想补一补组成原理实操的人以及单纯想看看“软件学院为什么要学这么硬核的东西”的好奇人士。我会把实验二最常见的几种题目变体、设计思路、核心模块实现、排错方法全部讲一遍全程用我自己实操时最真实的感受来写争取让你看完之后心里有底而不是看完一堆理论更焦虑了。2. 实验二的设计思路先别急着写代码把架构想清楚2.1 不同题型的共性框架我翻了山大软院这几年计算机组成原理课设实验二的题目发现虽然每一届的具体要求会微调但大框架基本逃不出这么几类Logisim画数据通路图给出一段汇编代码要求你用Logisim搭建一个能执行这些指令的处理器数据通路并验证程序运行结果。Verilog/VHDL写CPU用硬件描述语言实现一个支持固定指令集的简单CPU仿真通过即可。基于FPGA的完整实现在开发板上跑通程序通常还会要求接上LED、数码管或者串口来观察结果。说实话这三种形式的底层逻辑是一样的。Logisim版本更偏向于让你直观地看到数据是怎么流动的信号是怎么对齐的Verilog版本则更接近真实芯片设计的思路需要考虑时序、组合逻辑、复位信号这些东西FPGA版本则是把前两者的综合既要写代码又要上板调试。不管你分到哪一种第一步永远是同一件事把指令集定下来。实验二不会让你设计一个x86或者ARM那么复杂的指令集通常就几条指令比如ADD、SUB、LOAD、STORE、BEQ之类的最多再加个AND、OR。先看清楚题目给你的指令编码格式是R型还是I型寄存器编号占几位立即数占几位这些直接决定了你后面每一个模块的接口宽度。以我当年做的版本为例指令集一共8条指令包含算术运算ADD、SUB、AND、OR、访存指令LW、SW和分支指令BEQ。寄存器堆16个每个32位指令格式分R型和I型两种。R型指令高6位是操作码中间5位是rs5位是rt5位是rd低11位留空I型指令高6位是操作码接着5位rs、5位rt最后16位是立即数。这个基本就是MIPS的简易版也是计算机组成原理教材里最经典的RISC风格指令集。2.2 为什么软件学院也要做这么硬核的课设很多软件工程的同学拿到这个题目之后第一反应是“我不是学软件的吗为什么要写Verilog模拟电路”我当时也有这个疑惑但做完了才想明白这门课设真正训练的不是你的硬件设计能力而是**“用底层思维理解上层行为”**的能力。一个软件工程师写代码时哪怕用的是Python这种高度抽象的脚本语言如果完全不了解一条赋值语句在CPU里经历了取指、译码、执行、访存、回写这几个阶段就很难理解为什么某个操作这么慢为什么有缓存和没有缓存差了十万八千里为什么并发编程会有内存屏障这种诡异的概念。计算机组成原理课设就是把这些本来停留在脑子里的抽象概念变成一个你必须让它在仿真波形里真正跑起来的东西。而且就算单纯从动手能力的角度讲用Logisim画数据通路也好用Verilog写状态机也好本质上和写软件的逻辑是一样的都是在“用组合逻辑和时序逻辑描述一个确定的系统行为”。这个思路一旦打通了以后理解操作系统怎么调度进程、编译器怎么生成中间代码都会轻松很多。2.3 设计前的必要准备在打开Logisim或者新建Verilog工程之前有几件事必须做否则后面必然返工第一把指令集编码表画出来。不要只在脑子里想想拿一张纸画出每条指令的操作码、源寄存器字段、目标寄存器字段、立即数位宽。这张表就是你的“需求文档”后面的每一个模块设计都以它为准。第二把数据通路草图画出来。哪怕画得很丑也没关系关键是把每一类指令的执行路径标出来。比如R型指令取指→读寄存器→ALU计算→写回寄存器I型访存指令取指→读寄存器和符号扩展立即数→ALU计算地址→读写存储器分支指令取指→读寄存器→ALU比较→决定PC是否跳转。这张图画好之后你的控制器和各个模块的端口自然就出来了。第三想清楚是单周期还是多周期。实验二通常不会要求你做流水线因为流水线要考虑冒险Hazard复杂度直接翻倍。绝大多数课设要求的是单周期或者多周期CPU。单周期就是每条指令在一个时钟周期内完成所有操作都同步进行控制器输出的是基于当前指令的组合逻辑信号多周期则是把每条指令拆成取指、译码、执行、访存、回写五个阶段每个阶段用状态机控制一个周期只干一件事。单周期的好处是设计简单、直觉清晰坏处是CPU主频做不快因为关键路径太长——一条指令里最慢的操作决定了整个时钟周期。多周期的好处是每条指令的平均周期数更合理但控制器的设计复杂度会高很多。我个人的建议是除非题目明确要求多周期否则老老实实做单周期把精力花在把每个模块做对。实验二考察的核心是对数据通路和控制信号的理解不是CPU性能。3. 核心模块解析从寄存器堆到控制器的逐一拆解3.1 存储器和寄存器堆先跑起来的最小单元如果只能先实现一个模块我强烈建议从寄存器堆开始。不是因为它最简单而是因为它是数据通路的“心脏”几乎所有指令都要读写它。寄存器堆的本质就是一个小的存储阵列用地址来选通某一个存储单元进行读写。对于16个32位寄存器的设计需要5位地址因为2^532这里留了余量数据宽度32位。两个读端口和一个写端口是标配两个读端口是为了R型指令能同时读两个源操作数rs和rt一个写端口是为了把结果写回rd。Verilog里这种寄存器堆非常好写一个二维数组加上两个组合逻辑读端口再加一个时序逻辑写端口就搞定了。这里有几个非常容易犯的错写使能信号疑惑写信号的edge必须对齐时钟边沿如果判断条件写错了数据会延迟一拍甚至压根写不进去。x0寄存器问题如果你参考的是MIPS的寄存器堆0号寄存器永远为0写操作对它无效。但MIPS的寄存器堆规格不那么好做因为每次读0号寄存器都要判断地址是否为0这会增加组合逻辑延迟。同步读还是异步读很多教材里的寄存器堆是异步读也就是不依赖时钟只要地址一变数据立刻出来。但在真实设计中同步读更常见也更不容易出时序问题。Logisim里内置的寄存器堆默认是异步读而Verilog仿真中如果你用了assign read_data reg_file[addr];这种写法实际综合出来也是异步读。两种方式都行但要保持一致否则仿真和综合结果可能对不上。存储器的设计稍微复杂一点。实验二里通常会有一个数据存储器和一个指令存储器。指令存储器比较简单就是按PC地址读取指令组合逻辑就可以完成。数据存储器则需要考虑读写时序。有一个很重要的细节在Logisim里如果直接将指令存储器的输出连到指令寄存器不需要额外的控制信号但在Verilog中指令存储器经常被实现为同步读写的模块导致取指阶段要花一个额外的周期。这个问题在单周期设计里尤其需要警惕因为你的CPU状态机如果假设“取指只要一个周期”结果存储器是同步读写的那PC就会多走一拍整个程序就乱了。所以我的建议是Verilog里把指令存储器设计成异步读也就是assign instr mem[pc]这种形式这样PC一变化指令立刻就出来了不需要额外的取指周期。数据存储器则可以设计成同步写、异步读写操作在时钟上升沿生效读操作随时生效。这种设计最符合单周期CPU的逻辑也不容易出错。3.2 ALU的设计与扩展加减法之外还要考虑什么ALU说难不难说简单也不简单。基本的加法和减法非常容易写assign result a b;就解决了一半问题。但课设里真正考察的是你对ALU控制信号的理解以及分支指令怎么用ALU。我用的ALU支持六种操作加、减、按位与、按位或、按位异或、小于比较SLT。控制信号3位分别对应000到101。其中加法和减法用于算术运算同时也用于访存指令的地址计算按位与、按位或、按位异或用于逻辑运算小于比较用于分支判断。关于小于比较SLT这里有个坑如果你只是比较a b在无符号数下没问题但在有符号数下就错了。比如-1 3无符号比较的结果是0xFFFFFFFF 3显然不对。所以如果你的指令集里有SLT并且用的是有符号数比较就需要做符号位判断和溢出判断。这个逻辑写起来不复杂但很容易漏掉边界情况。我的做法是扩展一位符号位直接用补码做比较最后再截断回32位输出。分支指令BEQ相等则跳转在单周期CPU里也需要用到ALU吗不一定。比较相等可以直接用异或门实现所有位异或出来如果都是0就说明相等或者等价的逻辑但为了统一控制很多设计会让ALU去做减法再根据Zero标志位判断是否相等。这样优点是硬件简化缺点是CPU每执行一条分支指令ALU都要做一次减法运算即使某些分支只需要判断标志位。除了基本的六种操作还有一个容易被忽略的模块零扩展和符号扩展Sext和Zext。I型指令的立即数只有16位需要扩展到32位才能进ALU。扩展方式取决于指令语义地址计算用的是符号扩展因为地址可能带符号逻辑运算用的是零扩展因为不需要符号位。这个问题虽然简单但直接关系到访存指令的地址算得对不对很多同学程序跑飞都是因为这里扩展错了。3.3 控制器整个CPU的“总指挥”控制器是单周期CPU里最考验理解的模块。它本质上是一个组合逻辑模块输入是当前指令的操作码如果是I型指令还包括功能码也就是funct字段输出是哪些控制信号有效。每个控制信号都对应数据通路里的一个选择器或者使能端。控制器里最核心的几个信号RegWrite是否写寄存器堆。ALUSrcALU的第二个操作数来自寄存器rt还是立即数。MemWrite是否写数据存储器。MemRead是否读数据存储器如果存储器是异步读则可以省略。MemToReg写回寄存器的数据来自于ALU结果还是存储器输出。RegDst写回寄存器的地址是rd还是rt。Branch当前指令是否为分支指令。ALUOpALU操作类型的选择信号和funct共同决定ALU的最终控制信号。控制器设计的核心挑战是精确判断每条指令应该有哪些信号有效。我当年第一个版本犯的经典错误是所有指令都把MemToReg信号设为1导致R型指令回写的不是一个计算结果而是一个内存地址的数据。这种错误在仿真里非常难发现因为你看波形的时候只会看寄存器有没有写入不会细想到底写入了什么。一个实用的排错技巧给每条指令列一个控制信号真值表逐条核对。比如ADD就是RegWrite1, ALUSrc0, MemWrite0, MemToReg0, RegDst1, Branch0LW就是RegWrite1, ALUSrc1, MemWrite0, MemToReg1, RegDst0, Branch0SW就是RegWrite0, ALUSrc1, MemWrite1, MemToRegx, RegDstx, Branch0。把这张表画出来再对着你的控制器逻辑逐行检查比满天看波形高效得多。如果你用的Logisim控制器实现起来更直观右侧放一个ROM地址接指令操作码数据就是控制信号。每一条指令一行把这个真值表变成ROM的内容基本就完事了。这种方案在Verilog里也行用case语句实现本质上是查表。4. 实操录手把手把单周期CPU跑起来4.1 简易MIPS子集的指令集定义在开始写代码之前先把指令集定义清楚这个是所有模块设计的基础。我当年用的指令集如下参考了MIPS的编码格式但是简化版指令格式操作码/功能码含义ADDRop000000, funct100000$rd $rs $rtSUBRop000000, funct100010$rd $rs - $rtANDRop000000, funct100100$rd $rs $rtORRop000000, funct100101$rd rs | rtSLTRop000000, funct101010$rd ($rs $rt) ? 1 : 0LWIop100011$rt Mem[$rs sign_extend(imm16)]SWIop101011Mem[$rs sign_extend(imm16)] $rtBEQIop000100if ($rs $rt) PC PC 4 sign_extend(imm16 2)这是一条几乎和教材完全一样的指令集也是计算机组成原理教学里最经典的RISC风格指令集。它的优势在于格式统一、类型分明、控制信号容易设计特别适合用来做课程设计。注意BEQ的分支地址是相对于PC4的偏移而且偏移量要左移2位因为指令是4字节对齐的。这个细节很容易被忽略如果你的PC是按字为单位递增的比如用32位设计、每次PCPC4那么BEQ的偏移量必须左移否则跳转目的地会完全不对如果你的PC是按字节为单位递增的比如用16位设计又需要另一套逻辑。一定要先确定你的PC单位再写分支逻辑。4.2 Verilog实现模块划分与关键代码以下是我当年实现单周期CPU的模块划分。每个模块一个文件顶层模块负责连线。PC模块pc.v32位寄存器保存当前指令地址。输入为下一条指令地址pc_next时钟上升沿更新。指令存储器inst_mem.v异步按PC地址读取指令。初始化时可以用$readmemh加载机器码文件。寄存器堆reg_file.v16个32位寄存器两个异步读端口一个同步写端口。0号寄存器恒为0。ALUalu.v组合逻辑根据3位alu_control执行加减与或比较操作输出结果和zero标志位。数据存储器data_mem.v)按地址读写。同步写异步读。写使能有效时在时钟上升沿写入读操作随时有效。主控制器main_control.v根据6位opcode输出各类控制信号RegWrite、ALUSrc等。ALU控制器alu_control.v根据ALUOp和funct决定ALU的实际操作。顶层模块cpu_top.v把上述所有模块连起来相当于数据通路图里那条“总线”。顶层模块的核心连线逻辑大概是这样的PC的输出连接到指令存储器的地址端口指令存储器的输出拆分成各个字段高6位是opcode接主控制器R型指令的funct接ALU控制器rs、rt、rd分别接寄存器堆的读地址和写地址立即数通过sign_extend模块扩展到32位再输入到ALU的第二个操作数通过多路选择器选择rt还是立即数。Verilog写顶层连线时有个容易混淆的地方单周期CPU没有状态机所有控制信号都是组合逻辑因此PC更新之后指令从存储器取出来、译码、执行、回写整个过程在一个时钟周期内完成。也就是说上升沿来临时PC更新同时寄存器堆和存储器的写操作生效。这样设计的关键是确保所有时序逻辑的输入都在同一个时钟边沿之前稳定下来这在单周期CPU里是靠组合逻辑的传播延迟保证的。我建议先用Logisim搭一版数据通路图然后在纸上画出各模块的输入输出端口最后才写Verilog。因为Verilog用文字描述连线很容易“脑子里知道、代码里连错”Logisim这种图形化工具反而更接近硬件设计工程师的习惯。先在Logisim里把数据通路调通了Verilog就是翻译一遍的事。4.3 Logisim实现如果不写代码怎么办如果你这门课用的是Logisim流程会更加可视化但也更容易出错。Logisim里的常用组件有Register、RAM、ROM、ALU需要自己搭或使用内置等。数据通路图的画法基本就是抄教材第三章的经典单周期数据通路图。Logisim里有一个比较大的坑是时钟同步。Logisim默认整个电路里所有时钟组件都是同一个时钟源但你如果不小心加了两个时钟组件比如寄存器堆一个、RAM一个它们可能不同步导致数据写入的时序错乱。解决办法是只用全局时钟所有触发元件PC、寄存器堆的写端口、RAM的写端口都挂到同一个时钟引线上。另外Logisim的RAM组件有个可选项“数据导入”可以加载一个hex或bin文件作为内存初始值。但它的加载格式和Verilog的$readmemh不一样且分大小端。如果机器码文件的字节序不对程序跑起来会完全莫名其妙。我当年就遇到过这种仿真波形里看起来PC在走指令也有输出但就是算不对最后发现是机器码文件的内存字节序搞反了。这个问题在Verilog里虽然也有但Logisim里更加隐蔽因为Logisim的RAM组件默认就是小端存储而有些助教给的机器码是大端格式加载进去之后所有常数都反了。4.4 上板调试如果要求FPGA有些班级的实验二会要求最终在FPGA开发板上跑通程序并且通过LED或者串口输出结果。这一步比纯仿真要麻烦得多因为我需要处理时钟分频、复位、按键消抖这些问题。如果你用的是开发板上的50MHz或者100MHz时钟直接接给CPU是不行的因为单周期CPU如果按50MHz跑且每条指令都是一个周期写回来的指令和寄存器更新的时序其实非常紧。课程设计阶段通常不需要做速度优化直接用一个分频器把时钟降到1Hz到10Hz用LED演示寄存器内容的变化即可。但这种低速时钟如果你用always (posedge clk_div)分频出来的信号有时候会有毛刺导致寄存器写入了错误的数据。建议用PLL锁相环分频或者用板载的低速时钟实在不行就慢速按键单步执行。复位信号也很重要。FPGA上电瞬间寄存器堆和PC的值是随机的如果不做全局复位程序可能从非0地址开始执行或者寄存器堆里有残留数据。我在做实验时习惯把所有寄存器、PC、存储器写地址都加一个同步复位——复位信号高电平时清零。这样调试的时候按一下复位整个CPU回到0地址重新开始问题定位会快很多。上板调试比纯仿真容易遇到的问题更多但原理其实是一样的——所有看起来“玄学”的错误最终都是某个信号高一位或者低一位的问题或者时序差了一拍。建议先用仿真把逻辑调通再上板不要一上来就烧板子那样只会浪费时间。5. 高频问题排查那些年我们一起踩过的坑5.1 程序“跑偏”的三大元凶做一个单周期CPU最容易出现的问题是程序执行结果不对。我把过去几年里我见到过的、自己也踩过的坑归纳一下基本逃不出三大类PC更新异常、立即数扩展错误、控制信号错位。第一类PC更新异常。症状是仿真波形里PC跳到了一个完全不应该出现的地址。原因通常有两个一是分支指令的地址计算不对比如忘了左移2位或者分支目标不是相对于PC4而是相对于PC。二是PC的使能信号没处理好比如分支判断的逻辑挂在PC的更新逻辑里时如果zero信号和branch信号的“与”关系写反了导致应该跳的时候不跳、不应该跳的时候乱跳。第二类立即数扩展错误。症状是访存指令访问了一个巨大的地址或者运算结果多了一堆高位1。原因就是我在前面提过的符号扩展和零扩展搞混了。RISC ISA里指令的立即数比如LW和SW通常都是有符号的必须用符号扩展。如果你的ALU只支持无符号运算就会把0xFFFF当成65535而不是-1地址直接飞到天上。第三类控制信号错位。症状比较隐蔽有时候结果对有时候结果错。原因通常是RegDst和MemToReg这两个多路选择器的选择信号接反了。举个真实案例我同学实现了整个CPU但执行LW指令时寄存器写入的都是地址值而不是内存中的值。后来查了半天发现是MemToReg信号接到了RegDst的位置上也就是说写入寄存器堆的数据选择器选了ALU结果而写入寄存器堆的地址选择器选错了。这种错误看波形根本看不出来必须对照控制信号真值表逐条核对。5.2 仿真波形不对怎么看波形定位问题很多人做实验二时看波形完全靠猜看到某个信号不对就随便改一行代码然后再仿真看看不对再改。这种“盲调”效率极低而且很容易把原来对的逻辑改坏。我的经验是先看PC再看指令然后看每一条指令对应的控制信号最后看寄存器写入。这个顺序其实对应了CPU的执行流程PC决定取哪条指令指令决定控制信号控制信号决定数据通路的动作。当你发现某个寄存器的最终写入值不对时倒着往回查先看寄存器堆的写数据端口write_data来自哪里是ALU结果还是内存输出如果来自ALU就看ALU的输入对不对。ALU的第一个操作数通常来自寄存器堆查看寄存器堆的两个读地址是否正确。如果rs、rt的地址不对那就是译码逻辑有问题如果读数据对但ALU输出不对那就是ALU本身的操作类型控制信号错了或者ALUSrc选择了立即数但立即数扩展错了。再高级一点写一个小的测试bench逐步打印CPU内部状态。比如每执行完一条指令就把PC、指令、寄存器写地址、写数据输出到仿真日志里。对照你的汇编程序的预期结果一步步检查。这比看波形直观得多。5.3 常见问题速查表为了更直观点我把做实验二以及后续实验三时最常见的问题整理成了一个速查表你可以直接对照排查现象可能原因解决办法程序根本没跑PC不变复位信号一直有效或时钟没接好检查复位引脚和时钟连接确认时钟上升沿是否有效PC跳到一个奇怪地址分支立即数没左移2位或分支目标算错检查BEQ的偏移地址计算逻辑用仿真波形验证寄存器堆写入的数据全是0RegWrite信号没生效或写入地址是0号寄存器检查RegWrite是否在正确周期拉高检查RegDst选择访存指令读出来的数据不对存储器地址算错符号扩展问题或字节序问题检查立即数扩展方式检查内存初始化文件大小端ALU结果差了一个常数ALUSrc选择了rt但不是立即数或立即数扩展对了但ALU控制信号不对对照每条指令的控制信号真值表逐行排查BEQ该跳不跳Branch信号没拉高或zero信号和ALU结果不对应确认分支比较用的是ALU输出还是独立比较器仿真过了但FPGA上结果不对时钟分频毛刺、复位不彻底用PLL分频加全局复位先慢速调试这张表不是万能的但覆盖了90%以上的组都能遇到的问题。剩下那10%要么是设计本身就有从根上错的地方比如数据通路图画错了要么是仿真工具使用不熟练导致的幻觉问题。不管哪种回到“指令集表格数据通路草图控制信号真值表”这三件套逐项核对基本都是能定位出来的。6. 从课设到真正的理解实验二能带给你的隐藏收益很多同学做实验二的目标很单纯赶紧做完拿个分交差。这个心态我太理解了因为课设时期时间紧、任务重谁都不想在这里过多停留。但如果你愿意多花一点点时间把实验二里学到的东西往上抽象一层你会发现这个东西的价值远远超过学分本身。首当其冲的就是理解程序是怎么变成电路动作的。你写的每一行高级语言代码最终都对应着一条条机器指令在CPU里的执行。为什么有时候写了n1和n性能差别很大为什么有些循环可以被编译器优化掉有些不行为什么函数调用要花那么多时间在保存和恢复寄存器上这些问题如果你亲手搭过CPU、亲手看过指令在数据通路里怎么流动答案几乎是刻在脑子里的不需要背。其次是建立“时序”思维。软件工程师调试程序时面对的是逻辑上的先后顺序但硬件工程师调试CPU时面对的是物理上的时钟边沿。一个信号什么时候稳定、什么时候采样、什么时候传播到下一级这些在纯软件项目里几乎不需要考虑但在计算机组成原理的实验里是生死攸关的。这种思维方式对以后理解多线程同步、理解数据库事务、理解分布式系统里的时序问题都非常有帮助。我在做实验二之前一直以为“CPU执行一条指令”是怎么也绕不清楚的概念。做完之后再去翻计算机组成原理教材的处理器章节感觉完全是两本书。教材里那些密密麻麻的图在你亲手搭过一遍之后会变得异常清晰因为你知道每个信号从哪里来、到哪里去、为什么在这一步才起作用。7. 我的调试工具链与效率技巧这部分也许是最实用的一部分。很多同学调试不顺利不是设计有问题而是调试工具和方法不对导致小问题被放大成大问题。第一仿真波形里不要一次看完所有信号。测试时先关注几个关键点PC的变化、指令存储器输出、寄存器写使能、ALU输出。这四个信号能确认CPU是否在“跑”。然后再逐步增加关注信号RegDst、MemToReg、立即数扩展后的值。这和软件调试的“先跑通再优化”是一个道理先把流程跑通再关注每个流程是否正确。第二用Logisim做单步仿真。Logisim里可以手动触发时钟每一步都能看到数据通路里所有信号的实时状态。我发现用Logisim调数据通路比用Verilog仿真调要直观得多因为你能“看见”数据在哪一步被卡住了。Verilog仿真虽然也能看信号波形但波形是一维的不如二维的电路图直观。而且Logisim可以在同一条线上显示不同的颜色蓝色、绿色表示不同位宽一旦发现一个不应该有值的地方有了值立刻就能定位到问题模块。第三如果你是Verilog党多用$display和$monitor不要只看波形。在仿真脚本里每执行一条指令就打印一条日志格式建议这样PC0x00000004, instr0x8C010000, rs_value0x00000000, rt_value0x00000001, alu_result0x00000001, reg_write_addr5, reg_write_data0x00000001。这种日志配合波形看效率倍增。尤其是寄存器写回的数据打印出来就知道写没写对不用在波形里翻来翻去找。第四建立“最小可复现用例”的意识。仿真跑不过去的时候不要一上来就跑完整程序。先写一个只有三条指令的小程序执行完看结果再加一条分支指令进去测试跳转最后再上完整的测试程序。这和软件工程里的单元测试思想一样但很多同学做硬件实验时完全没有这个意识直接一上来跑整段程序出了问题就一头雾水。8. 我踩过的几个关键坑以及怎么绕开做实验二的过程中有几个坑我印象特别深这里单独拿出来讲一下因为这算是从“能跑”到“正确”的关键转折点。第一个坑是**zero信号的时序问题**。在单周期设计里zero信号是由ALU的组合逻辑产生的。如果你在状态机里使用了这个信号来决定是否跳转且状态机是纯组合逻辑控制的话没有时序问题。但如果你的BEQ指令是在寄存器堆读到数据后、在当前时钟周期内就根据zero信号修改pc_next这个路径上组合逻辑的深度就很关键。在Verilog仿真里可能没问题但综合到FPGA上这条路径的延迟可能很长导致时钟频率上不去。实验二阶段通常不会让你跑高频但如果真的遇到“仿真过了上板就出错”的怪事多半就是这条路径的时序问题。解决办法是给PC加使能信号让PC不更新的那一拍里zero信号随便抖动都无所谓。第二个坑是指令存储器和数据存储器共用地址空间的问题。有些题目要求指令和内存共用一块存储器冯诺依曼结构有些要求分开哈佛结构。无论是哪种都要注意读写地址的范围。如果你用哈佛结构指令存储器和数据存储器分开地址空间可以重叠但PC地址和数据存储器地址要独立计算。我当年使用的是哈佛结构PC从0x0000开始取指令而数据存储器从0x0000开始放数据两者互不干扰。但如果你不小心把PC连到了数据存储器的读端口或者把数据存储器的地址接到了PC上程序就会从数据地址里取指令导致完全不可控的行为。第三个坑是内存初始化文件。在Logisim和Verilog里加载机器码到一个初始化文件是常见操作。但这个文件的格式hex还是bin、地址范围、大小端之间的对应关系版本之间差别很大。我强烈建议先把一个已知正确的机器码文件加载到内存然后用仿真跑一个只有一条指令的程序比如ADD $1, $2, $3执行完观察寄存器1的值是否等于寄存器2加寄存器3。这一步通过之后再加载完整程序。很多人跳过了这一步直接加载完整程序出了问题就怀疑是CPU逻辑错误其实可能是机器码文件加载错了。第四个坑是没有给复位留缓冲。如果你的CPU没有复位信号那么上电时寄存器堆和PC是随机值。虽然仿真工具默认会初始化这些寄存器为0但真实的FPGA不会这样做。FPGA板子上电后如果没有一个稳定的复位信号CPU可能从任何地址开始执行甚至可能一开始就执行了内存里的数据。做实验二的时候你可能不在乎FPGA上电行为但如果将来做实验三比如完整功能CPU或者流水线CPU复位信号的缺失会直接让你陷入“跑起来就乱、不管乱在哪里”的泥潭。所以一开始设计时就要留一个全局异步复位、按周期同步释放的复位方案。9. 一个完整的单周期CPU寄存器传输级代码示例这部分我给出一个较为完整的单周期CPU核心代码骨架你可以照着这个思路去实现自己的版本。注意这不是标准答案只是我当年实现的一个简化版本去掉了部分冗余逻辑保留了最关键的部分。建议你先理解不要直接抄。9.1 顶层模块数据通路的连线module cpu_top( input clk, input rst_n, output [31:0] current_pc, output [31:0] current_inst ); wire [31:0] pc_next; wire [31:0] pc_current; wire [31:0] instr; // 控制信号 wire reg_write, alu_src, mem_write, mem_read; wire mem_to_reg, reg_dst, branch; wire [1:0] alu_op; // 数据通路相关 wire [4:0] reg_write_addr; wire [31:0] reg_read_data1, reg_read_data2; wire [31:0] imm_ext; wire [31:0] alu_in2; wire [31:0] alu_result; wire zero_flag; wire [31:0] mem_read_data; wire [31:0] write_back_data; wire [31:0] pc_branch_target; // PC寄存器 pc_register u_pc( .clk(clk), .rst_n(rst_n), .pc_next(pc_next), .pc_current(pc_current) ); // 指令存储器 inst_mem u_inst_mem( .addr(pc_current), .inst(instr) ); // 主控制器 main_control u_main_ctrl( .opcode(instr[31:26]), .reg_write(reg_write), .alu_src(alu_src), .mem_write(mem_write), .mem_read(mem_read), .mem_to_reg(mem_to_reg), .reg_dst(reg_dst), .branch(branch), .alu_op(alu_op) ); // 寄存器堆 reg_file u_reg_file( .clk(clk), .rst_n(rst_n), .reg_write(reg_write), .read_addr1(instr[25:21]), .read_addr2(instr[20:16]), .write_addr(reg_write_addr), .write_data(write_back_data), .read_data1(reg_read_data1), .read_data2(reg_read_data2) ); // 立即数扩展 sign_extend u_sext( .imm16(instr[15:0]), .imm32(imm_ext) ); // ALU第二操作数选择 mux2_32 u_alu_src_mux( .sel(alu_src), .a(reg_read_data2), .b(imm_ext), .y(alu_in2) ); // ALU alu u_alu( .a(reg_read_data1), .b(alu_in2), .alu_control(alu_control), .result(alu_result), .zero(zero_flag) ); // 数据存储器 data_mem u_data_mem( .clk(clk), .addr(alu_result), .write_data(reg_read_data2), .mem_write(mem_write), .read_data(mem_read_data) ); // 回写数据选择 mux2_32 u_wb_mux( .sel(mem_to_reg), .a(alu_result), .b(mem_read_data), .y(write_back_data) ); // 写回寄存器地址选择 mux2_5 u_reg_dst_mux( .sel(reg_dst), .a(instr[20:16]), .b(instr[15:11]), .y(reg_write_addr) ); // 分支目标计算 assign pc_branch_target pc_current 4 (imm_ext 2); // PC选择 mux2_32 u_pc_mux( .sel(branch zero_flag), .a(pc_current 4), .b(pc_branch_target), .y(pc_next) ); // ALU控制信号由ALUOp和funct共同生成 alu_control u_alu_ctrl( .alu_op(alu_op), .funct(instr[5:0]), .alu_control(alu_control) ); assign current_pc pc_current; assign current_inst instr; endmodule这个代码体现了单周期CPU的本质组合逻辑完成所有控制信号的生成和计算时序逻辑只在PC更新、寄存器堆写入、数据存储器写入这三个点使用。9.2 关键子模块寄存器堆和ALU寄存器堆里O号寄存器恒为零其他正常读写。注意写端口在上升沿判断写使能读端口用组合逻辑直接输出。module reg_file( input clk, rst_n, reg_write, input [4:0] read_addr1, read_addr2, write_addr, input [31:0] write_data, output [31:0] read_data1, read_data2 ); reg [31:0] regs [0:15]; integer i; always (posedge clk or negedge rst_n) begin if (!rst_n) begin for (i 0; i 16; i i 1) regs[i] 32b0; end else if (reg_write write_addr ! 5b0) begin regs[write_addr] write_data; end end assign read_data1 (read_addr1 5b0) ? 32b0 : regs[read_addr1]; assign read_data2 (read_addr2 5b0) ? 32b0 : regs[read_addr2]; endmoduleALU的核心是算术逻辑运算附带上零标志输出。下面这个写法很简单但是注意SLT的有符号处理。如果题目要求无符号比较可以直接用无符号比较运算符如果是有符号比较需要额外处理。真实的处理器里一般是用减法结果的最高位符号位来判断也就是result[31]为1说明a小于b。module alu( input [31:0] a, b, input [2:0] alu_control, output reg [31:0] result, output zero ); always (*) begin case (alu_control) 3b000: result a b; 3b001: result a - b; 3b010: result a b; 3b011: result a | b; 3b100: result a ^ b; 3b101: result ($signed(a) $signed(b)) ? 32d1 : 32d0; default: result 32b0; endcase end assign zero (result 32b0); endmodule9.3 专业课视角这样设计是在模仿什么这段代码如果拿给一个搞过X86、ARM的人看他可能会笑“这玩意儿也好意思叫CPU”但在计算机组成原理的教学语境下它的价值恰恰在于“足够简单但五脏俱全”。它复刻了经典MIPS处理器的所有核心概念包括指令格式、数据通路、控制信号、内存访问模式。这个处理器甚至可以直接跑一个编译好的MIPS二进制程序在仿真层面完全满足课设需求。如果你后续继续学习操作系统你会发现这段单周期CPU的理解直接映射到“进程切换”的概念上PC就像程序的当前位置寄存器里的IP寄存器堆就是进程上下文的一部分。如果你后续学习编译器你会发现在生成目标代码时指令的选择、寄存器分配这些策略本质上是这个简简单CPU最优化问题的抽象。理解一个最简单的CPU能帮你理解所有更复杂的CPU。10. 把实验二再做深一点你可能没用到的扩展方向实验二本身并不难难的是你做完之后能不能把它延伸到实验三或者真实世界的CPU设计思路里。这里我列几个“如果时间允许我建议你可以试试”的扩展方向这些方向都是我当年做实验二时觉得很有价值的尝试哪怕只是仿真层面跑一下都比只交作业收获大。第一个扩展方向是实现多周期CPU。单周期CPU每条指令一个时钟周期简单但效率低。多周期CPU把每一条指令拆成取指IF、译码ID、执行EX、访存MEM、回写WB五个阶段使用一个有限状态机切换五个阶段。你会发现实现多周期CPU并不比单周期复杂多少但你对“一条指令要花好几个周期”的理解会非常直观。而且考试里那些“某条指令在第几个周期执行”的经典题做完多周期CPU后根本不用背。第二个扩展方向是加入中断和异常处理。单周期CPU的控制器里通常没有针对异常和中断的设计。如果给CPU加上一个外部中断请求信号要求CPU在执行完当前指令后响应中断、保存返回地址到某个特殊寄存器、跳转到中断服务程序这就涉及到了“流水线屏障”“保存现场”等概念。这个扩展会直接挑战你对“PC保存和恢复”的理解做起来很有成就感但建议放到实验二做完有余力时再考虑。第三个扩展方向是尝试引入总线和简单的外设交互。你可以把LED、数码管的驱动模块挂到数据存储器的地址线上让CPU通过SW指令控制LED亮灭。这一步看似简单但实际上是嵌入式开发比如跑在FPGA里的软核处理器的雏形。你会发现让CPU“跑起来”和“跑起来以后能做点什么”是两码事前者是体系结构后者是系统集成。11. 关于工具链和参考资源的实操建议第一Logisim版本选择。如果你用的是Logisim建议用Logisim Evolution也叫Logisim-evolution因为原生Logisim已经多年不更新很多新组件和bug修复都没有。Evolution版本在中文社区也有不少教程在GitHub上可以找到编译好的压缩包。第二Verilog仿真工具。你要是用Verilog又不想在公司里装大全套的Vivado或Quartus可以用Icarus Verilog做命令行仿真配合GTKWave看波形或者用VS Code里的Verilog插件。不过课设一般不会被禁止用Vivado多数助教自己也用Vivado提交工程文件时Vivado工程会更被认可。Vivado唯一的缺点是太庞大但只做仿真不用上板的话其实只用到其中很小一部分功能。第三参考教材。我强烈推荐《计算机组成与设计硬件/软件接口》这本书Patterson和Hennessy的它对单周期CPU、多周期CPU和流水线CPU的讲解极其经典里面正好有MIPS指令集和单周期数据通路的完整图。你课设里遇到的所有疑问几乎都能在这本书的第四章到第七章找到答案。国内很多学校用的唐朔飞《计算机组成原理》更偏重理论但实验设计的思路还是以Patterson的书更接轨。第四善用交流。课设不是一个人的战斗。如果你卡在一个bug里超过两个小时还没头绪喊室友过来一起看波形或者去论坛发帖描述现象往往一个新的视角能立刻发现问题。我做实验二时卡了一个下午在BEQ的跳转目标上结果室友看了一眼图说“你这里的imm_ext应该左移两位再接加而不是先加再左移”一句话就救了命。12. 最后的真话实验二这个东西说白了就是一个“把你脑子里的抽象CPU变成能跑的具体CPU”的过程。中间会遇到很多挫折比如仿真好几个小时、波形看得眼睛疼、上板后莫名其妙的错误这些都是正常的。每一个做过这个课设的人都经历过这个过程。但只要你坚持把它做完、做对你一定会有一种奇妙的体验当你在波形里看到第一条指令正确地执行完寄存器堆的值变成了你预期中的数那一刻你突然就理解了自己每天用的计算机到底在干什么。这个感觉比考试考高分要珍贵得多。我至今还记得当年我的实现跑通ADD指令的那一刻仿真波形上pc从0跳到4寄存器堆的1号寄存器从0变成3我心里冒出一句“哇原来这就是CPU啊。”希望你看完这篇之后也能在调试过程里找到这样的瞬间——哪怕它不是第一眼看到也是在无数次抓狂之后那个让你终于松了一口气的瞬间。