SystemVerilog · Module 13
Events
SystemVerilog events — declaring with event, triggering with -> (Active region) and ->> (NBA region), waiting with @ and wait(triggered), the same-time-step race that hides the single most common event bug, broadcast semantics, passing events by ref, barrier and phase patterns, and the failure modes every verification engineer ships at least once.
Module 13 · Page 13.2
An event is the simplest IPC mechanism: a named trigger point that lets one process notify another that something happened — with no data, no queueing, just a signal. The concept is simple, but the difference between @event and wait(event.triggered) hides the single most common event bug in all of SystemVerilog. This page walks through both, plus the broadcast semantics, the -> vs ->> region distinction, the canonical patterns, and the failure modes.
1. Engineering Problem — Why Events Exist
A SystemVerilog testbench launches dozens of concurrent threads — a driver, a monitor, a scoreboard, a sequencer, four parallel channel agents. Those threads need to coordinate, but they cannot share state safely and cannot block waiting on each other without a primitive.
Without events, the only ways one thread can tell another "something just happened" are:
- Polling a shared flag — wastes simulation time, races with the writer, never deterministic.
- A blocking task call — couples the threads tightly; the producer waits on the consumer.
- A mailbox handshake — works, but adds a queue you did not need when there is no data to pass.
The event primitive is the IEEE 1800 answer: a named, broadcast trigger with no payload, evaluated in the simulator's scheduling regions and cheaper than any of the above. One process fires it with ->; any number of processes wait on it with @ or wait(triggered); all waiters unblock at the same simulation time.
2. Mental Model — A Starting Pistol
The picture every engineer carries:
An event is a starting pistol. The shooter (
->e) fires it. Everyone listening (@eorwait(e.triggered)) hears it at the same instant and runs. No baton is handed over — just the bang. After the bang fades, the next listener who arrives hears nothing until the shooter fires again.
Two invariants this picture preserves:
- Broadcast. One trigger unblocks every process currently waiting. This is unlike a mailbox
put(), which unblocks exactly oneget()call. Use events when "all listeners should react now"; use mailboxes when each item must be consumed by exactly one consumer. - No history. The trigger is an instantaneous event, not a persistent flag. A waiter that arrives after the trigger fired sees nothing — unless it uses
wait(e.triggered), which reads a per-time-step flag instead of waiting for the next edge.
The same-time-step race in §6 is the place this mental model breaks down for @e specifically. The waiter who reaches the line exactly when the gun fires may or may not hear it, depending on simulator scheduling order.
3. Visual Explanation — Where the Trigger Lives
SystemVerilog has two trigger operators that differ only in which scheduling region the trigger lands in. The full IEEE 1800 region pipeline for a single time slot:
The blocking trigger -> fires in the Active region
event regionsThe non-blocking trigger ->> defers to the NBA region
event regionsThe region distinction is the entire reason there are two trigger operators. Section 5 makes the consequence concrete.
4. Syntax & Semantics — Declaration, Trigger, Wait
4.1 The five operations
// ── Declaration ───────────────────────────────────────────────
event done; // module-level event — any process in scope can use it
event phase_start [4]; // array of 4 independent events — one per phase
// ── Blocking trigger: -> ──────────────────────────────────────
->done; // fires the event; returns immediately (not a blocking statement)
// all processes currently blocking on @done are unblocked
// ── Non-blocking trigger: ->> ─────────────────────────────────
->>done; // scheduled trigger — fires in the NBA region of this time step
// avoids certain race conditions with @done (see §5)
// ── Wait for the NEXT trigger: @ ─────────────────────────────
@done; // blocks until ->done fires at a FUTURE time step
// if ->done already fired THIS time step, @ will MISS it
// ── Check if triggered THIS time step: wait(triggered) ────────
wait(done.triggered); // returns immediately if ->done fired anywhere in this time step
// blocks until ->done fires if it has NOT yet fired this step
// ── Null check: event handle may be null ──────────────────────
if (done == null) // unassigned event variable; triggers on it will fail
$error("event not initialised");4.2 The .triggered property
event exposes a single read-only property: e.triggered returns 1 during the simulation time step in which ->e fired, and 0 otherwise. wait(e.triggered) is just the canonical way to read that property without busy-waiting.
event done;
initial begin
@(posedge clk);
->done; // fires during this clk edge time step
if (done.triggered) // true — same time step as the trigger
$display("[T=%0t] just fired", $time);
end
initial begin
@(posedge clk);
#1; // advance past the trigger's time step
if (done.triggered) // false — trigger flag cleared on next step
$display("never printed");
end5. Simulation View — -> vs ->> and the Scheduling Race
The IEEE 1800-2017 stratified-event-region pipeline (visualised in §3) is what gives -> and ->> their different behaviour.
| Operator | Region | When waiters in the same time slot see it |
|---|---|---|
->e | Active region of the current time slot | Any process already sitting on @e is unblocked immediately. A process that reaches @e later in the same Active region misses the trigger. |
->>e | NBA region of the current time slot | Deferred past the rest of the Active region. Every @e evaluated in the same Active region sees it on its next resume. |
wait(e.triggered) | reads a persistent per-time-step flag | Immune to the race — returns true if ->e fired any time during this step, Preponed through Postponed. |
5.1 The race made concrete
event e;
// ── Scenario: trigger and waiter at the same simulation time ──
initial begin
#10;
$display("[A] firing event at t=%0t", $time);
->e;
#0; // delta cycle — still t=10
$display("[A] after trigger");
end
// ── PROBLEM: this waiter may miss the event ───────────────────
initial begin
#10; // arrives at t=10, same as the trigger
@e; // if ->e already fired at t=10 before this process was
// scheduled, @ waits for the NEXT ->e — possibly forever
$display("[B] caught with @ at t=%0t", $time);
end
// ── SAFE: this waiter always catches same-time triggers ───────
initial begin
#10;
wait(e.triggered); // catches ->e whether it fired before or after this line
$display("[C] caught with wait(triggered) at t=%0t", $time);
end5.2 The race as a waveform
The diagram below shows the same time slot at t=10 where thread A's ->e and threads B / C's @e / wait(triggered) all resume. Whether thread B catches the trigger depends on simulator scheduling order — which is not defined by IEEE 1800. Thread C's wait(triggered) reads a persistent flag and is order-independent.
Figure — same-time-step race: @e is scheduling-dependent, wait(e.triggered) is not
8 cycles5.3 Picking the right trigger and wait
event e;
// ── ALWAYS wrong: same thread, -> then @e ─────────────────────
initial begin
->e; // fires now
@e; // ALWAYS misses — it already fired this time step
end
// ── Sometimes works: ->> then @e (NBA vs Active region) ───────
initial begin
->>e; // scheduled to NBA region
@e; // this Active-region wait may now catch the deferred trigger
end
// ── Always safe: wait(e.triggered) ────────────────────────────
initial begin
->e;
wait(e.triggered); // returns immediately — .triggered = 1 this step
end6. Waveform & Broadcast — One Trigger, Many Waiters
A single ->e unblocks every process currently blocking on @e. There is no "first listener wins" semantic. This is the broadcast behaviour that distinguishes events from mailboxes.
event start_test;
// ── Start signal: trigger once, unblock 4 independent agents ──
initial fork
begin : controller
@(posedge clk);
$display("[CTRL] Starting all agents at t=%0t", $time);
->start_test; // ONE trigger unblocks ALL four agents below
end
begin : agent_0
@start_test; drive_channel(0);
end
begin : agent_1
@start_test; drive_channel(1);
end
begin : agent_2
@start_test; drive_channel(2);
end
begin : agent_3
@start_test; drive_channel(3);
end
join
// All 4 agents start simultaneously when the controller fires ->start_test.7. Synthesis — Not Applicable
event is a simulation-only construct. It has no hardware footprint, no synthesised gates, and no place in synthesisable RTL — synthesis tools reject the keyword outright. This section is intentionally omitted; the topic does not warrant it.
The corresponding hardware idiom in RTL is a pulse signal combined with a sampling register (req_pulse, done_pulse) — a related concept covered separately under always-block and CDC handshake lessons, not under IPC events.
8. Verification View — Patterns That Ship in Real Testbenches
The three patterns below recur in every UVM and SV-DV environment. Read them as the canonical event-shaped solutions to barrier sync, phase control, and producer / consumer handoff.
8.1 Barrier synchronisation — wait for N threads
parameter N = 4;
event thread_done [N];
int done_count = 0;
event all_done;
// Each thread runs work then signals done
initial begin
for (int i = 0; i < N; i++) begin
automatic int idx = i; // capture for fork
fork
begin
run_channel(idx);
->thread_done[idx]; // signal completion
end
join_none
end
end
// Counter: each completion increments; the last one fires the barrier
initial begin
for (int i = 0; i < N; i++) begin
automatic int idx = i;
fork
begin
@thread_done[idx];
done_count++;
if (done_count == N) ->all_done;
end
join_none
end
end
// Main thread waits for all channels
initial begin
@all_done;
$display("[MAIN] All %0d channels complete at %0t", N, $time);
$finish;
end8.2 Test phase control
Use a chain of events to sequence the test phases — reset, init, run, drain. Each phase fires its done event when complete; the next phase waits on it.
event reset_done;
event init_done;
event test_done;
event drain_done;
initial fork
// ── Sequencer: drives the phase transitions ───────────────
begin : sequencer
apply_reset(); ->reset_done;
init_dut(); ->init_done;
run_test(); ->test_done;
drain_pipeline(); ->drain_done;
end
// ── Monitor: only starts after init ───────────────────────
begin : monitor
wait(init_done.triggered); // safe even if sequencer raced ahead
forever capture_bus_activity();
end
// ── Coverage: samples after test completes ────────────────
begin : coverage
wait(test_done.triggered);
sample_final_coverage();
end
// ── Watchdog: bound the drain phase ───────────────────────
begin : watchdog
wait(test_done.triggered);
fork
wait(drain_done.triggered); // normal completion
#100_000 $fatal(1, "[WDG] Drain timeout");
join_any
disable fork;
$finish;
end
joinNote the use of wait(.triggered) in the consumer threads — it is immune to whatever scheduling order the simulator picks for the four forked branches. Using @ here would re-introduce the same-step race.
8.3 Passing events by ref
Events behave like class handles — passing them by value to a task creates a copy and the trigger never reaches the caller's event object. The ref keyword is mandatory.
// ── Correct: ref event ────────────────────────────────────────
task automatic run_and_signal(ref event done_ev, input int n);
repeat (n) @(posedge clk);
->done_ev; // triggers whichever event was passed in
$display("[TASK] Done after %0d cycles", n);
endtask
event ch0_done, ch1_done;
initial fork
run_and_signal(ch0_done, 10); // fires ch0_done after 10 cycles
run_and_signal(ch1_done, 20); // fires ch1_done after 20 cycles
join_none
// Wait for both channels to complete
fork
wait(ch0_done.triggered);
wait(ch1_done.triggered);
join
$display("[TB] Both channels done");8.4 Event aliasing
event a = b; makes a and b two handles to the same event object. Triggers on either are seen by waiters on the other. This is intentional when designing reusable agents that publish an event handle to the environment; it is a debugging nightmare when introduced accidentally.
event a, b;
a = b; // a now refers to the same event object as b
->a;
wait(b.triggered); // unblocks — a and b are the same underlying event9. Industry Usage — Where Events Land in Real Verification
- UVM
uvm_event— every UVM environment ships anuvm_event_poolof named events. The underlying mechanism is the SystemVerilogeventplus a persistent trigger flag and an optional data payload. Internalising raw events is the prerequisite for understanding whyuvm_event::wait_trigger()is the canonical UVM signal. - Phase handshakes — UVM's
phase_doneobjection-completion pattern is conceptually an event broadcast: one objection-drop fires a trigger every component is listening on. - VIP start / stop signals — every protocol VIP (APB / AHB / AXI / DDR / Ethernet) exposes a
start_trafficevent the test layer fires when stimulus generation begins. The agents inside the VIP all wait on it concurrently. - End-of-test draining — the classic
wait(test_done.triggered); #1; disable fork; $finish;pattern shows up in nearly every block-level testbench. Thewait(.triggered)form is non-negotiable here;@ships bugs that surface only at regression scale. - Sequencer→driver handshakes — UVM's
get_next_item/item_donepair is mailbox-shaped at the API level but uses events internally for the resume signalling.
10. Design Review Notes — What a Senior Will Flag
| Pattern in the diff | What review will say |
|---|---|
task t(event e); ... endtask | "Pass by ref — event argument without ref creates a local copy; triggers never reach the caller's object." |
->e; @e; in the same thread | "@e always misses a same-thread ->e. Use wait(e.triggered) or split into two threads." |
@e with no timeout in test-level code | "Wrap in fork ... join_any with a watchdog. A hung sim is harder to debug than a failed one." |
a = b; on event without a comment | "Aliasing — document it. Future maintainers will assume a and b are independent." |
initial forever ->e; | "Zero-time loop — starves the scheduler. Add a @(posedge clk) or #1 advance." |
@e used inside SVA disable iff or @() event control | "Confirm region semantics — assertion-region waits behave differently from procedural @." |
event done; declared but never triggered | "Dead code — remove. The reader will spend ten minutes searching for the trigger that doesn't exist." |
The single highest-value rule: default to wait(e.triggered) for one-shot events; reserve @e for cases where the waiter is provably armed before the producer fires (e.g. the waiter sits on a clock edge and the producer fires later in the same cycle).
11. Debugging Guide — Real Failures, Real Fixes
Scoreboard misses every Nth transaction at random
MISSED-TRIGGERforever begin
drive_one();
->pkt_sent; // Active-region trigger
end
forever begin
@pkt_sent; // Active-region wait — racy
score_one();
end->pkt_sent in the Active region. Scoreboard reaches @pkt_sent in the same Active region but after the trigger thread ran — the wait misses. The next drive_one() overwrites the missed transaction's effect before the scoreboard rearms.@pkt_sent with wait(pkt_sent.triggered), or push the trigger to NBA with ->>pkt_sent. The first fix is preferred because it documents the intent ("I want any trigger this step") at the consumer site.Watchdog timer fires but @drain_done never returns
@-IN-SAME-THREADinitial begin
@test_done;
drain_pipeline();
->drain_done; // fires immediately
@drain_done; // ALWAYS misses — same thread, same step
$finish;
end->drain_done fires in the Active region of the current time step. @drain_done on the very next line evaluates in the same Active region; the trigger already fired; the wait blocks for the next trigger that never comes.@drain_done (the trigger is the closer's signal — there is no second waiter), or rewrite as wait(drain_done.triggered);. Same-thread -> immediately followed by @ is always wrong.run_and_signal task fires but caller never sees the trigger
BY-VALUE-EVENTtask automatic run_and_signal(event done_ev, int n); // missing ref
repeat (n) @(posedge clk);
->done_ev;
endtask
event ch_done;
initial fork
run_and_signal(ch_done, 10);
join_none
@ch_done; // hangs — ch_done was never triggered@ch_done never returns. Adding $display confirms the task ran; the trigger appears to vanish.event is a handle. Without ref, the task receives a copy of the handle — its ->done_ev fires that local copy, not the caller's ch_done. The caller's event was never triggered.ref: task automatic run_and_signal(ref event done_ev, int n);. Lint rules at every shop flag event task arguments without ref — turn that rule on.Sequential @ where the test author meant a barrier
MISSING-FORKinitial begin
@ch0_done; // blocks until ch0 completes
@ch1_done; // THEN blocks until ch1 completes — sequential!
$display("both done");
endch1 happens to finish before ch0, the test waits for ch0, then ch1.triggered is already cleared and the second @ hangs forever.@ statements wait one at a time. The author meant "wait for both" (a barrier) but wrote "wait for ch0, then wait for ch1" (a chain). If ch1 fires first, its trigger is gone by the time the second @ is reached.fork ... join, or use wait(ch0_done.triggered); wait(ch1_done.triggered); so the order does not matter.Simulator hangs at t=0 with no error
ZERO-DELAY-LOOPinitial forever
->done; // delta-cycle loop — no time advancet=0 repeatedly, then the runtime kills the process at the iteration limit. No assertion, no $finish, no progress.->done does not advance simulation time. A forever loop with no time delay or event control creates an infinite zero-delay loop that starves every other process in the design.forever begin @(posedge clk); ->done; end or forever begin #1; ->done; end. Any procedural event-control or delay breaks the zero-time loop.12. Interview Insights — What Interviewers Actually Probe
The @event vs wait(event.triggered) distinction is the single most-asked event question across Intel, Cadence, Synopsys, NVIDIA, Qualcomm, AMD, Apple, and Arm verification rotations. Strong answers explain not just the rule but why the rule exists at the scheduling-region level.
@event is edge-sensitive — it blocks until the next trigger and may miss a trigger that fires at the exact same simulation time step (scheduling-order dependent). wait(event.triggered) reads a per-time-step flag — it returns immediately if the trigger fired anywhere in the current step, regardless of scheduling order, and is immune to the same-time race.
The deeper answer is about scheduling regions: ->e lands in the Active region; @e reading "did e fire?" also evaluates in the Active region, so whichever runs first wins. e.triggered is a flag the simulator sets for the entire time slot (Preponed through Postponed), so reading it is order-independent.
13. Exercises
1. Design — barrier without a counter (Foundation)
Implement an N-thread barrier using only event[N] and a single wait per thread, without a shared int counter or a coordinator process. Hint: you may use fork ... join and an array of @ waits inside it.
2. Debug — the disappearing transaction (Intermediate)
A regression drops 0.4% of transactions across 5,000 seeds. The driver code is forever begin drive_one(); ->pkt_sent; end and the scoreboard is forever begin @pkt_sent; score_one(); end. Both use the same simulator and the same event. Walk through the diagnostic: which $display calls would you add, what would the timing log show, and what is the canonical fix?
3. Code review — passing events (Intermediate)
A teammate submits a task task wait_and_clear(event e); @e; clear_state(); endtask and a caller event done; wait_and_clear(done); ->done;. What are the three bugs and what is the fix for each?
4. Trade-off — wait(triggered) vs @ everywhere (Advanced)
A junior teammate proposes a coding-style rule: "always use wait(e.triggered) and never use @e — it is strictly safer." Argue the case against the rule. When does @e legitimately beat wait(.triggered), and what cost does the blanket-rule pay in code readability, simulator performance, or expressiveness?
14. Summary
An event is a starting pistol — a named, broadcast trigger that fires instantly and persists no history. One ->e unblocks every @e or wait(e.triggered) currently listening. The single failure mode you must internalise is the same-time-step race: @e may miss a trigger that fires at the same simulation time, and wait(e.triggered) is the order-independent fix.
Defaults to memorise. Trigger with ->e. Wait with wait(e.triggered) for any one-shot trigger you cannot prove is fired strictly after the waiter arrives. Pass events to tasks with ref. Never write ->e; @e; in the same thread.
Next up: 13.3 — Semaphores, the IPC primitive for mutual exclusion and credit-based flow control. Where events are broadcast signals, semaphores are queue-and-serve gates.