第 3 章 · 共 16 章
跑通你的第一个仿真:EDA Playground、波形与 $finish
真正跑起来一个 SystemVerilog testbench:搭建 EDA Playground、理解 `timescale 及其更现代的替代写法 timeunit/timeprecision、弄清楚为什么仿真不加 $finish 就停不下来、用 $dumpfile/$dumpvars 打印并查看波形,并拿到一份完整、可以直接复制运行的例子。
前两章讲了"设计 vs. 验证"的心智模型和读懂代码需要的基础语法。但读懂不等于跑起来——HDL 是那种"动手跑一遍比看十遍更容易学会"的东西。这一章完全聚焦在让仿真真正跑起来的具体操作:去哪跑代码、怎么让它停下来、怎么看仿真跑完之后发生了什么。
去哪跑代码:EDA Playground
跟着这个系列学习不需要在本地装仿真器。EDA Playground 在浏览器里跑一个真实的仿真器,免费、免安装:
- 注册一个免费账号(或者用"以访客身份继续")。
- 左边会看到 Testbench 和(可选的)Design 两个面板。这个系列的所有例子,只用 Testbench 面板里的一个文件就够了,不需要单独维护一个 DUT 文件。
- 在 Tools & Simulators 里选一个仿真器。任何一个免费选项(比如 Icarus Verilog)都能跑通这个系列几乎全部的例子,用不到付费授权。这里要提两个例外,免得后面碰到时显得意外:UVM 系列需要换一个支持 UVM 的仿真器(Icarus 不支持 UVM 类库,到时候会在那里明说);本系列自己的
clocking block那一节(第 13 章)也需要换成一个完整支持 SystemVerilog 的仿真器,而不是 Icarus——Icarus 压根没实现 clocking block(这是实测确认过的,不是猜的),到时候也会在第 13 章明说,并给出具体该选哪个。 - 勾选 Open EPWave after run,这样仿真跑完会自动打开波形查看器。
- 把代码贴进 Testbench 面板,点 Run。
整个流程就是这样:贴代码、点 Run、看日志和波形。从这里开始的每个例子都是按照能直接贴进 Testbench 面板、原样跑通来写的。
`timescale:一个"时间单位"到底是什么
前面几章一直在用 #10,但从没说清楚这个"10"到底是什么单位。`timescale 就是回答这个问题的,它通常写在文件的第一行:
`timescale 1ns/1ps- 第一个数字(
1ns)是时间单位——#10真正的意思:10 个这个单位,这里就是 10ns。 - 第二个数字(
1ps)是时间精度——仿真器内部实际跟踪的最小时间刻度,用于四舍五入。
如果不写 `timescale,大多数仿真器会退回到一个工具相关的默认值——这正是为什么值得显式写清楚,而不是把它交给运气。下面的每个例子都以 `timescale 1ns/1ps 开头,这是这类教学例子里常见、合理的默认取值。
timeunit/timeprecision:更现代的写法
`timescale 是从 Verilog 继承下来的编译指令(compiler directive):它更像文本替换,作用于文件里从这一行往后、直到下一个 `timescale 出现之前的所有模块——如果多个文件各自用了不同的 `timescale 一起编译,很容易在不知情的情况下让某个模块用错了时间单位,是个经典的坑。
SystemVerilog 提供了一种更现代的写法:直接用 timeunit/timeprecision 关键字,写在 module/interface/program/package 内部,作用范围明确限定在这一个模块里,不会被编译顺序或者别的文件影响:
module tb;
timeunit 1ns;
timeprecision 1ps;
// ... 和 `timescale 1ns/1ps 效果一样,但只作用于这个模块
endmodule也可以写成更紧凑的单行形式,效果完全一样:
module tb;
timeunit 1ns/1ps;
// ...
endmodule`timescale 至今仍然随处可见——很多现存代码、教程范例都用它,本系列后面的例子为了简洁也继续沿用它。但新写的代码更推荐用 timeunit/timeprecision:语义上完全一样(第一个值是时间单位,第二个值是时间精度),只是换成了作用域明确、不依赖编译顺序的关键字形式,能避免 `timescale 那种"忘了在哪个文件里被谁悄悄改掉了"的问题——这也是目前大多数 UVM 代码库的实际写法。
顺带提一句
program,既然它刚才在那份列表里出现过:这个系列里所有的 testbench 都写在普通的module里,和 DUT 用的是同一种构造。SystemVerilog 还有一种program块——概念上和module类似,但历史上专门用来写 testbench 代码,有自己独立的调度区域(在同一个仿真时间步里,等所有基于module的逻辑都稳定下来之后才运行,主要是为了避开 testbench 和 DUT 之间的某些竞争条件)。它在遗留代码里还能见到,但这个系列一路要搭建的、class 化加virtual interface驱动的写法(以及 UVM 本身)都是写在普通module里的,所以这里不会用到program——认得出它就够了,不需要自己主动去用。
$finish:为什么仿真好像永远停不下来
仿真不会因为你的 initial 块跑到最后一行就自动停止——如果没有东西告诉仿真器"该停了",它会一直等待更多事件(或者被工具自己的运行时上限打断)。$finish 就是告诉仿真器"现在停下"的系统任务:
initial begin
// ... 激励和检查 ...
$finish;
end忘记写 $finish 是初学者第一次仿真"卡住"或者什么都不打印的头号原因——仿真要么撞上 EDA Playground 服务端的超时限制,要么根本到不了本该打印总结信息的那一步。这个系列后面每一个可运行的完整例子,顶层 initial 块的最后一句都是 $finish。
$dumpfile / $dumpvars:录制波形
$display 只能打印你调用它那一瞬间的值,但如果想看一个信号的值随时间怎么变化,就需要波形。两个系统任务负责打开这个功能:
initial begin
$dumpfile("waves.vcd"); // EPWave 会自动打开的输出文件
$dumpvars(0, tb); // 从模块 tb 开始,递归 dump 它内部的所有信号
end$dumpfile("waves.vcd")指定波形要写到哪个文件(EDA Playground 的 EPWave 查看器会自动识别这个文件,不需要你自己去打开它)。$dumpvars(0, tb)开始录制。第一个参数是往下递归多少层(0表示"所有层级",也就是tb内部不管嵌套多深的所有信号都会被记录);第二个参数是从哪个模块开始。
把这两句放在顶层 initial 块靠前的位置,在开始驱动信号之前——$dumpvars 执行之前发生的事情不会出现在波形里。
怎么看波形
一个带了 $dumpfile/$dumpvars 的仿真跑完之后,EPWave 会打开,通常包含:
- 左边的信号列表——点一个信号名把它加入波形视图(有些查看器默认已经把信号加进去了)。
- 顶部的时间轴,单位就是
`timescale里指定的那个。 - 每个信号对应一条彩色波形轨迹——对单比特信号来说,低电平线代表
0,高电平线代表1;对 4 态信号,x通常会用一种明显不同的颜色/花纹标出来。 - 一根可以在时间轴上点击拖动的游标,用来读某个信号在某个时刻的具体值;还有缩放功能,方便展开一段密集变化的区间,或者收起一大段空闲的时间。
最常用的操作:把游标拖到某个输入变化之后,看输出是不是真的按预期更新了。
一个完整、可以直接跑的例子
把第 1 章的 mux2 包进一个真正的 testbench,把上面讲的所有东西都用上——把下面这段代码直接贴进 EDA Playground 的 Testbench 面板,原样运行:
`timescale 1ns/1ps
module mux2 (
input logic sel,
input logic a,
input logic b,
output logic y
);
always_comb begin
y = sel ? b : a;
end
endmodule
module tb;
logic sel, a, b, y;
mux2 dut (.sel(sel), .a(a), .b(b), .y(y));
initial begin
$dumpfile("waves.vcd");
$dumpvars(0, tb);
sel = 1'b0; a = 1'b1; b = 1'b0;
#10;
$display("t=%0t sel=%0b a=%0b b=%0b y=%0b", $time, sel, a, b, y);
sel = 1'b1;
#10;
$display("t=%0t sel=%0b a=%0b b=%0b y=%0b", $time, sel, a, b, y);
$finish;
end
endmodule跑起来之后应该打印两行(sel=0 时 y 跟着 a,sel=1 时 y 跟着 b),并且打开一个带 sel、a、b、y 四条轨迹的波形,可以直接点开检查。以后碰到后面章节里那些没有包在可运行 module 里的代码片段,也可以用这个模板改造:把片段里的声明和逻辑放进一个 tb 模块的 body(或者它自己的 class/task,从 initial 里调用),需要看波形就加上 $dumpfile/$dumpvars,最后以 $finish 收尾。
小结
- EDA Playground 在浏览器里跑一个真实仿真器——把代码贴进 Testbench 面板,选一个免费仿真器(比如 Icarus Verilog),点 Run。
`timescale <单位>/<精度>(通常写在文件第一行)定义了#10这种延时到底是多少时间、仿真器内部时间跟踪精确到什么程度。timeunit/timeprecision是语义相同、但作用域限定在单个module/interface/program/package内的关键字写法,不依赖编译顺序,是比`timescale更现代、更推荐的写法。$finish让仿真停下来——忘记写它是第一次仿真看起来"卡住"或者什么都不打印的最常见原因。$dumpfile("文件名.vcd")+$dumpvars(0, 顶层模块),放在顶层initial块靠前的位置,用来录制一份跑完之后可以查看的波形。- 波形查看器包含信号列表、时间轴和彩色轨迹;拖动游标可以读某个信号在某个时刻的具体值。
- 后面任何一章的裸代码片段,都可以套进
module tb; ... endmodule、加一个以$finish收尾的initial块,照着这一章完整例子的模式改造成可运行版本。
一个仿真看起来永远卡住、什么都不打印,最可能的原因是什么?
`timescale 1ns/1ps 的含义是什么?
相比 `timescale 编译指令,用 timeunit/timeprecision 关键字写在模块内部,主要的优势是什么?
哪个系统任务用来告诉仿真器停止运行?(带开头的 $,小写)