Every DMA test ends a job the same way: wait (irq_vif.irq === 1'b1). It works, but look at what it costs. Every test needs a virtual interface it should not care about. A virtual sequence that wants to react to the interrupt needs one too, and objects carrying pin handles do not survive the move to a subsystem with forty interrupt lines. Nothing records when interrupts happened, so nothing can cover them or check that none is left pending at the end. How do you make an interrupt something the testbench observes rather than something every test pokes at?
Treat the interrupt line like any other interface: give it a passive agent whose monitor watches the wire and reports what it sees. The monitor reports in two forms at once. Every change of the line becomes an irq_item on an analysis port, for scoreboards and coverage. Every rising edge also triggers a named event, for anything that wants to wait. Think of a doorbell wired to a smart home hub: the hub logs every ring, and anyone in the house who asked to be told gets a notification, without running a wire to the button.
The event is a uvm_event taken from uvm_event_pool::get_global("dma_irq"). The global pool is a table of events keyed by name, shared by the whole simulation. The monitor and a test that never met each other call get_global with the same name and get the same event object, so the test can wait for the interrupt with no virtual interface and no handle passed through config_db.
The monitor samples the line on every clock and acts only on a change. It creates a fresh irq_item for each change, writes it, and triggers the event when the new level is 1. The agent is passive by nature: an interrupt is driven by the DUT, so there is nothing for a driver to do, and the agent builds only the monitor.
// snippet: from the course DMA bench
class irq_item extends uvm_sequence_item;
bit level;
`uvm_object_utils(irq_item)
function new(string name = "irq_item");
super.new(name);
endfunction
endclass
// A passive interrupt monitor: publishes every change of the line, and triggers
// the global event "dma_irq" on each rising edge, so tests and sequences can wait
// for the interrupt without holding a virtual interface.
class irq_monitor extends uvm_monitor;
`uvm_component_utils(irq_monitor)
virtual irq_if vif;
uvm_analysis_port #(irq_item) ap;
uvm_event irq_ev;
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 irq_if)::get(this, "", "vif", vif))
`uvm_fatal("NOVIF", "irq_if not set for the interrupt monitor")
irq_ev = uvm_event_pool::get_global("dma_irq");
endfunction
task run_phase(uvm_phase phase);
bit last = 0;
forever begin
@(posedge vif.clk);
if (vif.irq !== last) begin
irq_item it = irq_item::type_id::create("it");
it.level = vif.irq;
last = vif.irq;
ap.write(it);
if (it.level) irq_ev.trigger();
`uvm_info("IRQ_MON", $sformatf("irq -> %0b", it.level), UVM_MEDIUM)
end
end
endtask
endclass
class irq_agent extends uvm_agent;
`uvm_component_utils(irq_agent)
irq_monitor mon;
uvm_analysis_port #(irq_item) ap;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
super.build_phase(phase);
mon = irq_monitor::type_id::create("mon", this);
endfunction
function void connect_phase(uvm_phase phase);
ap = mon.ap;
endfunction
endclassdma_env_irq extends the block env and adds one child. A type override in the test swaps it in for dma_env, so the env source is untouched and every other test still builds the plain env. The top module already sets the interface for uvm_test_top.env.irq.*, which costs nothing when no irq agent exists.
// snippet: from the course DMA bench
// The block env plus an interrupt agent, swapped in by a factory override.
class dma_env_irq extends dma_env;
`uvm_component_utils(dma_env_irq)
irq_agent irq;
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
super.build_phase(phase);
irq = irq_agent::type_id::create("irq", this);
endfunction
endclass
// The test waits on the interrupt agent's event instead of a virtual interface.
class dma_irq_agent_test extends dma_base_test;
`uvm_component_utils(dma_irq_agent_test)
function new(string name, uvm_component parent);
super.new(name, parent);
endfunction
function void build_phase(uvm_phase phase);
dma_env::type_id::set_type_override(dma_env_irq::get_type());
super.build_phase(phase);
endfunction
task run_phase(uvm_phase phase);
uvm_event irq_ev = uvm_event_pool::get_global("dma_irq");
axis_pkt_seq pkt = axis_pkt_seq::type_id::create("pkt");
bit [31:0] status;
phase.raise_objection(this);
reg_write(KEY, 32'h0F0F_0F0F);
reg_write(LEN, 4);
reg_write(CTRL, 32'h3);
if (!pkt.randomize() with { n == 4; }) `uvm_fatal("RAND", "axis_pkt_seq randomize failed")
fork
pkt.start(env.in_agt.sqr);
join_none
irq_ev.wait_trigger(); // no virtual interface needed here
reg_read(STATUS, status);
`uvm_info("IRQ", $sformatf("interrupt event seen, STATUS=%08h", status), UVM_LOW)
reg_write(STATUS, 32'h6);
#100ns;
phase.drop_objection(this);
endtask
endclassThe test has no irq_vif. It gets the event from the pool, starts the packet in the background, and blocks on wait_trigger() until the monitor reports the rising edge.
The bench's virtual sequence dma_vseq has an interrupt branch that waits on irq_vif.irq and then starts dma_irq_seq, which grabs the APB sequencer so its STATUS read and clear jump ahead of any queued polling. With the agent in place, that branch needs no virtual interface either:
// snippet: dma_vseq's interrupt branch, waiting on the agent's event instead of the wire
begin
uvm_event irq_ev = uvm_event_pool::get_global("dma_irq");
irq_ev.wait_trigger(); // the irq agent saw a rising edge
isr.start(apb_sqr, this); // grab, read STATUS, write 1 to clear, ungrab
status = isr.status;
poll.stop = 1;
endWhy the handler grabs, and how grab differs from lock, is in Lock versus grab.
dma_irq_agent_test with +UVM_VERBOSITY=UVM_MEDIUM:
UVM_INFO dma_tb.sv(1082) @ 375000: uvm_test_top.env.irq.mon [IRQ_MON] irq -> 1
UVM_INFO dma_tb.sv(1326) @ 415000: uvm_test_top [IRQ] interrupt event seen, STATUS=00000002
UVM_INFO dma_tb.sv(1082) @ 465000: uvm_test_top.env.irq.mon [IRQ_MON] irq -> 0
UVM_INFO dma_tb.sv(411) @ 555000: uvm_test_top.env.sb [SB] 4 words matched, 0 mismatched
UVM_ERROR : 0
UVM_FATAL : 0At 375 ns the monitor sees the line rise and triggers the event; the test wakes and reads STATUS, which takes one APB access (with the DUT's wait state), so the read returns at 415 ns with DONE set.
The test writes 0x6 to STATUS, and at 465 ns the monitor sees the line fall. The line falling is the proof that the clear worked.
The scoreboard's 4 matched words show the agent changed nothing about data checking.
The DMA's interrupt is level-sensitive: irq = IRQ_EN & (DONE | ERR), and it stays high until software clears the cause. The cause bits are write-1-to-clear (W1C): writing 1 to STATUS bit 1 clears DONE, writing 1 to bit 2 clears ERR, and writing 0 leaves a bit alone. An edge (pulse) interrupt is high for a fixed short time and needs no clear. The two need different handling:
Level interrupt (the DMA) | Edge or pulse interrupt | |
|---|---|---|
Monitor | Report changes; the line stays high until cleared | Must not miss a one-clock pulse; sample every clock or use the pulse's own edge |
Handler duty | Read the cause, clear exactly those bits | Read the cause; nothing to clear on the line |
If the handler forgets | Line stays high, no new rising edge, the next interrupt is never seen | Nothing on the line; the cause register may still need clearing |
Checks | Line falls after the clear; low at end of test | Pulse width; one pulse per event |
A good handler clears what it read, not a constant. Writing back the STATUS value it just read clears exactly the causes it is about to handle, and leaves alone a cause that arrived between the read and the write. The bench's dma_irq_seq writes the constant 0x6, which is fine for a block with one job at a time and is the first thing to change at subsystem level.
A storm is an interrupt that re-asserts as fast as it is handled, usually because the clear never reaches the cause (a wrong address, a read-only bit, a clear blocked by a still-active condition). A level-sensitive handler written as while (irq) handle(); then spins forever in zero or near-zero time. Production handlers bound their loop and report: count handler entries per job, and raise an error when the count passes a limit the spec allows. The irq monitor's analysis stream makes that check easy, because it records every rise with its time.
The wire alone says only that an interrupt happened. The cause comes from the STATUS read the handler makes, which the APB monitor observes. A coverage subscriber that listens to both streams can pair each rising edge with the next STATUS read and sample cause (DONE, ERR, both) crossed with context (IRQ_EN, job length, whether a second job was queued).
// not-run: covergroups are not supported in the course's Verilator checks
class irq_cause_cov extends uvm_subscriber #(apb_item);
`uvm_component_utils(irq_cause_cov)
bit irq_pending; // set by the irq stream, see write_irq below
bit [1:0] cause; // {ERR, DONE} from the STATUS read
covergroup cg;
cp_cause: coverpoint cause { bins done = {2'b01}; bins err = {2'b10}; bins both = {2'b11}; }
endgroup
function new(string name, uvm_component parent);
super.new(name, parent);
cg = new();
endfunction
function void write(apb_item t); // from the APB monitor
if (irq_pending && !t.write && t.addr == STATUS) begin
cause = t.rdata[2:1];
cg.sample();
irq_pending = 0;
end
endfunction
function void write_irq(irq_item t); // connected through a uvm_analysis_imp_decl(_irq) imp
if (t.level) irq_pending = 1;
endfunction
endclassA team's interrupt handler read STATUS and cleared DONE with a constant write of 0x2. Their regression ran two jobs per test. The second job of one random seed ended with a short packet, set ERR instead of DONE, and the test hung until the timeout. The waveform showed the interrupt line high from the end of the first job onward.
The first job in that seed had also ended in error, so the handler's 0x2 cleared nothing that mattered and ERR stayed set. The line never fell, so when the second job ended there was no new rising edge, the monitor triggered nothing, and wait_trigger() waited forever. The fixes were the two rules above: clear what you read (write the STATUS value back), and check after every handler that the line actually fell. An end-of-test check that the interrupt line is low turns the next such bug into a one-line error instead of a hang.
uvm_event_pool::get_global("dma_irq") before the irq agent's build_phase has run. Does it get the same event the monitor triggers?Yes. get_global creates the event the first time any caller asks for that name and returns the same object to every later caller, whatever the order. Who asks first does not matter, only that both use the same string, which is why the name belongs in a shared constant.
It stays high. Writing 1 to bit 1 clears DONE, but ERR is still set and irq = IRQ_EN & (DONE | ERR). The monitor sees no falling edge, and no new rising edge can come until ERR is cleared too. Writing back the value read (0x6) clears both.
irq_monitor publish falling edges too, when tests only wait for rising ones?Because checkers need them. The fall is the evidence that a clear worked, the time between rise and fall is the handler latency, and an end-of-test check needs to know the last level. Publishing every change costs almost nothing and lets each subscriber decide what matters.
One agent per line, one event per line. Name each event after its source (dma0_irq, dma1_irq) in the global event pool, so a test or virtual sequence waits on exactly the source it means. The two-DMA subsystem bench still hands the test raw irq0_vif and irq1_vif; at that size it works, at twenty lines it does not.
Shared or ORed lines need a cause read. When several sources share one wire, the edge only says someone wants service. The handler reads the cause register (STATUS here, an interrupt controller's pending register on a chip), and the check pairs each serviced cause with the event that predicted it.
Prove every source fired. Coverage per line (rose with its enable set, held until cleared, cleared by the right write) catches a line that was never exercised, and a check in check_phase that no line is still pending catches an interrupt nobody serviced.
Swapped wiring is invisible to a test that waits for all of them. If dma0 and dma1 interrupts are crossed at integration, a test that waits for both still passes; pairing each edge with its own cause read is what exposes it.
Next: the agents so far handle one transaction at a time. Fast buses keep many in flight: Pipelined and out-of-order drivers splits the driver into threads.