A whiteboard interview: an AXI slave returns read data out of order. Write the scoreboard. You draw an associative array keyed by ARID, one expected item per ID, and look up each response by its RID. The interviewer nods and asks one more question: the master issues two reads with ID 5 before either returns. What does your scoreboard do? Your array has one slot for ID 5. The second prediction either overwrites the first or gets reported as an error. Neither is right, because two outstanding reads on one ID are perfectly legal AXI.
AXI tags every transaction with an ID. The protocol makes two promises about responses. Responses with different IDs may return in any order. Responses with the same ID return in the order the requests were issued. So order is not gone; it is kept separately per ID. The data structure that matches those two rules exactly is an associative array of queues: pending[id] is a FIFO of the predictions for that ID.
Picture a coat check that hands out tickets in a few colours. Coats of different colours can come back in any order, but within one colour the attendant always returns the oldest coat first. You do not need a slot per colour; you need a short line per colour. A response's ID chooses the line, and order within the line chooses the partner.
The DMA has no IDs, so this example is AXI-style: a response item with an ID, an address and data, and a scoreboard fed by two analysis ports. It was built and run on a scratch bench; the scoreboard is shown in full.
// snippet: verified in a scratch run (Verilator + the open-source UVM library)
class axi_rsp extends uvm_sequence_item;
bit [3:0] id; // match the DUT's ID width exactly
bit [31:0] addr;
bit [31:0] data;
`uvm_object_utils(axi_rsp)
function new(string name = "axi_rsp");
super.new(name);
endfunction
function string convert2string();
return $sformatf("id=%0h addr=%08h data=%08h", id, addr, data);
endfunction
endclass
`uvm_analysis_imp_decl(_exp)
`uvm_analysis_imp_decl(_act)
class axi_id_scoreboard extends uvm_scoreboard;
`uvm_component_utils(axi_id_scoreboard)
uvm_analysis_imp_exp #(axi_rsp, axi_id_scoreboard) exp_export;
uvm_analysis_imp_act #(axi_rsp, axi_id_scoreboard) act_export;
axi_rsp pending[bit [3:0]][$]; // one in-order queue per ID
int unsigned n_match, n_mismatch;
function new(string name, uvm_component parent);
super.new(name, parent);
exp_export = new("exp_export", this);
act_export = new("act_export", this);
endfunction
virtual function void write_exp(axi_rsp t);
pending[t.id].push_back(t); // same ID again: queue behind the first
endfunction
virtual function void write_act(axi_rsp t);
axi_rsp exp;
if (!pending.exists(t.id)) begin
`uvm_error("SB_UNEXP", $sformatf("response with nothing outstanding: %s", t.convert2string()))
n_mismatch++;
return;
end
exp = pending[t.id].pop_front(); // the oldest outstanding for this ID
if (pending[t.id].size() == 0) pending.delete(t.id);
if (t.addr !== exp.addr || t.data !== exp.data) begin
`uvm_error("SB_MISMATCH", $sformatf("expected %s, got %s", exp.convert2string(), t.convert2string()))
n_mismatch++;
end else n_match++;
endfunction
function void check_phase(uvm_phase phase);
foreach (pending[id])
`uvm_error("SB_LEFTOVER", $sformatf("id %0h: %0d response(s) never returned", id, pending[id].size()))
if (n_match == 0) `uvm_error("SB_EMPTY", "nothing was compared")
endfunction
function void report_phase(uvm_phase phase);
`uvm_info("SB", $sformatf("%0d matched, %0d mismatched", n_match, n_mismatch), UVM_LOW)
endfunction
endclassDelete an ID's queue when it empties. Then pending.exists(id) means "something is outstanding on this ID", and the leftover report at the end names only IDs that really owe a response.
Store the item handle only because the producer publishes a fresh object per transaction. If a VIP monitor reuses its item, copy before push_back.
The key is the full ID width. A key narrower than the bus's ID silently merges unrelated IDs into one queue and turns legal reordering into false mismatches.
The scratch test predicts the four reads in the picture and returns them in a legal order: ID 5 (the first one), ID 1, ID 2, then ID 5 again.
(scratch run: axi_id_test)
UVM_INFO scratch_tb.sv(64) @ 50000: uvm_test_top.sb [SB] 4 matched, 0 mismatched
UVM_ERROR : 0Then a broken interconnect: the two ID 5 responses come back swapped, and ID 1 never answers.
(scratch run: axi_id_bug_test)
UVM_ERROR scratch_tb.sv(52) @ 10000: uvm_test_top.sb [SB_MISMATCH] expected id=5 addr=00000200 data=cafe0200, got id=5 addr=00000300 data=cafe0300
UVM_ERROR scratch_tb.sv(52) @ 30000: uvm_test_top.sb [SB_MISMATCH] expected id=5 addr=00000300 data=cafe0300, got id=5 addr=00000200 data=cafe0200
UVM_ERROR scratch_tb.sv(59) @ 40000: uvm_test_top.sb [SB_LEFTOVER] id 1: 1 response(s) never returned
UVM_INFO scratch_tb.sv(64) @ 40000: uvm_test_top.sb [SB] 1 matched, 2 mismatched
UVM_ERROR : 3Both failures are real protocol violations and both are caught for the right reason. The same-ID swap is reported as two mismatches on ID 5, because within one ID order is part of the contract. The missing response is named by ID at the end. A scoreboard that searched an ID's queue for any matching address would have passed the swap, and so would have hidden a real ordering bug.
The interviewer's trap is a common first design, and it fails on legal traffic. The model below is plain SystemVerilog: three reads with IDs 5, 2, 5, returned legally as 2, 5, 5. A one-slot-per-ID map overwrites the first ID 5 prediction with the second, then reports a false mismatch when the first ID 5 response arrives. The queue-per-ID map matches all three.
module tb_same_id;
int unsigned slot[int unsigned]; // one slot per ID: the trap
int unsigned q[int unsigned][$]; // one queue per ID: the fix
int unsigned iss_id[3] = '{5, 2, 5};
int unsigned iss_addr[3] = '{32'h200, 32'h100, 32'h300};
int unsigned ret_id[3] = '{2, 5, 5}; // legal: same-ID responses in issue order
int unsigned ret_addr[3] = '{32'h100, 32'h200, 32'h300};
int slot_bad = 0, q_bad = 0;
int unsigned id;
initial begin
foreach (iss_id[i]) begin
slot[iss_id[i]] = iss_addr[i]; // the second ID 5 overwrites the first
q[iss_id[i]].push_back(iss_addr[i]); // the second ID 5 waits behind the first
end
foreach (ret_id[i]) begin
id = ret_id[i];
if (!slot.exists(id) || slot[id] != ret_addr[i]) slot_bad++;
else slot.delete(id);
if (!q.exists(id) || q[id].size() == 0 || q[id].pop_front() != ret_addr[i]) q_bad++;
else if (q[id].size() == 0) q.delete(id);
end
$display("one slot per ID: %0d wrong pairing(s) on legal traffic", slot_bad);
$display("queue per ID: %0d wrong pairing(s), %0d queue(s) left", q_bad, q.num());
if (slot_bad == 1 && q_bad == 0 && q.num() == 0)
$display("PASS: a queue per ID matched legal same-ID traffic that one slot per ID got wrong");
else $display("FAIL: unexpected result");
$finish;
end
endmoduleLook at how the slot design fails. The first ID 5 response is compared against the second read's address, so the error message says the DUT returned the wrong data for address 0x300, and debugging it leads you to a DUT that did nothing wrong. The first prediction, overwritten at issue time, is simply gone: had that response really been lost, no check would ever have noticed. The alternative "fix", rejecting a second prediction on a busy ID as an error, fails differently but just as wrongly, on the first test that issues two reads with one ID. A queue per ID is not an optimisation; it is the only structure that matches the protocol's rule.
Situation | What the scoreboard must do |
|---|---|
Reads and writes | AXI read IDs (ARID/RID) and write IDs (AWID/BID) are separate spaces. Keep separate maps, or key on {direction, ID}. |
Several masters into one slave | The interconnect widens the ID with port bits so responses can be routed back. Key the slave-side scoreboard on the widened ID, or translate it back to {master, ID} first. |
Multi-beat reads | Read beats of different IDs may interleave. Assemble beats per ID until RLAST, then compare the whole burst against the front of that ID's queue. |
Same ID, different masters | Two masters may both use ID 3. Key on {master, ID}; a bare ID would pair one master's response with another's prediction. |
Response codes | Predict RRESP/BRESP too. A SLVERR or DECERR for an unmapped address is an expected outcome, not an excuse to skip the compare. |
pending has no entry for 7. Name two causes, one in the DUT and one in the testbench.DUT: the slave or interconnect produced a response nobody asked for, or corrupted the ID. Testbench: the request monitor missed the request (for example it did not sample ARID correctly), or the scoreboard's key is narrower than the bus ID and 7 is really a different ID. Check the request monitor's log for an ARID = 7 request first: that splits the two.
pending when its queue becomes empty?So that the existence of a key means "something is outstanding on this ID". The unexpected-response check can then be a simple exists, and the leftover report in check_phase lists only IDs that still owe responses, rather than every ID ever used with an empty queue.
Yes, if the DUT keeps order within each destination but may interleave destinations. The key is whatever the protocol says preserves order: an AXI ID, a TDEST, a flow or virtual-channel number. The rule is the same: one queue per ordering domain, and the front of the right queue is the partner.
Next: pairing rules decide which expected item an actual one meets. When the numbers still look wrong, Debugging a Scoreboard shows how to read them.