SystemVerilog 基础

第 16 章 · 共 16 章

包与作用域:动手搭建一个小型 Testbench

学会用 package 跨文件组织代码、避免命名冲突;然后把前面十五章的积木——接口、类、随机化、mailbox、task、fork-join、断言——拼装成一个真正跑起来的小型 testbench,看看手写这一切在规模变大后会遇到什么麻烦。

这是 SystemVerilog 基础系列的最后一章。前面十五章逐一介绍了语言的各个积木,这一章做两件事:先补上最后一块缺失的拼图——package 组织跨文件的代码;然后把之前学过的所有东西拼装成一个真正能跑起来的小型 testbench,看看手写这一切会遇到什么麻烦,为什么下一个系列需要 UVM 这样的方法学。

package:跨文件组织代码,避免命名冲突

项目变大之后,类型和类的定义通常会拆到多个文件里。如果没有明确的组织方式,所有声明都堆在"全局"作用域下,不同文件之间的命名冲突就成了真实的风险。package 把相关的声明(类型、类、函数)打包进一个命名空间,其他文件用 import 把需要的部分引进来:

// mux_pkg.sv
package mux_pkg;
  class mux_txn;
    rand bit sel;
    rand bit a;
    rand bit b;
 
    function void print();
      $display("txn: sel=%0b a=%0b b=%0b", sel, a, b);
    endfunction
  endclass
endpackage
// 使用方
import mux_pkg::*;   // 引入 mux_pkg 里的所有声明
 
module tb;
  mux_txn txn;
  initial begin
    txn = new();
    void'(txn.randomize());
    txn.print();
  end
endmodule

import mux_pkg::*; 引入包里的所有声明;也可以写得更精确,比如 import mux_pkg::mux_txn; 只引入这一个类名。没有放进任何 package/module/class 的声明会落在文件所在的"编译单元"作用域里,隐式地在同一个编译单元内可见——但依赖这种隐式作用域容易在项目变大后引发意外的命名冲突。经验法则和前面几章的建议一致:显式地把可复用的类型/类放进 package,用 import 明确引入,而不是依赖隐式的编译单元作用域

组装一个小型 testbench

把前面十五章的积木拼在一起:第 13 章的 interface 连接 DUT 和 testbench,第 10/12 章的类和随机化产生激励,第 14 章的 mailbox 在生成器和驱动器之间传递事务,第 9 章的 task 封装可复用行为,第 15 章的立即断言检查结果——目标 DUT 还是第 1、4 章那个最熟悉的 mux2

interface mux2_if;
  logic sel, a, b, y;
 
  modport dut_mp (input sel, a, b, output y);
  modport tb_mp  (output sel, a, b, input y);
endinterface
 
module mux2 (mux2_if.dut_mp bus);
  always_comb begin
    bus.y = bus.sel ? bus.b : bus.a;
  end
endmodule

virtual interface:让类也能访问接口

前面十五章里,class 只处理数据和纯软件逻辑,从没直接碰过 interface——这不是巧合:interface 是在模块(或顶层)里例化的,而 class 活在纯验证的世界里,没有办法直接例化或访问一个 interface。但真实的驱动器(driver)必须能读写 DUT 的信号,怎么办?

答案是 virtual interface:一种特殊的句柄类型,可以指向一个已经在别处例化好的 interface 实例,让类内部的代码也能读写它:

virtual mux2_if.tb_mp vif;  // 声明一个句柄,可以指向任意一个 mux2_if 实例(以 tb_mp 视角)

这个句柄本身的用法和第 10 章讲过的类句柄一样——声明时不指向任何东西,需要显式赋值(通常在构造函数里,由外部把顶层模块里真正例化的接口传进来)才会生效。下面把驱动器写成一个持有 virtual interface 的类,这正是 UVM 里真实驱动器的写法:

