An APB driver had passed every regression for a year, against a register block that always took one wait state. Then the design team removed the wait state. Overnight, a third of register writes stopped having any effect. The driver's log said every write was sent. The DUT's log said it never saw them. Nobody had changed the driver.
What exactly does a driver owe the bus, and how can a driver that "sends" every write still lose some?
A driver is the only component allowed to change the DUT's input pins. It pulls one transaction at a time from the sequencer and turns it into signal changes at the right clock edges, waiting for the DUT's handshakes. Think of a simultaneous interpreter: the sentence (the transaction) is fixed, but when each word is spoken depends on the listener, and a good interpreter waits until the listener has caught up before starting the next sentence.
A driver owns timing, nothing else. It does not decide what to send (that is the sequence), does not check what comes back (that is the scoreboard) and does not report what happened on the bus (that is the monitor). Two timing rules from the bench keep it out of trouble. It waits on @(posedge vif.clk), the same event every other bench thread uses. And it drives with nonblocking assignments (<=), so its changes land after the edge, exactly like a flip-flop output, and a value it reads right after the edge is the value from before it.
Setup phase (edge 1 to 2): PSEL=1, PENABLE=0, with address, direction and write data stable. It always lasts exactly one clock.
Access phase (edge 2 onward): PENABLE=1, everything else held, until the slave answers PREADY=1. The DMA adds one wait state, so this access takes two clocks.
Completion (edge 4): the transfer happens on the edge where PSEL, PENABLE and PREADY are all 1. Read data (PRDATA) and PSLVERR are valid in that same cycle, so that is where the driver captures them and where the monitor samples.
// snippet: from the course DMA bench
class apb_driver extends uvm_driver #(apb_item);
`uvm_component_utils(apb_driver)
virtual apb_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 apb_if)::get(this, "", "vif", vif))
`uvm_fatal("NOVIF", "apb_if not set for this driver")
endfunction
task run_phase(uvm_phase phase);
vif.psel <= 1'b0;
vif.penable <= 1'b0;
@(posedge vif.rst_n);
forever begin
seq_item_port.get_next_item(req);
drive(req);
seq_item_port.item_done(); // rdata travels back inside req itself
end
endtask
// Setup phase: PSEL=1, PENABLE=0 for one clock. Access phase: PENABLE=1, held
// until the slave answers PREADY=1; read data is valid in that cycle.
// Every wait is on the same event, @(posedge vif.clk), and every drive is a
// nonblocking assignment, so a value read right after the edge is the value
// from before it: the one the DUT sampled.
task drive(apb_item it);
@(posedge vif.clk);
vif.paddr <= it.addr;
vif.pwrite <= it.write;
vif.pwdata <= it.data;
vif.psel <= 1'b1;
vif.penable <= 1'b0;
@(posedge vif.clk);
vif.penable <= 1'b1;
do @(posedge vif.clk); while (!vif.pready);
it.rdata = vif.prdata;
it.slverr = vif.pslverr;
vif.psel <= 1'b0;
vif.penable <= 1'b0;
endtask
endclassThe run_phase first drives PSEL and PENABLE low and waits for rst_n to rise. A driver that starts pulling items at time 0 would drive a transfer into a DUT still in reset, where it is silently dropped. Parking the strobes first also means the bus is never X when reset releases.
get_next_item(req) blocks until a sequence offers an item; drive(req) performs the transfer; item_done() tells the sequence it is finished. There is no argument to item_done: read data goes back inside req, which the sequence still holds. The handshake itself, from both sides, is the subject of the driver handshake lesson; here the interest is what happens between the two calls.
drive() maps line for line onto the waveform. @(posedge vif.clk) then the setup values. @(posedge vif.clk) then PENABLE. Then do @(posedge vif.clk); while (!vif.pready);: wait at least one edge, then keep waiting while PREADY was low before that edge. When the loop exits, the transfer has completed on the edge just passed, so the task captures PRDATA and PSLVERR and parks the strobes. Every assignment to a pin is nonblocking; the two captures into it are blocking because it is a class variable, not a pin.
// 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
endclassSame shape, different protocol. The item carries an idle count, so the driver inserts that many clocks of TVALID=0 before the beat: randomized gaps are stimulus, so they come from the item, not from the driver. Then it holds TVALID with the data until it has seen TREADY on an edge, which is the AXI-Stream rule that a beat moves on TVALID and TREADY together.
The pre_drive function is the driver's extension point. It is virtual and returns the item to drive, a modified copy, or null to drop the beat. The base version returns the item unchanged. dma_truncate_test swaps in axis_truncating_driver with a factory override, and that class's pre_drive clones the item and forces TLAST early, without editing a line of the base driver. Error injection through hooks like this is the subject of the error injection topic.
Neither driver survives a reset in the middle of a transfer: if rst_n falls during the access phase, drive() keeps waiting for a PREADY that a reset DUT may never give. The bench's reset-aware variants (apb_driver_ra, axis_driver_ra) race the item loop against @(negedge rst_n) and call item_done for the item they hold; the agent patterns topic builds them, and Stopping sequences and reset covers the sequence side. Drivers that keep several transfers in flight are in Responses and pipelined drivers. And neither driver raises objections: end of test belongs to the test, or to a scoreboard that knows what is still owed.
dma_truncate_test shows both drivers and the hook at work. The APB driver programs the DMA for an 8-word job, the stream driver sends the packet, and the overriding driver's pre_drive cuts it short by forcing TLAST on beat 3 (counting from 0):
UVM_INFO dma_tb.sv(520) @ 275000: uvm_test_top.env.in_agt.drv [TRUNC] TLAST forced on beat 3
UVM_INFO dma_tb.sv(761) @ 385000: uvm_test_top [TRUNC] DMA reported ERR for the short packet, as expected
UVM_INFO dma_tb.sv(411) @ 485000: uvm_test_top.env.sb [SB] 4 words matched, 0 mismatched
UVM_ERROR : 0The reporter is `env.in_agt.drv` even though the class is now axis_truncating_driver: the factory replaced the object, not its place in the tree.
4 words matched because the scoreboard predicts from what the input monitor saw (four beats, the last with TLAST), not from what the sequence intended. The monitor does not care which driver class produced the pins.
Back to the year-old driver. Its access phase was written like this:
// bad-example: drops the access phase when PREADY is already high
task drive(apb_item it);
@(posedge vif.clk);
vif.paddr <= it.addr; vif.pwrite <= it.write; vif.pwdata <= it.data;
vif.psel <= 1'b1; vif.penable <= 1'b0;
@(posedge vif.clk);
vif.penable <= 1'b1;
while (!vif.pready) @(posedge vif.clk); // tests PREADY before any edge has passed
vif.psel <= 1'b0;
vif.penable <= 1'b0;
endtaskWith a wait-state slave PREADY is low when the while first tests it, so the loop waits an edge and all is well. The new slave drove PREADY high whenever it was idle. Now the while test passed immediately, in the same timestep as vif.penable <= 1'b1. The next two lines scheduled PSEL and PENABLE low in that same timestep, and with nonblocking assignments the last one scheduled wins. PENABLE never rose on the bus. The DUT saw a setup phase followed by idle, which is no transfer at all, and the driver reported success.
The fix is the bench's do @(posedge vif.clk); while (!vif.pready);. The body runs before the test, so the access phase is always on the bus for at least one edge, and the PREADY tested is the value from before that edge. It is the APB rule in code: the access phase lasts at least one clock, and the transfer completes on an edge where PREADY is high. Protocol assertions in the interface would have caught it on the first run: "PSEL in setup must be followed by PENABLE" fails immediately. That is one argument for the checks covered in Sampling, races and X.
it.rdata = vif.prdata; right after the do-while loop exits. The DMA's PRDATA is combinational from the address. Is the captured value the one from the completing cycle?Yes. The loop exited right after the completing edge, in the same timestep, before any nonblocking update scheduled at that edge has taken effect. PSEL, PENABLE and the address are still the values from the access cycle, so PRDATA is still the read data for this transfer.
idle gap out of axis_item and into the driver as a fixed config knob, "because timing is the driver's job". What does the bench lose?Randomized, per-beat gaps that a sequence can constrain. Timing execution is the driver's job; timing choices are stimulus. With the gap in the item, a test can ask for back-to-back beats, long stalls or a mix through constraints, and the driver stays the same.
Usually not. The test already holds an objection while its sequences run, and a sequence's finish_item does not return until item_done, so the transfer completes before the sequence can let the test end. Per-item driver objections add overhead to every transfer and hide the real owner of end of test. Some teams do use them; a scoreboard that holds the phase through phase_ready_to_end until outstanding work is done is the cleaner alternative.
Next: the driver produced the transfer, and the monitor saw it at edge 4. In The monitor you will build that observer, and see why it must hand every subscriber a brand-new object.