ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

简介:北航计算机组成课程P0-P6全套实验资料,覆盖逻辑电路设计、汇编程序与处理器流水线实现的完整训练链,适合正在修读该课程或自学处理器设计的本科生。包内共四百四十一个文件,核心是一百七十三个Verilog源码文件,另有五十余个Logisim工作区、四十余个MARS汇编程序、二十余个逻辑电路图,并附带C代码、工程配置和说明文档;压缩包整体约十点一七MB,便于快速下载与本地复用。已有三千四百八十四人学习下载。内容从斐波那契数列、Moore与Mealy型有限状态机等基础实验,到单周期CPU与多周期流水线CPU的完整实现,再到哈密顿回路、循环递归等汇编题目,系统覆盖各阶段核心任务,可作为理解数据通路、控制器以及流水线冒险的直观参考。目录按实验阶段组织,方便逐关对照、调试与优化。

1. 计算机组成 P0-P6:一套课设为什么能逼人亲手造 CPU

计算机组成课设里的 P0-P6,常被排成一条“从零造 CPU”的连续线:P0/P1 让你被 Verilog 语法和仿真工具磨一遍,P2/P3 开始搭寄存器堆和 ALU,P4/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 <= 1'b0; // 复位优先,非阻塞赋值 else q <= d; end endmodule

再写tb_dff.v:

module 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 更新逻辑和指令地址对齐:

// 最小取指模块:每次执行后 PC+4,支持相对跳转 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 <= 32'h0000_0000; // 复位入口地址 else if (branch_taken) pc <= branch_target; // 分支目标 else pc <= pc + 32'h0000_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, -1
I 型 (ori/andi)零扩展至 32 位ori $t0, $t1, 255
J 型26 位偏移左移 2 位并拼接 PC 高位j label
B 型分支符号扩展后左移 2 位,再与 PC+4 相加beq $t0, $t1, loop

单周期里 ALU 需要同时处理有符号和无符号立即数,所以在译码阶段就要确定扩展方式,而不是在 ALU 内部乱猜。我习惯把ImmExtCtrl做成一个 2 bit 控制信号:00零扩展,01符号扩展,10左移两位,这样控制表清楚,后续写流水线也不容易串信号。

3.4 单周期控制信号表和测试顺序

控制信号表我建议按下面六列来列:RegWrite,ALUSrc,MemWrite,MemRead,MemtoReg,Branch。lw需要MemRead=1、MemtoReg=1;sw需要MemWrite=1、ALUSrc=1且不写寄存器;beq需要Branch=1,ALU 做减法比较。测试顺序也很关键:先测单条 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 != 5'b0 && ex_mem_waddr == rs) forwardA = 2'b10; // ALU 输入 A 来自 EX/MEM 结果 else if (mem_wb_wen && mem_wb_waddr != 5'b0 && mem_wb_waddr == rs) forwardA = 2'b01; // ALU 输入 A 来自 MEM/WB 结果 else forwardA = 2'b00; // 无冲突,使用寄存器堆原值 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_W+INDEX_W]; wire [INDEX_W-1:0] index = addr[OFFSET_W+INDEX_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(f"pc={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。没有回归保护的改动,就像在迷宫里闭眼换线,修好一个亮也许同时弄灭三个灯。与其把最后一周耗在肉眼盯波形上,不如把验证自动化,让每一次失败都有明确的信号位置和预期值。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进