➕ SystemVerilog 加法器测试平台 —— 从零开始构建可扩展的验证环境

作为芯片验证工程师,我们的目标是构建一个模块化、可重用的测试平台,能够高效地验证设计功能。
今天,以一个非常简单的加法器为例,演示如何用 SystemVerilog 的面向对象特性搭建一个完整的验证环境。这个环境包含了所有标准组件:事务对象、生成器、驱动器、监视器、计分板、环境、测试用例,以及它们之间的通信机制(mailbox、event、virtual interface)。


🎯 一、设计规格:我们要验证什么?

设计功能

我们有一个组合逻辑加法器 my_adder,它的功能很简单:

  • rstn 为 1(复位无效)时,计算 sum = a + b,并产生进位 carry
  • rstn 为 0(复位有效)时,输出 sumcarry 均为 0。

接口:它使用了一个接口 adder_if 来连接所有信号。

module my_adder (adder_if _if);
  always_comb begin
    if (_if.rstn) begin
      _if.sum <= 0;
      _if.carry <= 0;
    end else begin
      {_if.carry, _if.sum} <= _if.a + _if.b;
    end
  end
endmodule

注意:加法器本身没有时钟,是纯组合逻辑。但为了模拟实际电路中的时序(比如输入来自寄存器),我们在测试平台中引入了一个独立的时钟接口 clk_if,用于同步驱动和采样。


🏗️ 二、验证环境架构

我们的测试平台采用标准的分层结构,各个组件职责单一,通过标准接口(mailbox、event)通信。整体架构如下:

┌─────────────────────────────────────────────────────────────┐
│                           Test                              │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐     │
│  │Generator │  │  Driver  │  │  Monitor │  │Scoreboard│     │
│  └────┬─────┘  └────┬─────┘  └────┬─────┘  └────┬─────┘     │
│       │              │              │              │        │
│    drv_mbx           │           scb_mbx           │        │
│       │              │              │              │        │
│       └──────────────┼──────────────┼──────────────┘        │
│                      │              │                       │
└──────────────────────┼──────────────┼───────────────────────┘
                       │              │
                 ┌─────▼──────┐ ┌─────▼──────┐
                 │   Driver   │ │  Monitor   │
                 │   (线程)   │ │   (线程)    │
                 └─────┬──────┘ └─────┬──────┘
                       │              │
                       ▼              ▼
                  ┌───────────────────────────────┐
                  │        Interface (_if)        │
                  └───────────────┬───────────────┘
                                  │
                                  ▼
                          ┌───────────────┐
                          │     DUT       │
                          │   my_adder    │
                          └───────────────┘

组件职责

  • 生成器(Generator):产生随机事务(包括 rstnab),放入 drv_mbx
  • 驱动器(Driver):从 drv_mbx 获取事务,在时钟沿驱动到 DUT 接口。
  • 监视器(Monitor):在每个时钟沿采样 DUT 的输入和输出,打包成事务放入 scb_mbx
  • 计分板(Scoreboard):从 scb_mbx 获取事务,计算预期结果并与实际输出比较。
  • 环境(Environment):实例化所有组件,连接 mailbox 和事件。
  • 测试(Test):创建环境并启动它。
  • 接口(Interface):封装 DUT 信号和测试时钟,供组件通过 virtual interface 访问。

📦 三、核心组件详解

1. 事务对象(Packet)—— 数据信封

事务对象定义了所有需要交换的数据:输入激励(rstn, a, b)和预期/实际输出(sum, carry)。它还提供了打印和复制方法。

class Packet;
  rand bit       rstn;      // 复位信号,随机化
  rand bit[7:0]  a;         // 输入a,随机化
  rand bit[7:0]  b;         // 输入b,随机化
  bit [7:0]      sum;       // 和(从DUT捕获)
  bit            carry;     // 进位(从DUT捕获)

  function void print(string tag="");
    $display("T=%0t %s a=0x%0h b=0x%0h sum=0x%0h carry=0x%0h", 
             $time, tag, a, b, sum, carry);
  endfunction

  function void copy(Packet tmp);
    this.a = tmp.a;
    this.b = tmp.b;
    this.rstn = tmp.rstn;
    this.sum = tmp.sum;
    this.carry = tmp.carry;
  endfunction
endclass

比喻:事务对象就像一个信封,里面装着要寄出的信(a, b, rstn)和回信(sum, carry)。生成器写好信,驱动器寄出,监视器收回回信,计分板检查回信是否正确。


2. 生成器(Generator)—— 写信员

