An interrupt handler sequence works for weeks. Then someone tidies it: after clearing STATUS, it calls regmodel.update() "to make sure the hardware matches the model". Under random traffic, interrupts start going missing, one in a few hundred. A scratch run on the DMA bench shows what that one line does even when nothing is pending: regmodel.update() wrote addr 04 1 time(s), data 6. It wrote 1 to both write-1-to-clear bits of STATUS. Had DONE or ERR been set at that moment, the write would have cleared it before any handler read it.
Interrupt status is where the register model's assumptions are weakest: the hardware sets bits on its own, software clears them by writing ones, and several sequences touch the same register at unpredictable times. This lesson builds an interrupt service routine (ISR) sequence that reads and clears STATUS through the model, explains why the value it read is the only trustworthy one, and shows how to wait for a register to change without flooding the bus.
A mailbox flag goes up when the postman delivers, not when you look at it. To handle the mail you open the box (read), take out exactly what you found (clear what you saw), and close it. You never decide to put the flag down because your notebook says there should be no mail: the postman may have come while you were walking back. The DMA works the same way. STATUS.DONE and STATUS.ERR are set by the hardware and cleared by writing 1 (W1C); irq = IRQ_EN & (DONE | ERR), so the interrupt line stays high until both are cleared.
In model terms, DONE and ERR are volatile: they change without a bus write, so the mirror cannot know their current value. Three rules follow, and every rule below is one of them applied:
The read's return value is the truth; the mirror is a stale note. Decide from the value read() returned.
Clear exactly what you read, with an explicit write() of those bits. A W1C write of a bit you did not see may clear an event that arrived after your read.
Never let generic code write status registers: update(), reset() followed by update(), or any "sync the model" helper.
The bench's raw-APB handler, used by dma_vseq, grabs the APB sequencer, reads STATUS and writes 0x6. The interrupt itself is detected by the interrupt agent, which triggers a uvm_event that the test or a virtual sequence waits on.
// 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
endclassIt works on the bench because one job runs at a time, but look at the last access: it clears DONE and ERR whatever it read. If ERR arrives between the read and the write, the handler destroys it unseen. That is the mailbox mistake in two lines.
The register-model version reads STATUS by name and writes back exactly the W1C bits it saw. It was verified in a scratch test on the bench:
// snippet: scratch ISR sequence
class dma_ral_isr_seq extends uvm_sequence;
`uvm_object_utils(dma_ral_isr_seq)
dma_reg_block rm;
uvm_reg_data_t status; // what the handler saw, for the caller
function new(string name = "dma_ral_isr_seq");
super.new(name);
endfunction
task body();
uvm_status_e st;
rm.status.read(st, status, .parent(this));
if (st != UVM_IS_OK) `uvm_error("ISR", "STATUS read failed")
if (status & 32'h6)
rm.status.write(st, status & 32'h6, .parent(this)); // W1C what was seen, nothing else
endtask
endclassThree details carry the design. The decision uses status, the value read() returned, never rm.status.done.get_mirrored_value(). The clear is an explicit write(), not set() plus update(). And passing .parent(this) makes the register accesses children of the ISR sequence, so if the handler must not be interleaved with other register traffic it can lock() or grab() the APB sequencer first, as dma_irq_seq does, and its own accesses still go through. Through the model that is two lines in the ISR's body(): regmodel.default_map.get_sequencer().lock(this); before the first access and ...unlock(this); after the last.
With several sequences polling, servicing and configuring at once, prediction must come from the bus. The explicit predictor sees every transfer, from the ISR, from a polling sequence, from a raw-APB sequence that never heard of the model, and updates the mirror for each one. What it cannot do is update it instantly: the bench's own job test shows the lag. Right after status.read() returns 2, the mirror still says DONE = 0, and asking it costs two warnings:
+UVM_TESTNAME=dma_ral_job_test (run.sh dma_ral_tb.sv)
UVM_WARNING reg/uvm_reg_field.svh(1327) @ 425000: reporter [UVM/FLD/GET_MIRRORED_VAL/VOL] Mirrored value returned for volatile field "regmodel.status.done" may not reflect the actual current value.
UVM_WARNING reg/uvm_reg_field.svh(1327) @ 425000: reporter [UVM/FLD/GET_MIRRORED_VAL/VOL] Mirrored value returned for volatile field "regmodel.status.err" may not reflect the actual current value.
UVM_INFO dma_ral_tb.sv(346) @ 425000: uvm_test_top [RAL] STATUS=2: done=0 err=0The library is telling you the same thing as the mailbox: a volatile field's mirror is a note, not the box. An ISR that branched on the mirror here would conclude there was nothing to service and leave the interrupt line high forever.
Polling STATUS over APB until DONE appears works (apb_poll_seq does it), but every poll is bus traffic that can shift the timing you are trying to test. Two backdoor alternatives cost no bus cycles. The simple one is a peek() loop on a clock edge. The library's own mechanism is an active-mirror backdoor: override is_auto_updated() to name the fields to track and wait_for_change() to block until the hardware changes, then call start_update_thread(). The library then re-reads the register through the backdoor after every change and predicts the tracked fields, so their mirror follows the DUT with no bus traffic at all.
// snippet: scratch extension of the bench's backdoor
class live_backdoor extends dma_reg_backdoor;
function new(string name = "live_backdoor");
super.new(name);
endfunction
virtual function bit is_auto_updated(uvm_reg_field field);
return field.get_parent().get_name() == "count";
endfunction
virtual task wait_for_change(uvm_object element);
@(vif.count); // the bound interface sees the DUT's COUNT
endtask
endclass
// end_of_elaboration: rm.count.set_backdoor(live);
// run_phase, before traffic: live.start_update_thread(rm.count);The bench's dma_bd_if exposes KEY, LEN and COUNT, not STATUS, so the scratch test tracks COUNT. With STATUS on the interface, the same three methods would let a test wait on DONE without a single APB read.
The scratch test runs three jobs: 4 of 4 words, then LEN = 8 with only 3 words sent (early TLAST, so the DMA reports ERR), then 2 of 2. After each interrupt it prints the live COUNT mirror, starts the ISR and samples the interrupt line 20 ns later. Scratch run, Verilator:
UVM_INFO scratch.sv(617) @ 365000: uvm_test_top [ISR] irq high; COUNT mirror (live backdoor) = 4
UVM_INFO scratch.sv(620) @ 445000: uvm_test_top [ISR] handler read STATUS=2, cleared 2
UVM_INFO scratch.sv(622) @ 465000: uvm_test_top [ISR] irq after the handler = 0
UVM_INFO scratch.sv(617) @ 645000: uvm_test_top [ISR] irq high; COUNT mirror (live backdoor) = 3
UVM_INFO scratch.sv(620) @ 725000: uvm_test_top [ISR] handler read STATUS=4, cleared 4
UVM_INFO scratch.sv(622) @ 745000: uvm_test_top [ISR] irq after the handler = 0
UVM_INFO scratch.sv(617) @ 925000: uvm_test_top [ISR] irq high; COUNT mirror (live backdoor) = 2
UVM_INFO scratch.sv(620) @ 1005000: uvm_test_top [ISR] handler read STATUS=2, cleared 2
UVM_INFO scratch.sv(622) @ 1025000: uvm_test_top [ISR] irq after the handler = 0
UVM_INFO dma_tb.sv(411) @ 1125000: uvm_test_top.env.sb [SB] 9 words matched, 0 mismatched
UVM_ERROR : 0COUNT mirror = 4, 3, 2 with no COUNT read on the bus: the active-mirror backdoor kept it current. Note the second job: COUNT = 3 of LEN 8, the words that arrived before TLAST.
STATUS = 4 on the second job: ERR alone, no DONE. The handler cleared exactly bit 2. A handler that branched on DONE would have left this interrupt pending.
irq = 0 each time, 20 ns after the handler: the clear really reached the hardware. That one sample is the ISR's own check; without it, a handler that clears the wrong bit passes silently.
Back to the opening. Two library rules combine. First, in the IEEE 1800.2 library this course runs, uvm_reg_field::needs_update() returns 1 for every volatile field that is not RO, RC or RS, whatever the desired and mirrored values are. DONE and ERR are volatile W1C fields, so STATUS always needs an update. Second, update() writes, for a W1C field, the inverse of the desired value: to make a W1C bit read as 0 you write 1. With the desired value 0, that is a 1 in both bits. The same scratch test that confirmed the bench's GO trap shows both rules:
scratch run, Verilator
UVM_INFO scratch.sv(677) @ 315000: uvm_test_top [UPD] ctrl.needs_update()=0 status.needs_update()=1
UVM_INFO scratch.sv(680) @ 365000: uvm_test_top [UPD] APB writes to CTRL after ctrl.update(): 0
UVM_INFO scratch.sv(684) @ 455000: uvm_test_top [UPD] regmodel.update() wrote addr 04 1 time(s), data 6The first two lines are war story 13 confirmed: after ctrl.write(3) the mirror already holds GO = 1, so go.set(1); ctrl.update() writes nothing and no second job starts. The third line is the lost interrupt: the block-level update() skipped every register that matched its mirror and wrote STATUS anyway, with 0x6. Keep update() for configuration registers you have just set(), call it per register rather than per block, and service status only with explicit reads and writes.
The write clears only DONE, so ERR stays set and, with IRQ_EN on, the interrupt line stays high (or rises again). The handler runs again and services ERR. That is the point of clearing only what you read: an event that arrives during servicing is never lost. Writing 0x6 instead would have cleared ERR without anyone seeing it.
rm.status.done.get_mirrored_value() before reading, "to save a bus access when nothing is pending". What is wrong with that?DONE is volatile: the hardware sets it without a bus write, so the mirror only changes after someone reads STATUS. The mirror would say 0 when DONE is set, the handler would skip the read, and the interrupt would stay pending forever. The library even warns when a volatile field's mirror is read. The read is the only way to know.
If the plan item is "COUNT is readable through the register interface", yes: the backdoor proves the hardware value, not the APB read path, its address decode or its read mux. The backdoor is for waiting and for cheap checks during the job; at least one frontdoor read per test keeps the bus path covered.
Next: one DMA, one model. The Register Model at SoC Scale puts two DMA models into a chip model, moves one of them, reuses the block's sequences unchanged, and keeps a model with a hundred thousand registers fast.