SystemVerilog Basics

Chapter 16 of 16

Packages, Scope, and a Small Testbench Capstone

Learn to organize code across files and avoid naming collisions with package, then assemble fifteen chapters' worth of building blocks — interfaces, classes, randomization, mailboxes, tasks, fork-join, and assertions — into one small testbench that actually runs, and see what trouble hand-writing all of this runs into at scale.

This is the last chapter of the SystemVerilog Basics track. The previous fifteen chapters introduced the language's building blocks one at a time; this chapter does two things: first fills in the last missing piece — organizing code across files with package — then assembles everything learned so far into one small testbench that actually runs, to see what trouble hand-writing all of this runs into, and why the next track needs a methodology like UVM.

package: organizing code across files, avoiding naming collisions

As a project grows, type and class definitions usually get split across multiple files. Without an explicit organizing scheme, every declaration piles into a "global" scope, and naming collisions between files become a real risk. package bundles related declarations (types, classes, functions) into a namespace, and other files pull in what they need with 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
// consumer
import mux_pkg::*;   // bring in everything declared in mux_pkg
 
module tb;
  mux_txn txn;
  initial begin
    txn = new();
    void'(txn.randomize());
    txn.print();
  end
endmodule

import mux_pkg::*; brings in every declaration in the package; you can also be more specific, like import mux_pkg::mux_txn; to bring in just that one class name. Anything not placed inside a package/module/class falls into the file's "compilation unit" scope, implicitly visible within that unit — but relying on that implicit scope tends to invite unexpected naming collisions as a project grows. The rule of thumb matches earlier chapters' advice: explicitly put reusable types/classes into a package and bring them in with import, rather than relying on implicit compilation-unit scope.

Assembling a small testbench

Putting the last fifteen chapters' building blocks together: chapter 13's interface connects the DUT and testbench, chapters 10/12's classes and randomization generate stimulus, chapter 14's mailbox passes transactions between a generator and a driver, chapter 9's task wraps reusable behavior, and chapter 15's immediate assertion checks the result — the target DUT is still the most familiar one, mux2 from chapters 1 and 4.

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: letting a class reach an interface too

Across the last fifteen chapters, class only ever dealt with data and pure software logic, never touching an interface directly — that's not a coincidence. An interface gets instantiated inside a module (or at the top level), while a class lives entirely in the verification-only world with no way to instantiate or directly reach one. But a real driver has to read and write the DUT's signals somehow — so how?

The answer is virtual interface: a special handle type that can point to an interface instance that already exists elsewhere, letting code inside a class read and write it:

virtual mux2_if.tb_mp vif;  // a handle that can point to any mux2_if instance, via the tb_mp view

This handle behaves just like the class handles from chapter 10 — it points to nothing when declared, and needs an explicit assignment (usually in the constructor, with the real instance passed in from outside) before it does anything. Here's the driver rewritten as a class holding a virtual interface — exactly how a real UVM driver is written:

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;  // wait for the combinational logic to settle
 
        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

This closes the loop chapter 1 opened: stimulus (randomize()) → drive (vif.sel = txn.sel, etc.) → check (assert). Every testbench this track has built stops there — checking whether an individual result was correct. Measuring whether an entire test run was thorough enough — whether every combination worth testing actually got exercised, not just whether the ones that did happen to pass — is a related but separate skill, with its own dedicated tool and its own systematic methodology; dv-methodology picks it up from here.

That #1 is a simplification that only works for a combinational DUT: mux2 is pure combinational logic, so waiting 1 time unit after changing the inputs — enough for the combinational logic to settle — is enough to sample the output safely. Swap in a sequential DUT (a clocked register, say) and a guessed delay like #1 creates a race condition between the driver and the DUT — exactly when, relative to the clock edge, to drive signals and sample results needs to be pinned down with the clocking block chapter 13 already covered, so the testbench doesn't fight the DUT's own clock edge for timing. Just remember this line's #1 is simplified to fit this chapter's purely combinational example, and shouldn't be carried over to sequential logic without thinking it through.

