Part 6 · Testbench Architecture · Intermediate

Monitor Design

Passive observation, clocking-block sampling, protocol reconstruction state machines, and broadcasting transactions via mailboxes.

The passive contract

A monitor has exactly one job: watch interface pins and reconstruct transactions — and it must do that job without ever driving a signal. The moment a monitor writes to the bus, your checking path is observing its own stimulus and the testbench can no longer detect DUT bugs on those signals. Enforce passivity structurally: give the monitor a virtual interface handle whose modport/clocking block exposes inputs only , so a drive attempt is a compile error rather than a code-review hope.

systemverilog
interface bus_if (input logic clk);
  logic        valid;
  logic        ready;
  logic        last;
  logic [31:0] data;

  // Driver clocking block: drives valid/data, samples ready
  clocking drv_cb @(posedge clk);
    output valid, last, data;
    input  ready;
  endclocking

  // Monitor clocking block: EVERYTHING is an input
  clocking mon_cb @(posedge clk);
    input valid, ready, last, data;
  endclocking

  modport MON (clocking mon_cb);   // passive view only
endinterface

Sampling through mon_cb also fixes the race question: the clocking block samples signal values from just before the clock edge (the preponed region), so the monitor sees exactly the stable values the DUT flops saw — never the mid-timestep garbage of a raw @(posedge clk) read after other processes have updated signals.


Reconstructing multi-cycle transactions

Pins carry beats; consumers want transactions. For any protocol where one logical operation spans multiple cycles — a burst, a packet, an address-then-data sequence — the monitor needs a small reconstruction state machine that assembles beats into a complete transaction object before publishing it.

diagram
PROTOCOL RECONSTRUCTION FSM (burst example)

        valid && ready                 valid && ready && !last
       ┌───────────────┐             ┌──────────────┐
       │               ▼             │              ▼
  ┌────────┐      ┌─────────┐  beat  │        ┌─────────┐
  │  IDLE   │      │ COLLECT  │───────┘        │ COLLECT  │
  │ wait 1st│      │ push data│                │ (loops)  │
  │  beat   │      │ to queue │                └─────────┘
  └────────┘      └────┬────┘
       ▲                │ valid && ready && last
       │                ▼
       │          publish txn (all beats)  mailboxes
       └────────────────┘
systemverilog
class bus_txn;
  logic [31:0] data_q[$];   // collected beats
  time         start_t;
  time         end_t;

  function string convert2string();
    return $sformatf("txn beats=%0d first=0x%08h last=0x%08h",
                     data_q.size(),
                     data_q.size() ? data_q[0] : 'x,
                     data_q.size() ? data_q[$] : 'x);
  endfunction
endclass

A complete monitor class

systemverilog
class bus_monitor;
  virtual bus_if.MON vif;
  // one mailbox per consumer — never share one mailbox
  mailbox #(bus_txn) mbx_scb;   // → scoreboard
  mailbox #(bus_txn) mbx_cov;   // → coverage collector
  int unsigned       txn_count;

  function new(virtual bus_if.MON vif,
               mailbox #(bus_txn) mbx_scb,
               mailbox #(bus_txn) mbx_cov);
    this.vif     = vif;
    this.mbx_scb = mbx_scb;
    this.mbx_cov = mbx_cov;
  endfunction

  task run();
    forever begin
      bus_txn tr = new();
      collect_one(tr);
      txn_count++;
      broadcast(tr);
    end
  endtask

  // FSM: IDLE → COLLECT beats until last
  task collect_one(bus_txn tr);
    // IDLE: wait for first accepted beat
    do @(vif.mon_cb); while (!(vif.mon_cb.valid && vif.mon_cb.ready));
    tr.start_t = $time;
    // COLLECT: gather beats through the one tagged last
    forever begin
      tr.data_q.push_back(vif.mon_cb.data);
      if (vif.mon_cb.last) break;
      do @(vif.mon_cb); while (!(vif.mon_cb.valid && vif.mon_cb.ready));
    end
    tr.end_t = $time;
  endtask

  function void broadcast(bus_txn tr);
    // each consumer gets its own copy — no shared mutable object
    bus_txn cp = new tr;   // shallow copy is fine: queue of values
    void'(mbx_scb.try_put(tr));
    void'(mbx_cov.try_put(cp));
  endfunction
endclass

Code walkthrough

  1. vif is typed bus_if.MON — the input-only clocking block makes driving impossible.

  2. collect_one() only advances on valid && ready — it tracks accepted beats, not offered ones.

  3. Each consumer mailbox receives its own object — a consumer mutating a shared handle would corrupt the other's view.

  4. try_put() on an unbounded mailbox never blocks; the monitor must never stall the protocol it observes.

  5. txn_count feeds end-of-test sanity checks: a monitor that counted zero transactions means the test exercised nothing.

Interview angle

Classic questions: "Why must a monitor be passive?" (otherwise checking observes its own stimulus and the TB cannot catch DUT corruption on those pins), and "Why sample via a clocking block?" (preponed-region sampling guarantees the monitor sees the same values the DUT registered, eliminating same-timestep races). Be ready to sketch the reconstruction FSM for a burst protocol on a whiteboard.

Key takeaways

  • Enforce passivity with an input-only clocking block — make driving a compile error.

  • Reconstruct multi-cycle protocols with a small FSM keyed on accepted beats (valid && ready).

  • Broadcast a separate transaction copy to each consumer mailbox.

  • Count transactions in the monitor — it is the ground truth for end-of-test sanity checks.

Common pitfalls

  • Sampling raw @(posedge clk) instead of the clocking block — same-timestep races give phantom values.

  • Counting offered beats (valid alone) instead of accepted beats (valid && ready) — duplicates during back-pressure.

  • Pushing one shared object handle to multiple mailboxes — one consumer's edit corrupts the other.

  • A blocking put() into a bounded mailbox — a slow consumer back-pressures the monitor and it misses pins activity.