FPGA 入门 24:仿真看波形——写 Testbench、dump 波形、读图
更新时间:2026-09-12。本文是
fpga/入门层第 26 篇。TB 基础见 Testbench 与仿真,仿真对但上板错见进阶 CDC 亚稳态,自检 TB 见进阶 高级 Testbench。
本文要回答的问题
- 仿真和上板到底在验证两件什么不同的事?为什么「仿真 100% 对」仍然可能上板乱跳?
- 一个可运行的 Testbench 由哪几部分组成?时钟、复位、激励、时钟块、$finish 各自怎么写?
- 时间是怎么在仿真里推进的:
#5、timescale、always #5 clk=~clk到底表示多少纳秒、多少兆赫? - VCD/FSDB/WLF 波形文件有什么区别,Icarus+GTKWave 和 Vivado 仿真器各自怎么 dump?
- 波形上的 0、1、x、z、跳变沿、非阻塞「晚一拍」分别怎么读?怎样用游标和时间标记定位第一个错误?
- Testbench 里为什么可以用不可综合语句?task、forever、wait、$random、自动比对该怎么组织?
- 怎样从「人肉看波形」升级到「自检 Testbench」:参考模型、记分牌、覆盖率、断言是什么直觉?
一、仿真三要素
- Testbench(TB):产生时钟/复位/激励、检查输出,不综合,可用
#延时/initial/$任务; - DUT:被测模块,在 TB 里例化;
- 波形文件:VCD/FSDB/WLF,用 GTKWave/Vivado 查看。
二、最小 Testbench
`timescale 1ns/1ps
module tb_counter;
reg clk=0, rst_n=0; wire [3:0] q;
counter dut(.clk(clk),.rst_n(rst_n),.q(q)); // 例化 DUT
always #5 clk = ~clk; // 半周期5ns -> 100MHz
initial begin
$dumpfile("wave.vcd"); // 记录波形
$dumpvars(0, tb_counter);
rst_n = 0; #23; // 拉低复位一段时间
rst_n = 1; // 释放复位
#2000; // 跑 2us
$display("final q=%0d", q);
$finish;
end
endmodule跑:iverilog -o s.vvp tb.v counter.v → vvp s.vvp → gtkwave wave.vcd。
三、怎么读波形
| 现象 | 含义 | 处理 |
|---|---|---|
| 复位后 reg 变确定值 | 正常 | — |
| 一直 x | 未初始化/未驱动/冲突 | 追复位和驱动源 |
| z 高阻 | 三态没人驱动/没接线 | 查使能与端口连接 |
| 非阻塞信号“晚一拍”变 | 正常(下一沿更新) | 沿对齐看 |
| 游标量时间 | 验证周期/波特率/分频 | 对期望时间 |
方法:从“第一个看起来不对的时刻”往前追,定位是哪个寄存器该变没变/该来的信号没来。
四、仿真时间是怎么流动的:timescale 与时钟生成
仿真器里没有真实的秒表,时间完全由 Testbench 里的延时语句推进。理解这一点,很多「波形不动、时间不对」的问题就消失了。
- 时间尺度指令(写法是反引号加 timescale 1ns/1ps)表示时间单位是 1ns、仿真精度是 1ps:代码里
#5就是延时 5 个单位即 5ns,延时值最小按 1ps 解析。单位决定#的含义,精度决定能表达多细的延时,两者都不能省。 always #5 clk = ~clk;让时钟每 5ns 翻转一次,完整周期是 10ns,对应 100MHz;想要 50MHz 就写#10(周期 20ns)。初值用reg clk=0给定,时钟从 0 开始翻转。- 复位和激励通常放在 initial 块里顺序执行:
rst_n=0; #23; rst_n=1;表示先保持复位 23ns 再释放;之后用一系列#延时 + 赋值构造输入序列,最后#2000; $finish;让仿真跑够时间后停止。 - 没有任何延时的「零时间循环」会让仿真卡在同一时刻不动;反之忘了
$finish仿真会一直跑到工具默认超时。合理的结束条件既可以是固定延时,也可以是等待某个事件(如收到指定字节数)后调用$finish。
Testbench 自身没有端口、不被综合,它像一块「虚拟面包板」:声明 reg 驱动 DUT 输入、wire 观察 DUT 输出、例化 DUT 并接线。时钟与复位是全局激励,业务激励可以按模块接口分组写成 task,让测试用例像顺序脚本一样可读。
五、激励的常用写法:从手写波形到 task
入门阶段最容易卡住的是「怎么把一串有时间关系的输入喂进去」。几种递进写法:
- 直接延时赋值:
#20; start=1; #10; start=0;适合产生单个脉冲,如启动一次发送或模拟一次按键。 - task 封装事务:把「拉低起始位、保持若干拍、送 8 个数据位、拉高停止位」这种重复序列写成带参数的 task,调用一次就发送一个字节;测 100 个字节就是循环调用 100 次,波形和代码都干净。
- 同步激励:在 always/initial 里用
@(posedge clk)与时钟对齐后再改输入,模拟真实的同步上游;给 DUT 的激励尽量在时钟沿之后稍作偏移(如#1),避免和采样沿完全重合造成竞争。 - 随机激励:用
$random、$urandom生成随机数据、随机等待间隔,能打到人想不到的边界;配合固定随机种子可复现某次失败。 - 响应驱动激励:等待 DUT 的 valid/ready/empty/full 信号再给下一笔,模拟真实握手,而不是自顾自地灌数据。
一个良好习惯是把「时钟复位、事务 task、具体用例、自动检查、结束控制」分成不同 initial 块或独立模块,用例只描述「发什么、期望什么」,底层时序细节复用。这套结构在 高级 Testbench 里会进一步发展成完整的验证平台。
六、读波形的方法论:从第一个错误往回追
看波形不是把所有信号摊开发呆,而是有一套定位流程:
- 先对齐时间基准:确认时钟周期对不对、复位何时释放,很多「全 x、不动」只是时钟或复位没给对。
- 明确预期:在每个关键时刻,先在纸上说出「这个沿之后某寄存器应该变成什么」,再去波形里核对。
- 找第一个出错点:不要从最终错误结果往回瞪,而是找到输出第一次偏离预期的时刻,问题通常就在它前一个时钟沿的输入/组合逻辑上。
- 沿对齐读寄存器:非阻塞赋值在时钟沿采样旧值、沿后更新,波形上表现为「输出比导致它的条件晚一拍」,这是正常的流水线行为而不是错误。
- 用游标量时间:测量两个沿、一个脉冲宽度、一帧位宽、一次分频周期,直接和手算的常数核对(波特率、消抖窗口、扫描驻留都靠这一招)。
- 分层缩小范围:先看模块边界信号(输入输出是否对),再逐层看内部寄存器,像二分查找一样把责任模块隔离出来。
读总线信号时,把进制切换成十六进制或无符号十进制比看一根根位线高效;对状态机信号用枚举名(SystemVerilog enum 或 radix 设为 symbolic)显示状态名,状态跳转对错一目了然;把互补信号(如 valid/ready、full/empty)放在相邻轨,握手问题立刻可见。波形工具里的搜索、标记、时间差值功能要刻意练习,定位速度主要取决于这些操作是否熟练。
七、看懂 x 和 z:未知与高阻的排查
仿真四值逻辑里最容易困惑新手的是 x 与 z,它们各自指向不同的硬件问题:
- x(未知):寄存器没给初值、存在多个驱动相互冲突、case/if 分支没覆盖导致输出未赋值,都会产生 x。上电瞬间大量 x 正常,关键看复位释放后是否收敛为确定值;复位后仍是 x,就沿数据依赖往回追,找到第一个没有被有效赋值的源。
- z(高阻):三态总线没有任何驱动者使能、inout 端口方向没切换、某根 wire 根本没接,都会出现 z。总线在某时刻应当恰有一个主设备驱动,z 往往意味着使能时序错或漏接。
- x 会传播:x 参与运算通常继续产生 x,一个未复位的状态位可能让整个数据通路一片 x;这反而是好事,它在仿真里暴露了「依赖上电初值」的隐患。
- 真实硬件没有 x:FPGA 上电后每个触发器是确定的 0 或随机合法值(很多器件全局置位为 0),不会停在 x,所以「仿真靠 x 碰巧没出错」或「仿真全 x 但上板好像能跑」都不能作为设计正确的证据,必须显式复位控制逻辑(复位纪律见 时钟与复位)。
排查手段:在波形里对 x/z 设置高亮,定位它们第一次出现的时刻和信号;综合器的多驱动、未连接、latch 警告往往和仿真里的 x/z 一一对应,两边对照看能快速锁定。
八、从看波形到自检:参考模型与记分牌
人肉看波形只能验证很短的序列,稍微复杂的设计必须让 Testbench 自己判断对错。入门可以按三步升级:
- 打印比对:在 DUT 输出有效时用
$display打印实际值,Testbench 里同时算一个期望值,二者不等就打印错误并累计错误计数,结束时报告通过/失败条数。 - 参考模型:用行为级代码(不考虑时序、只求功能正确)写一个简单「黄金模型」,比如 FIFO 的入队出队用软件队列模拟、滤波器用浮点直接算,给 DUT 和参考模型喂同样激励,逐笔比对输出。
- 记分牌(scoreboard):把「输入事务」记录到队列,在输出端按顺序取出核对,天然适用于流水线、FIFO、通信链路这类输出相对输入有延迟或重排的设计。
再进一步是覆盖率思维:列出要覆盖的情形(复位、回卷、满载、空读、连发、错误帧、每个状态跳转),用例逐个点名覆盖,并尽量用随机约束产生大量边界组合。SystemVerilog 断言(SVA)可以直接在波形和日志里标记「valid 拉高时数据必须稳定」「full 时不能再写」这类协议性质被违反的时刻。自检 TB 的价值在于:修改代码后能一键回归,立刻知道改动有没有破坏旧功能,这是项目规模变大后不可或缺的安全网(系统方法见进阶 高级 Testbench 与仿真加速篇)。
九、仿真的边界:哪些坑它天生看不见
必须在心智里把「功能仿真」和「物理稳健性」彻底分开。功能仿真默认零门延迟、触发器完美、时钟同时到达,因此以下问题它基本无法暴露:
- 跨时钟域亚稳态:异步信号不打拍,仿真里可能每次都「恰好采对」,上板才偶发错误,需按 CDC 设计并用专门的 CDC 检查;
- 建立/保持时序违例:组合路径太长、频率太高,仿真照样给出正确逻辑值,要靠静态时序分析 STA 和时序报告判断(见 XDC 管脚与时序、时序报告入门);
- 复位/上电问题:TB 常给「完美复位」,硬件上复位释放不同步、时钟未稳定就工作会出错(见 时钟与复位、PLL locked);
- 真实输入非理想:按键抖动、串口电平、传感器建立时间、信号完整性,在仿真里都被理想化成干净波形。
正确的验证组合是:仿真快速迭代功能、STA 保证时序、CDC 检查保证跨域、上板用 ILA 和仪器确认物理行为。仿真激励也要尽量「真实」——异步输入刻意相对时钟沿随机放置、复位时序按板上实际情况给、外部协议按手册时序造,这样仿真才能覆盖尽量多的真实场景,而不是只在理想化假设下自证正确。
十、三套常见仿真流程怎么选
- Icarus Verilog + GTKWave:免费、轻量、命令行可脚本化,适合纯 RTL 功能仿真。流程是编译、运行、看波形三步:用 iverilog 把 Testbench 和设计一起编译成 vvp,用 vvp 执行生成 VCD,用 gtkwave 打开。TB 里写
$dumpfile("wave.vcd"); $dumpvars(0, tb_top);才会记录波形;$dumpvars的层级参数控制记录到哪一层,写 0 表示记录该实例以下全部信号。VCD 是通用文本格式,文件可能很大,长时间仿真可只 dump 需要的层次或用$dumpoff/$dumpon分段记录。 - Vivado 内置仿真器(xsim):图形化、能直接理解工程里的 IP 和 Xilinx 原语,调试 IP(FIFO、PLL、ILA)时几乎必用。把 Testbench 设为 simulation top,点 Run Simulation 即可,波形在 GUI 里添加信号后重新运行;IP 要确保生成了 simulation wrapper,否则黑盒没有行为。
- ModelSim/Questa 等商业仿真器:编译速度、调试能力、SystemVerilog/UVM 支持更强,企业项目常见,操作概念(编译、细化、仿真、加波形、run 时间)与前两者一致。
选型原则:纯算法和自研 RTL 用开源工具链足够,CI 里也方便自动跑;涉及厂商 IP、原语或混合语言时用厂商自带仿真器。无论用哪个,都应把「编译—运行—自检结果通过/失败」做成一条命令或脚本,避免每次手工点按钮,这与本文第八节的自检 TB 配合才能形成回归测试。
十一、五个典型波形案例
- 计数器不该回卷却提前回卷:用游标量回卷周期,若恰在 2 的幂处回卷,几乎可以断定比较值位宽不够被截断;核对比较常数和寄存器位宽即可(位宽陷阱见 数据类型 与 计数器篇)。
- 状态机跳了不存在的状态:先看复位时 state 是否回到初始态,再看跳转条件的组合输入在出错沿是什么值;常因分支未覆盖、条件用了锁存的旧值或阻塞/非阻塞用错(见 FSM、阻塞非阻塞)。
- 握手丢数据:把 valid、ready、data 放相邻轨,检查 valid 拉高时 ready 是否真的被采样、data 是否保持到握手完成;单周期脉冲被错过多因 valid 与接收方使能不在同一时钟域或没做保持。
- 串口乱码:量起始位到各位的宽度是否等于分频常数,核对采样点是否在位中央,检查 LSB 先发顺序;再和 UART 篇 的误差表对照。
- 输出晚了一拍或两拍:先判断是设计本来就有的寄存器延迟,还是多写了一级寄存;沿对齐地把「条件成立的拍」和「输出变化的拍」标出来,级数一目了然。
这些案例的共同点是:先量时间、再对沿、最后追数据依赖。波形调试不是玄学,而是把「我以为代码会这样」和「硬件实际在每个沿做了什么」逐拍对齐的过程。
十二、仿真自查清单与练习建议
每次写 Testbench 前过一遍:是否有正确 timescale、时钟频率是否和设计假设一致、复位是否保持足够时间并在时钟稳定后释放、激励是否覆盖正常路径和边界(最小/最大值、回卷、满载、连发、错误输入)、是否检查了输出而不只是 dump 波形、仿真是否能自己结束并返回通过/失败。代码改动后,养成「先跑回归仿真再综合」的习惯,综合一次几十秒到几分钟,仿真几秒就能挡掉绝大多数低级逻辑错误,迭代效率高一个量级。
练习建议按模块复杂度递进:先给计数器和分频器写 TB(学会造时钟、量周期);再给按键消抖写 TB(学会造异步抖动、数脉冲);给 UART 收发写 TB(学会 task 封装事务和闭环自收自发);给 FIFO 写 TB(学会随机读写、记分牌比对、空满边界);最后给一个含状态机和接口的小系统写带参考模型的自检 TB(入门综合项目见 神经网络加速器 的小规模 MAC 阵列)。每完成一个,把 Testbench 当作和设计代码同等重要的交付物保存,逐步积累一套自己的验证脚手架。仿真能力决定了你能安全驾驭的设计复杂度——当系统大到无法靠肉眼看波形时,自检 TB 和覆盖率就是你敢改代码的底气。
十三、仿真跑不起来:高频报错与自查
新手在仿真阶段会反复遇到几类「编译或运行就报错」的问题,先按这张清单自查,往往不用问人:
- 端口连接不匹配(port not found / width mismatch):例化时信号名拼错、位宽不一致、漏接输出。先把例化和模块声明逐端口对齐,宽位信号接成 1 位会只取最低位,功能错但不一定报错,要自己保证位宽。
- 多驱动(multiple drivers):同一 wire 在两个 always 或连续赋值里被赋值,综合和仿真都会报;根源通常是把本该拆成两个信号的逻辑写在了一个变量上。
- 缺少时间单位导致
#5含义不明:没写 timescale 时工具可能给默认值或告警,时钟频率与你想的完全不同;每个文件头统一写时间尺度指令。 - 仿真不结束或立刻结束:不结束多为忘了
$finish或时钟驱动了无限事件;立刻结束多为所有 initial 块都没有足够延时,0 时刻就跑到了$finish,激励根本没来得及施加。 - 全 x / 全 z:复位没生效、时钟没翻转、端口没连,按第七节的方法追第一个 x/z 源。
- 工具找不到模块(unknown module):编译命令里漏加设计文件或文件顺序问题;命令行工具要把所有 .v 文件一次传给编译命令,IP 仿真还要加厂商库。
- 延迟在仿真中无效:
#延时只能用于 Testbench,放进可综合设计会被忽略或报错;设计里需要的「等待」要用时钟周期计数器实现(时钟使能法见 计数器篇)。
遇到结果不符但无报错时,先确认你 dump 的层级包含目标信号、仿真运行时间够不够长(波特率分频常数真实值可能要几十微秒,纯行为仿真建议测试时用小参数缩短时间),再进入逐拍追波形的流程。
十四、把仿真做成可回归的工程习惯
单人练习和团队工程的分水岭,在于仿真能否「无人值守地告诉你对不对」。几个低成本但收益巨大的习惯值得从入门期就建立:
- 统一目录约定:设计放 rtl,Testbench 放 sim/tb,波形产物放 sim/work 并加入忽略列表,脚本一条命令完成清理、编译、运行。
- 测试用例即文档:每个 initial 用例前注释「目的、激励、期望」,让别人(包括三个月后的你)不用看波形也能理解在验证什么。
- 用任务和函数消重:时钟复位、读写字节、等待握手全部 task 化,用例主体只剩事务调用,改接口时只改一处。
- 退出码反映结果:自检发现错误时除了打印,还调用系统任务让脚本返回失败,这样仿真能接入持续集成,提交即跑回归。
- 参数化加速:把长定时常数(波特率、消抖)做成 parameter,仿真传入小值、上板传真实值,同一份 RTL 两边都能快速验证。
- 保存失败现场:出错时打印时间点、输入事务、实际与期望输出,必要时自动 dump 波形到错误时刻附近,避免手动重现。
掌握这些之后,你看待仿真的角度会从「写完代码看看波形」变成「先定义要证明的性质,再用激励和检查去证明它」。功能正确性只是起点,配合静态时序分析(XDC 与时序)、跨时钟域检查(CDC)和上板 ILA 观测(ILA),四种手段各司其职,才能构成对一个 FPGA 设计的完整信心。入门阶段不必急于学习 UVM 这类重型方法学,但「自检、可复现、可回归」的内核,在你用的还是普通 Verilog TB 时就应该养成。
十五、仿真器是怎么"跑"时间的:事件调度直觉
很多人用了很久仿真器,却没建立"仿真时间和真实时间不是一回事"的概念,这会导致对延迟、竞争、波形跳变的种种误解。仿真器本质上是一个事件驱动的调度器:它维护一个按时间排序的事件队列,初始时把"时刻 0 的所有 initial/always 触发"放进去,然后不断取出最早的事件执行;执行某段进程可能产生新的赋值事件,按它们的延迟排进未来的时间点,仿真时间就这样跳到下一个有待处理事件的时刻。关键在于,仿真时间只在有事件时才前进,没事件的区间被直接跳过——所以 testbench 里 #100 等待在真实墙上时钟里可能瞬间完成,仿真 1 毫秒的电路也许只花计算机零点几秒,两者毫无固定比例。理解了这一点,就能解释为什么仿真器能精确处理任意小的延迟、为什么没有激励时波形是平的。
同一仿真时刻内还分先后"区域",这正是阻塞与非阻塞差异的根源:活动区域里各进程求值,非阻塞赋值的更新被推迟到稍后区域统一生效,于是所有时钟沿寄存器读到的都是旧值、并在区域末尾一起更新。#1 这类零/微小延迟的作用,就是把信号变化人为挪到稍晚的时间点或区域,避免在同一瞬间既采样又更新造成的竞争,testbench 里常在时钟沿后 #1 再施加激励,让 DUT 先干净地采到沿、再看到输入变化。还有一个易被忽视的点:仿真器对"没有敏感事件的进程"不会空转轮询,它只在敏感信号变化或延迟到期时被唤醒,这与真实电路"每个时钟沿所有触发器都动作"的感觉不同,但结果在 RTL 抽象层是等价的。建立这套事件调度心智模型后,# 延迟、@(posedge clk)、wait()、非阻塞更新、x 的传播都会从"仿真器的怪癖"变成可预测的确定行为,你也能更有意识地写出没有竞争、波形干净、可回归的 testbench。
十六、断言、覆盖率与自检:让仿真自己判断对错
只靠人眼看波形,工程规模稍大就不可持续:几十个信号、上万周期的仿真,没人能逐帧核对,而且"看一眼没问题"无法防止以后改代码引入回归。工业界因此用三套机制把验证从"人眼检查"升级为"机器自动判定",初学者越早接触越受益。第一套是自检 testbench:不要只产生激励,还要在测试代码里内置一个能算出期望值的参考模型(行为模型、黄金文件或简单的数学关系),每个有效结果出来时自动比对,发现不符就用错误标志或 $error 报告并计数,最后打印"通过/失败 + 失败条数"。这样 testbench 一旦跑过就是明确的通过或失败,而不是一句含糊的"波形好像对"。对有标准可循的模块(UART、FIFO、运算单元),参考模型往往几十行就能写好,收益极大。
第二套是断言(assertion):用 SVA(SystemVerilog Assertions)或简单的 immediate assertion,把"协议必须永远成立"的性质直接写出来,例如"请求拉高后最多 5 拍内必须出现应答""valid 与 ready 握手时数据必须稳定""计数器永远不应超过上限"。断言的好处是它在性质被违反的第一时间、在肇事波形附近就报错,比等到最终输出错了再倒查高效得多,特别适合锁定接口协议和状态机不变量。第三套是覆盖率:代码覆盖率统计哪些行、哪些条件分支、哪些状态机状态被激励走到了,没覆盖到的地方就是验证盲区;功能覆盖率则用覆盖组记录你关心的关键场景组合(不同长度、不同波特率、异常帧等)是否都被打到。覆盖率不保证正确,但能告诉你"还测漏了什么",指导你补定向测试或受约束随机激励。把三者串起来的现代流程是:随机或定向激励 → DUT 与参考模型并行比对 → 断言实时盯协议 → 覆盖率衡量完备度 → 失败即回归报警。入门阶段不必一次学全 SVA 语法,但应从今天起给每个 testbench 加自动比对和最终的 pass/fail 报告,养成"让仿真自己说话"的习惯,这是区分玩具工程与可交付工程的重要标志,也为中级阶段的高级验证方法打下基础。
常见坑对照
| 坑 | 后果 | 正确做法 |
|---|---|---|
| TB 忘了造时钟/复位 | 波形不动/全 x | always 造 clk、initial 给 rst |
| 没 $dumpvars | 没波形文件 | 加 dumpfile/dumpvars |
| 激励没跑够时间 | 没覆盖回卷/跳转 | 跑到覆盖目标情形 |
| 复位后还 x | 复位没接/没生效 | 查 rst 连接与极性 |
| 仿真对=上板对的错觉 | CDC/时序翻车 | 上板+STA 另验 |
| 依赖 x/初值碰巧对 | 上板随机 | 显式复位,别依赖初值 |
相关与延伸
入门基础:Testbench 与仿真、开发全流程。 深入:进阶 高级 Testbench(自检/参考模型/随机)、仿真加速与覆盖率、CDC 亚稳态(仿真看不出的坑)。
一句话总结
仿真是不接上板、让 RTL 随虚拟时间跑起来看波形,是验证逻辑最快的手段:写一个不综合的 Testbench,用 always #5 clk=~clk 造时钟、initial 给复位和激励、$dumpfile/$dumpvars 记录波形、$finish 结束,例化被测 DUT,再用 GTKWave(iverilog/vvp 流程)或 Vivado 自带仿真器看波形。读波形关键:复位后 reg 应变确定值,一直 x 是未初始化/没驱动/冲突、z 是三态没人驱动或没接线,非阻塞赋值晚一拍变是正常的,用游标量时间核对周期/波特率/分频,从“第一个不对的点”往前追。要牢记仿真只验证功能、默认零延时完美同步,CDC 亚稳态、时序违例、复位/上电随机、外部抖动这些上板问题仿真看不出来——仿真做功能、STA 和上板做稳健性,两者都不能省。
上一篇:26 UART 回环(帧/波特率/收发) | 下一篇:28 XDC 管脚与时序(create_clock/管脚/电平) | FPGA 系列目录