ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Verilog Testbench 入门:从验证点到自动比对的完整方法

Verilog Testbench 入门:从验证点到自动比对的完整方法 写 Testbench 这事说难不难说简单也真不简单。我见过太多人把 RTL 写得行云流水一到验证环节就开始抓瞎要么信号永远不对要么仿真跑完一片空白。其实 Testbench 从根上讲就是用代码去写一份“给硬件看的测试计划”你看待它的方式决定了你能不能把它写明白。这篇是我 Verilog 学习系列的第十篇聊聊 Testbench 编程怎么入门、怎么系统化地写、以及实测中踩过哪些坑适合刚啃完 Verilog 基础语法、准备正经做点验证的读者。1. Testbench 到底在解决什么问题很多人写 Testbench 的时候容易陷入一个误区觉得它是“把信号拉高拉低、加几个延时”就行了。但实际做验证时你会发现 Testbench 的核心价值不是“产生信号”而是逼着你在写代码之前就把设计的行为边界想清楚。1.1 Testbench 和 RTL 代码的根本区别RTL 代码描述的是硬件电路它最终会被综合成门级网表映射到 FPGA 或者芯片上。所以 RTL 里写的东西必须“可综合”比如 always 块里不能用太复杂的初始化、不能用随意的延时语句。但Testbench 是仿真代码它不参与综合它的任务只有一个模拟外部环境观察设计行为。打个比方RTL 模块是舞台上的演员Testbench 就是导演外加剧本。导演负责喊“开始”“停”剧本规定演员在某个时间点该说什么话、做动作。演员管自己演导演管节奏和检查表演是否符合预期。这个类比可以帮你快速理解 Testbench 的每一个组成部分。因为不参与综合Testbench 里你可以大胆使用#10这类延时语法、可以随便用initial块、可以在仿真中途用$finish强制结束。这都是 RTL 里写不出来、也绝不能写进可综合代码的东西。1.2 先列验证点再写代码我个人的习惯是动笔写 Testbench 之前先花五分钟把“验证点清单”列出来。这个习惯帮我避免了至少 80% 的返工。验证点就是从设计需求里拆出来的、必须被满足的行为描述。每个验证点最终都要在 Testbench 里对应到一段可执行的检查逻辑。举个例子如果要验证一个带同步复位的 8 位计数器验证点可以粗略拆成下面三个复位释放后计数器输出应该为 0使能信号拉高时每个时钟上升沿计数加 1计数到最大值 255 后下一个有效沿回到 0。这三条看起来很简单但它们直接决定了 Testbench 的结构——你需要初始化复位和使能信号、需要产生一个时钟、需要用一个 initial 块控制复位时序还需要在每次变化后检查结果。验证点清单写出来之后Testbench 的框架其实已经浮出水面了。我习惯把这些验证点直接以注释形式贴在 Testbench 代码的顶部这样每次运行仿真看到报错时我能立刻知道这条错误对应的是哪条设计需求不用回头到处翻文档。2. 搭好 Testbench 的基础框架Testbench 基础框架不外乎四件事时间声明、时钟生成、复位行为、模块例化。这四件事相互独立却又紧密配合。很多新手一上来就想把激励写得花样百出结果基础框架没搭稳后面排查起来苦不堪言。2.1 timescale 声明仿真的时间标尺在写任何 Testbench 之前都必须在文件顶部写清楚时间标尺也就是timescale声明。这行不是可选配置是必选项。常见的写法是timescale 1ns / 1ps它的含义是时间单位是 1ns时间精度是 1ps。时间单位影响#10这种延时到底代表多长时间时间精度则影响仿真器内部的时间舍入精度。如果只写1ns / 1ns那延时用 0.1ns 就没法表示仿真器会就近取整容易造成信号时序和预期不符。我最初自学时踩过一个坑文件里没写timescale然后用了#5延时仿真结果怎么看怎么不对劲。后来才发现没有时间单位的#5在仿真器里就是“5 个 tick”但 tick 到底是多少完全取决于默认配置非常容易被误导。2.2 时钟生成initial 和 always 的配合时钟是同步数字系统的心脏Testbench 里生成时钟最常用的写法是两个块配合reg clk; initial clk 0; always #5 clk ~clk;为什么是initial加always的组合而不是只用一个 always因为always #5 clk ~clk;这条语句本身就能一直翻转但它需要一个初始值——如果 clk 一开始是 X翻转之后还是 X。所以先用initial把 clk 置为 0然后always每 5 个时间单位翻转一次。这段代码产生的时钟周期是多少半周期是 5ns全周期就是 10ns也就是 100MHz。如果设计跑的是 50MHz就把#5改成#10。这个换算关系一定要清楚我见过有同事想生成 100MHz结果写成了#10仿真出来的频率差了一倍整个验证时序全乱套。2.3 复位信号的正确打开方式复位是数字设计中绕不开的话题。Testbench 里产生复位信号看起来简单但有几个细节会影响仿真结果的正确性。先看一段比较标准的复位写法reg rst_n; initial begin rst_n 0; #100; rst_n 1; end这个写法把复位拉低 100ns然后释放。问题在于什么时候释放复位才“安全”如果你在释放复位的同时正好赶上时钟上升沿那 DUT 内部可能出现亚稳态或者不确定的状态。实际工程中我们更习惯让复位释放避开时钟上升沿或者在复位释放语句之前先等一个时钟沿。比如改成这样initial begin rst_n 0; #100; (negedge clk); // 等时钟下降沿再释放避开上升沿 rst_n 1; end当然这只是一个工程习惯不代表所有项目都要这样。但作为 Testbench 入门养成“让关键变化避开时钟沿”的意识对你后续写更复杂的接口测试会很有帮助。2.4 例化待测模块端口连接要小心Testbench 里必须把待测设计DUT实例化进来端口连接强烈建议用“名称连接”别用“顺序连接”。顺序连接一旦端口顺序写错编译期可能不报错但信号关系全乱排查起来非常痛苦。一个标准的例化长这样counter u_counter ( .clk (clk), .rst_n (rst_n), .en (en), .count (count) );这个模块名叫counter例化名是u_counter括号里每个端口都对应接了 Testbench 里的一个 reg 或 wire。这种写法清晰、不怕端口顺序调整是你应该长期坚持的风格。基础框架说到这里已经足够撑起一个最简单的 Testbench 了。但光有框架还不行真正的难点在于怎么产生有效的激励、怎么让 Testbench 自动判断结果对不对。3. 激励生成与自动检查让 Testbench 自己干活写 Testbench 最容易忽略的一件事就是它不应该只是“手动拨信号”而应该具备自动检查能力。一个好的 Testbench跑完一行日志能直接告诉你每个验证点是通过还是失败这才叫真正干活。3.1 initial 块顺序激励的基本手段最直接的激励生成方式就是initial块。一个initial块内的语句按书写顺序执行配合#延时就能产生出“先做 A再等一段时间做 B”的效果。举个例子上面提到的计数器最简单的激励序列可以这样写initial begin en 0; #120; // 让复位先跑一会儿 en 1; // 打开计数使能 #200; en 0; // 关闭 #50; $finish; end注意begin和end是必须的因为initial块内只有单条语句时可以不写但一旦有多条顺序语句就必须用begin...end包起来。这个坑很多人踩过——写了好几行赋值忘了加begin结果只有第一行被当成 initial 块的内容。多个initial块之间是并行执行的这一点非常重要。Testbench 里你可以把时钟生成、复位、激励、监测分别写进不同的initial块让它们各司其职、并行运行。这样写出来的代码结构清爽逻辑也清楚。3.2 task 封装重复操作如果你要测试的是 UART、I2C、SPI 这类有握手协议的接口你会发现大量激励操作是重复的每次都要拉低信号、延时、发数据、收响应。这时候应该用task把重复操作打包。先看一个最简单的 task 结构task write_byte; input [7:0] data; begin // 假设是模拟一个简单的写时序 scl 0; #10; sda 0; // 起始条件 #10; scl 1; #10; scl 0; // 依次发送 8 bit 数据 repeat (8) begin sda data[7]; data data 1; #10; scl 1; #10; scl 0; end #10; sda 1; // 停止条件 #10; scl 1; end endtask这段代码里有几个值得注意的地方task的输入参数在任务内部是可读的但data data 1这种写法修改的是 task 内部的局部副本不会影响外部变量所以安全。还有repeat循环配合位移可以紧凑地把 8 bit 数据逐位送出。把这段 task 放在initial块里调用initial begin init_bus(); write_byte(8hA5); write_byte(8h5A); #100; $finish; end这样一来测多个数据组合的时候只用反复调用write_byte()就行不需要每个操作都重新写一遍时序。这就是 task 在 Testbench 里的价值——封装你的协议时序让激励代码像调用函数一样清晰。当然这个例子非常简单真正 I2C 协议还有 ACK 应答、地址帧等细节你可以在自己项目里逐步补充。3.3 自动比对让代码自己报错手动看波形来验证结果不是不行但项目一大眼睛根本盯不过来。正确做法是在 Testbench 里加自动比对逻辑——设计产生一个结果Testbench 立刻判断这个结果是否符合预期然后把结论打印出来。最简单的自动比对思路是if...elsealways (posedge clk) begin if (rst_n en count_expected ! count) begin $display(ERROR at time %0t: expected %0d, got %0d, $time, count_expected, count); end end这里$display用来打印信息$time显示当前仿真时间方便定位错误发生的位置。count_expected是你在 Testbench 里维护的期望值。如果是计数器期望值可以用一个 reg 在 Testbench 中同步递增然后和 DUT 的输出比较。除了$display还有一个非常好用的系统任务$monitor。它会在你指定的信号发生变化时自动打印一次当前值非常适合用来观察信号轨迹initial begin $monitor(time%0t, clk%b, rst_n%b, en%b, count%0d, $time, clk, rst_n, en, count); end$monitor只需要写一次之后整个仿真过程中只要任一被监控信号发生变化它就会自动触发打印。它的缺点是信号变化频繁时日志会刷得很厉害但作为调试阶段的辅助工具非常实用。3.4 仿真控制与波形导出Testbench 里常用的控制语句还有$finish和$stop。$finish结束仿真$stop暂停仿真但保留现场常用于交互式调试。如果你用的是 Icarus Verilog 这类命令行仿真工具$finish是你最后一定会用到的一行。波形导出也别忘。Icarus Verilog 配合 GTKWave 是个非常好用的开源组合而导出波形只需要在 Testbench 里加三行initial begin $dumpfile(wave.vcd); $dumpvars(0, testbench_top); end$dumpfile指定输出文件名$dumpvars(0, testbench_top)表示导出整个 Testbench 层次包括下面例化的 DUT的全部信号。这里的testbench_top是你的顶层模块名具体以你的命名为准。跑完仿真后用 GTKWave 打开wave.vcd就能看到信号波形。4. 一个完整的 Testbench 实战计数器验证理论说了不少现在完整过一遍实际项目流程。从最简单也最经典的计数器开始从验证点到代码、再到仿真运行一条龙走通。4.1 设计目标与验证点假设有一个 8 位计数器模块接口定义如下clk时钟上升沿有效rst_n同步复位低有效en使能高有效count8 位计数输出。功能要求是复位后计数值为 0en 为高时每个时钟沿加 1计数到 255 后自动回绕到 0。对应验证点复位释放后 count 0en 拉高后每个时钟沿 count 加 1count 到 255 后再加一变成 0en 拉低时count 保持不变。我再加一个验证点在复位有效期间即使 en 为高count 也应该保持 0。这四条如果全部跑通这个模块的验证就可以说基本到位了。4.2 DUT 代码与 Testbench 完整代码DUT 代码不算这篇主角但为了完整起见给出一份可用的写法module counter ( input wire clk, input wire rst_n, input wire en, output reg [7:0] count ); always (posedge clk) begin if (!rst_n) count 8d0; else if (en) count count 8d1; end endmodule注意这里用的是非阻塞赋值它是描述时序逻辑的标准写法能有效避免仿真时出现意外的竞争行为。对应的 Testbenchtimescale 1ns / 1ps module testbench_top; reg clk; reg rst_n; reg en; wire [7:0] count; // 期望值 reg [7:0] expect_count; // 时钟生成10ns 周期 initial clk 0; always #5 clk ~clk; // 复位与使能激励 initial begin rst_n 0; en 0; #25; rst_n 1; #10; en 1; #300; en 0; #50; $finish; end // 自动比对 always (posedge clk) begin if (!rst_n) begin expect_count 8d0; end else if (en) begin expect_count expect_count 8d1; end end always (posedge clk) begin if (rst_n (count ! expect_count)) begin $display(ERROR at %0t: expect%0d, get%0d, $time, expect_count, count); end end // 导出波形 initial begin $dumpfile(counter_tb.vcd); $dumpvars(0, testbench_top); end // 模块例化 counter u_counter ( .clk (clk), .rst_n (rst_n), .en (en), .count (count) ); endmodule有几个细节值得细说。第一expect_count是用一个 always 块跟着时钟沿同步构建的“软件参考模型”。它和我们用count判断的主要区别在于expect_count既承担了期望值的角色也让 Testbench 的比对更加自动化。注意expect_count和count的初始值都经过复位归零所以从复位结束那一刻起两者就应该是同步的。第二比对使用了!而不是!。!会连 X 和 Z 态也一起比较。如果设计中出现了不定态!会把 X 当作不匹配之外的另一种情况处理很容易漏报错。用!能让你更早发现设计里潜在的不定态问题。第三$finish放在激励的最后仿真会在 385ns 左右结束。如果你打开波形看复位 25ns、释放后 10ns 开使能、计数 300ns足够观察到多个完整周期和一次 255 回绕。4.3 用 Icarus Verilog 跑起来我用的是 Icarus Verilogiverilog GTKWave 这套开源工具链非常轻量适合学习和中小规模验证。编译仿真分两步走第一步编译iverilog -o counter_tb.vvp counter.v counter_tb.v这里counter.v是 DUT 源码counter_tb.v是 Testbench。-o指定输出的可执行仿真文件叫counter_tb.vvp。如果编译通过没有任何输出这就对了。第二步运行仿真vvp counter_tb.vvp运行结束后终端会打印出你可能设置的$display和$monitor信息。如果比对逻辑发现错误ERROR at ...会直接打在屏幕上。没有 ERROR说明基本验证点跑通了。然后打开波形gtkwave counter_tb.vcd在 GTKWave 里左侧是信号列表把clk、rst_n、en、count拖到右侧就能看到波形。你可以用鼠标滚轮缩放用 ctrl滚轮调整找到 255 回绕的那个时刻亲眼确认设计行为。4.4 把验证点清单和仿真结果对应起来跑完仿真后我习惯把验证点一个一个人工对着波形确认一遍。这不代表自动比对没用——自动比对解决的是“海量周期里有没有错”的问题人工确认解决的是“自动比对代码本身有没有写错”的问题。我见过不止一次自动比对代码写错、导致 Testbench 永远不报错的情况。独立于自动比对手工确认关键节点是防呆的最后一道防线。5. 常见问题与排查心得写 Testbench 的过程也是和自己的认知较劲的过程。下面的问题清单几乎每个初学 Testbench 的人都会遭遇至少两三条。5.1 高频问题速查表现象可能原因排查与解决办法仿真启动后立刻结束激励块里没有足够延时或者$finish放得太早检查 initial 块的执行顺序确认延时语句和$finish位置信号始终是 X模块没有赋初值或者复位没有正确驱动给所有 reg 赋初值确认复位信号能拉低到有效状态时钟一直不变always #5 clk ~clk;没写或者忘写initial clk 0;检查时钟生成代码确认 initial 和 always 都存在波形文件打开是空的Testbench 里没有$dumpfile和$dumpvars补上波形导出三行重新编译仿真仿真时间长得离谱$finish缺失仿真永远跑不完在激励块末尾加上$finish端口信号电平不符合预期模块例化用顺序连接端口连错改成名称连接逐个核对 DUT 接口定义自动比对永不报错比对逻辑里期望值没更新比对条件和设计行为不匹配检查期望信号是否随周期同步变化必要时加入独立参考模型多个文件时间单位不一致有的模块没写 timescale有的精度不够统一在文件顶部声明 timescale精度至少取到仿真精度的最小粒度这张表我实际用下来覆盖面相当广。尤其是“信号全是 X”这一点十个新手里有八个会栽在这上面。5.2 三条调试心得第一从“能跑的模型”开始不要一上来就追求完美。我早期写 Testbench 总想一步到位把所有验证点全部塞进去结果经常出现一堆错误混在一起、根本不知道先修哪个。现在的做法是先写一个最简版本——时钟、复位、一个激励、一个$finish。确认仿真能跑、波形能看之后再逐步丰富激励、加入自动比对。每一步都验证过再往下走排查成本大大降低。第二用好$display这个“调试神器”。每次不确定某个时刻信号到底是什么状态直接加一行打印比盯着波形猜快得多。我在调试 I2C 接口时每个关键节拍都会打一行时间戳和信号状态配合$monitor基本能把问题定位到具体哪一拍上。等调通了再删掉多余打印保留关键信息。第三千万要重视复位时序。我有一回调一个带反馈的模块波形上数据总是不对查了半天发现是复位释放的时候正好撞上时钟上升沿DUT 内部的状态捕获到了半复位半工作的不确定态。这个问题的本质是复位释放没有避开时钟沿。从那以后我养成一个习惯释放复位前先(posedge clk)或者(negedge clk)等其他时钟沿主动避开竞争窗口。写了这么多 Testbench我个人实际操作中最大的感受是Testbench 写得好不好直接反映你对设计理解的深度。当你把验证点一条条列出来、把激励规律想清楚的时候很多 RTL 里埋着的隐患就已经暴露了。它在编程层面不复杂复杂的是你怎么想清楚“这个设计在每种情况下应该干什么”。如果你把这块基本功打扎实了后面做复杂项目不管是 UART、SPI、I2C 还是图像处理流水线写验证都会顺手得多。这篇就聊到这里下一步可以试试把同样的思路扩到带状态机的模块上那才是 Testbench 真正拉开差距的地方。
返回列表