The interviewer slides the marker across. Here is a stream DMA: words come in, each one is XORed with a key register, words go out. Write me the scoreboard. Twenty minutes. Ten minutes later you have a tidy class with a queue and a compare. Then the questions start. What happens when an output word arrives with nothing expected? When the test ends with words still in flight? When the test sends nothing at all? How would a colleague extend this through a factory override? The code compiled in your head. It just did not answer any of the questions that decide the grade.
This lesson covers the five pieces senior coding rounds ask for most: a scoreboard, a monitor, a driver loop, a register adapter and a virtual sequence. For each one: what to write in twenty minutes, what to say while you write, and the bugs interviewers plant or wait for you to make. The reference solutions are the real classes from the course bench, compiled and run by every test in this course. When you can produce them from memory, practise against the clock in UVM Coding Practice.
A driving examiner already knows you can turn the wheel. They watch your mirror checks, your speed at junctions and what you do when a child runs out. A coding interviewer already knows you can write a class. They watch for the decisions: where the expected value comes from, what happens at the edges (empty queue, end of test, reset), whether the protocol handshake is right, and whether you say why as you go. A missing semicolon is forgiven at once. A scoreboard that predicts from the stimulus sequence is not.
So narrate. Say the contract before you write the code ("this monitor publishes one new item per accepted beat"), write the skeleton, fill the core, then spend the last minutes on the edges and on naming what you left out. The Whiteboard Delivery Framework drills that rhythm.
Drill | Write in 20 minutes | Say while writing | Bugs interviewers plant |
|---|---|---|---|
Scoreboard | Three inputs, a prediction queue, in-order compare, end-of-test checks | "Predict from observed traffic, never from the sequence" | Non-virtual write, |
Monitor | vif from config_db, clocking-block sampling, handshake-qualified capture, new item per beat | "Fresh object every time; monitors never raise objections" | One reused item, sampling on VALID alone, sampling outside the clocking block |
Driver loop | Reset wait, get_next_item, hold VALID until READY, item_done exactly once | "item_done on every path, with no argument unless someone reads responses" | Missing item_done on a drop path, VALID dropped without READY, |
Reg adapter | Constructor, reg2bus, bus2reg with read data and status | "provides_responses is 0 because the driver fills the request" | Reads returning the write-data field, status always OK, no constructor |
Virtual sequence | Handles to agent sequencers, fork of parallel children, interrupt handler with grab | "No virtual sequencer needed; the test hands me the handles" | No stop for the background poller, no ungrab, |
Write three inputs, because the expected output depends on two things the bench observes: the KEY register write on APB and each input word on the stream. Each input word becomes a prediction, data ^ key, on a queue; each output word pops the oldest prediction and compares. Then two end-of-test checks that most candidates forget: words still expected, and nothing compared at all. The three uvm_analysis_imp_decl(_apb), (_in) and (_out) lines sit above the class in the bench; mention them as you draw the inputs.
// snippet: from the course DMA bench
// Predicts every output word from the input word and the KEY register the
// DUT was programmed with, then compares in order. The write_* methods are
// virtual so a derived scoreboard (a factory override) can extend them.
class dma_scoreboard extends uvm_scoreboard;
`uvm_component_utils(dma_scoreboard)
uvm_analysis_imp_apb #(apb_item, dma_scoreboard) apb_export;
uvm_analysis_imp_in #(axis_item, dma_scoreboard) in_export;
uvm_analysis_imp_out #(axis_item, dma_scoreboard) out_export;
bit [31:0] key;
bit [31:0] expected[$];
int unsigned n_match, n_mismatch;
function new(string name, uvm_component parent);
super.new(name, parent);
apb_export = new("apb_export", this);
in_export = new("in_export", this);
out_export = new("out_export", this);
endfunction
virtual function void write_apb(apb_item t);
if (t.write && !t.slverr && t.addr == KEY) key = t.data;
endfunction
virtual function void write_in(axis_item t);
expected.push_back(t.data ^ key);
endfunction
virtual function void write_out(axis_item t);
bit [31:0] exp;
if (expected.size() == 0) begin
`uvm_error("SB_UNEXP", $sformatf("output word %08h with nothing expected", t.data))
n_mismatch++;
return;
end
exp = expected.pop_front();
if (t.data !== exp) begin
`uvm_error("SB_MISMATCH", $sformatf("expected %08h, got %08h", exp, t.data))
n_mismatch++;
end else begin
n_match++;
end
endfunction
function void check_phase(uvm_phase phase);
if (expected.size() != 0)
`uvm_error("SB_LEFTOVER", $sformatf("%0d expected word(s) never came out", expected.size()))
if (n_match == 0)
`uvm_error("SB_EMPTY", "no words were compared: the test checked nothing")
endfunction
function void report_phase(uvm_phase phase);
`uvm_info("SB", $sformatf("%0d words matched, %0d mismatched", n_match, n_mismatch), UVM_LOW)
endfunction
endclassSay: "KEY comes from the APB monitor, filtered on write, no error and the right address, so a write that failed on the bus does not change the prediction."
Say: "write_* are virtual, so a derived scoreboard installed with a factory override can extend them."
Say: "SB_EMPTY turns a test that checked nothing into a failure. Zero compares usually means the stimulus never ran or the test ended early."
// bad-example: three planted bugs in a scoreboard
function void write_out(axis_item t); // not virtual: a factory override's version never runs
bit [31:0] exp = expected.pop_front(); // an extra output word pops an empty queue
if (t.data != exp) // != with an X operand is X, and if (X) takes the else
`uvm_error("SB_MISMATCH", "mismatch")
endfunctionEach one hides a failure. The non-virtual method is war story 2 of this course: an overridden scoreboard whose extra checks never ran, because the analysis imp calls the method through the base class type. Popping an empty queue returns zero (a simulator may warn), so an unexpected word is compared against garbage instead of being reported as unexpected. And != on four-state data lets an X output word pass as a match; !== compares X and Z exactly.
The monitor gets its virtual interface from config_db with a fatal if it is missing, samples through the interface's monitor clocking block, and publishes only on a beat that really moved: TVALID and TREADY. It creates a new item for every beat, because subscribers store what they receive.
// snippet: from the course DMA bench
class axis_monitor extends uvm_monitor;
`uvm_component_utils(axis_monitor)
virtual axis_if vif;
uvm_analysis_port #(axis_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);
super.build_phase(phase);
if (!uvm_config_db#(virtual axis_if)::get(this, "", "vif", vif))
`uvm_fatal("NOVIF", "axis_if not set for this monitor")
endfunction
task run_phase(uvm_phase phase);
forever begin
@(vif.mon_cb);
if (vif.mon_cb.tvalid && vif.mon_cb.tready) begin
axis_item it = axis_item::type_id::create("it");
it.data = vif.mon_cb.tdata;
it.last = vif.mon_cb.tlast;
`uvm_info("AXIS_MON", it.convert2string(), UVM_HIGH)
ap.write(it);
end
end
endtask
endclass// bad-example: a monitor with three planted bugs
task run_phase(uvm_phase phase);
axis_item it = axis_item::type_id::create("it"); // one object for the whole run
forever begin
@(posedge vif.clk); // samples outside the clocking block
if (vif.tvalid) begin // no TREADY: a stalled beat is published every clock
it.data = vif.tdata;
ap.write(it); // every stored copy now changes on the next beat
end
end
endtaskIf the interviewer upgrades the drill to an AXI write-data monitor, the same rules apply with two more: index by beat and mask by strobe. The snippet below is the core loop; a narrow transfer would also offset by the address lane.
// snippet
do begin
@(posedge vif.aclk iff (vif.wvalid && vif.wready)); // accepted beats only
for (int i = 0; i < BUS_BYTES; i++)
if (vif.wstrb[i]) t.data[(beat*BUS_BYTES + i)*8 +: 8] = vif.wdata[i*8 +: 8];
last = vif.wlast;
beat++;
end while (!last);The driver waits for reset to end, then loops: get an item, drive it at the clock edge with nonblocking assignments, hold TVALID until TREADY, and call item_done() on every path, including the path where a derived driver drops the beat. The pre_drive hook is what lets a factory override inject errors without copying this loop.
// snippet: from the course DMA bench
class axis_driver extends uvm_driver #(axis_item);
`uvm_component_utils(axis_driver)
virtual axis_if vif;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
super.build_phase(phase);
if (!uvm_config_db#(virtual axis_if)::get(this, "", "vif", vif))
`uvm_fatal("NOVIF", "axis_if not set for this driver")
endfunction
task run_phase(uvm_phase phase);
vif.tvalid <= 1'b0;
@(posedge vif.rst_n);
forever begin
axis_item beat;
seq_item_port.get_next_item(req);
beat = pre_drive(req);
if (beat != null) begin
repeat (beat.idle) @(posedge vif.clk);
@(posedge vif.clk);
vif.tdata <= beat.data;
vif.tlast <= beat.last;
vif.tvalid <= 1'b1;
do @(posedge vif.clk); while (!vif.tready); // the beat moves on TVALID && TREADY
vif.tvalid <= 1'b0;
end
seq_item_port.item_done();
end
endtask
// Hook for derived drivers: return the beat to drive (the sequence's item, or a
// modified copy), or null to drop it. The base driver drives every item as is.
virtual function axis_item pre_drive(axis_item it);
return it;
endfunction
endclass// bad-example: a driver loop with three planted bugs
forever begin
seq_item_port.get_next_item(req);
if (req.idle > 15) continue; // dropped beat: no item_done, the next call errors
@(posedge vif.clk);
vif.tdata <= req.data;
vif.tvalid <= 1'b1;
@(posedge vif.clk); // one clock, whether or not TREADY was high
vif.tvalid <= 1'b0;
seq_item_port.item_done(req); // queues a response nobody reads
endThe first bug produces the log in the Debug Round: "Get_next_item called twice without item_done". The second loses beats whenever the DUT applies backpressure. The third fills the sequence's response queue, which holds 8 by default: UVM 1.2 reports an overflow error from the ninth item, while the IEEE 1800.2 reference library drops the extras silently unless the report is switched on.
Expect the follow-up can this driver send back-to-back beats? Honestly, no: the reference driver drops TVALID after every beat, so a zero-gap stream never happens. The course's coverage model exposed exactly that (the gap=0 hole in Closure and Sign-off Questions). The fix is to keep TVALID high when the next item is already waiting, for example by checking with try_next_item before dropping it.
Write the constructor first, because it carries the contract: provides_responses = 0, since the APB driver writes rdata and slverr back into the request item and there is no separate response. reg2bus turns a register operation into an APB item. bus2reg turns an observed or completed item back into an operation, taking read data from rdata and turning PSLVERR into UVM_NOT_OK.
// snippet: from the course DMA bench
// Translates between register operations and APB items. The APB driver writes
// read data and PSLVERR back into the request item, so there is no separate
// response: provides_responses = 0.
class apb_reg_adapter extends uvm_reg_adapter;
`uvm_object_utils(apb_reg_adapter)
function new(string name = "apb_reg_adapter");
super.new(name);
supports_byte_enable = 0;
provides_responses = 0;
endfunction
virtual function uvm_sequence_item reg2bus(const ref uvm_reg_bus_op rw);
apb_item it = apb_item::type_id::create("it");
it.addr = rw.addr;
it.write = (rw.kind == UVM_WRITE) || (rw.kind == UVM_BURST_WRITE);
it.data = rw.data;
return it;
endfunction
virtual function void bus2reg(uvm_sequence_item bus_item, ref uvm_reg_bus_op rw);
apb_item it;
if (!$cast(it, bus_item))
`uvm_fatal("NOT_APB", "bus2reg got an item that is not an apb_item")
rw.kind = it.write ? UVM_WRITE : UVM_READ;
rw.addr = it.addr;
rw.data = it.write ? it.data : it.rdata;
rw.status = it.slverr ? UVM_NOT_OK : UVM_IS_OK; // a bus error is a status, not a fatal
endfunction
endclass// bad-example: bus2reg with three planted bugs
virtual function void bus2reg(uvm_sequence_item bus_item, ref uvm_reg_bus_op rw);
apb_item it;
void'($cast(it, bus_item)); // a wrong item type passes silently
rw.kind = it.write ? UVM_WRITE : UVM_READ;
rw.addr = it.addr;
rw.data = it.data; // reads return the request's data field, not rdata
rw.status = UVM_IS_OK; // PSLVERR never reaches the register model
endfunctionSay why the cast failure is a fatal and the slave error is not: the adapter only ever receives APB items, so a different type is a wiring bug that must stop the run, while PSLVERR is a legal bus outcome that the register model reports as a status. If the interviewer asks for provides_responses = 1, explain that the driver would then return a separate response with put_response, and without it the register sequence waits forever.
The bench's job sequence has no virtual sequencer. The test hands it the two agent sequencers and the interrupt interface, then starts it with start(null). Inside, three children run in parallel: a background poller on APB, the data packet on the stream, and an interrupt handler that grabs the APB sequencer so its accesses jump ahead of queued polls.
// snippet: from the course DMA bench
// The interrupt handler: grab the APB sequencer so the handler's accesses go
// ahead of any queued polling, read STATUS, clear DONE/ERR, then let go.
class dma_irq_seq extends uvm_sequence #(apb_item);
`uvm_object_utils(dma_irq_seq)
bit [31:0] status;
function new(string name = "dma_irq_seq");
super.new(name);
endfunction
task access(bit [7:0] addr, bit wr, bit [31:0] data, output bit [31:0] rdata);
apb_item it = apb_item::type_id::create("it");
start_item(it);
if (!it.randomize() with { addr == local::addr; write == wr; data == local::data; })
`uvm_fatal("RAND", "apb_item randomize failed")
finish_item(it);
rdata = it.rdata;
endtask
task body();
bit [31:0] unused;
grab(); // jump the queue, hold the sequencer
`uvm_info("IRQ", "handler has the APB sequencer", UVM_MEDIUM)
access(STATUS, 0, 0, status);
access(STATUS, 1, 32'h6, unused); // write 1 to clear DONE and ERR
ungrab();
endtask
endclass
// One whole DMA job across two agents. It has no sequencer of its own: the
// test hands it the two agent sequencers and the interrupt interface, and
// starts it with start(null).
class dma_vseq extends uvm_sequence;
`uvm_object_utils(dma_vseq)
apb_sequencer apb_sqr;
axis_sequencer axis_sqr;
virtual irq_if irq_vif;
rand bit [31:0] key;
rand int unsigned len;
constraint len_c { len inside {[2:16]}; }
bit [31:0] status; // STATUS as read by the interrupt handler
function new(string name = "dma_vseq");
super.new(name);
endfunction
task write_reg(bit [7:0] addr, bit [31:0] data);
apb_rw_seq s = apb_rw_seq::type_id::create("s");
s.addr = addr; s.write = 1; s.data = data;
s.start(apb_sqr, this); // this vseq is the parent
endtask
task body();
apb_poll_seq poll = apb_poll_seq::type_id::create("poll");
axis_pkt_seq pkt = axis_pkt_seq::type_id::create("pkt");
dma_irq_seq isr = dma_irq_seq::type_id::create("isr");
if (apb_sqr == null || axis_sqr == null)
`uvm_fatal("VSEQ", "set apb_sqr and axis_sqr before starting")
write_reg(KEY, key);
write_reg(LEN, len);
write_reg(CTRL, 32'h3); // IRQ_EN | GO
if (!pkt.randomize() with { n == len; }) `uvm_fatal("RAND", "axis_pkt_seq randomize failed")
fork
poll.start(apb_sqr, this); // background polling
pkt.start(axis_sqr, this); // the data, in parallel
begin // the interrupt handler
wait (irq_vif.irq === 1'b1);
isr.start(apb_sqr, this);
status = isr.status;
poll.stop = 1;
end
join
`uvm_info("VSEQ", $sformatf("job done: STATUS=%08h after %0d polls", status, poll.polls), UVM_LOW)
endtask
endclassPlanted bug: no `poll.stop = 1`. The poller loops forever, so join never completes and the test hangs until the timeout.
Planted bug: no `ungrab()`. In the UVM reference library the lock is removed when the handler sequence ends, with a SEQFINERR error. If the handler waited for anything after its accesses, every poll would be frozen for that time.
Planted bug: `join_none` and return. The virtual sequence returns before the job is done, and the test drops its objection with words still in flight.
+UVM_TESTNAME=dma_vseq_test (+UVM_VERBOSITY=UVM_MEDIUM)
UVM_INFO dma_tb.sv(572) @ 405000: uvm_test_top.env.apb.sqr@@vseq.isr [IRQ] handler has the APB sequencer
UVM_INFO dma_tb.sv(623) @ 575000: reporter@@vseq [VSEQ] job done: STATUS=00000002 after 4 polls
UVM_INFO dma_tb.sv(411) @ 675000: uvm_test_top.env.sb [SB] 6 words matched, 0 mismatched
UVM_ERROR : 0`apb.sqr@@vseq.isr` is the handler running on the APB sequencer as a child of vseq, because each child was started with this as its parent.
`reporter@@vseq`: the virtual sequence was started on a null sequencer, so its messages come from the global reporter.
STATUS=00000002 after 4 polls: DONE was set, and the poller ran four times before the handler stopped it.
Every write() passes the same handle. Subscribers that store it all point at one object, which the monitor overwrites on each beat, so stored entries change after the fact. Create a new item per beat (or clone before writing).
bus2reg. It most likely copies it.data (the request's write-data field) into rw.data for reads instead of it.rdata. The bus is right, the translation back is wrong.
A driver problem. If the driver always drops VALID between beats, no seed and no constraint can produce a zero gap. Fix the agent so it keeps VALID high when another item is ready, then the coverage hole can close.
Next: these bugs produce real logs. In Debug Round: Find the Bug in This Log the interviewer hands you the tail of one and asks what happened.