Example Walkthrough: A Hand-Rolled FIFO Testbench
Sections A through E built this exact testbench piece by piece — transaction, generator, driver, monitor, reference model, scoreboard, an assertion-based protocol checker, and the environment/test tying all of it together. This page is the capstone: every piece, shown together, in the order it'd actually be designed — no UVM base classes, no factory, no config_db, no phasing, anywhere in it.
Like every capstone on this site, this code is written to be syntactically correct and conceptually complete, but it hasn't been run through a simulator — a reference for how the pieces fit together, not a drop-in verified testbench.
The DUT and interface
interface fifo_if (input bit clk, input bit rst_n);
logic wr_en;
logic [7:0] wr_data;
logic full;
logic rd_en;
logic [7:0] rd_data;
logic empty;
endinterface
The transaction
class fifo_txn;
typedef enum {WRITE, READ} op_e;
rand op_e op;
rand bit [7:0] wr_data;
bit [7:0] rd_data;
function void display();
$display("op=%s wr_data=%0h rd_data=%0h", op.name(), wr_data, rd_data);
endfunction
endclass
The generator
class generator;
mailbox #(fifo_txn) gen2drv;
int num_txns;
function new(mailbox #(fifo_txn) gen2drv, int num_txns);
this.gen2drv = gen2drv;
this.num_txns = num_txns;
endfunction
task run();
repeat (num_txns) begin
fifo_txn txn = new();
if (!txn.randomize())
$error("Randomization failed");
gen2drv.put(txn);
end
endtask
endclass
The driver
class driver;
virtual fifo_if vif;
mailbox #(fifo_txn) gen2drv;
function new(virtual fifo_if vif, mailbox #(fifo_txn) gen2drv);
this.vif = vif;
this.gen2drv = gen2drv;
endfunction
task run();
vif.wr_en <= 0;
vif.rd_en <= 0;
forever begin
fifo_txn txn;
gen2drv.get(txn);
@(posedge vif.clk);
if (txn.op == fifo_txn::WRITE) begin
vif.wr_en <= 1;
vif.wr_data <= txn.wr_data;
end else begin
vif.rd_en <= 1;
end
@(posedge vif.clk);
vif.wr_en <= 0;
vif.rd_en <= 0;
end
endtask
endclass
The monitor
class monitor;
virtual fifo_if vif;
mailbox #(fifo_txn) mon2sb;
function new(virtual fifo_if vif, mailbox #(fifo_txn) mon2sb);
this.vif = vif;
this.mon2sb = mon2sb;
endfunction
task run();
forever begin
@(posedge vif.clk);
if (vif.wr_en && !vif.full) begin
fifo_txn txn = new();
txn.op = fifo_txn::WRITE;
txn.wr_data = vif.wr_data;
mon2sb.put(txn);
end
if (vif.rd_en && !vif.empty) begin
fifo_txn txn = new();
txn.op = fifo_txn::READ;
txn.rd_data = vif.rd_data;
mon2sb.put(txn);
end
end
endtask
endclass
The reference model
class fifo_ref_model;
bit [7:0] model_q[$];
function void write(fifo_txn txn);
if (txn.op == fifo_txn::WRITE)
model_q.push_back(txn.wr_data);
endfunction
function bit [7:0] predict_read();
if (model_q.size() == 0) begin
$error("Reference model: read predicted with nothing queued");
return 'x;
end
return model_q.pop_front();
endfunction
endclass
The scoreboard, with coverage
class scoreboard;
mailbox #(fifo_txn) mon2sb;
fifo_ref_model model;
int pass_count = 0;
int fail_count = 0;
fifo_txn current_txn;
covergroup fifo_cg;
coverpoint current_txn.op;
endgroup
function new(mailbox #(fifo_txn) mon2sb, fifo_ref_model model);
this.mon2sb = mon2sb;
this.model = model;
fifo_cg = new();
endfunction
task run();
forever begin
mon2sb.get(current_txn);
fifo_cg.sample();
if (current_txn.op == fifo_txn::WRITE) begin
model.write(current_txn);
end else begin
bit [7:0] expected = model.predict_read();
if (current_txn.rd_data !== expected) begin
fail_count++;
$error("Mismatch: expected=%0h actual=%0h", expected, current_txn.rd_data);
end else begin
pass_count++;
end
end
end
endtask
endclass
The assertion-based protocol checker
module fifo_protocol_checker (
input bit clk,
input bit rst_n,
input logic full,
input logic empty
);
property full_empty_mutex;
@(posedge clk) disable iff (!rst_n) !(full && empty);
endproperty
assert property (full_empty_mutex)
else $error("Protocol violation: full and empty asserted simultaneously");
endmodule
bind sync_fifo fifo_protocol_checker checker_inst (
.clk(clk), .rst_n(rst_n), .full(full), .empty(empty)
);
The environment
class environment;
virtual fifo_if vif;
mailbox #(fifo_txn) gen2drv;
mailbox #(fifo_txn) mon2sb;
generator gen;
driver drv;
monitor mon;
fifo_ref_model model;
scoreboard sb;
function new(virtual fifo_if vif, int num_txns);
this.vif = vif;
gen2drv = new();
mon2sb = new();
gen = new(gen2drv, num_txns);
drv = new(vif, gen2drv);
mon = new(vif, mon2sb);
model = new();
sb = new(mon2sb, model);
endfunction
task run();
fork
drv.run();
mon.run();
sb.run();
join_none
gen.run();
endtask
endclass
Testbench-top
module tb_top;
bit clk, rst_n;
always #5 clk = ~clk;
fifo_if fifo_if_inst (.clk(clk), .rst_n(rst_n));
sync_fifo dut (
.clk (clk),
.rst_n (rst_n),
.wr_en (fifo_if_inst.wr_en),
.wr_data (fifo_if_inst.wr_data),
.full (fifo_if_inst.full),
.rd_en (fifo_if_inst.rd_en),
.rd_data (fifo_if_inst.rd_data),
.empty (fifo_if_inst.empty)
);
environment env;
initial begin
rst_n = 0;
#20 rst_n = 1;
env = new(fifo_if_inst, 20);
env.run();
#100;
$display("PASS=%0d FAIL=%0d coverage=%0.1f%%",
env.sb.pass_count, env.sb.fail_count, env.sb.fifo_cg.get_coverage());
$finish;
end
endmodule
How it all connects
tb_top's initial block releases reset, then constructs one environment and calls run(). Inside the constructor, every mailbox and component gets built and wired in one place — gen2drv connecting generator to driver, mon2sb connecting monitor to scoreboard, model connecting to scoreboard, vif reaching driver and monitor both. run() forks the driver, monitor, and scoreboard as background processes that live for the rest of the test, then runs the generator directly: it randomizes 20 transactions and put()s each one into gen2drv. The driver get()s them out one at a time and drives the FIFO's pins for one clock each; the monitor, watching those same pins independently, reconstructs what it observes and put()s it into mon2sb; the scoreboard get()s that, samples coverage, and — for reads — compares the observed data against the reference model's prediction. Independently of all of that, the bound fifo_protocol_checker watches full/empty every single cycle, regardless of whether anything else in the pipeline is even working. tb_top's #100 gives the last transactions time to drain before reporting a final pass/fail/coverage summary.
What this hand-rolled testbench maps onto in UVM
| This topic's component | UVM's standardized equivalent |
|---|---|
fifo_txn (no base class) | fifo_txn extends uvm_sequence_item |
generator | uvm_sequencer + a uvm_sequence |
driver (constructor-injected vif) | uvm_driver, vif via uvm_config_db |
monitor | uvm_monitor, broadcasting via uvm_analysis_port |
fifo_ref_model + scoreboard (two classes) | fifo_scoreboard (combined into one) |
environment (built in a constructor) | uvm_env's build_phase/connect_phase |
tb_top's #100 guess | uvm_test's raise_objection()/drop_objection() |
| Hardcoded/injected driver variant | Factory type_id::create() + set_type_override() |
Every row on the right already exists, fully built, on UVM's own page for that exact component — for the identical FIFO. Reading the two capstones side by side, component for component, is the fastest way to see precisely what standardization buys: not a different testbench, the same testbench, with every hand-rolled rough edge this topic named replaced by a named, general mechanism.
Foundations, stimulus generation, driving and observing, checking (data and protocol), environment composition, concurrency, coverage, and an honest accounting of where all of it breaks down at scale — 17 pages, one running FIFO example, built entirely without UVM. UVM picks up from exactly here.