module tb_top;
  import mux_pkg::*;
 
  mux2_if bus_if();
  mux2 dut (bus_if);
 
  mailbox #(mux_txn) mbx = new();
  driver             drv;
 
  // generator: produces 5 random transactions, puts them in the 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);  // pass the real interface instance from the top level
 
    fork
      generator();
      drv.run();
    join
    $display("testbench done");
  end
endmodule

The driver class has no idea which module bus_if was instantiated in — it only knows it has a vif handle. That's exactly the value of virtual interface: code inside the class reads and writes vif.sel/vif.a/vif.b/vif.y just like ordinary signals, while which actual interface instance the handle points to is decided from outside (when tb_top constructs drv) — the driver class itself never needs to know or care.

generator() (a task) and drv.run() (a class method) are two concurrent processes that only exchange data through the mbx mailbox — the generator doesn't need to care how far along the driver is with the previous transaction, and the driver doesn't need to care how the generator produced them. The two are completely decoupled. This is exactly the "communication pattern between a generator and a driver in a classic testbench" mentioned at the end of chapter 14, and it's the seed of how UVM's sequencer and driver cooperate — while the driver class holding a virtual interface maps directly onto the standard way a UVM driver reaches the DUT.

What happens when this gets bigger

The testbench above has just one 4-signal DUT, one transaction type, and a single generator-driver pipeline — and it already uses interfaces, packages, classes, randomization, mailboxes, tasks, fork-join, and assertions, nearly everything this whole track covered. A real project's verification environment might have a DUT with dozens of interfaces, a dozen different transaction types (normal traffic, error injection, boundary scenarios...), the same driver needing different behavior across different tests, plus a need for unified coverage collection, unified report formatting, and unified component lifecycle management. If every project hand-writes this entire infrastructure from scratch, chapter 1's pain point reappears exactly as described: "every project's testbench looked different, was hard to reuse, and was expensive for newcomers to pick up."

That's exactly the problem the next track — UVM — solves. UVM isn't a new set of language features; it's a standardized methodology built entirely on top of the language capabilities this track covered: it defines how components should be layered (reusing chapters 10–11's classes and inheritance), how stimulus should be generated and passed around (standardizing chapters 12 and 14's randomization and mailbox communication patterns), and how to swap a component's implementation without touching structural code (relying on chapter 11's virtual-method polymorphism). Everything this track taught gets reused in UVM — you just stop hand-writing it fresh for every project.

Summary

  • package bundles related types, classes, and functions into a namespace, brought in with import package::*; (or import package::specificName;) — less prone to naming collisions than relying on implicit compilation-unit scope.
  • virtual interface is a handle that lets a class — living in the purely software verification world — read and write an already-instantiated interface. It's the only way a class can reach real signals, and it's the standard way a UVM driver accesses the DUT.
  • A small but complete testbench typically uses all of: interface (connecting DUT and TB), class + rand (generating stimulus), virtual interface (letting a driver class reach real signals), mailbox (passing transactions between processes), task (wrapping reusable behavior), fork...join (running multiple processes concurrently), and assertions (checking results).
  • Decoupling the generator and driver through a mailbox is the core pattern of classic testbench architecture, and the seed of UVM's sequencer/driver collaboration.
  • This testbench generates stimulus, drives it, and checks the result — but never measures whether testing was thorough enough. That's a separate, dedicated skill (covergroup and the methodology around it) covered from scratch in dv-methodology.
  • Hand-writing this infrastructure works fine for a small project, but it reproduces chapter 1's "hard to reuse, hard to maintain" problem at scale — exactly what the next track, UVM, standardizes away.

What does the line import mux_pkg::*; do?

In the example testbench, why use a mailbox to run generator and driver as separate processes, instead of one task that produces and immediately drives each transaction in sequence?

Why does the driver class need a virtual interface member (like virtual mux2_if.tb_mp vif;) instead of just instantiating a mux2_if directly inside the class?

Which keyword brings a package's declarations into the current file? (lowercase)