The memory controller behind an AXI port takes about 100 cycles to return read data, and it is designed to keep 16 reads in flight and return them in whatever order its banks finish. Your agent's driver does get_next_item, drives the address, waits for the data, and calls item_done. It is correct, and it issues one read every 100 cycles. The reorder logic, the ID tracking and the read-data interleaving in the DUT are never touched. The interviewer leans in: "How would you write a driver that keeps several reads outstanding and copes with data coming back out of order?"
A busy restaurant kitchen does not cook one order at a time. The waiter clips each ticket to a rail as the order comes in and walks away to take the next one. Dishes leave the pass in whatever order they finish, and each one is matched to its ticket by table number before it goes out. A pipelined driver works the same way. An address thread takes requests and issues them on the request channel, clipping each to an outstanding table. A separate data thread watches the response channel, finds the matching entry by ID, and sends the answer back to the sequence that asked.
The sequence side of this (sending many requests before collecting responses, or using a response handler) is taught in Responses and pipelined drivers. This lesson builds the driver: the threads, the table, the handshake calls, and what goes wrong in each.
The example is an AXI-style read channel: an AR (address) channel and an R (data) channel, each with its own valid/ready handshake, and a 4-bit ID on both. Single-beat reads keep it short; bursts add a beat counter per entry but no new ideas. It ran as a scratch test alongside the DMA bench, with a small read slave that gives later addresses shorter latency, so data returns out of order.
// snippet: a pipelined read driver (scratch run alongside the DMA bench)
class axi_rd_item extends uvm_sequence_item;
rand bit [3:0] id;
rand bit [31:0] addr;
bit [31:0] rdata; // filled in by the response
`uvm_object_utils(axi_rd_item)
function new(string name = "axi_rd_item");
super.new(name);
endfunction
endclass
class axi_rd_driver extends uvm_driver #(axi_rd_item);
`uvm_component_utils(axi_rd_driver)
virtual axi_rd_if vif; // arid, araddr, arvalid, arready, rid, rdata, rvalid, rready
int unsigned max_outstanding = 4;
axi_rd_item pending[int][$]; // outstanding reads: a queue per ID
int unsigned n_pending;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
task run_phase(uvm_phase phase);
vif.arvalid <= 1'b0;
vif.rready <= 1'b0;
@(posedge vif.rst_n);
vif.rready <= 1'b1;
fork
addr_thread();
data_thread();
join
endtaskThe table is an associative array of queues: pending[id] holds every read outstanding with that ID, oldest first. It is a queue per ID, not one slot, because AXI allows several transactions with the same ID in flight; they must complete in order, so the oldest entry is always the one a response with that ID answers. A single slot per ID would overwrite the first read when the second was issued.
// snippet: axi_rd_driver, continued
task addr_thread();
forever begin
axi_rd_item req;
seq_item_port.get(req); // take the item: no item_done to call later
while (n_pending >= max_outstanding) @(posedge vif.clk); // respect the DUT's limit
pending[req.id].push_back(req); // clip the ticket to the rail first
n_pending++;
@(posedge vif.clk);
vif.arid <= req.id;
vif.araddr <= req.addr;
vif.arvalid <= 1'b1;
do @(posedge vif.clk); while (!vif.arready);
vif.arvalid <= 1'b0;
end
endtaskget() is get_next_item() and item_done() in one call. The sequence's finish_item returns as soon as the driver has the item, so the sequence can send the next read straight away: that is what lets several reads be outstanding at once. With get_next_item you would instead call item_done() right after the address handshake; the effect is the same. What you must never do is call item_done after get: the item is already done, and the extra call is a fatal error (SQRBADITMDN, item_done with no outstanding request).
The max_outstanding wait is the driver honouring the DUT's (or the protocol configuration's) limit. Recording the entry before the address is driven means a fast slave can never answer a read the table does not know yet.
// snippet: axi_rd_driver, continued
task data_thread();
forever begin
@(posedge vif.clk);
if (vif.rvalid && vif.rready) begin
axi_rd_item req, rsp;
int id = vif.rid;
if (!pending.exists(id) || pending[id].size() == 0) begin
`uvm_error("R_UNEXP", $sformatf("read data for id=%0d with no read outstanding", id))
continue;
end
req = pending[id].pop_front(); // same ID: the oldest read is the one answered
n_pending--;
rsp = axi_rd_item::type_id::create("rsp");
rsp.set_id_info(req); // route the answer to the right sequence
rsp.id = req.id; rsp.addr = req.addr; rsp.rdata = vif.rdata;
seq_item_port.put_response(rsp);
end
end
endtask
endclassset_id_info(req) copies the request's sequence ID and transaction ID into the response. The sequencer uses the sequence ID to find which running sequence the response belongs to, and the sequence can use the transaction ID to match a response to one particular request (get_response(rsp, tr_id)). An unexpected ID is reported, not ignored: data the driver cannot match is a DUT bug or a driver bug, and either way the test must fail.
// snippet: axi_rd_seq body
bit [3:0] ids[4] = '{1, 2, 3, 1};
for (int i = 0; i < 4; i++) begin
axi_rd_item it = axi_rd_item::type_id::create($sformatf("rd%0d", i));
start_item(it);
if (!it.randomize() with { id == ids[i]; addr == 4 * i; }) `uvm_fatal("RAND", "randomize failed")
finish_item(it); // returns at once: the driver used get()
end
repeat (4) begin
axi_rd_item rsp;
get_response(rsp); // whichever answer arrives next
if (rsp.rdata !== (rsp.addr ^ 32'hA5A5_0000)) `uvm_error("RD", "bad read data")
endThe scratch run with +UVM_VERBOSITY=UVM_MEDIUM (driver messages trimmed to the important ones):
UVM_INFO scratch_tb.sv(216) @ 115000: uvm_test_top.aenv.drv [AR] issued id=1 addr=0c (4 outstanding)
UVM_INFO scratch_tb.sv(235) @ 165000: uvm_test_top.aenv.drv [R] data id=3 addr=08 rdata=a5a50008
UVM_INFO scratch_tb.sv(264) @ 165000: uvm_test_top.aenv.sqr@@s [SEQ] got response id=3 addr=08
UVM_INFO scratch_tb.sv(264) @ 175000: uvm_test_top.aenv.sqr@@s [SEQ] got response id=2 addr=04
UVM_INFO scratch_tb.sv(264) @ 185000: uvm_test_top.aenv.sqr@@s [SEQ] got response id=1 addr=00
UVM_INFO scratch_tb.sv(264) @ 195000: uvm_test_top.aenv.sqr@@s [SEQ] got response id=1 addr=0c
UVM_INFO scratch_tb.sv(303) @ 195000: uvm_test_top [PIPE] 4 of 4 reads answered correctly
UVM_ERROR : 0
UVM_FATAL : 0All four addresses were issued before the first data came back: 4 outstanding at 115 ns.
Data returned 08, 04, 00, 0c: out of order across IDs, which is what the slave was built to do.
Address 0c had the shortest latency of all, yet it came back last. It shares id=1 with address 00, and same-ID reads complete in order, so the slave held it, and the driver's pop_front matched it to the right request.
A write driver has three channels instead of two, and the same structure: one thread per channel, tied together by a table. The rules that shape it are AXI's ordering rules, so they belong in the driver's design notes and in your interview answer:
Channel | Thread duty | Ordering rule that matters |
|---|---|---|
AW (write address) | Issue addresses, record each in the table | Several outstanding, same-ID responses come back in order |
W (write data) | Send each transaction's beats, in AW order | AXI4 forbids interleaving write data of different transactions; data may lead its address |
B (write response) | Match by BID, retire the oldest entry for that ID, put_response | One response per transaction, after its last data beat |
Reads have one rule writes do not: read data beats of different IDs may interleave in AXI4. With bursts, the data thread therefore keeps a beat count per outstanding entry and retires an entry only on its RLAST.
Comment out rsp.set_id_info(req) and the very first response kills the run:
UVM_FATAL @ 165000: uvm_test_top.aenv.sqr [SQRPUT] Driver put a response with null sequence_idA freshly created response has no sequence ID, so the sequencer cannot tell which sequence it belongs to. This one is loud, which makes it the easy one.
Now keep set_id_info but let the sequence return after sending its four reads, without collecting. The test still passes, with four warnings:
UVM_WARNING @ 165000: uvm_test_top.aenv.sqr [Sequencer] Dropping response for sequence 1, sequence not found. Probable cause: sequence exited or has been killed
UVM_WARNING @ 175000: uvm_test_top.aenv.sqr [Sequencer] Dropping response for sequence 1, sequence not found. Probable cause: sequence exited or has been killedEvery read's data was thrown away and nothing checked it. In a regression where warnings are not counted as failures, that is a silent false pass. The same warning appears after a reset when the driver answers reads issued by a sequence that the test has since killed. A reset-aware pipelined driver therefore flushes its outstanding table when reset asserts (the DUT has forgotten those reads too), instead of answering for sequences that no longer exist.
Each sequence's response queue holds 8 by default. A sequence that issues 16 reads before collecting any, against a driver whose max_outstanding is 16, overflows it from the ninth response: UVM 1.2 reports an error, and the IEEE 1800.2 reference library drops the response silently unless the report is switched on. Collect as you go, use a response handler, or raise the depth with set_response_queue_depth(). The details are in Responses and pipelined drivers.
get_next_item and calls item_done() only when the read data arrives. Does it still pipeline?No. The sequence's finish_item blocks until item_done, so the sequence cannot send the second read until the first is answered, and the address thread never has more than one request to issue. Call item_done() right after the address handshake (or use get()) and return the data with put_response.
axi_rd_item pending[int], one entry per ID. The sequence issues two reads with id=1. What happens?The second read overwrites the first in the table. When the first read's data returns, it is matched to the second request: wrong address, wrong data check, wrong sequence transaction ID. The second read's data then finds no entry and is reported as unexpected. Same-ID transactions need a queue per ID.
It is still answering reads that were outstanding before the reset, for a sequence the test killed. After reset the DUT has forgotten those reads, so the driver should flush its outstanding table when reset asserts (and its data thread should ignore the bus during reset), not put responses for them.
Next: with slaves, resets, interrupts and pipelines covered, the agents are complete. Environment & Test assembles them into envs and reuses the block env in a two-DMA subsystem.