首页 / 资讯中心 / 文章详情

从Verilog到流水线CPU:P0-P6课设完整避坑指南

从Verilog到流水线CPU:P0-P6课设完整避坑指南 ★ FEATURED ARTICLE
简介北航计算机组成课程P0-P6全套实验资料覆盖逻辑电路设计、汇编程序与处理器流水线实现的完整训练链适合正在修读该课程或自学处理器设计的本科生。包内共四百四十一个文件核心是一百七十三个Verilog源码文件另有五十余个Logisim工作区、四十余个MARS汇编程序、二十余个逻辑电路图并附带C代码、工程配置和说明文档压缩包整体约十点一七MB便于快速下载与本地复用。已有三千四百八十四人学习下载。内容从斐波那契数列、Moore与Mealy型有限状态机等基础实验到单周期CPU与多周期流水线CPU的完整实现再到哈密顿回路、循环递归等汇编题目系统覆盖各阶段核心任务可作为理解数据通路、控制器以及流水线冒险的直观参考。目录按实验阶段组织方便逐关对照、调试与优化。1. 计算机组成 P0-P6一套课设为什么能逼人亲手造 CPU计算机组成课设里的 P0-P6常被排成一条“从零造 CPU”的连续线P0/P1 让你被 Verilog 语法和仿真工具磨一遍P2/P3 开始搭寄存器堆和 ALUP4/P5 上单周期和流水线数据通路P6 再把 Cache 接进访存环节。它解决的不只是“看懂书”而是逼你回答三个问题信号到底哪个周期有效、数据到底什么时候写回、为什么仿真全对但上板就翻车。这套路线的价值不在 P6 的最终代码而在中途每一个让你反复查波形的深夜。适合两类人准备啃硬课、想系统验证自己是否真理解处理器的人以及想把 Verilog 从“会写”练到“敢写”的人。2. 从 P0 到 P2仿真工具链与 Verilog 基础关2.1 P0/P1/P2 到底在练什么很多人把这几个前置实验当成“浪费时间”直接用某份参考代码跳过。我见过的翻车现场里十有八九都是因为 P0-P2 没亲自跑过仿真工具链后面到了 CPU 级调试时连波形怎么看都没概念。P0 一般解决环境问题编辑器、仿真器、波形工具确认你写的 RTL 能在一分钟内完成编译和仿真P1 解决语法习惯问题阻塞赋值和非阻塞赋值的区别、always 块的触发条件、模块例化时的端口连接P2 开始真正面对寄存器堆、ALU 这类可综合的组件。这套顺序的设计思路是先在“没有时序焦虑”的小模块里建立仿真直觉再进入 CPU 级联调。P2 阶段最常见的练习是把寄存器堆写成双口读、单口写两个读端口分别用组合逻辑输出写端口只在时钟上升沿更新。别看模块小这里埋了两个后面必炸的坑异步复位释放时机、写口冲突的判定。把这两个问题在 P2 阶段想透后面 P5/P6 会省很多事。2.2 最小可复现的仿真环境与第一段测试代码我一般用 Linux 下的开源仿真链做日常验证Verilator 负责把 Verilog 编成 C 仿真可执行文件GTKWave 看波形。对于课程实验这种量级的代码这套组合足够快而且能直接跑汇编测试用例转成的 hex 文件。先装环境# Ubuntu/Debian 系 sudo apt-get update sudo apt-get install verilator gtkwave装完后写一个最简单的 D 触发器验证你的仿真流程是否能跑通。新建dff.v// 带异步复位的 D 触发器P0 阶段的标准练手模块 module dff ( input wire clk, input wire rst, input wire d, output reg q ); always (posedge clk or posedge rst) begin if (rst) q 1b0; // 复位优先非阻塞赋值 else q d; end endmodule再写tb_dff.vmodule tb_dff; reg clk, rst, d; wire q; dff u_dff (.clk(clk), .rst(rst), .d(d), .q(q)); // 时钟周期 10 个时间单位半周期 5 always #5 clk ~clk; initial begin clk 0; rst 1; d 0; #10 rst 0; // 复位至少保持 10ns #20 d 1; #10 d 0; #30 $finish; end endmodule编译运行verilator --cc --exe --build tb_dff.v dff.v -o sim_dff ./sim_dff这步如果顺利说明 Verilog 编译、C 封装、链接、运行这一整条链路没问题。之后每写完一个模块我都会用同样的命令跑仿真。逻辑说明dff.v里用always (posedge clk or posedge rst)描述异步复位综合工具会把rst当成复位信号而非普通数据信号。tb_dff.v里#5 clk ~clk定义了 10ns 的时钟周期复位保持 10ns 是为了让模块进入确定状态避免初始值不定导致后续测试全乱。2.3 时钟周期、复位窗口和 initial 块的三个参数P0-P2 阶段最值得调的三个参数是时钟周期、复位窗口和 initial 块里的激励间隔。时钟周期决定仿真速度但不能快到超过组合逻辑延迟否则在后续 CPU 仿真里会出现“看起来该稳定的信号还没稳定”。复位窗口则必须覆盖到第一个时钟上升沿之前常见写法是rst先拉高至少一个完整时钟周期再在时钟低电平区间撤销。initial begin clk 0; rst 1; #15 rst 0; // 15ns 后撤销保证第一个上升沿处在复位态 end有人习惯把rst恢复写在时钟上升沿那一条语句之后这会引发仿真时的复位毛刺。原因是时钟上升沿和复位撤销同时发生时模块内部寄存器进入亚稳态的仿真近似状态。另一个容易忽略的点是激励里不能只改变一次输入最好每一拍都检查输出先#10看当前输出再改输入再等下一拍。这样做的理由是验证组合逻辑和时序逻辑的分界而不是把测试写成一整段连续赋值。到了 P2 结束你应该能不看参考代码写出带读使能的寄存器堆、32 位 ALU、一个能产生正确进位/溢出标志的加法器。如果这三样还需要到处找例程先别进 P3因为 CPU 数据通路上每一个控制信号最终都会回到这些组件上。3. P3 到 P4单周期 CPU 的取指、译码、写回3.1 单周期设计为什么值得先写直接上流水线是很多人的冲动但 P3/P4 阶段的单周期实现价值在于它让“一个时钟周期内完成什么”这件事变得极端清晰。一条指令从 PC 指向指令存储器开始到写回寄存器堆结束所有组合逻辑必须在下一个时钟沿前稳定这就是单周期最核心的时序约束。我一般会把单周期 CPU 的控制信号列成一张表再动手写 RTL而不是边写边想。因为 CPU 的每个控制信号都是“当前指令类型”的函数漏一个RegWrite或MemtoReg仿真波形一般看不出来但跑测试程序时结果会莫名奇妙地偏掉。单周期的调试成本明显低于流水线因为它没有冒险概念数据通路里每个阶段的延迟都加在同一个周期内。3.2 最小数据通路骨架与取指模块下面这个片段是我常用的取指模块写法重点在 PC 更新逻辑和指令地址对齐// 最小取指模块每次执行后 PC4支持相对跳转 module fetch_stage ( input wire clk, input wire rst, input wire branch_taken, input wire [31:0] branch_target, output reg [31:0] pc ); always (posedge clk or posedge rst) begin if (rst) pc 32h0000_0000; // 复位入口地址 else if (branch_taken) pc branch_target; // 分支目标 else pc pc 32h0000_0004; // 顺序执行按字节地址 4 end endmodule这里最容易错的是 PC 的递增步长。如果指令字长 32 位地址按字节编址那么下一条指令地址是pc 4不是pc 1。很多人写 ROM 初始化文件时用的是 word 地址然后直接把 PC 低位接到 ROM 地址线上结果只能取到奇偶地址错位的指令。逻辑说明branch_taken一旦拉高当前周期取出的下一条 PC 被替换为branch_target这等效于在取指阶段直接改变 PC单周期里不需要额外的流水线冲刷。3.3 指令 ROM 的字节序、PC 对准和立即数扩展单周期 CPU 测试程序通常以十六进制文本形式加载到 ROM 或 RAM而不同工具生成 hex 的字节序不一定一致。P3 阶段翻车最多的三个问题ROM 初始化文件里把 word 顺序写反导致第一条指令读出来是 0x00000000分支跳转目标没做符号扩展负偏移变成了一个很大的正数地址信号直接用了指令中的 imm而不是pc 4 sign_ext(imm)。指令类型立即数扩展方式典型指令I 型 (addi/lw/sw)符号扩展至 32 位addi $t0, $t1, -1I 型 (ori/andi)零扩展至 32 位ori $t0, $t1, 255J 型26 位偏移左移 2 位并拼接 PC 高位j labelB 型分支符号扩展后左移 2 位再与 PC4 相加beq $t0, $t1, loop单周期里 ALU 需要同时处理有符号和无符号立即数所以在译码阶段就要确定扩展方式而不是在 ALU 内部乱猜。我习惯把ImmExtCtrl做成一个 2 bit 控制信号00零扩展01符号扩展10左移两位这样控制表清楚后续写流水线也不容易串信号。3.4 单周期控制信号表和测试顺序控制信号表我建议按下面六列来列RegWrite,ALUSrc,MemWrite,MemRead,MemtoReg,Branch。lw需要MemRead1、MemtoReg1sw需要MemWrite1、ALUSrc1且不写寄存器beq需要Branch1ALU 做减法比较。测试顺序也很关键先测单条 addi再测 lw/sw 组合再测跳转最后混在一起。如果一开始就把所有指令混在一个测试程序里跑遇到错误时根本分不清是译码错还是访存错。4. P5 到 P6流水线转发、冲刷与 Cache 参数4.1 五级流水线的切分标准进入流水线后原来“一条指令一个周期”的舒适区消失了。IF/ID/EX/MEM/WB 五个阶段之间插了四组流水线寄存器每一组都只保存该阶段需要的指令子集和控制信号。切分的关键不是“看起来差不多”而是每组寄存器的位宽必须覆盖该阶段后续所有阶段用到的信号。漏掉一个写回地址到 WB 阶段就不知道该写哪个寄存器漏掉一个MemRead控制信号访存阶段不知道自己要不要读内存。流水线设计里控制信号本身也是流水线的一部分。它们不是全局直接连到各阶段的组合逻辑而是跟着指令走完五个阶段每过一级寄存器更新一次。这样才能保证lw的MemtoReg信号在 WB 阶段才生效而不是在 ID 阶段就干扰 ALU。4.2 转发、暂停和冲刷的三张网P5 最核心的代码是转发单元。它解决的是“上一条指令还没写回下一条指令就要读”的问题。下面是经典的 EX 阶段转发判断// 转发控制EX 阶段检测 ID/EX 与 EX/MEM、MEM/WB 的寄存器冲突 wire [4:0] rs id_ex_rs; wire [4:0] rt id_ex_rt; wire [4:0] ex_mem_waddr ex_mem_rd; wire ex_mem_wen ex_mem_regwrite; wire [4:0] mem_wb_waddr mem_wb_rd; wire mem_wb_wen mem_wb_regwrite; always (*) begin if (ex_mem_wen ex_mem_waddr ! 5b0 ex_mem_waddr rs) forwardA 2b10; // ALU 输入 A 来自 EX/MEM 结果 else if (mem_wb_wen mem_wb_waddr ! 5b0 mem_wb_waddr rs) forwardA 2b01; // ALU 输入 A 来自 MEM/WB 结果 else forwardA 2b00; // 无冲突使用寄存器堆原值 end逻辑说明forwardA是 ALU 第一个操作数的选择信号优先级上 EX/MEM 转发高于 MEM/WB因为它更新。两个 5 bit 地址都要排除$zero因为写$zero不应该覆盖转发路径。此段代码真正的工作方式是把写回数据从 EX 或 MEM 阶段“搬”回 ID/EX 寄存器出口让 ALU 输入避开寄存器堆的延迟。转发不能覆盖所有情况load-use冲突必须暂停。暂停的实现是 ID/EX 阶段的寄存器保持一个周期同时 IF/ID 不更新并把 PC 的写使能关掉。有人图省事直接在每个冲突处都插入气泡后果是 CPI 直线上升P6 跑分测试时差距会非常明显。4.3 Cache 直写/回写与块大小、相联度取舍P6 接入 Cache 时首先要决定写策略和块参数。下面这个参数表是我做模拟时默认从这组值开始试的参数默认值说明块大小4 word (16B)块太小命中率低块太大 tag 联想起伏组数16 组组相联 4 路时共 64 行替换策略LRU简单轮转会导致循环访问抖动写策略写回 写分配比直写在连续写场景下少访问内存Verilog 里描述 Cache 索引时地址切分最容易出错。对于块大小 16B、组数 16 的 Cache地址低 4 位是 block offset中间 4 位是 index其余是 tag。这段切分逻辑必须用参数化语句parameter OFFSET_W 4; // 2^4 16B 块 parameter INDEX_W 4; // 16 组 parameter TAG_W 32 - OFFSET_W - INDEX_W; wire [TAG_W-1:0] tag addr[31:OFFSET_WINDEX_W]; wire [INDEX_W-1:0] index addr[OFFSET_WINDEX_W-1:OFFSET_W]; wire [OFFSET_W-1:0] offset addr[OFFSET_W-1:0];写回 Cache 的状态机至少有四个状态空闲、比较命中、写回脏行、分配新行。很多人省略“写回脏行”状态把新数据直接覆盖到脏行上结果仿真时前面写过但没写回内存的数据全部丢失最终程序结果和单周期 CPU 版本对不上。注意Cache 的命中率不是越高越好。如果为了提升命中率把块大小调到 64B每次 miss 都要从内存连续读 16 个 word如果你的总线接口是逐 word 读这个 refill 开销可能会让平均访存时间变长。这也是 P6 阶段看起来“Cache 命中率挺高但跑分变慢”的常见原因。5. 避坑P0-P6 最容易翻车的 5 类问题5.1 波形看起来没问题测试点却不过现象把每一条指令的波形逐项核对PC、ALU 输出、寄存器写地址都正确但跑完整程序时某个寄存器的值和期望结果差一个数。原因寄存器堆写数据总线上的来源选择错了lw应该从内存读数据写回但控制信号把MemtoReg接反了导致 ALU 结果被写回寄存器。单条指令波形看不出问题因为只看了 ALU 输出路径没看写回路径。解决在 WB 阶段把RegWriteData单独拉出来分别打印 ALU 结果和内存读数据用自动对比脚本和期望值逐周期比对别只看波形。5.2 仿真全过上板后随机死机现象在仿真器里跑一百个测试都正常烧到板子上跑一会儿就卡死或输出错乱。原因RTL 里混用了组合逻辑赋值和时序逻辑赋值或者某个输入信号在时钟沿的同时发生变化形成数据竞争最常见的是用always (*)给一个在时序块里也被驱动的信号赋值。解决把所有跨时钟沿的数据路径统一成always (posedge clk)组合逻辑只输出到中间 wire中间 wire 不能再被第二个 always 块驱动。上板前检查综合报告的 warning重点关注incomplete sensitivity list和multi-driven signal。5.3 Cache 命中率上去了吞吐反而掉了现象把 Cache 从直接映射改成四路组相联后miss 数明显下降但运行总周期数反而变多。原因每次访问都要比较四路 tag访问延迟的关键路径变长导致设计无法跑到原来的最高时钟频率或者回写状态机每命中一次都要做 LRU 更新引入额外气泡。解决把 Cache 访问拆成两拍第一拍查 tag 和 valid第二拍根据命中结果读数据或进入 refill同时把 LRU 状态位放到 tag 数组旁边避免额外的一级判断逻辑。先跑最坏频率再回头调块大小不要盲目堆相联度。5.4 组合逻辑自环、仿真警告和时序违例现象仿真时某块逻辑在没有任何输入变化时输出不停翻转或者说波形里出现类似振荡的密集毛刺。原因一个组合 always 块的输出又回到它自己的输入形成组合环路。寄存器堆写地址来自译码译码结果又依赖寄存器堆输出这种环在大型 CPU 里很难一眼看出来。解决编译后打开仿真器生成的lint警告列表搜索combinational loop定位到具体信号后打断环路插入一级流水线寄存器。低频下环也能跑“正确”但频率一高就变成时序违例综合工具报的 setup/hold violation 往往指向同一个信号。5.5 改了一处后面全部崩版本与回归意识现象修好一个分支预测bug后原本正常的访存测试开始失败而且失败的方式没有规律。原因没做版本管理也没有回归测试改动是叠加在未经验证的中间状态上。解决用 git 做最小粒度的提交每修一个 bug 只改一条线同时准备一个run_all.sh脚本把 P0-P5 所有测试用例全部跑一遍。这个习惯不是课程要求但它是区分“碰运气过验收”和“自己真正掌握设计”的分界线。连续三次回归失败时优先git diff看自己到底改了什么而不是加大打印信息量。6. 最后怎么把验证做扎实回归脚本、差分测试与断言6.1 回归脚本每次改动先跑全部用例把测试用例放在tests/目录每个用例包含 hex 和期望输出。每改一次 RTL直接跑回归不依赖人工看波形#!/usr/bin/env bash set -euo pipefail CASES$(ls tests/*.hex) for c in $CASES; do name$(basename $c .hex) ./build/run_cpu tests/$name.hex out/$name.log diff -u tests/$name.expected out/$name.log \ echo PASS $name \ || echo FAIL $name done这段脚本把测试用例编译出的期望日志和实际运行结果对比任何不一致都会在终端里打印 FAIL。逻辑说明set -euo pipefail保证脚本在未定变量或管道出错时直接终止避免后一个用例覆盖前一个错误。你可以把用例按难度递增命名比如t00_addi.hex、t01_lw_sw.hex这样失败时能快速知道是哪一类指令的问题。6.2 差分测试让自研 CPU 和参考模型对拍比回归脚本更狠的是差分测试同一个程序同时跑你的 CPU 和另一个独立参考实现比如课程提供的标准模拟器或自己用 Python 写的指令级模型。每一步执行完对比 32 个寄存器和内存关键区域。这个做法的好处是省去人工设计“期望输出”只要参考模型正确任何不一致都是你的 CPU bug。# 差分测试参考模型与 DUT 各执行一条指令后对比寄存器堆 for r in range(32): if ref_regs[r] ! dut_regs[r]: print(fpc{pc:#x} reg[{r}] ref{ref_regs[r]:#x} dut{dut_regs[r]:#x}) break差分测试能抓出流水线转发优先级错误、分支延迟槽处理错误、以及 Cache 写回时序错误。它不需要写完整测试汇编只要不断随机生成指令序列或搬用现成的冒泡排序、斐波那契程序跑一次就能覆盖大量边界情况。6.3 断言把“卡死”变成一行日志如果 CPU 跳到非法地址或信号机上卡住仿真会无限循环。加断言比肉眼观察$finish是否执行更直接// 非法指令地址断言PC 超出 ROM 范围时立刻结束仿真 always (posedge clk) begin if (pc ROM_BOUND) $error(PC out of range: %08x, pc); end$error在 Verilator 里会让仿真在产生错误报告后停止方便快速定位是哪条分支把 PC 带偏了。另一个实用断言是检查寄存器写使能是否在复位期间无效复位时RegWrite拉高会把寄存器堆写成确定值掩盖初始化问题但这段写法在综合时会产生不必要的复位逻辑。如果你问我这门实验给我留下的最大习惯是什么先写回归脚本再动 RTL。没有回归保护的改动就像在迷宫里闭眼换线修好一个亮也许同时弄灭三个灯。与其把最后一周耗在肉眼盯波形上不如把验证自动化让每一次失败都有明确的信号位置和预期值。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站