package mux_pkg;
  class mux_txn;
    rand bit sel;
    rand bit a;
    rand bit b;
 
    function void print();
      $display("txn: sel=%0b a=%0b b=%0b", sel, a, b);
    endfunction
  endclass
 
  class driver;
    virtual mux2_if.tb_mp vif;
    mailbox #(mux_txn)    mbx;
 
    function new(virtual mux2_if.tb_mp vif, mailbox #(mux_txn) mbx);
      this.vif = vif;
      this.mbx = mbx;
    endfunction
 
    task automatic run();
      mux_txn txn;
      bit     expected_y;
      repeat (5) begin
        mbx.get(txn);
 
        vif.sel = txn.sel;
        vif.a   = txn.a;
        vif.b   = txn.b;
        #1;  // 等待组合逻辑稳定
 
        expected_y = txn.sel ? txn.b : txn.a;
        assert (vif.y == expected_y)
          else $error("mismatch: sel=%0b a=%0b b=%0b expected=%0b actual=%0b",
                       txn.sel, txn.a, txn.b, expected_y, vif.y);
 
        txn.print();
      end
    endtask
  endclass
endpackage

这闭合了第 1 章开始铺垫的那个环:生成激励(randomize())→ 驱动(vif.sel = txn.sel 等)→ 检查结果(assert)。这个系列搭的每一个 testbench 都止步于此——只检查单次结果对不对。衡量整轮测试到底够不够彻底——是不是每一种值得测的组合都真的被跑到过,而不只是跑到的那些恰好都通过了——是一项相关但独立的技能,有自己专门的工具和系统性方法论;dv-methodology 会从这里接着讲。

#1 只是针对纯组合 DUT 的简化写法mux2 是纯组合逻辑,改变输入后延时 1 个时间单位、等组合逻辑传播稳定,就能安全采样输出。但如果 DUT 换成时序电路(比如带时钟的寄存器),直接用 #1 这种"猜一个延时"的写法会让 driver 和 DUT 之间产生采样竞争(race condition)——具体应该在时钟的哪个边沿附近驱动信号、采样结果,需要用第 13 章讲过的 clocking block 来明确约定,避免和 DUT 内部的时钟沿抢时间。这里只需要记住:这一行 #1 是为了配合本章这个纯组合 DUT 例子而简化的写法,不要不假思索地把它带到时序电路上。

module tb_top;
  import mux_pkg::*;
 
  mux2_if bus_if();
  mux2 dut (bus_if);
 
  mailbox #(mux_txn) mbx = new();
  driver             drv;
 
  // 生成器:产生 5 个随机事务,放入 mailbox
  task automatic generator();
    mux_txn txn;
    repeat (5) begin
      txn = new();
      void'(txn.randomize());
      mbx.put(txn);
    end
  endtask
 
  initial begin
    drv = new(bus_if, mbx);  // 把顶层真正例化的接口句柄传给 driver
 
    fork
      generator();
      drv.run();
    join
    $display("testbench done");
  end
endmodule

driver 类完全不知道 bus_if 是在哪个模块里例化的,它只知道自己有一个 vif 句柄——这正是 virtual interface 的价值:类内部的代码可以像操作普通信号一样读写 vif.sel/vif.a/vif.b/vif.y,而这个句柄具体指向哪个接口实例,是由外部(tb_top 例化 drv 时)决定的,driver 类本身完全不用关心。

generator()(task)和 drv.run()(类的方法)是两个并发进程,只通过 mbx 这个 mailbox 交换数据——生成器不需要关心驱动器什么时候处理完上一个事务,驱动器也不需要关心生成器是怎么产生这些事务的,两者完全解耦。这正是第 14 章末尾提到的"经典 testbench 里生成器与驱动器之间的通信方式",也是 UVM 里 sequencer 和 driver 之间协作模式的雏形——而 driver 类持有 virtual interface 这一点,则直接对应 UVM 驱动器访问 DUT 的标准写法。

手写到这里,规模变大之后会发生什么

上面这个 testbench 只有一个 4 信号的 DUT、一种事务类型、一条生成器-驱动器流水线,已经用到了接口、包、类、随机化、mailbox、task、fork-join、断言——几乎是这整个系列学过的所有东西。而真实项目里的验证环境,DUT 可能有几十个接口,需要十几种不同的事务类型(正常流量、错误注入、边界场景……),同一个驱动器可能要在不同测试里换成不同的行为,还需要统一收集覆盖率、统一格式的报告、统一的组件生命周期管理——如果每个项目都从零手写这一整套基础设施,第 1 章说过的痛点就会原样重现:"每个项目的 testbench 长得都不一样,难以复用,新人接手成本很高"。

这正是下一个系列——UVM——要解决的问题。UVM 不是新的语言特性,而是建立在这一整个系列学过的语言能力之上的一套标准化方法学:它规定了组件该怎么分层(复用第 10、11 章的类和继承)、激励该怎么生成和传递(标准化第 12、14 章的随机化和 mailbox 通信模式)、如何在不改动结构代码的前提下替换某个组件的实现(依赖第 11 章讲过的虚方法多态)。这个系列学的每一样东西,都会在 UVM 里被重新用到——只是不用再每个项目都手写一遍。

小结

  • package 把相关的类型、类、函数打包进命名空间,用 import package::*;(或 import package::具体名字;)引入;比依赖隐式的编译单元作用域更不容易发生命名冲突。
  • virtual interface 是一种句柄,可以让纯软件世界里的 class 也能读写某个已经例化好的 interface——这是类无法直接例化接口时,唯一能接触到真实信号的方式,也是 UVM 驱动器访问 DUT 的标准写法。
  • 一个小型但完整的 testbench,通常会同时用到:interface(连接 DUT 与 TB)、class+rand(产生激励)、virtual interface(让驱动器类接触到真实信号)、mailbox(进程间传递事务)、task(封装可复用行为)、fork...join(并发运行多个进程)、断言(检查结果)。
  • 生成器和驱动器通过 mailbox 解耦,是经典 testbench 架构的核心模式,也是 UVM sequencer/driver 协作的雏形。
  • 这个 testbench 生成激励、驱动、检查结果——但从不衡量测试是否够彻底。这是一项独立、专门的技能(covergroup 以及围绕它的方法论),会在 dv-methodology 里从头讲起。
  • 手写这套基础设施在小项目里可行,但规模变大后会重现第 1 章提到的"难以复用、难以维护"问题——这正是下一个系列 UVM 要标准化解决的。

import mux_pkg::*; 这行代码的作用是什么?

在示例 testbench 里,为什么用 mailbox 让 generator 和 driver 分别作为独立进程运行,而不是写成一个任务顺序产生并立刻驱动每个事务?

用哪个关键字把一个 package 里的声明引入到当前文件?(英文小写)

为什么 driver 类需要一个 virtual interface(比如 virtual mux2_if.tb_mp vif;)成员,而不是直接在类里例化一个 mux2_if?