SystemVerilog · Module 13
Mailboxes
SystemVerilog mailboxes — the typed FIFO that decouples producer from consumer. Bounded vs unbounded back-pressure, typed vs untyped type safety, put / get / peek / try_* / num semantics, the canonical generator → driver → monitor → scoreboard pipeline, mailbox-in-a-class wiring, the handle-aliasing bug every shop ships at least once, and the UVM TLM-FIFO connection.
Module 13 · Page 13.4
A mailbox is a first-in-first-out queue that passes objects safely between concurrent processes. The producer never has to know when the consumer is ready; the consumer never has to know when the producer will deliver. That decoupling is what makes the generator → driver → monitor → scoreboard pipeline possible — and it is exactly what UVM's TLM ports abstract over.
This is the third and final IPC primitive. Where event signals moments and semaphore gates access, mailbox carries items.
1. Engineering Problem — Why Mailboxes Exist
A SystemVerilog testbench generator runs at host-CPU speed and can emit a million transactions per simulation second. The driver is bound to the DUT's clock and pushes one transaction every ~5 cycles. The monitor sees responses arrive at arbitrary times. The scoreboard wants to compare expected against actual in order — independent of whichever side ran first.
Without a queue between them, the only options are:
- A shared
Txnvariable. The producer overwrites it before the consumer reads. Every fast burst of stimulus silently corrupts the test. - A
dynamic_arrayof items with hand-rolled put / get indices. Race-prone, full of off-by-one bugs, no built-in blocking semantics. - An event + handshake. Works for synchronisation but does not carry data; you still need somewhere to put the object.
The mailbox is the IEEE 1800 answer: a built-in typed FIFO with put() / get() blocking semantics, optional bounded depth for back-pressure, and peek() / try_* / num() for everything in between. Every non-UVM testbench is wired with mailboxes; every UVM testbench wraps them in TLM ports.
2. Mental Model — A Post Office FIFO
The picture every engineer carries:
A mailbox is a post office queue. Senders walk up and
put()letters at the back; the recipient picks them up from the front withget()in the order they arrived. If the recipient is slow, letters pile up. If the queue has a fixed capacity (new(N)), senders wait at the door once it is full. If the queue is empty, the recipient sits and waits.
Three invariants this picture preserves:
- FIFO is contractual. IEEE 1800 specifies first-in-first-out delivery. Unlike semaphores, the order across multiple waiters is defined:
get()calls are served in the order they blocked. - Each item has one recipient. A
put()unblocks exactly oneget(), even if ten consumers are waiting. This is unicast, distinct from event broadcast. - The queue stores handles, not copies. Class-typed mailboxes pass references. If the producer mutates the object after
put(), the consumer sees the mutated state. The fix is to allocate a freshnew()perput()or toclone()— covered in §10 debug labs.
3. Visual Explanation — Producer, FIFO, Consumer
Mailbox FIFO — generator puts at the tail, driver gets from the head
mailboxThe diagram captures the canonical pattern. Most testbench wiring is some composition of this figure with the components renamed: monitor → scoreboard, agent → sequencer, reference-model → checker.
4. Syntax & Semantics — new(), put, get, peek, try_*, num
4.1 Declaration forms
// ── Unbounded, untyped ────────────────────────────────────────
mailbox mb = new(); // grows without limit; can hold any type
// ── Bounded, untyped ──────────────────────────────────────────
mailbox mb = new(8); // max 8 items; put() blocks when full
// ── Typed (parameterised) — preferred ─────────────────────────
mailbox #(ApbTxn) mb = new(); // only ApbTxn objects allowed; compile-time check
// ── Typed AND bounded — most common in production ─────────────
mailbox #(ApbTxn) mb = new(4); // typed, bounded — hardware-accurate back-pressure4.2 The complete method set
mb.put(item); // add item — BLOCKS if mailbox is full (bounded only)
mb.get(item); // remove first item — BLOCKS if mailbox is empty
mb.peek(item); // copy first item WITHOUT removing — BLOCKS if empty
int ok = mb.try_put(item); // non-blocking put: 1=success, 0=full (bounded) or always 1 (unbounded)
int ok = mb.try_get(item); // non-blocking get: 1=success, 0=empty
int ok = mb.try_peek(item); // non-blocking peek: 1=success, 0=empty
int n = mb.num(); // number of items currently in the queueput adds at the tail; get removes from the head; peek copies the head without removing. The blocking trio (put, get, peek) suspends the calling process; the try_* trio returns immediately with a 0/1 success code.
4.3 Bounded vs unbounded — back-pressure semantics
| Aspect | Unbounded new() | Bounded new(N) |
|---|---|---|
put() blocks? | Never — always succeeds immediately | Yes — blocks when queue holds N items |
get() blocks? | Yes — blocks when queue is empty | Yes — blocks when queue is empty |
| Memory risk | Yes — can grow without bound if producer outruns consumer | No — capped at N items |
| Models hardware? | No — real interfaces always have bounded buffers | Yes — models actual hardware FIFO depth |
| Use when | Generator-to-driver where the bus speed naturally rate-limits | Back-pressure paths, hardware-accurate models, preventing runaway queues |
4.4 Typed vs untyped — type safety
// ── TYPED mailbox (always preferred) ──────────────────────────
mailbox #(ApbTxn) mb_typed = new();
ApbTxn t = new();
mb_typed.put(t); // OK
// mb_typed.put(42); // COMPILE ERROR — type mismatch caught immediately
ApbTxn out;
mb_typed.get(out); // no cast needed — type already guaranteed
// ── UNTYPED mailbox (use only for polymorphic infrastructure) ──
mailbox mb_untyped = new();
ApbTxn apb = new();
AhbTxn ahb = new();
mb_untyped.put(apb); // stores a handle of type ApbTxn
mb_untyped.put(ahb); // stores a handle of type AhbTxn — untyped allows both
BaseTxn base;
mb_untyped.get(base); // get returns a handle — base could be ApbTxn or AhbTxn
ApbTxn cast_result;
if (!$cast(cast_result, base))
$error("Expected ApbTxn but got something else!");4.5 peek() — inspect without consuming
peek() copies the front item without removing it. Use it when the consumer needs to decide whether to consume — priority routing, conditional draining, ordered processing across multiple queues.
ApbTxn t;
mb.peek(t); // copies the front item into t — item stays in the queue
// blocks if queue is empty (just like get)
if (t.write)
mb.get(t); // conditionally consume only if it is a write transaction
// ── Priority router pattern ───────────────────────────────────
task automatic route_transactions();
Txn t;
forever begin
mb.peek(t); // look at the head without committing
if (t.priority == HIGH) begin
mb.get(t);
high_prio_queue.put(t);
end else begin
mb.get(t);
low_prio_queue.put(t);
end
end
endtask5. Simulation View — Blocking Semantics and FIFO Order
get() and put() block at the Active region of the current time slot. When put() adds an item, any process suspended on get() is unblocked immediately — in the same time step, no time advance — and resumes in FIFO order across waiters. This is the rule that distinguishes mailboxes from semaphores: with semaphores the wake-up order is unspecified; with mailboxes IEEE 1800 §15.4.3 guarantees first-blocked / first-served.
The same applies to put() on a bounded mailbox: when get() drains a slot, the longest-blocked put() is the one that proceeds.
try_put() and try_get() never block — they read the current state, attempt the operation atomically, and return the 0/1 result in the same Active region.
6. Waveform — Bounded Back-Pressure in Action
A bounded mailbox is the IPC primitive that models hardware FIFO depth. The waveform below shows a fast producer (writes every clock) feeding a slow consumer (reads every 4 clocks) through a mailbox #(Packet) = new(4). The producer fills the queue in 4 cycles, then put() blocks at cycle 5. The consumer drains one slot at cycle 7; the producer's blocked put() immediately unblocks and adds the next packet at cycle 8. The queue oscillates near full thereafter — natural back-pressure with no race, no lost data, no runaway memory.
Figure — bounded mailbox(4): fast producer stalls when full, resumes on each drain
12 cyclesmailbox #(Packet) tx_fifo = new(4); // max 4 packets in flight
initial fork
begin : producer
for (int i = 0; i < 20; i++) begin
Packet p = new(); p.id = i;
tx_fifo.put(p); // blocks when 4 items are in the queue
$display("[GEN] Put packet %0d (queue=%0d)", i, tx_fifo.num());
end
end
begin : consumer
Packet p;
forever begin
tx_fifo.get(p);
#20; // slow consumer — 20 time units per packet
$display("[DRV] Drove packet %0d", p.id);
end
end
join_none
// Producer sends quickly but pauses when 4 packets queue up.
// Consumer drains the queue; producer resumes. Natural back-pressure.7. Synthesis — Not Applicable
mailbox is a simulation-only construct. It has no hardware footprint, no synthesised gates, and no place in synthesisable RTL — synthesis tools reject the keyword outright. This section is intentionally omitted; the topic does not warrant it.
The corresponding hardware idiom in RTL is a synchronous FIFO with full / empty flow-control flags (covered separately under the FIFO and CDC lessons), or an AXI write-data buffer behind a ready / valid handshake. The mailbox is the simulation-side analog of those structures — the verification environment models the hardware FIFO's depth with new(N).
8. Verification View — The Canonical Testbench Pipeline
8.1 Multiple producers and consumers — unicast distribution
Any number of processes can call put() on the same mailbox; items queue in FIFO order regardless of producer. Any number of processes can call get(); each item is consumed by exactly one of them. This is the work-queue pattern — N workers competing for jobs from a shared queue.
mailbox #(Job) job_queue = new();
initial fork
// Job generator: fills the queue
begin
for (int i = 0; i < 100; i++) begin
Job j = new(); j.id = i;
job_queue.put(j);
end
end
// Worker 0: takes jobs as they become available
begin : worker_0
Job j;
forever begin
job_queue.get(j); // one item, one worker
process_job("W0", j);
end
end
// Worker 1: competes with Worker 0 for the same queue
begin : worker_1
Job j;
forever begin
job_queue.get(j); // each job goes to exactly ONE worker
process_job("W1", j);
end
end
join_none
// Workers share the load — each job is processed exactly once8.2 The complete generator → driver → monitor → scoreboard pipeline
The standard SystemVerilog testbench architecture uses mailboxes at every connection point. Each component is an independent process; mailboxes carry data across the boundaries. This is the blueprint for every non-UVM verification environment.
// ── Environment: declares all shared IPC objects ──────────────
mailbox #(ApbTxn) gen2drv = new(); // generator → driver
mailbox #(ApbTxn) gen2sb = new(); // generator → scoreboard (reference)
mailbox #(ApbTxn) mon2sb = new(); // monitor → scoreboard (actual)
semaphore bus_lock = new(1); // one driver on bus at a time
event all_sent; // generator signals completion
task generator(int n_txns);
repeat (n_txns) begin
ApbTxn t = new(); assert(t.randomize());
gen2drv.put(t); // send to driver
gen2sb.put(t); // send reference copy to scoreboard
end
->all_sent;
endtask
task driver();
ApbTxn t;
forever begin
gen2drv.get(t); // wait for next transaction
bus_lock.get(); // acquire bus (the semaphore lesson)
drive_apb_txn(t); // apply to DUT
bus_lock.put(); // release bus
end
endtask
task monitor();
ApbTxn t;
forever begin
sample_apb_output(t); // observe DUT response
mon2sb.put(t); // forward to scoreboard
end
endtask
task scoreboard();
ApbTxn expected, actual;
int pass_count = 0, fail_count = 0;
forever begin
gen2sb.get(expected); // reference from generator
mon2sb.get(actual); // actual from monitor
if (expected.data == actual.data) pass_count++;
else begin
fail_count++;
$error("[SB] MISMATCH: exp=0x%h got=0x%h at %0t",
expected.data, actual.data, $time);
end
end
endtask
initial begin
fork
generator(200); // 200 transactions
driver();
monitor();
scoreboard();
join_none
wait(all_sent.triggered); // wait for generator (safer than @)
#5000; // drain: let last transactions propagate
if (gen2sb.num() != 0)
$error("[END] Scoreboard has %0d unchecked transactions", gen2sb.num());
$display("[END] Test complete at %0t", $time);
$finish;
end8.3 Mailbox in a class — the building block for reusable agents
Mailboxes are objects; they can be stored as class properties and wired through constructors. This is how reusable verification components — drivers, monitors, scoreboards — are connected without global variables.
class ApbDriver;
mailbox #(ApbTxn) mbx; // handle to the input mailbox
function new(mailbox #(ApbTxn) m);
mbx = m; // wired in at construction
endfunction
task run();
ApbTxn t;
forever begin
mbx.get(t);
drive_apb(t);
end
endtask
endclass
class Scoreboard;
mailbox #(ApbTxn) expected_mbx;
mailbox #(ApbTxn) actual_mbx;
int pass_cnt, fail_cnt;
function new(mailbox #(ApbTxn) exp, mailbox #(ApbTxn) act);
expected_mbx = exp;
actual_mbx = act;
endfunction
task run();
ApbTxn e, a;
forever begin
expected_mbx.get(e);
actual_mbx.get(a);
if (e.data == a.data) pass_cnt++;
else begin fail_cnt++; $error("Mismatch"); end
end
endtask
endclass
class Env;
mailbox #(ApbTxn) gen2drv, gen2sb, mon2sb;
ApbDriver drv;
Scoreboard sb;
function new();
gen2drv = new(); gen2sb = new(); mon2sb = new();
drv = new(gen2drv); // pass mailbox handle to driver
sb = new(gen2sb, mon2sb); // pass both handles to scoreboard
endfunction
endclassMailbox handles are class references — passing one to a constructor or task does not copy the underlying queue. Both holders see the same queue and the same items.
9. Industry Usage — From Raw SV to UVM
- UVM
uvm_tlm_fifois a mailbox with TLM ports bolted on. The underlying mechanism is identical: a typed FIFO between two components. UVM addsput_export/get_exportfor wiring visualisation in Verdi, peek / try-peek API parity, and integration with the phase and reset machinery. - UVM
uvm_analysis_port+uvm_tlm_analysis_fifois the broadcast-then-buffer pattern: an analysis port multicasts every monitor observation to every subscribed scoreboard's analysis-FIFO. Each subscribed FIFO is itself a typed mailbox. - Sequencer ↔ Driver handshake uses an internal mailbox for the
get_next_item/item_donepattern. Understanding raw mailboxes is the prerequisite for debugging UVM sequencer stalls. - Block-level testbench pipelines in every protocol VIP (APB / AHB / AXI / DDR / Ethernet / PCIe) use mailboxes at the agent's monitor → scoreboard boundary, even when the rest of the agent is UVM-wrapped.
- Regression infrastructure (transaction recorders, log aggregators, coverage merging) often uses mailboxes between simulator and external Python / Tcl post-processors via DPI wrappers.
- Cross-thread debug instrumentation — the
+ipc_traceplusarg pattern from the semaphores lesson typically puts trace events into a mailbox the test-end drains and writes to a file.
10. Design Review Notes — What a Senior Will Flag
| Pattern in the diff | What review will say |
|---|---|
mailbox mb = new(); (untyped) in new code | "Use mailbox #(MyType) — every shop's style guide requires typed mailboxes. Untyped is reserved for genuinely polymorphic infrastructure." |
mailbox #(Pkt) mb = new(); (unbounded) where the producer can outrun the consumer | "Bound it. Unbounded mailboxes are how regressions silently OOM after hour two — visible only at scale." |
Txn t = new(); forever begin t.randomize(); mb.put(t); end (handle aliasing) | "Re-using one handle aliases every slot. Allocate a fresh new() each iteration or call mb.put(t.clone())." |
mb.get(t); in test-level code with no timeout | "Wrap in fork ... join_any with a watchdog. A hung mailbox is harder to debug than a failed one." |
if (mb.try_get(t)) ... in a zero-delay forever loop | "Busy-spin — no time advance. Add @(posedge clk) or #1. Use the blocking get() whenever possible." |
mb.num() checked then mb.get(item) called | "Race — between the num() read and the get(), another consumer may have drained the queue. Use try_get or hold the consumer count separately." |
mb.put(item); mb.get(item); in the same thread | "Pointless — you put then immediately took it back. Either the producer is in a different thread or the mailbox is unnecessary." |
Unbounded mailbox with no mb.num() watchdog | "Add a debug-only watchdog that fails fast when num() > expected_max. Catches the producer-outpaces-consumer bug at minute one, not hour six." |
mailbox mb; with no mb = new(...) | "Null handle — mailbox must be constructed. The first put() / get() will throw a null-handle exception." |
The single highest-value rule: always typed, always bounded, always cloned unless you have a specific reason otherwise. Three rules cover 90% of mailbox bug classes.
11. Debugging Guide — Real Failures, Real Fixes
Random scoreboard mismatches on long tests only
HANDLE-ALIASINGApbTxn txn = new();
forever begin
assert(txn.randomize()); // SAME handle, re-randomized
gen2drv.put(txn);
gen2sb.put(txn); // both mailboxes hold the same handle
endput()s. When the consumer is delayed, the producer re-randomises the same object the consumer is still holding — the scoreboard's expected and the DUT's actual diverge based on whoever wins the race for the handle.new() each iteration, or pass mb.put(txn.clone()). A 3-line patch eliminates the entire class of bug. UVM coding-style guides at every shop mandate clone() before analysis-port writes for exactly this reason.Simulator OOMs at hour 6 of regression
UNBOUNDED-MAILBOXmailbox #(Pkt) gen2drv = new(); // unbounded
forever begin
Pkt p = new(); p.id = id++;
gen2drv.put(p); // 1 Gbps producer
end
forever begin
Pkt p; gen2drv.get(p);
scoreboard.slow_check(p); // 10 Mbps consumer
end$finish reached.mailbox #(Pkt) gen2drv = new(1024). Add a watchdog process that asserts gen2drv.num() < 512 every microsecond and $fatals if violated. Long regressions now fail in under a minute when the consumer dies, instead of OOM-ing silently after hours.Scoreboard hangs after exactly N transactions; N varies per seed
MISSING-GENERATOR-SIGNALtask scoreboard();
forever begin
gen2sb.get(expected);
mon2sb.get(actual); // blocks forever if monitor missed one
compare(expected, actual);
end
endtaskbus_lock.put() releasing too early). The scoreboard's gen2sb.get() succeeds — there's the reference. The mon2sb.get() blocks forever because the actual never arrived. No assertion catches it; the scoreboard simply waits.fork mon2sb.get(actual); #1000 $fatal(1, "[SB] mon2sb starved"); join_any disable fork;. The test now fails fast with the exact mailbox and time, dropping debug from days to minutes.Untyped-mailbox runtime cast error in regression
UNTYPED-WRONG-TYPEmailbox mb = new(); // untyped
ApbTxn apb = new(); mb.put(apb);
AhbTxn ahb = new(); mb.put(ahb); // both types allowed
ApbTxn out;
mb.get(out); // gets the AhbTxn first (FIFO)
// — runtime error: cannot assign AhbTxn handle to ApbTxn variable$cast errors in the scoreboard. The failure depends on producer ordering — visible only when the AHB agent puts before the APB agent. A $cast exception stack trace shows the failure point but not the cause.ApbTxn and AhbTxn handles. The consumer assumes one type and fails the cast on the other. Type errors that would have been compile-time on a typed mailbox become runtime errors at scale.mailbox #(BaseTxn) mb = new(); if both types share a base, then $cast at the consumer with explicit handling. Better: use two typed mailboxes — mailbox #(ApbTxn) and mailbox #(AhbTxn) — and peek / route at the source.Test hangs only when verbosity is set to NONE
BUSY-SPIN-TRY_GETinitial forever begin
int ok;
Pkt p;
ok = mb.try_get(p);
if (verbose) $display("got %0d", p.id);
if (ok) handle(p);
end+UVM_VERBOSITY=HIGH the test passes. With +UVM_VERBOSITY=NONE the test loops at zero-time, hits the simulator's iteration limit, and the run is killed.try_get loop with no time advance. The verbose $display call was the only thing yielding to other processes via the underlying I/O. Strip it and you get a busy-spin that starves every other process at t=0.get() — the simulator naturally suspends until a put() arrives. If you genuinely need try_get (e.g. you have a fallback), add a @(posedge clk) or #1 at the bottom of the loop to make the time advance explicit.12. Interview Insights — What Interviewers Actually Probe
A mailbox new() (unbounded) never blocks put() — the queue grows as needed. A mailbox new(N) (bounded) blocks put() when the queue holds N items; the producer waits until the consumer drains a slot.
Prefer unbounded when the producer is naturally rate-limited and you do not need to model hardware back-pressure (e.g. a generator-to-driver mailbox where the driver's bus speed acts as the bottleneck). Prefer bounded whenever the producer can outrun the consumer — unbounded mailboxes silently accumulate handles and turn a slow consumer into a memory leak that surfaces only at regression scale.
13. Exercises
1. Design — bounded mailbox as a hardware FIFO model (Foundation)
Build a mailbox #(Pkt) tx_fifo = new(16) that models an AXI write-data FIFO behind a slow downstream sink. The producer pushes one packet per clock; the consumer drains one per four clocks. Add mb.num() instrumentation that prints the queue depth every cycle. Confirm the queue saturates at 16 and the producer's put() blocks correctly.
2. Debug — the silent OOM (Intermediate)
A regression OOMs after 5 hours with no error message. The testbench uses mailbox #(Pkt) gen2drv = new(); (unbounded) feeding a slow scoreboard. Walk through the diagnostic: which mailbox property would have warned you at minute one, and what is the canonical bounded-mailbox + watchdog fix?
3. Code review — the work queue (Intermediate)
A teammate submits a job-queue implementation: mailbox jobs = new(); (untyped, unbounded) with two worker threads each calling Job j; jobs.get(j);. Identify the three bugs and propose the canonical fixes.
4. Trade-off — typed unbounded vs typed bounded (Advanced)
A junior teammate proposes: "always use mailbox #(T) = new(8) everywhere — bounded by default." Argue the case against the blanket rule. When does an unbounded mailbox legitimately beat a bounded one, and what cost does the blanket rule pay in producer-throughput behaviour, debug complexity, or test-determinism?
14. Summary
A mailbox is a typed FIFO between concurrent processes. The producer put()s at the tail; the consumer get()s from the head; the queue decouples their rates. Bounded mailboxes model hardware back-pressure; typed mailboxes catch errors at compile time. peek() inspects without consuming; try_* are the non-blocking variants; num() reports the depth.
Defaults to memorise. Always typed (mailbox #(MyType)). Always bounded if the producer can outrun the consumer. Always allocate a fresh new() per put() to avoid handle aliasing, or clone() if you must keep the local handle. Wrap test-level blocking get() / put() calls in fork ... join_any with a watchdog — a hung mailbox is harder to debug than a failed test.
This closes Module 13. Across the three IPC primitives:
- Events signal moments — broadcast, no data, no history. Use
wait(triggered)over@for one-shot triggers. - Semaphores gate access — counting credits, atomic multi-key, no fairness guarantee. Single global lock order per environment.
- Mailboxes carry items — typed FIFO, optional back-pressure, FIFO wake-up order. The shape every UVM TLM port abstracts over.
The next module covers Process Control — fork-join, fork-join_any, fork-join_none, disable fork, wait fork, and the process class.