生成器负责产生指定数量(loop)的随机事务,并通过 mailbox 发送给驱动器。它使用 drv_done 事件来同步,确保驱动器处理完一个事务后才产生下一个。

class generator;
  int  loop = 10;               // 要产生的事务数量
  event drv_done;               // 驱动器完成事件
  mailbox drv_mbx;              // 通向驱动器的邮箱

  task run();
    for (int i = 0; i < loop; i++) begin
      Packet item = new;
      item.randomize();          // 随机化 a, b, rstn
      $display("T=%0t [Generator] Loop:%0d/%0d create next item", $time, i+1, loop);
      drv_mbx.put(item);         // 放入邮箱
      $display("T=%0t [Generator] Wait for driver to be done", $time);
      @(drv_done);               // 等待驱动器完成
    end
  endtask
endclass

比喻:生成器就像写信员,他不断写新信(随机化),然后放进邮筒(mailbox),并等待邮差(driver)回信号(drv_done)表示信已寄出,才写下一封。


3. 驱动器(Driver)—— 邮差

驱动器从邮箱获取事务,在时钟沿将 rstnab 驱动到 DUT 接口,然后触发 drv_done 事件。

class driver;
  virtual adder_if m_adder_vif;    // 连接 DUT 的接口
  virtual clk_if  m_clk_vif;       // 测试时钟接口
  event drv_done;
  mailbox drv_mbx;

  task run();
    forever begin
      Packet item;
      drv_mbx.get(item);            // 阻塞等待事务
      @(posedge m_clk_vif.tb_clk);  // 等待时钟上升沿
      item.print("Driver");
      m_adder_vif.rstn <= item.rstn;
      m_adder_vif.a    <= item.a;
      m_adder_vif.b    <= item.b;
      ->drv_done;                    // 通知生成器
    end
  endtask
endclass

比喻:驱动器就是邮差,他从邮筒取信,然后在指定时间(时钟沿)把信投递到用户(DUT)的信箱,然后按铃(drv_done)告诉写信员可以写下一封。


4. 监视器(Monitor)—— 监控摄像头

监视器在每个时钟沿采样 DUT 的输入和输出,打包成事务,然后放入发给计分板的邮箱。

class monitor;
  virtual adder_if m_adder_vif;
  virtual clk_if   m_clk_vif;
  mailbox scb_mbx;                 // 通向计分板的邮箱

  task run();
    forever begin
      Packet m_pkt = new;
      @(posedge m_clk_vif.tb_clk);  // 等待时钟上升沿
      #1;                           // 小延迟,确保采样到稳定值
      m_pkt.a     = m_adder_vif.a;
      m_pkt.b     = m_adder_vif.b;
      m_pkt.rstn  = m_adder_vif.rstn;
      m_pkt.sum   = m_adder_vif.sum;
      m_pkt.carry = m_adder_vif.carry;
      m_pkt.print("Monitor");
      scb_mbx.put(m_pkt);           // 发送给计分板
    end
  endtask
endclass

比喻:监视器就像监控摄像头,它对准用户(DUT)的信箱,每次有人取信或放信(时钟沿),就拍一张照片(采样),然后把照片存入档案袋(mailbox)交给保安室(scoreboard)。


5. 计分板(Scoreboard)—— 保安室

计分板从邮箱获取事务,根据输入 abrstn 计算预期的 sumcarry,然后与实际值比较,并打印 PASS/ERROR 信息。

class scoreboard;
  mailbox scb_mbx;

  task run();
    forever begin
      Packet item, ref_item;
      scb_mbx.get(item);
      item.print("Scoreboard");

      // 复制一份用于计算预期值
      ref_item = new;
      ref_item.copy(item);

      // 根据复位状态计算预期结果
      if (ref_item.rstn)
        {ref_item.carry, ref_item.sum} = ref_item.a + ref_item.b;
      else
        {ref_item.carry, ref_item.sum} = 0;

      // 比较
      if (ref_item.carry != item.carry)
        $display("[%0t] Scoreboard Error! Carry mismatch ref_item=0x%0h item=0x%0h", $time, ref_item.carry, item.carry);
      else
        $display("[%0t] Scoreboard Pass! Carry match", $time);

      if (ref_item.sum != item.sum)
        $display("[%0t] Scoreboard Error! Sum mismatch ref_item=0x%0h item=0x%0h", $time, ref_item.sum, item.sum);
      else
        $display("[%0t] Scoreboard Pass! Sum match", $time);
    end
  endtask
endclass

