A subsystem regression has 400 seeds. Seed 317 hangs until the global timeout; every other seed passes. The hung test is waiting on irq_ev.wait_trigger(), and the waveform shows the interrupt line high, with the monitor's trigger at exactly the clock edge on which the test called wait_trigger(). The test arrived one scheduling step late, on the same edge, and waited for a ring that had already happened.
This lesson is about the objects UVM gives you for that kind of coordination: uvm_event, uvm_barrier, and the global pools that let unrelated components find them by name. Each solves a real problem, each has one trap, and the race above is the most famous.
A SystemVerilog event is a doorbell. ->ev rings it; @ev listens for the next ring. If you start listening a moment after it rang, you hear nothing until it rings again. uvm_event wraps that doorbell in an object with state, so it can also be read like a flag on a mailbox: once triggered it stays on until someone calls reset(). It records the trigger time, can carry a data object with the trigger, counts its waiters, and supports callbacks that run before and after each trigger.
// snippet: an event callback (IEEE 1800.2 form)
class irq_cb extends uvm_event_callback #(uvm_object);
`uvm_object_utils(irq_cb)
function new(string name = "irq_cb"); super.new(name); endfunction
virtual function bit pre_trigger(uvm_event #(uvm_object) e, uvm_object data);
return 0; // 1 would cancel the trigger
endfunction
virtual function void post_trigger(uvm_event #(uvm_object) e, uvm_object data);
`uvm_info("IRQCB", "dma_irq triggered", UVM_HIGH)
endfunction
endclass
// attach: irq_ev.add_callback(cb); (append by default)That gives you three ways to wait, and choosing among them is the whole skill. wait_trigger() listens for the next ring. wait_ptrigger() also accepts a ring that already happened in the current time step (the echo). wait_on() returns at once if the flag is up, however long ago it was raised, and otherwise waits for the first trigger.
The bench's interrupt monitor never hands its virtual interface to anyone. It fetches a global event by name in build_phase and triggers it on every rising edge of the line:
// snippet: from irq_monitor in the course DMA bench (dma_tb.sv)
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
endtaskdma_irq_agent_test waits on the same name. Nothing connects the two components: the global event pool is the meeting point. uvm_event_pool::get_global("dma_irq") returns the one event stored under that name, creating it on first use, so it does not matter which side asks first.
// snippet: from dma_irq_agent_test in the course DMA bench (dma_tb.sv)
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);
endtaskThis is the decoupling that makes the interrupt agent reusable (its full story is in Interrupt Agents). The wait is safe here only because the job takes several hundred nanoseconds: the test is blocked in wait_trigger() long before the line rises.
uvm_event::wait_trigger() is @m_event and wait_ptrigger() checks m_event.triggered first; that is the whole difference, and the library's own comment on wait_trigger warns that a wait arriving after a same-delta trigger "may never occur and thus cause deadlock". The model below runs both waits against a module-level event, each arriving just after the trigger in the same time step:
// The same-time-step race in plain SystemVerilog. Both waiters arrive at 10,
// just after the trigger. @ev waits for the NEXT trigger; ev.triggered is still
// true for the rest of the time step.
module event_race;
event ev;
bit saw_at, saw_triggered;
initial #10 -> ev; // the "monitor" triggers at 10
initial begin // waiter 1: @ev (what wait_trigger does)
#10; #0; // same time, after the trigger
fork
begin @ev; saw_at = 1; end
#5;
join_any
if (saw_at) $display("@ev: seen");
else $display("@ev: missed (it waits for the next trigger)");
end
initial begin // waiter 2: ev.triggered (what wait_ptrigger checks)
#10; #0;
fork
begin wait (ev.triggered); saw_triggered = 1; end
#5;
join_any
if (saw_triggered) $display("wait(triggered): seen");
else $display("wait(triggered): missed");
end
endmodulewait(triggered): seen
@ev: missed (it waits for the next trigger)Two honest caveats. First, #0 makes the order deterministic here; in a real bench the order of two processes waking on the same edge is up to the simulator, which is why the hang appears on one seed or after a tool upgrade. Second, a scratch run of the same race through uvm_event on Verilator 5.048 did not miss: that simulator treats an event declared inside a class differently from a module-level one. A simulator that passes is not proof that your wait is safe; the rule is what you code to.
Call | Returns at once if... | Use it for |
|---|---|---|
| never: waits for the next trigger | Repeating events where you want the next occurrence, and the waiter is known to be early. |
| it was triggered earlier in this time step | A waiter that may arrive on the same edge as the trigger. |
| it is on (triggered since the last | One-shot conditions: "reset done", "config done". Pass |
| it is off | Waiting for someone to |
| as above | Receiving the object passed to |
| (function) | Turning the flag off; |
| (functions) | Diagnostics, and end-of-test checks for stranded waiters. |
A trigger can carry an object, which turns the event into a tiny one-to-many message. The scratch run below passes the STATUS and COUNT a handler read; the waiter casts it back:
// snippet: trigger with data (scratch run, sync_tb.sv)
class irq_info extends uvm_object;
`uvm_object_utils(irq_info)
int unsigned status;
int unsigned count;
function new(string name = "irq_info");
super.new(name);
endfunction
endclass
// waiter
uvm_object obj;
irq_info info;
done_ev.wait_trigger_data(obj);
if (!$cast(info, obj)) `uvm_fatal("CAST", "dma_done carried the wrong type")
// triggerer
info = irq_info::type_id::create("info");
info.status = 'h2;
info.count = 8;
done_ev.trigger(info);A uvm_barrier releases its waiters only when a threshold number of them have arrived. The typical use is a rendezvous: three agents finish their own configuration at different times, and none may start traffic until all are ready. The barrier pool works like the event pool:
// snippet: three agents meet at a barrier (scratch run, sync_tb.sv)
uvm_barrier cfg_done = uvm_barrier_pool::get_global("cfg_done");
cfg_done.set_threshold(3);
fork
begin #120ns; cfg_done.wait_for(); /* APB agent starts */ end
begin #40ns; cfg_done.wait_for(); /* stream agent starts */ end
begin #300ns; cfg_done.wait_for(); /* sink agent starts */ end
joinscratch run: +UVM_TESTNAME=barrier_test
UVM_INFO sync_tb.sv(114) @ 40000: uvm_test_top [BAR] stream agent configured, waiting
UVM_INFO sync_tb.sv(113) @ 120000: uvm_test_top [BAR] APB agent configured, waiting
UVM_INFO sync_tb.sv(115) @ 300000: uvm_test_top [BAR] sink agent configured, waiting
UVM_INFO sync_tb.sv(114) @ 300000: uvm_test_top [BAR] stream agent released
UVM_INFO sync_tb.sv(113) @ 300000: uvm_test_top [BAR] APB agent released
UVM_INFO sync_tb.sv(115) @ 300000: uvm_test_top [BAR] sink agent releasedThe barrier's trap is its count. If the threshold says 3 and a configuration with only two agents runs, both wait forever. If a waiting thread is killed (a reset, a disable fork), its arrival is still counted unless someone calls cancel(). Set the threshold from the same config object that decides how many agents exist.
uvm_pool#(KEY,T) is an associative array wrapped in an object, and uvm_queue#(T) a queue; each has a get_global_pool() / get_global_queue() singleton per type. The event and barrier pools are string-keyed pools of objects. One behaviour matters more than the rest: `get()` on a missing key inserts it. For object pools it creates a new object under that name; for a pool of integers or handles it stores the default value. The scratch run shows the integer case:
scratch run: +UVM_TESTNAME=pool_test
UVM_INFO sync_tb.sv(135) @ 0: uvm_test_top [POOL] exists(dma2.jobs)=0
UVM_INFO sync_tb.sv(136) @ 0: uvm_test_top [POOL] get(dma2.jobs)=0, num()=3 after the getUse exists() before get() whenever a missing key means a bug. And use global pools sparingly: they are global mutable state, invisible in the component tree, shared by every test in the run.
You need to... | Use | Why not the others |
|---|---|---|
Pass every transaction to any number of listeners | TLM analysis port | An event carries one moment and drops data a slow waiter misses; a port delivers every item, in order, through |
Signal a moment ("interrupt", "reset done") to whoever cares |
| A port needs a connection and a subscriber class; config_db is for values, not moments. |
Make N threads meet before any continues |
| N events plus a counter is a barrier you wrote yourself. |
Hand a setting to a component before it runs | config_db or a config object | Events and pools have no build-phase precedence rules and no audit trail. |
Share a registry across unrelated code (rare) |
| Prefer a config object reachable from the env; a global pool is the last resort. |
The bench's interrupt test uses wait_trigger() safely because the test is early. The scratch run exercises the other waits on a UVM event, including the name typo that the next section is about:
scratch run: +UVM_TESTNAME=ev_race_test
UVM_INFO sync_tb.sv(69) @ 70000: uvm_test_top [RACE] wait_on 50 ns later: returned at once (is_on=1, trigger time 20000)
UVM_INFO sync_tb.sv(73) @ 70000: uvm_test_top [RACE] after reset: is_on=0
UVM_INFO sync_tb.sv(82) @ 90000: uvm_test_top [DATA] dma_done carried STATUS=2 COUNT=8
UVM_INFO sync_tb.sv(97) @ 290000: uvm_test_top [TYPO] waiting on "dma-done": never triggered; pool now holds 4 eventswait_on returned 50 ns after the trigger: the event stays on until reset(). That is the safe wait for a one-shot condition.
trigger with data delivered the handler's object to a waiter that never saw the handler.
The typo created a fourth event. The run triggered dma_done; the waiter listened on dma-done, which nothing will ever trigger.
An SoC team split interrupt handling across two packages. The interrupt VIP triggered "dma_irq"; a sequence written by another engineer waited on "dma_irq0", copied from a spec that numbered the line. The test hung until the 9,200-second default timeout, on every seed, and the first two days went into the DUT, because the waveform clearly showed the interrupt firing.
The root cause is that get_global() never fails: it creates whatever name you ask for. Two fixes became the team's rule. Event names are constants in the agent's package (localparam string DMA_IRQ_EV = "dma_irq";), so a typo is a compile error. And the base test's check_phase walks the event pool and reports any event that has waiters but was never triggered, which turns the next hang into a one-line diagnosis:
// snippet: in the base test
function void check_phase(uvm_phase phase);
uvm_event_pool pool = uvm_event_pool::get_global_pool();
string name;
super.check_phase(phase);
if (pool.first(name)) do begin
uvm_event ev = pool.get(name);
if (ev.get_num_waiters() > 0 && ev.is_off())
`uvm_warning("EV_STRANDED", $sformatf("event \"%s\" has %0d waiter(s) and was never triggered", name, ev.get_num_waiters()))
end while (pool.next(name));
endfunction"reset_done" once at 200 ns. A sequence started at 500 ns calls wait_ptrigger() on it. What happens, and what should it call?It hangs: the trigger was in an earlier time step, so wait_ptrigger() waits for a new one that never comes. A one-shot condition needs wait_on(), which returns because the event is still on (nobody reset it).
The three remaining agents wait forever at the barrier; the test ends on the global timeout. The threshold must come from the same configuration that decides which agents exist, for example the number of active agents in the env config object.
uvm_pool::get() on a missing key dangerous in a scoreboard that counts jobs per channel?It inserts the key with a default value instead of failing, so a typo or an unexpected channel silently creates a new entry with count 0. Use exists() first when a missing key is a bug, and report it.
Next: synchronisation decides who waits. The next lesson is about what a message means once it is reported, and how to waive one known error without hiding the next real one: Report Catchers and Waivers.