Part 6 · Testbench Architecture · Intermediate
Phasing a Hand-Built TB
Reset, config, main, drain, and report phases by convention; env-orchestrated run(); fork-join of component loops; clean shutdown.
Phases by convention
UVM enforces phases with machinery; a hand-built bench enforces them with convention plus one orchestrating task . The env owns a run() task that calls per-phase tasks strictly in order — components never decide for themselves when a phase starts. Five phases cover almost every bench:
reset_phase — assert/deassert DUT reset, clear TB state (model state, scoreboard queues, counters).
config_phase — program DUT registers and TB knobs before any traffic; zero-time or bus-config traffic only.
main_phase — start all component run() loops, run stimulus to completion.
drain_phase — stimulus done; wait for in-flight transactions to flush (activity-window or objection-based).
report_phase — final checks, statistics, PASS/FAIL banner; zero-time, functions only.
ENV-ORCHESTRATED PHASE TIMELINE
time ──────────────────────────────────────────────────────►
reset ─► config ─► main ─────────────────────► drain ─► report
│ │ │ │ │
│ │ fork: gen.run() │ scb.final_check()
│ │ drv.run() │ cov.report()
│ │ mon.run() │ banner
│ │ scb.run() │
│ │ join_none (free-running) │
│ │ wait: stimulus complete │
│ │ │
rst=1 regs monitors/scb keep running idle-window
N cycles written through drain ───────────────┘
rst=0The orchestrating run() task
class env;
generator gen;
driver drv;
bus_monitor mon_in, mon_out;
ref_model mdl;
scoreboard scb;
virtual bus_if vif;
task run();
reset_phase();
config_phase();
main_phase();
drain_phase();
report_phase();
endtask
task reset_phase();
vif.rst_n <= 0;
scb.flush(); mdl.reset(); // TB state mirrors DUT reset
repeat (10) @(posedge vif.clk);
vif.rst_n <= 1;
repeat (3) @(posedge vif.clk); // post-reset settle
endtask
task config_phase();
drv.write_reg(CTRL_ADDR, 32'h0000_0001); // enable DUT
drv.write_reg(MODE_ADDR, cfg.mode);
endtask
task main_phase();
fork : main_threads
drv.run(); // forever loops — never return
mon_in.run();
mon_out.run();
mdl.run();
scb.run();
join_none
gen.run(); // returns when last item generated
wait (drv.items_driven == gen.n_items); // true stimulus completion
endtask
task drain_phase();
int idle = 0;
int unsigned seen = mon_out.txn_count;
while (idle < 100) begin
@(posedge vif.clk);
if (mon_out.txn_count != seen) begin seen = mon_out.txn_count; idle = 0; end
else idle++;
end
disable main_threads; // clean shutdown of forever loops
endtask
task report_phase();
scb.final_check();
scb.report();
endtask
endclassCode walkthrough
Service components (driver, monitors, model, scoreboard) are forever loops started with fork...join_none — they never return, so nothing may join on them.
The generator is the only run() that returns; main_phase then waits on the driver's completion counter, not the generator's return.
Monitors and scoreboard stay alive through drain_phase — that is the whole point of draining.
disable main_threads only after the idle window proves quiescence; killing threads mid-transaction corrupts the last compares.
reset_phase resets TB state too — a scoreboard holding pre-reset expected entries fails every post-reset compare.
Clean shutdown and mid-test reset
The labeled fork : main_threads plus disable main_threads idiom is the standard kill switch, but it is a blunt one: threads die wherever they happen to be. The discipline that makes it safe is ordering — only disable after quiescence is proven, and never hold a semaphore or a bounded-mailbox slot across a point where the thread may be killed. For benches that need mid-test reset , the same machinery loops: detect reset, disable the traffic threads, flush mailboxes and TB state, then re-fork and continue — which is exactly why TB state cleanup lives in reset_phase rather than scattered through the components.
Label every fork you may need to kill — disable fork (unlabeled) in a class context can kill more than you intended, including innocent sibling threads.
Flush mailboxes after a kill: a dead consumer leaves items that the next phase would misinterpret as live traffic.
Keep report_phase function-only — anything time-consuming there delays $finish and can race the watchdog.
Interview angle
The classic prompt is "You have no UVM — how do you structure a test?" Walk the five phases in order, name who starts the forever loops (env, fork...join_none), what defines stimulus completion (driver counter, not generator return), and when threads are killed (after the drain idle-window). This maps one-to-one onto UVM's run-time phases, which is exactly the point interviewers want you to make.
Key takeaways
One env-owned run() task calls reset/config/main/drain/report in order — components never self-phase.
Forever-loop components start under fork...join_none; only the generator returns.
Stimulus completion = driver's counter reaching the generated count, never the generator returning.
disable a labeled fork only after quiescence — and reset TB state wherever you reset the DUT.
Common pitfalls
join (not join_none) on forever loops — main_phase hangs forever on threads that never return.
Killing monitor/scoreboard threads at end of stimulus — in-flight transactions are never checked.
Forgetting scb.flush()/mdl.reset() in reset_phase — stale expected entries fail every compare after reset.
Unlabeled disable fork — kills sibling threads (e.g., the watchdog) you meant to keep alive.