Part 6 · Testbench Architecture · Intermediate

Walkthrough: Complete Mini Testbench

An end-to-end annotated class-based testbench for a simple register-file DUT: interface, harness, transaction, generator, driver, monitor, scoreboard, and test.

The target and the architecture

The DUT is a small synchronous register file with a valid/ready write-read port: on valid && ready, a write stores wdata at addr, and a read returns the stored value on rdata the following cycle. Simple enough to fit on a page, real enough to need every testbench layer. This is the interview deliverable: be able to write this from memory.

diagram
MINI TB ARCHITECTURE

  test ──builds──► env
                    │
        ┌───────────┼──────────────┬─────────────┐
        ▼           ▼              ▼             ▼
     generator ──► driver       monitor ───► scoreboard
        rand txns   │ vif.drv_cb   │ vif.mon_cb   │ exp model:
        (mailbox)   ▼              ▼   (mailbox)  │ assoc array
                ┌───────── bus_if ─────────┐      │ mem[addr]
                │  valid ready write       │      ▼
                │  addr wdata rdata        │   exp == act ?
                └───────────┬──────────────┘
                            ▼
                       regfile DUT

Transaction, generator, driver

systemverilog
// ---- transaction: the unit of communication between layers ----
class rf_txn;
  rand bit        write;
  rand bit [3:0]  addr;
  rand bit [31:0] wdata;
       bit [31:0] rdata;     // filled by monitor on reads
  static int next_id;
       int id;               // debug breadcrumb

  function new();
    id = next_id++;
  endfunction

  function string sprint();
    return $sformatf("txn%0d %s addr=%h wdata=%h rdata=%h",
                     id, write ? "WR" : "RD", addr, wdata, rdata);
  endfunction
endclass

// ---- generator: scenario layer ----
class generator;
  mailbox #(rf_txn) out;
  int unsigned n;
  event done;

  function new(mailbox #(rf_txn) out, int unsigned n);
    this.out = out;  this.n = n;
  endfunction

  task run();
    repeat (n) begin
      rf_txn t = new();
      if (!t.randomize()) $fatal(1, "randomize failed");
      out.put(t);           // blocks if driver is behind (bounded mb)
    end
    -> done;
  endtask
endclass

// ---- driver: command layer ----
class driver;
  mailbox #(rf_txn) in;
  virtual bus_if vif;

  function new(mailbox #(rf_txn) in);
    this.in = in;
  endfunction

  task run();
    forever begin
      rf_txn t;
      in.get(t);
      @(vif.drv_cb);
      vif.drv_cb.valid <= 1;
      vif.drv_cb.write <= t.write;
      vif.drv_cb.addr  <= t.addr;
      vif.drv_cb.wdata <= t.wdata;
      do @(vif.drv_cb); while (vif.drv_cb.ready !== 1);
      vif.drv_cb.valid <= 0;   // one idle cycle between txns
    end
  endtask
endclass

Commentary

  • rf_txn carries a static-counter id — when the scoreboard screams, the id pins the failure to one generator decision.

  • The generator never sees a clock; it produces transactions and blocks on the mailbox — pure scenario layer.

  • The driver owns all timing: clocking-block drives, the ready wait loop, and the return-to-idle cycle.


Monitor, scoreboard, env, test, harness

systemverilog
// ---- monitor: passive observation only ----
class monitor;
  mailbox #(rf_txn) out;
  virtual bus_if vif;

  function new(mailbox #(rf_txn) out);
    this.out = out;
  endfunction

  task run();
    forever begin
      rf_txn t = new();
      @(vif.mon_cb);
      if (vif.mon_cb.valid === 1 && vif.mon_cb.ready === 1) begin
        t.write = vif.mon_cb.write;
        t.addr  = vif.mon_cb.addr;
        t.wdata = vif.mon_cb.wdata;
        if (!t.write) begin
          @(vif.mon_cb);            // read data is one cycle later
          t.rdata = vif.mon_cb.rdata;
        end
        out.put(t);
      end
    end
  endtask
endclass

// ---- scoreboard: functional layer with inline reference model ----
class scoreboard;
  mailbox #(rf_txn) in;
  int unsigned expected_n, seen_n, errors;
  bit [31:0] mem [bit [3:0]];   // associative array = golden model

  function new(mailbox #(rf_txn) in, int unsigned expected_n);
    this.in = in;  this.expected_n = expected_n;
  endfunction

  task run();
    forever begin
      rf_txn t;
      in.get(t);
      if (t.write) mem[t.addr] = t.wdata;
      else begin
        bit [31:0] exp = mem.exists(t.addr) ? mem[t.addr] : '0;
        if (t.rdata !== exp) begin
          errors++;
          $display("SCB ERROR: %s expected rdata=%h", t.sprint(), exp);
        end
      end
      seen_n++;
    end
  endtask

  task wait_done();
    wait (seen_n >= expected_n);
  endtask

  function void report();
    if (errors == 0) $display("TEST PASSED: %0d txns checked", seen_n);
    else             $display("TEST FAILED: %0d errors", errors);
  endfunction
endclass

// ---- env and test wire it all together (per earlier lessons) ----
class env;
  virtual bus_if vif;
  int unsigned num_txns = 100;
  generator gen;  driver drv;  monitor mon;  scoreboard scb;
  mailbox #(rf_txn) gen2drv;  mailbox #(rf_txn) mon2scb;

  function void build();
    gen2drv = new(1);   mon2scb = new();
    gen = new(gen2drv, num_txns);  drv = new(gen2drv);
    mon = new(mon2scb);            scb = new(mon2scb, num_txns);
    drv.vif = vif;  mon.vif = vif;
  endfunction

  task run();
    fork gen.run(); drv.run(); mon.run(); scb.run(); join_none
    wait (gen.done.triggered);
    scb.wait_done();
    disable fork;
  endtask
endclass

class base_test;
  env e;  virtual bus_if vif;
  virtual task run();
    e = new();  e.vif = vif;  e.build();  e.run();  e.scb.report();
  endtask
endclass

Per-piece commentary

  1. Monitor samples only on observed valid && ready — it reconstructs transactions from pins, never trusting the driver.

  2. The scoreboard's associative array IS the reference model — a write updates it, a read checks against it.

  3. Reads of never-written addresses expect the reset value — a spec decision encoded in one line of the model.

  4. wait_done() on a transaction count is the simplest end-of-test contract; later lessons replace it with smarter drain logic.

  5. The test is four lines because every lower layer carries its own weight — that brevity is the architecture working.

Interview angle

Practice writing this whole stack — interface with clocking blocks, harness, six classes — in under 30 minutes on a whiteboard. Interviewers rarely care about perfect syntax; they watch whether the mailbox directions, the vif handoff, and the end-of-test logic come out right without hesitation.

Key takeaways

  • One transaction class flows through the whole TB: generator randomizes it, driver drives it, monitor reconstructs it, scoreboard checks it.

  • The monitor must rebuild transactions from pins — that independence is what makes checking trustworthy.

  • An associative array is a perfectly respectable reference model for memory-like DUTs.

  • If your test class is longer than ten lines, work has leaked up from a lower layer.

Common pitfalls

  • Monitor reading the generator's transactions instead of pins — the TB then checks itself, not the DUT.

  • Counting end-of-test on generated transactions but checking on monitored ones — off-by-in-flight hangs.

  • Using == instead of !== for data compare — X values slide through as matches.

  • Forgetting the extra cycle for read data in the monitor — every read appears corrupted by one transaction.