简介:这份资源面向计算机组成原理课程的学习者与备考学生,聚焦北航P0至P6系列实验,覆盖从数字逻辑电路到CPU设计的完整技能链条。包内以173个Verilog源文件、43个Mars汇编程序、19个Logisim电路文件为主,辅以C语言代码、工程配置与实验文档,压缩包约10.17MB,共441个文件,按实验阶段组织,便于对照检索。内容涵盖用Logisim搭建斐波那契数列电路、Moore与Mealy型有限状态机及单周期CPU,用Verilog实现单周期与多周期流水线CPU,以及用Mars编写哈密顿回路、循环递归、矩阵转化等汇编题目,能帮助读者理解数据通路设计、流水线冒险处理与汇编编程思路。已有3507人学习下载,适合需要参考电路实现与代码框架、查漏补缺的课程学习者。
1. 从一根导线到一条流水线:计算机组成课设到底在造什么
很多人第一次打开 Logisim,以为这门课就是拖几个与门或门、连一连线、跑通一个加法器就完事。真正让人卡住的从来不是单个门电路,而是当你要把 Mars 里跑的一段汇编,变成 CPU 每个时钟周期里稳定翻转的电平信号时,中间那层"约定"没人替你兜底。北航计算机组成 P0-P6 这条线,本质上就是逼你亲手把这层约定造出来:P0 用 Logisim 搭组合逻辑和状态机,P1 到 P3 逐步把单周期 CPU 拼完整,P4 引入 Verilog 和 ISE 做可综合设计,P5 上流水线,P6 处理异常与转发。搞懂它,你就搞懂了"指令为什么能被执行"这件事的底层机制,而不是背一张数据通路图。这套资源值得深挖的地方,正在于它把从汇编语义到硬件时序的每一段翻译过程都摊开给你看。
2. 单周期 CPU 的数据通路:一条指令到底走了哪些路
2.1 指令译码不是查表,是控制信号的组合展开
单周期 CPU 最容易被误解成"取指-译码-执行-访存-写回"五个盒子串起来。真实实现里,译码阶段干的事情是把 32 位指令的不同位段拆开,分别送进寄存器堆地址、立即数扩展单元和控制单元。以 MIPS 的 R 型指令为例,rs、rt、rd、shamt、funct各占固定位段,控制单元根据 opcode 和 funct 生成RegDst、ALUSrc、MemtoReg、RegWrite、MemRead、MemWrite、Branch、ALUOp这一组信号。这些信号不是"命令",而是多路选择器的选择端和使能端,它们在同一时刻共同决定数据往哪流。
常见的实现思路是把控制单元写成一个大的组合逻辑块,输入是 opcode(有时加 funct),输出是上面那组控制位。Logisim 里可以用真值表或者分层的与或逻辑搭,Verilog 里则是一个always @(*)的 case 块。关键在于:控制信号必须在时钟上升沿之前稳定,否则寄存器堆会在错误的时刻写入。这就是单周期 CPU 的第一个隐性约束——所有组合逻辑的延迟必须塞进一个时钟周期内。
// 控制单元的核心:opcode 决定大部分信号,funct 只对 R 型细分 always @(*) begin case (opcode) 6'b000000: begin // R 型 RegWrite = 1'b1; RegDst = 1'b1; // 写 rd 而非 rt ALUSrc = 1'b0; // 第二操作数来自寄存器 MemtoReg = 1'b0; ALUOp = 2'b10; // 交给 ALU 控制再看 funct end 6'b100011: begin // lw RegWrite = 1'b1; RegDst = 1'b0; ALUSrc = 1'b1; // 第二操作数是立即数 MemtoReg = 1'b1; // 写回数据来自内存 ALUOp = 2'b00; end // sw / beq / j 等分支略 endcase end这段代码里ALUOp是个二级译码信号:它不直接控制 ALU,而是告诉 ALU 控制单元"这是 R 型,具体做什么看 funct"。这种两级设计是为了让主控制单元不必关心 R 型内部的加减与或,职责分离,改起来不容易翻车。参数上,RegDst选的是写寄存器编号来源,MemtoReg选的是写回数据来源,两个多路选择器一左一右,构成了写回通路的骨架。
2.2 立即数扩展与分支地址计算:符号位为什么必须复制
MIPS 的立即数有 16 位和 26 位两种,进入 32 位数据通路前必须扩展。I 型指令的 16 位立即数要做符号扩展,也就是把最高位复制到高 16 位,而不是简单补零。这个细节在 P2 阶段是血泪经验高发区:如果补零,负数偏移量会变成一个巨大的正数,beq跳转直接飞到内存边界外,仿真时表现为 PC 突然变成一个离谱的值。
分支地址的计算也不是"PC 加立即数"这么简单。MIPS 的分支目标地址是PC + 4 + (sign_extend(offset) << 2),左移两位是因为指令按字对齐,偏移量单位是指令字而非字节。这个左移在数据通路里就是一个把 32 位扩展结果低 30 位接到高位的连线操作,Logisim 里用分线器实现,Verilog 里写{ {14{offset[15]}}, offset, 2'b00 }。看起来是小事,但它是理解"为什么分支范围是 ±128KB"的钥匙——16 位偏移左移两位后是 18 位有符号数,正好覆盖这个范围。
提示:符号扩展和零扩展的区别,在 P2 阶段就要用测试用例卡死。写一个偏移量为负的分支指令,看 PC 是否往低地址跳,比事后调试省几个小时。
3. 从 Logisim 到 Verilog:为什么 P4 要换工具链
3.1 可综合与可仿真:ISE 里那条看不见的分界线
P0 到 P3 在 Logisim 里画图,直观但有个天花板:你画的是电路图,不是可综合的硬件描述。P4 切到 Verilog 加 ISE,核心变化不是语法,而是你必须开始区分"仿真能过"和"能综合成硬件"。ISE 的综合工具会把你的always块翻译成查找表和触发器,某些写法在仿真里行为正确,综合后却变成锁存器或者直接被优化掉。
最典型的翻车点是组合逻辑里漏写else。一个if没有配套else,仿真时信号保持原值看起来没问题,综合工具却会推断出一个锁存器来"记住"这个值。锁存器在 FPGA 里是稀缺资源,而且会引入时序分析上的麻烦。正确的做法是组合逻辑的always @(*)块里,每个输出在所有分支都有赋值,或者开头给默认值。
// 错误示范:缺少 else,综合出锁存器 always @(*) begin if (sel) out = a; end // 正确写法:默认值兜底,纯组合无锁存 always @(*) begin out = b; // 默认值 if (sel) out = a; end这两段代码在仿真波形上可能看不出差别,但综合报告里前者会多出 latch 警告。P4 阶段养成看综合报告的习惯,比在板子上调不通再回头找强得多。ISE 的警告列表里,latch inferred、signal has no load、combinational loop 这几类必须清零,它们对应的都是真实硬件行为偏差。
3.2 时序约束与时钟域:单周期为什么在板子上跑不快
单周期 CPU 在 Logisim 里可以随便设时钟频率,因为那是理想仿真。到了 ISE 综合进 FPGA,时钟周期受限于最长组合路径。取指、译码、执行、访存、写回全塞在一个周期里,关键路径往往落在 ALU 加内存访问这条链上。你会在时序报告里看到一个很大的 slack 负值,意思是这个设计在当前频率下建立时间不满足。
这就是 P5 引入流水线的直接动机,不是"为了先进而先进",而是单周期的关键路径物理上压不下去。流水线把一条指令拆成五段,每段之间插寄存器,单段组合逻辑变短,时钟频率才能提上去。代价是引入了数据冒险和控制冒险,需要转发和停顿来兜底。P4 到 P5 的过渡,本质是从"功能正确"跨到"时序可行",这个认知转变比多写几百行 Verilog 重要得多。
4. 五级流水线的冒险处理:转发、停顿与冲刷的取舍
4.1 数据冒险:转发通路为什么不能解决所有 RAW
流水线里后一条指令要用前一条指令还没写回的结果,叫 RAW 冒险。最直接的解法是转发:把 EX 段或 MEM 段的结果直接引到 ALU 输入端,不等写回。转发通路的判断条件是比较目标寄存器编号和源寄存器编号,同时检查RegWrite是否有效。
但转发有覆盖不到的情况:lw后面紧跟一条要用该数据的指令。lw的数据要到 MEM 段结束才拿到,而下一条指令在 EX 段就需要它,中间差了一个周期,转发也来不及。这时只能停顿一个周期,让流水线等一等。这个"load-use 冒险"是流水线设计里最经典的坑,判断逻辑要单独写:ID 段发现当前指令是lw且下一条指令的源寄存器与之相同,就拉高 stall 信号,冻结 PC 和 IF/ID 寄存器,同时在 EX 段插入一个气泡。
// load-use 冒险检测:ID 段组合逻辑 wire load_use_hazard = (id_ex_MemRead && ((id_ex_rt == if_id_rs) || (id_ex_rt == if_id_rt))); // 触发时:PC 保持、IF/ID 保持、EX 段控制信号清零(气泡)参数说明:id_ex_MemRead表示当前在 EX 段的指令是lw,id_ex_rt是它的目标寄存器,if_id_rs/rt是下一条指令的源寄存器。三个条件同时成立才停顿。这里容易踩的坑是把lw的目标寄存器和源寄存器比较时没排除$0,导致对零寄存器的读写误触发停顿,性能白白损失。
4.2 控制冒险:分支预测失败为什么要冲刷三拍
分支指令在 EX 段才算出是否跳转,而此时它后面已经有两条指令进了流水线。如果分支成立,这两条指令就是错的,必须冲刷掉。冲刷的实现是把 IF/ID、ID/EX 的控制信号清零,让它们变成空操作,同时把 PC 改成分支目标地址。这个动作在时序上表现为流水线里出现几个气泡,性能损失就是分支延迟槽的代价。
常见的实现思路是"预测不跳转",因为大多数情况下分支不成立,这样只在分支成立时冲刷。更激进的做法是在 ID 段就算分支地址,把冲刷减少到一拍,但需要额外的比较器和更长的 ID 段逻辑。P5 阶段通常先做保守版本,跑通再优化。这里的设计取舍很直白:冲刷拍数越少,控制逻辑越复杂,关键路径可能反而变长。没有绝对最优,只有在你当前频率目标下的平衡。
注意:冲刷时别忘了清
RegWrite和MemWrite,否则被冲刷的指令仍然会写寄存器或写内存,仿真时表现为寄存器值莫名其妙被改,这种 bug 靠看波形很难定位。
5. 异常与精确中断:P6 里那个最容易被忽略的机制
5.1 精确异常:为什么流水线里的异常要"排队"
P6 引入异常处理,核心概念是精确异常:当一条指令触发异常时,它之前的所有指令必须已完成,它之后的所有指令必须像没执行过一样。流水线里这很难,因为多条指令同时在飞。实现方式是给每条指令打上异常标记,随流水线寄存器一路带下去,等到 MEM 段或 WB 段再统一决定是否跳转到异常处理入口。
这个"带标记"的设计动机是:异常可能在取指(地址错)、译码(非法指令)、执行(溢出)、访存(缺页)多个阶段产生,但你不能在每个阶段都立刻跳转,否则前面还在飞的指令状态就乱了。把异常信息打包进流水线寄存器,等指令走到一个统一提交点再处理,是保证精确性的常见做法。EPC 寄存器记录触发异常的指令地址,Cause 寄存器记录异常类型,这两个寄存器是异常机制的骨架。
5.2 中断与异常的优先级:谁先被处理
多个异常同时发生时需要优先级。一般约定是:取指地址错 > 译码非法指令 > 执行溢出 > 访存异常 > 外部中断。这个顺序不是随意定的,它反映的是"越早发现的问题越根本"。外部中断优先级最低,因为它可以等,而取指都错了后面全无意义。P6 的测试用例往往会构造多个异常同时触发的场景,看你的优先级逻辑是否和约定一致。
实现上,异常检测逻辑分散在各段,但汇总在一个优先级编码器里,输出最终的异常类型和是否触发。触发时,PC 跳到异常入口,EPC 写入当前指令地址,状态寄存器切换到内核态。返回时用eret指令恢复 PC 和状态。这条链路里任何一环漏掉,都会表现为异常处理后程序跑飞,而且因为涉及状态切换,调试信息很少,属于典型的黑匣子问题。
6. 设计取舍:为什么"能跑通"和"设计对"是两回事
走到 P6 你会发现,同样一个能通过所有测试用例的 CPU,内部实现可能天差地别。有人把控制信号写成一张巨大的真值表,有人拆成两级译码;有人在 EX 段做转发,有人在 MEM 段才转发;有人分支在 ID 段解析,有人在 EX 段。这些选择在功能测试里看不出区别,但在时序报告、资源占用和后续扩展性上差距明显。
我一般会这么理解这套课设的价值:它逼你在没有标准答案的地方做取舍,并且承担取舍的后果。单周期转流水线是为了频率,流水线加转发是为了减少停顿,转发加停顿是为了正确性,异常加精确提交是为了可恢复。每一步都是在前一步的基础上打补丁,补丁打得好不好,取决于你对底层机制的理解深度,而不是背了多少张图。
验证方法上,除了跑官方测试用例,我建议自己构造几类边界场景:负偏移分支、load-use 紧邻、分支后紧跟异常指令、连续多条lw打满转发通路。这些场景能把大部分隐藏 bug 逼出来。仿真波形要重点看流水线寄存器的控制位,而不是只看最终寄存器堆的值——值对了不代表中间过程对,只是错误被掩盖了。
从那以后我每次做完一个模块,都强制自己先看综合报告和时序报告,再跑功能仿真,最后才上板。这个顺序反过来,踩坑的概率高得多。希望帮到你。
本文还有配套的精品资源,点击获取