2. 验证环境搭建:Verilog测试平台结构、时钟与复位生成、DUT实例化

好,咱们正式开始动手搭环境了。

说实话,很多刚入行的朋友觉得验证环境搭建就是写几个always块、例化一下DUT就完事了。嗯,我当年也这么想。直到有一次,我因为时钟和复位的时序没处理好,整整调了三天才发现是复位释放早了半个周期……从那以后,我对环境搭建这件事就再也不敢马虎了。

2.1 Verilog测试平台的基本结构

一个标准的Verilog测试平台,说白了就是三大部分:激励生成、DUT实例化、结果检查。我个人习惯把这三块分得清清楚楚,哪怕是一个很小的模块,也绝不混在一起写。

先看一个最基础的模板:

module tb_fp_add();

  // 1. 信号声明
  logic        clk;
  logic        rst_n;
  logic [31:0] a, b;
  logic [31:0] result;

  // 2. DUT实例化
  fp_add dut (
    .clk   (clk),
    .rst_n (rst_n),
    .a     (a),
    .b     (b),
    .result(result)
  );

  // 3. 时钟生成
  initial begin
    clk = 0;
    forever #5 clk = ~clk;
  end

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

  // 5. 激励与检查
  initial begin
    // 测试逻辑写在这里
  end

endmodule

你想想看,这个结构是不是一目了然?每个部分各司其职。我见过有人把时钟生成和激励写在一个initial块里,结果调试的时候改一个地方就得动另一个逻辑,特别容易出问题。

我的习惯:每个initial块只做一件事。时钟一个块,复位一个块,激励一个块,检查一个块。这样后期维护和复用都方便。

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

时钟生成看起来简单,但里面有几个坑。最常见的写法是:

initial begin
  clk = 0;
  forever #5 clk = ~clk;
end

这里有个细节:#5表示半周期5个时间单位,所以周期是10个时间单位,频率就是100MHz(如果时间单位是1ns)。

但如果你需要多个时钟域怎么办?比如我们RISC-V浮点单元里,有时候需要主时钟和慢速时钟。我建议这样写:

initial begin
  clk_fast = 0;
  forever #5 clk_fast = ~clk_fast;
end

initial begin
  clk_slow = 0;
  forever #20 clk_slow = ~clk_slow;
end

为什么要分开initial块?因为如果写在一起,代码可读性会变差。而且,万一你要单独关掉某个时钟,分开写更容易控制。

注意:千万不要在时钟生成块里加任何延迟控制以外的逻辑。我曾经见过有人在时钟块里加了一个$display,结果仿真速度直接掉了30%。时钟块就应该只做时钟的事。

2.3 复位生成——异步复位同步释放

复位这块,很多教材上都会讲「异步复位同步释放」。但在验证环境里,我们通常简化处理。我个人习惯用这种写法:

initial begin
  rst_n = 0;
  repeat (5) @(posedge clk);  // 保持5个时钟周期的复位
  rst_n <= 1;                 // 在时钟上升沿释放
end

为什么要在时钟沿释放?因为这样可以避免复位释放时出现亚稳态。虽然仿真环境里不一定能复现,但养成好习惯总没错。

还有一种情况:你需要测试复位过程中DUT的行为。这时候可以加一个随机复位:

initial begin
  rst_n = 0;
  #( $urandom_range(10, 50) );  // 随机复位时长
  @(posedge clk);
  rst_n <= 1;
end

嗯,这个技巧我在做浮点除法器验证时用过。因为复位时长不同,DUT内部状态机的恢复行为可能不一样。随机化一下,能覆盖更多场景。

2.4 DUT实例化——接口匹配是重中之重

DUT实例化看起来就是连线,但这里最容易出问题。我总结了几条经验:

  1. 端口顺序要一致:Verilog允许按名字连接,也允许按位置连接。我强烈建议用名字连接,哪怕多打几个字。按位置连接一旦端口顺序变了,整个验证环境就废了。
  2. 未连接端口要处理:如果DUT有输出端口你没连,仿真器会报warning。但如果是输入端口没连,那就是X态,整个仿真都会出问题。
  3. 参数传递要小心:很多RISC-V浮点单元会通过参数配置位宽。比如:
fp_add #(
  .WIDTH(32)
) dut (
  .clk   (clk),
  .rst_n (rst_n),
  .a     (a),
  .b     (b),
  .result(result)
);

这里有个坑:如果你在顶层改了参数,但忘了改testbench里的例化参数,那仿真结果就全错了。我建议把参数定义成一个宏或者parameter,统一管理。

核心要点:DUT实例化时,每个端口都要问自己三个问题——这个信号从哪里来?它的位宽对吗?它的时序对吗?

2.5 整体结构图

下面这张图展示了我们搭建的测试平台整体结构。你可以看到,时钟和复位是独立的生成模块,DUT被包裹在中间,激励和检查分别从两侧接入。

Verilog测试平台结构图 时钟生成 clk_gen 复位生成 rst_gen DUT fp_add 激励生成 stimulus_gen 结果检查 checker clk rst_n a, b result 时钟和复位独立生成,激励与检查分离 每个模块只负责一件事,便于维护和复用

2.6 一些实用的小技巧

最后,分享几个我在实际项目中积累的经验:

  • 用`timescale统一时间单位:我习惯在testbench文件头部写`timescale 1ns/1ps。这样所有的时间延迟都是基于1ns的,精度到1ps,够用了。
  • 加一个初始化完成标志:比如init_done信号,在复位释放后拉高。这样激励块可以等这个信号有效后再开始发数据,避免在复位期间误操作。
  • 善用$time记录时间戳:在$display里加上$time,方便定位问题。比如:$display("[%0t] 输入a=%h, b=%h", $time, a, b);
避坑指南:我曾经在例化DUT时,把输入输出端口搞反了。结果仿真波形看起来一切正常,但数据就是不对。后来花了半天才发现是端口顺序写错了。所以,例化完DUT后,第一件事就是检查端口连接是否正确。

好了,环境搭建就讲到这里。你按照这个结构去写,基本上不会出大问题。记住:环境搭得稳,后面验证才省心。


专注资料整理