4. Testbench 编写入门:结构、时钟复位与 initial/always

说实话,很多刚入行的朋友觉得 Testbench 就是随便写点激励,跑个波形看看。嗯,我当年也这么想。直到有一次,我写了一个自以为完美的 Testbench,结果仿真跑了两天,发现 bug 是因为时钟没对齐——那种感觉,真的不想再体验第二次。

Testbench 说白了,就是给 DUT(Design Under Test)搭一个“测试台”。你想想看,芯片设计就像造一块手表,你不能直接把手表扔水里测防水吧?你得先做个测试环境。Testbench 就是这个环境。

4.1 Testbench 的基本结构

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

组成部分 作用 我的建议
信号声明 定义 DUT 的输入/输出信号 尽量用 reg 和 wire 区分清楚
DUT 实例化 把被测模块“挂”到 Testbench 上 用名字连接,别用位置连接
时钟生成 产生系统时钟 用 always 块,别用 initial 循环
复位生成 产生复位信号 同步复位和异步复位要区分
激励生成 产生测试输入 用 initial 块组织测试序列
结果检查 自动比对输出 能用 $display 就别手动看波形

来看一个最简单的例子:

module tb_counter();

  // 1. 信号声明
  reg        clk;
  reg        rst_n;
  reg        en;
  wire [3:0] count;

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

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

  // 4. 复位生成
  initial begin
    clk   = 0;
    rst_n = 0;
    en    = 0;
    #20;
    rst_n = 1;
    #10;
    en    = 1;
  end

  // 5. 结果检查
  always @(posedge clk) begin
    if (en)
      $display("Time=%0t, count=%d", $time, count);
  end

endmodule
我的习惯:信号声明时,输入对应 reg,输出对应 wire。为什么?因为 Testbench 里你要用 always 或 initial 给 DUT 的输入赋值,所以得用 reg。DUT 的输出你只是“看”,用 wire 就够了。

4.2 时钟生成——别小看这个 always

时钟生成是 Testbench 里最基础也最容易出错的地方。我见过有人用 initial 加 forever 循环来生成时钟:

// 不推荐的做法
initial begin
  clk = 0;
  forever #5 clk = ~clk;
end

这样写其实也能跑,但有个问题——如果你在同一个 initial 块里还有其他逻辑,forever 会卡住后面的代码。我个人习惯用 always 块:

// 推荐的做法
always #5 clk = ~clk;

为什么?因为 always 块天生就是循环执行的,而且不会阻塞其他 initial 块。你想想看,一个 always 块只干一件事——翻转时钟,多清爽。

如果你需要多个时钟域,比如一个 100MHz 和一个 50MHz:

reg clk_100m, clk_50m;

always #5   clk_100m = ~clk_100m;  // 100MHz
always #10  clk_50m  = ~clk_50m;   // 50MHz
我曾经踩过的坑:时钟频率算错了。100MHz 的周期是 10ns,半周期是 5ns。有人写成 always #10,结果跑出来是 50MHz。记住:always #N 里的 N 是半周期,不是全周期。

4.3 复位生成——同步还是异步?

复位信号怎么生成?这取决于你的 DUT 用的是同步复位还是异步复位。我建议 Testbench 里两种都支持:

// 异步复位
initial begin
  rst_n = 0;
  #100;
  rst_n = 1;
end

// 同步复位(需要时钟配合)
initial begin
  rst_n = 0;
  repeat (5) @(posedge clk);
  rst_n = 1;
end

我个人更倾向于异步复位。为什么?因为仿真刚开始时,时钟可能还没稳定。异步复位能保证 DUT 在时钟稳定前就进入复位状态。你想想看,如果时钟还没起来,同步复位根本没法生效。

还有一个细节:复位释放的时间点。我习惯在时钟上升沿之后释放复位,避免建立时间问题:

initial begin
  rst_n = 0;
  #100;
  @(posedge clk);  // 等一个时钟沿
  #1;              // 再等 1ns,避开时钟沿
  rst_n = 1;
end
关键点:复位释放时,加一个小的延迟(比如 #1),可以避免仿真中的竞争问题。这个技巧我在多个项目里都用过,效果很好。

4.4 initial 与 always——两个核心块的区别

initial 和 always 是 Verilog 里最常用的两个过程块。它们的区别其实很简单:

特性 initial always
执行次数 只执行一次 循环执行
典型用途 初始化、测试序列 时钟生成、组合逻辑、时序逻辑
能否综合 不能 可以(部分写法)
仿真行为 0 时刻开始,执行完就结束 0 时刻开始,永远不结束

在 Testbench 里,我通常这样分配:

  • initial 块:用来写测试序列。比如先复位,再等一段时间,然后发激励,最后检查结果。
  • always 块:用来生成时钟,或者监控某些信号的变化。

来看一个完整的例子:

module tb_fsm();

  reg clk, rst_n, in;
  wire out;

  fsm u_fsm (.clk(clk), .rst_n(rst_n), .in(in), .out(out));

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

  // 测试序列 —— initial 块
  initial begin
    clk   = 0;
    rst_n = 0;
    in    = 0;
    #20;
    rst_n = 1;
    #10;
    in = 1;
    #10;
    in = 0;
    #10;
    in = 1;
    #20;
    $finish;
  end

  // 监控输出 —— always 块
  always @(posedge clk) begin
    if (out)
      $display("Time=%0t: out goes high", $time);
  end

endmodule
我的经验:一个 Testbench 里可以有多个 initial 块和多个 always 块。它们都是并行执行的。我习惯把不同功能分开写——时钟一个块,复位一个块,激励一个块,检查一个块。这样调试起来特别方便。

4.5 知识结构图

下面这张图总结了 Testbench 的核心结构:

Testbench 核心结构图 Testbench 核心结构 Testbench 顶层模块 信号声明 (reg/wire) DUT 实例化 时钟生成 (always) 激励生成 (initial) 结果检查 (always) 所有模块并行执行 initial 块只执行一次,always 块循环执行 图:Testbench 结构示意图

4.6 实战小技巧

最后分享几个我在项目中积累的小技巧:

  • 用 `timescale 指定时间单位:在 Testbench 文件开头加上 `timescale 1ns/1ps,这样 #5 就是 5ns。
  • 用 $time 打印仿真时间:调试时特别有用,能精确知道每个事件发生的时间点。
  • 用 $finish 结束仿真:别让仿真一直跑下去,在测试序列最后加上 $finish。
  • 用 $display 和 $monitor:$display 在事件发生时打印,$monitor 在信号变化时自动打印。
注意:initial 块里不要写太复杂的逻辑。如果你发现一个 initial 块超过 50 行,就该考虑拆成多个块了。我曾经在一个项目里把整个测试序列写在一个 initial 块里,结果调试时根本分不清哪段代码对应哪个测试用例——教训深刻。

好了,Testbench 的基础结构就讲到这里。记住:时钟用 always,复位用 initial,激励用 initial,检查用 always。这个套路我用了十年,从来没出过问题。