比喻:计分板就是保安室的记录员,他拿到监控照片后,根据照片上的输入(a, b, rstn)自己算一遍(预期结果),然后和照片上的输出(sum, carry)对比,如果一致就说“正常”,不一致就报警(打印错误)。


6. 环境(Environment)—— 控制中心

环境将所有组件实例化,并连接它们的 mailbox、event 和 virtual interface。

class env;
  generator     g0;
  driver        d0;
  monitor       m0;
  scoreboard    s0;
  mailbox       scb_mbx;
  mailbox       drv_mbx;
  event         drv_done;
  virtual adder_if m_adder_vif;
  virtual clk_if   m_clk_vif;

  function new();
    g0 = new; d0 = new; m0 = new; s0 = new;
    scb_mbx = new; drv_mbx = new;
  endfunction

  virtual task run();
    // 连接 virtual interface
    d0.m_adder_vif = m_adder_vif;
    m0.m_adder_vif = m_adder_vif;
    d0.m_clk_vif = m_clk_vif;
    m0.m_clk_vif = m_clk_vif;

    // 连接 mailbox
    d0.drv_mbx = drv_mbx;
    g0.drv_mbx = drv_mbx;
    m0.scb_mbx = scb_mbx;
    s0.scb_mbx = scb_mbx;

    // 连接事件
    d0.drv_done = drv_done;
    g0.drv_done = drv_done;

    // 并发启动所有组件
    fork
      s0.run();   // 计分板一直运行
      d0.run();   // 驱动器一直运行
      m0.run();   // 监视器一直运行
      g0.run();   // 生成器运行固定次数后结束
    join_any      // 当生成器结束后,环境继续
  endtask
endclass

比喻:环境就像控制中心,把所有岗位(写信员、邮差、摄像头、保安)召集起来,给他们分配工作地点(interface)、通信渠道(mailbox)和信号(event),然后一声令下(fork),大家开始各司其职。


7. 测试(Test)—— 老板的指令

测试用例实例化环境并启动它。

class test;
  env e0;

  function new();
    e0 = new();
  endfunction

  virtual task run();
    e0.run();
  endtask
endclass

比喻:测试就是老板,他建好控制中心(env),然后说:“开始工作!”(run)。


8. 接口(Interface)—— 接线板

我们有两个接口:

  • adder_if:连接 DUT 的所有信号(rstn, a, b, sum, carry)。
  • clk_if:提供测试时钟 tb_clk,用于同步驱动和采样。
interface adder_if();
  logic        rstn;
  logic [7:0]  a;
  logic [7:0]  b;
  logic [7:0]  sum;
  logic        carry;
endinterface

interface clk_if();
  logic tb_clk;
  initial tb_clk <= 0;
  always #10 tb_clk = ~tb_clk;   // 产生周期20ns的时钟
endinterface

9. 顶层(Testbench Top)—— 总装车间

顶层模块实例化 DUT、接口、测试对象,并启动测试。

module tb;
  clk_if    m_clk_if   ();      // 实例化时钟接口
  adder_if  m_adder_if ();      // 实例化DUT接口
  my_adder  u0         (m_adder_if);  // 实例化DUT

  initial begin
    test t0;
    t0 = new;
    t0.e0.m_adder_vif = m_adder_if;  // 传递接口给环境
    t0.e0.m_clk_vif   = m_clk_if;
    t0.run();                         // 启动测试
    #50 $finish;                       // 运行一段时间后结束
  end
endmodule

比喻:顶层就是总装车间,把芯片(DUT)、接口板(interface)、测试台(test)都组装起来,然后按下启动按钮。


🧪 四、仿真结果分析

正常仿真输出

当设计正确时,仿真输出如下(部分截取):

T=10 Driver a=0x16 b=0x11 sum=0x0 carry=0x0
T=11 Monitor a=0x16 b=0x11 sum=0x0 carry=0x0
[11] Scoreboard Pass! Carry match ref_item=0x0 item=0x0
[11] Scoreboard Pass! Sum match ref_item=0x0 item=0x0
...
T=71 Monitor a=0x63 b=0xfb sum=0x5e carry=0x1
T=71 Scoreboard a=0x63 b=0xfb sum=0x5e carry=0x1
[71] Scoreboard Pass! Carry match ref_item=0x1 item=0x1
[71] Scoreboard Pass! Sum match ref_item=0x5e item=0x5e

观察

  • 驱动器在时间 10ns 驱动输入,监视器在 11ns 采样到输出。
  • 计分板计算预期结果(如 0x63+0xfb=0x15e,即 sum=0x5e, carry=1)与实际匹配,打印 PASS。

Buggy Design:故意引入错误验证检查器

