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.
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
endinterfaceSampling 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.
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
└────────────────┘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
endclassA complete monitor class
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
endclassCode walkthrough
vif is typed bus_if.MON — the input-only clocking block makes driving impossible.
collect_one() only advances on valid && ready — it tracks accepted beats, not offered ones.
Each consumer mailbox receives its own object — a consumer mutating a shared handle would corrupt the other's view.
try_put() on an unbounded mailbox never blocks; the monitor must never stall the protocol it observes.
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.