3、Verilog仿真与测试平台:Testbench结构、initial/always块、$display/$monitor、波形查看(VCD)、基本仿真流程

说实话,很多初学者写Verilog,代码看着挺漂亮,一仿真就翻车。为什么?因为测试平台没写好。我见过太多人把精力全放在RTL设计上,结果Testbench写得一塌糊涂,最后查bug查到怀疑人生。

今天我们就聊聊仿真测试这件事。说白了,Testbench就是给你的设计搭个舞台,让它自己演一遍,你在旁边看它演得对不对。

3.1 Testbench的基本结构

一个标准的Testbench长什么样?我习惯把它分成三块:

  • 激励生成:给DUT(Design Under Test)喂数据
  • DUT实例化:把待测模块接进来
  • 结果检查:看输出对不对

给你看个最简单的例子:

module tb_counter();
    // 1. 信号声明
    reg clk;
    reg rst_n;
    wire [3:0] count;

    // 2. DUT实例化
    counter u_counter (
        .clk    (clk),
        .rst_n  (rst_n),
        .count  (count)
    );

    // 3. 时钟生成
    always #5 clk = ~clk;

    // 4. 激励
    initial begin
        clk = 0;
        rst_n = 0;
        #20 rst_n = 1;
        #200 $finish;
    end

    // 5. 结果监控
    initial begin
        $monitor("time=%0t, count=%d", $time, count);
    end
endmodule

嗯,这里要注意:Testbench里没有端口列表。它是个封闭的环境,所有信号都在内部定义。

3.2 initial块与always块

这两个块是Verilog仿真的核心。我刚开始学的时候总搞混,后来想明白了一件事:

  • initial块:只执行一次。适合做初始化、复位、顺序激励
  • always块:循环执行。适合做时钟、周期性信号

重要区别:initial块在仿真0时刻开始执行,执行完就结束。always块会一直重复执行,直到仿真结束。

举个例子,我项目中常用的几种写法:

// 时钟生成 - always块
always #5 clk = ~clk;

// 复位序列 - initial块
initial begin
    rst_n = 0;
    #100 rst_n = 1;
    #1000 rst_n = 0;
    #50  rst_n = 1;
end

// 数据激励 - initial块
initial begin
    data_in = 0;
    #10 data_in = 8'hA5;
    #10 data_in = 8'h5A;
    #10 data_in = 8'hFF;
end

我的习惯:把时钟和复位放在一个initial块里,数据激励放在另一个initial块里。这样结构清晰,调试时一眼就能找到问题。

3.3 $display与$monitor

这两个系统函数,说白了就是打印信息用的。但用法有讲究:

函数 触发时机 典型用法
$display 执行到该语句时打印一次 打印关键节点信息
$monitor 监控变量变化时自动打印 持续跟踪信号变化
$strobe 当前时间步结束时打印 打印稳定后的值

我曾经踩过一个坑:用$display打印组合逻辑的输出,结果发现打印的值和波形对不上。为什么?因为$display在赋值语句之前就执行了。后来改用$strobe才搞定。

initial begin
    // 错误示范
    $display("count = %d", count);  // 可能打印的是旧值
    
    // 正确做法
    #1 $display("count = %d", count);  // 等一个delta周期
    // 或者用
    $strobe("count = %d", count);      // 稳定后打印
end

注意:$monitor在整个仿真中只能激活一个。如果你写了多个$monitor,只有最后一个生效。我建议只在顶层Testbench里用一个$monitor,其他调试信息用$display。

3.4 VCD波形查看

光看打印信息是不够的。遇到复杂问题,必须看波形。VCD(Value Change Dump)是最通用的波形格式,几乎所有仿真器都支持。

生成VCD文件很简单:

initial begin
    $dumpfile("wave.vcd");      // 指定文件名
    $dumpvars(0, tb_counter);   // 记录所有信号
end

这里有个参数要注意:

  • 0:记录所有层级的所有信号
  • 1:只记录当前层级
  • 2:记录当前层级及下一级

我一般用0,省事。但如果设计很大,VCD文件会爆炸。这时候可以指定只记录某些信号:

initial begin
    $dumpfile("wave.vcd");
    $dumpvars(0, tb_counter.u_counter.clk);   // 只记录时钟
    $dumpvars(0, tb_counter.u_counter.count); // 只记录计数
end

3.5 基本仿真流程

完整的仿真流程,我总结成四步:

  1. 编译:把Verilog文件编译成可执行文件
  2. 运行:执行仿真,生成VCD文件
  3. 调试:看波形、看打印信息
  4. 验证:检查结果是否符合预期

用命令行跑仿真大概是这样的:

# 1. 编译
iverilog -o tb_counter tb_counter.v counter.v

# 2. 运行
vvp tb_counter

# 3. 查看波形
gtkwave wave.vcd

核心思想:仿真不是为了证明代码是对的,而是为了找到代码哪里错了。所以Testbench要写得比RTL代码更仔细。

3.6 知识体系总览

我把本章的核心逻辑画了张图,方便你理解:

Verilog仿真与测试平台知识体系 Testbench 测试平台 激励生成 DUT实例化 结果检查 initial块 只执行一次 初始化、复位、顺序激励 仿真0时刻开始执行 always块 循环执行 时钟、周期性信号 直到仿真结束 系统函数 $display:单次打印 $monitor:持续监控 $strobe:稳定后打印 VCD波形文件 打印信息 仿真结果 调试与验证

这张图把整个仿真流程串起来了。你写Testbench的时候,脑子里要有这张图:激励怎么来?DUT怎么接?结果怎么看?

我的建议:刚开始写Testbench,别追求花哨。先把最基本的initial/always块用好,把$display和$monitor用熟。等这些基础打牢了,再学UVM、随机约束这些高级玩法。

好了,关于仿真测试平台,核心内容就这些。记住一句话:好的Testbench,能帮你省下80%的调试时间。我当年就是吃了这个亏,后来才老老实实把Testbench当正事来写。


公众号:蓝海资料掘金营,微信deep3321