为了证明检查器能发现设计错误,我们修改设计,让复位逻辑颠倒:rstn 为 1 时输出为 0,rstn 为 0 时才正常计算。

if (_if.rstn) begin   // 错误:应为 !_if.rstn
  _if.sum <= 0;
  _if.carry <= 0;
end else begin
  {_if.carry, _if.sum} <= _if.a + _if.b;
end

仿真输出

T=11 Monitor a=0x16 b=0x11 sum=0x27 carry=0x0
T=11 Scoreboard a=0x16 b=0x11 sum=0x27 carry=0x0
[11] Scoreboard Pass! Carry match ref_item=0x0 item=0x0
[11] Scoreboard Error! Sum mismatch ref_item=0x0 item=0x27
...
T=71 Monitor a=0x63 b=0xfb sum=0x0 carry=0x0
T=71 Scoreboard a=0x63 b=0xfb sum=0x0 carry=0x0
[71] Scoreboard Error! Carry mismatch ref_item=0x1 item=0x0
[71] Scoreboard Error! Sum mismatch ref_item=0x5e item=0x0

观察

  • rstn 为 1(复位无效)时,设计本应计算加法,但错误地输出 0,导致计分板报错。
  • rstn 为 0(复位有效)时,设计本应输出 0,但错误地计算了加法,同样报错。
  • 这证明计分板正确地检测到了设计错误。

🧠 五、关键概念与设计模式

1. 面向对象的封装

每个组件都是一个类,内部封装了 mailbox、event、virtual interface 等成员,以及 run() 任务。这使得组件可以独立开发、重用,且易于维护。

2. mailbox 通信

组件之间通过 mailbox 传递事务对象,实现生产者-消费者解耦。生成器(生产者)和驱动器(消费者)之间用 drv_mbx,监视器(生产者)和计分板(消费者)之间用 scb_mbx

3. event 同步

生成器和驱动器之间通过 drv_done 事件同步,实现流水线式激励生成:生成器放一个事务到邮箱,然后等待驱动器完成,再放下一个。这避免了邮箱无限堆积,也模拟了真实系统中“握手”的过程。

4. virtual interface

所有组件通过 virtual interface 访问物理信号。这使得组件可以在不修改代码的情况下连接到不同的 DUT 实例(例如在顶层传递不同的接口句柄)。

5. fork-join_any 控制

env.run() 中,使用 fork join_any 启动所有组件。因为生成器有固定循环次数,它会在完成后退出,而其他组件(driver、monitor、scoreboard)都是 forever 循环,所以 join_any 会在生成器完成后立即继续,允许环境结束(尽管其他组件还在后台运行)。然后在顶层用 $finish 结束仿真。

6. 分层结构

  • 事务层:Packet 类。
  • 组件层:generator、driver、monitor、scoreboard。
  • 环境层:env 类。
  • 测试层:test 类。
  • 物理层:interface、DUT、top module。

这种分层结构让验证环境易于扩展和维护。例如,要改变激励产生方式,只需修改 generator;要改变检查逻辑,只需修改 scoreboard;其他组件不受影响。


💡 六、总结与心法

组件 比喻 作用
Packet 信封 封装输入激励和输出结果
Generator 写信员 产生随机激励
Driver 邮差 将激励驱动到 DUT
Monitor 摄像头 采样 DUT 输出
Scoreboard 保安 比对预期和实际结果
Environment 控制中心 组装并连接所有组件
Test 老板 启动环境
Interface 接线板 连接 DUT 和验证组件

验证工程师的心法

一个好的测试平台应该像一个高效的工厂流水线:每个工位(组件)只做一件事,通过标准传送带(mailbox)和信号灯(event)协调工作。当设计有改动时,我们只需调整对应的工位,其他工位照常运行。这种模块化设计让验证环境变得可扩展、可重用、易调试

最后的小贴士

  • 在监视器中加一个 #1 延迟,确保采样到稳定的输出(因为组合逻辑在时钟沿后可能有短暂的不稳定)。
  • 在 scoreboard 中复制事务对象时,使用自定义的 copy() 方法,而不是直接赋值,避免引用别名问题。
  • 使用 $display 加上时间戳和标签,方便跟踪各个组件的执行顺序。
  • 始终验证你的检查器本身是否正确——故意引入设计错误,观察它是否能检测到。

掌握了这个示例,你就掌握了构建任何复杂验证环境的核心设计模式。无论面对的是简单的加法器还是复杂的处理器,这些思想和方法都能帮助你快速搭建出高质量的测试平台!

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