Before the code
This is written to teach the structure clearly, not as a drop-in production testbench — signal names, reset handling, and timing are simplified on purpose. The shape of it — one item class, two agents, one scoreboard with two named analysis inputs — is exactly the shape a real one takes.
1. The DUT interface
One interface bundles every signal on both sides of the FIFO, so the testbench only ever needs to pass around one handle instead of dozens of individual wires.
interface fifo_if (input bit wr_clk, input bit rd_clk);
logic wr_rst_n;
logic wr_en;
logic [7:0] wr_data;
logic full;
logic rd_rst_n;
logic rd_en;
logic [7:0] rd_data;
logic empty;
endinterface
2. One sequence item for both sides
The write side cares about data. The read side just needs "a request to pop one item" — so both sides share the same item class; the read sequence simply never uses the data field.
class fifo_item extends uvm_sequence_item;
rand bit [7:0] data;
`uvm_object_utils(fifo_item)
function new(string name = "fifo_item");
super.new(name);
endfunction
endclass
3. Write side: sequence, driver, monitor
The sequence randomizes 50 write items. The driver waits out full before asserting wr_en for exactly one cycle. The monitor watches the same pins passively and reports every write that actually happened.
class fifo_wr_seq extends uvm_sequence #(fifo_item);
`uvm_object_utils(fifo_wr_seq)
function new(string name = "fifo_wr_seq");
super.new(name);
endfunction
task body();
fifo_item item;
repeat (50) begin
item = fifo_item::type_id::create("item");
start_item(item);
item.randomize();
finish_item(item);
end
endtask
endclass
class fifo_wr_driver extends uvm_driver #(fifo_item);
`uvm_component_utils(fifo_wr_driver)
virtual fifo_if vif;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
if (!uvm_config_db #(virtual fifo_if)::get(this, "", "vif", vif))
`uvm_fatal("NOVIF", "virtual interface not set for fifo_wr_driver")
endfunction
task run_phase(uvm_phase phase);
vif.wr_en <= 1'b0;
@(posedge vif.wr_rst_n);
forever begin
seq_item_port.get_next_item(req);
@(posedge vif.wr_clk);
while (vif.full) @(posedge vif.wr_clk);
vif.wr_en <= 1'b1;
vif.wr_data <= req.data;
@(posedge vif.wr_clk);
vif.wr_en <= 1'b0;
seq_item_port.item_done();
end
endtask
endclass
class fifo_wr_monitor extends uvm_monitor;
`uvm_component_utils(fifo_wr_monitor)
virtual fifo_if vif;
uvm_analysis_port #(fifo_item) ap;
function new(string name, uvm_component parent);
super.new(name, parent);
ap = new("ap", this);
endfunction
function void build_phase(uvm_phase phase);
if (!uvm_config_db #(virtual fifo_if)::get(this, "", "vif", vif))
`uvm_fatal("NOVIF", "virtual interface not set for fifo_wr_monitor")
endfunction
task run_phase(uvm_phase phase);
fifo_item item;
forever begin
@(posedge vif.wr_clk);
if (vif.wr_en && !vif.full) begin
item = fifo_item::type_id::create("item");
item.data = vif.wr_data;
ap.write(item);
end
end
endtask
endclass
4. Read side: sequence, driver, monitor
Same shape, mirrored. The read sequence just asks for 50 pops — the item's data field is irrelevant here, it's only carrying "please read one now."
class fifo_rd_seq extends uvm_sequence #(fifo_item);
`uvm_object_utils(fifo_rd_seq)
function new(string name = "fifo_rd_seq");
super.new(name);
endfunction
task body();
fifo_item item;
repeat (50) begin
item = fifo_item::type_id::create("item");
start_item(item);
finish_item(item);
end
endtask
endclass
class fifo_rd_driver extends uvm_driver #(fifo_item);
`uvm_component_utils(fifo_rd_driver)
virtual fifo_if vif;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
if (!uvm_config_db #(virtual fifo_if)::get(this, "", "vif", vif))
`uvm_fatal("NOVIF", "virtual interface not set for fifo_rd_driver")
endfunction
task run_phase(uvm_phase phase);
vif.rd_en <= 1'b0;
@(posedge vif.rd_rst_n);
forever begin
seq_item_port.get_next_item(req);
@(posedge vif.rd_clk);
while (vif.empty) @(posedge vif.rd_clk);
vif.rd_en <= 1'b1;
@(posedge vif.rd_clk);
vif.rd_en <= 1'b0;
seq_item_port.item_done();
end
endtask
endclass
class fifo_rd_monitor extends uvm_monitor;
`uvm_component_utils(fifo_rd_monitor)
virtual fifo_if vif;
uvm_analysis_port #(fifo_item) ap;
function new(string name, uvm_component parent);
super.new(name, parent);
ap = new("ap", this);
endfunction
function void build_phase(uvm_phase phase);
if (!uvm_config_db #(virtual fifo_if)::get(this, "", "vif", vif))
`uvm_fatal("NOVIF", "virtual interface not set for fifo_rd_monitor")
endfunction
task run_phase(uvm_phase phase);
fifo_item item;
forever begin
@(posedge vif.rd_clk);
if (vif.rd_en && !vif.empty) begin
item = fifo_item::type_id::create("item");
item.data = vif.rd_data;
ap.write(item);
end
end
endtask
endclass
5. The scoreboard: where correctness is decided
This is the one place a beginner's first attempt usually trips up. A scoreboard needs to tell a write-side transaction apart from a read-side one, but the standard uvm_analysis_imp always calls a method named write() — so two of them on the same class would collide. The fix is the `uvm_analysis_imp_decl macro, which mints a distinctly-named analysis import for each side, calling write_wr() and write_rd() instead of both fighting over the same write().
`uvm_analysis_imp_decl(_wr)
`uvm_analysis_imp_decl(_rd)
class fifo_scoreboard extends uvm_component;
`uvm_component_utils(fifo_scoreboard)
uvm_analysis_imp_wr #(fifo_item, fifo_scoreboard) wr_imp;
uvm_analysis_imp_rd #(fifo_item, fifo_scoreboard) rd_imp;
fifo_item wr_q[$];
function new(string name, uvm_component parent);
super.new(name, parent);
wr_imp = new("wr_imp", this);
rd_imp = new("rd_imp", this);
endfunction
function void write_wr(fifo_item item);
wr_q.push_back(item);
endfunction
function void write_rd(fifo_item item);
fifo_item expected;
if (wr_q.size() == 0) begin
`uvm_error("SCBD", "read observed with nothing pending in the write queue")
return;
end
expected = wr_q.pop_front();
if (item.data !== expected.data)
`uvm_error("SCBD", $sformatf("mismatch: expected %0h, got %0h", expected.data, item.data))
else
`uvm_info("SCBD", $sformatf("match: %0h", item.data), UVM_HIGH)
endfunction
function void check_phase(uvm_phase phase);
if (wr_q.size() != 0)
`uvm_error("SCBD", $sformatf("%0d writes were never read back", wr_q.size()))
endfunction
endclass
This is the black-box scoreboard from Part 9's plan: it never looks at an internal pointer, only at what went in and what came out, in order. That's already enough to catch lost data, corrupted data, and reordering — the core functional claim from Part 9.
6. Wiring it together: env and test
The env creates both sides and the scoreboard, then connects them. The test starts both sequences at once, in fork...join, because the write side and read side are genuinely independent — exactly the point of an async FIFO.
class fifo_env extends uvm_env;
`uvm_component_utils(fifo_env)
uvm_sequencer #(fifo_item) wr_sqr;
fifo_wr_driver wr_drv;
fifo_wr_monitor wr_mon;
uvm_sequencer #(fifo_item) rd_sqr;
fifo_rd_driver rd_drv;
fifo_rd_monitor rd_mon;
fifo_scoreboard scbd;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
wr_sqr = uvm_sequencer #(fifo_item)::type_id::create("wr_sqr", this);
wr_drv = fifo_wr_driver::type_id::create("wr_drv", this);
wr_mon = fifo_wr_monitor::type_id::create("wr_mon", this);
rd_sqr = uvm_sequencer #(fifo_item)::type_id::create("rd_sqr", this);
rd_drv = fifo_rd_driver::type_id::create("rd_drv", this);
rd_mon = fifo_rd_monitor::type_id::create("rd_mon", this);
scbd = fifo_scoreboard::type_id::create("scbd", this);
endfunction
function void connect_phase(uvm_phase phase);
wr_drv.seq_item_port.connect(wr_sqr.seq_item_export);
rd_drv.seq_item_port.connect(rd_sqr.seq_item_export);
wr_mon.ap.connect(scbd.wr_imp);
rd_mon.ap.connect(scbd.rd_imp);
endfunction
endclass
class fifo_base_test extends uvm_test;
`uvm_component_utils(fifo_base_test)
fifo_env env;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
env = fifo_env::type_id::create("env", this);
endfunction
task run_phase(uvm_phase phase);
fifo_wr_seq wseq;
fifo_rd_seq rseq;
phase.raise_objection(this);
wseq = fifo_wr_seq::type_id::create("wseq");
rseq = fifo_rd_seq::type_id::create("rseq");
fork
wseq.start(env.wr_sqr);
rseq.start(env.rd_sqr);
join
phase.drop_objection(this);
endtask
endclass
One wiring step happens outside any UVM class, in the top-level testbench module: the virtual interface handle has to be handed into the UVM world once, before run_test() is called — uvm_config_db#(virtual fifo_if)::set(null, "*", "vif", fifo_if_inst); — which is exactly why every driver and monitor above starts its build_phase by fetching that same handle back out with get().
What this does, and doesn't, prove yet
Run this as-is, and the scoreboard will catch any data loss, corruption, or reordering the DUT commits over 50 essentially-simultaneous write and read sequences — the core claim from Part 9. It does not yet deliberately hit the clock-ratio extremes, the exact-full and exact-empty boundaries, or the reset scenarios that same plan called out. That's the next step past this post: parameterizing the write and read clock periods per test, adding directed sequences for the boundary cases, and layering functional coverage on top so "we tested the corner cases" becomes a number you can actually check, not a hope.
Takeaway
Nothing in this testbench is new relative to the rest of this series — it's Part 6's components, Part 7's phasing, and Part 9's plan, typed out. That's deliberate: once the structure is familiar, applying it to a different DUT next time is mostly a matter of swapping the interface and the scoreboard's comparison logic.
That's the whole series, start to finish. If you want to go from reading this to writing and running it yourself — with actual simulator feedback, not just reading code on a page — that's what the hands-on verification course covers in far more depth. See the course breakdown, or ask us a question if you're not sure it's the right fit yet.