SYSTEMVERILOG SERIES · SV-13

SystemVerilog Series — SV-13: Interprocess Synchronization and Communication — VLSI Trainers
SystemVerilog Series · SV-13

Interprocess Synchronization and Communication

Semaphores for mutual exclusion, mailboxes for inter-process message passing, the SV event type with persistent .triggered, wait_order() for event sequencing, and event variable assignment and merging.

🔗 Why These Primitives?

Concurrent testbench processes — generators, drivers, monitors, scoreboards — run simultaneously and must coordinate without race conditions. SystemVerilog provides three built-in synchronisation primitives, each solving a specific problem:

Semaphore — mutual exclusion

When only one process at a time should access a shared resource (e.g. a bus driver, a scoreboard). Think of a semaphore as a key dispenser — only the process holding a key can proceed.

Mailbox — message passing

When one process produces data that another consumes (generator → driver, monitor → scoreboard). A FIFO queue with blocking and non-blocking access. The producer never has to wait for the consumer to be ready.

Event — synchronisation signal. When one process needs to signal another that something has happened (e.g. reset done, packet sent). Unlike Verilog events, SV events have a persistent triggered state that lasts the entire time step, eliminating the classic race condition where the waiter runs just after the trigger fires.

🔑 Semaphores

A semaphore is a bucket of keys. A process that wants to enter a critical section calls get() to take a key; when done, it calls put() to return the key. If no keys are available, get() blocks until another process calls put().

// Creating a semaphore with N initial keys
semaphore sema4 = new(1);   // 1 key = binary semaphore (mutex)
semaphore pool  = new(4);   // 4 keys = counting semaphore

// Typical critical-section pattern
sema4.get();           // acquire — blocks if key unavailable
// ... critical section: only one process here at a time ...
sema4.put();           // release — unblocks a waiting process
The waiting queue is FIFO. Processes that block on get() are queued in the order they arrive. When keys are returned, the process that has been waiting longest is unblocked first.

🔨 Semaphore Methods

new(keyCount=0)
function semaphore
Create a new semaphore with keyCount initial keys.
put(keyCount=1)
task
Return keyCount keys. If a process is waiting, it unblocks if enough keys are now available.
get(keyCount=1)
task (blocking)
Acquire keyCount keys. Blocks until all requested keys are available.
try_get(keyCount=1)
function int
Non-blocking. Returns 1 if keys were acquired; 0 if not enough keys available (does not block).
semaphore sm = new(1);

// Blocking get
sm.get();           // blocks if no key
sm.get(2);          // blocks until 2 keys available

// Non-blocking try
if(!sm.try_get())
  $display("Semaphore busy — try later");

// Returning multiple keys at once
sm.put(3);          // return 3 keys in one call

📋 Semaphore Patterns

Pattern 1 — mutex: protect a shared bus driver

class Driver;
  semaphore bus_lock = new(1);

  task drive(Packet p);
    bus_lock.get();          // take the bus
    send_on_bus(p);          // only one process here at a time
    bus_lock.put();          // release the bus
  endtask
endclass

Pattern 2 — counting semaphore: limit concurrent workers

semaphore slots = new(4);   // max 4 concurrent operations
for(int i=0; i<100; i++) begin
  slots.get();               // wait for a free slot
  fork
    automatic int k = i;
    begin
      do_work(k);
      slots.put();           // free the slot when done
    end
  join_none
end
wait fork;

Pattern 3 — non-blocking try with fallback

task automatic try_access(semaphore sm);
  if(sm.try_get()) begin
    do_work();
    sm.put();
  end else begin
    use_fallback();   // resource busy — do something else instead
  end
endtask

📧 Mailboxes

A mailbox is a FIFO message queue between processes. One process puts messages in; another gets them out. The queue can be bounded (blocks the producer when full) or unbounded (never blocks the producer).

// Unbounded mailbox (default): put() never blocks
mailbox mbx = new();

// Bounded mailbox: put() blocks when queue is full
mailbox mbxBound = new(16);  // max 16 messages

// Mailbox is type-less by default — can hold any type
Packet p_in  = new;
Packet p_out;
mbx.put(p_in);          // stores handle to Packet
mbx.get(p_out);         // retrieves handle — p_out now points to same object as p_in

// A single untyped mailbox can hold different types — but type mismatch = runtime error
mbx.put(42);            // put an integer
mbx.get(p_out);         // RUNTIME ERROR — type mismatch: expected Packet, got int
Typical generator→driver pattern: The generator creates stimulus objects and calls mbx.put(pkt). The driver loops on mbx.get(pkt) and drives each packet onto the DUT interface. The mailbox decouples their rates — the generator can run ahead of the driver without blocking.

🔨 Mailbox Methods

new(bound=0)
function mailbox
bound=0 → unbounded. Positive → max queue depth. Null returned on failure.
num()
function int
Current message count. Use with care — valid only until next put/get.
put(message)
task (blocking)
Place in FIFO. Blocks if bounded and full.
try_put(message)
function int
Non-blocking. Returns 1 on success, 0 if full. Meaningful only for bounded mailboxes.
get(ref message)
task (blocking)
Remove first message. Blocks if empty. Runtime error on type mismatch.
try_get(ref message)
function int
Non-blocking. Returns 1 (success), 0 (empty), −1 (type mismatch).
peek(ref message)
task (blocking)
Copy first message without removing it. Blocks if empty. Multiple processes may unblock on one peek.
try_peek(ref message)
function int
Non-blocking peek. Returns 1 (success), 0 (empty), −1 (type mismatch).
mailbox mbx = new(8);  // bounded: 8 messages max
Packet p, q;

// Producer: send packets
foreach(pkts[i]) begin
  p = new;
  p.fill(i);
  mbx.put(p);          // blocks if 8 messages already queued
end

// Consumer: receive and check
forever begin
  mbx.get(q);           // blocks if empty
  check(q);
end

// Non-blocking check: inspect without consuming
if(mbx.try_peek(q) == 1)
  $display("Next message: len=%0d", q.length);
peek() can unblock multiple processes. Because peek() does not remove the message, any number of processes blocked on peek() or get() for the same mailbox may all unblock when a message arrives. The first process to call get() will remove the message; subsequent get() callers will block again if no more messages remain.

📧 Parameterised Mailboxes

A typed mailbox catches type mismatches at compile time instead of runtime, making testbench bugs easier to find.

// Define a mailbox that only holds strings
typedef mailbox #(string) s_mbox;
s_mbox sm = new;

sm.put("hello");   // OK
string s;
sm.get(s);        // s = "hello"
// sm.put(42);     // COMPILE ERROR — type mismatch caught at compile time

// Parameterised Packet mailbox
typedef mailbox #(Packet) pkt_mbx_t;
pkt_mbx_t gen2drv = new();   // generator → driver channel
pkt_mbx_t mon2scb = new();   // monitor → scoreboard channel
Prefer typed mailboxes. An untyped mailbox requires runtime type checking — a mismatch is only caught when get() is called, potentially far from the source of the bug. A mailbox#(Packet) catches mismatches at the point where you try to put() a wrong type, making the error immediately visible.

Events

SV events extend Verilog named events with three key improvements: a persistent triggered state (lasts the whole time step), the ability to be passed as arguments and assigned to each other, and a non-blocking trigger operator ->>.

// Declare events
event done, blast;

// Alias: done_too is another name for the SAME synchronisation object as done
event done_too = done;

// Blocking trigger: -> fires immediately
->done;

// Non-blocking trigger: ->> fires in the NBA region (no blocking)
->>done;

// Wait for the event: @ blocks until triggered
@done;                        // blocks — may MISS the trigger if it already fired
wait(done.triggered);        // level-sensitive — NEVER misses the trigger

Triggering Events — -> vs ->>

-> blocking trigger

// Executes immediately in Active region
// All processes waiting on @ev unblock now
// Trigger state is instantaneous
->ev;
// Statement after trigger executes
// in the same time step

->> non-blocking trigger

// Schedules the trigger in the NBA region
// Does NOT block the calling process
// Useful when triggering from always_ff
->>ev;
// Like NBA assignment: fires after active
// region completes

Passing events as task arguments

task trigger(event ev);
  ->ev;
endtask

event done, done_too = done;

fork
  @done_too;                // process A: wait via alias
  #1 trigger(done);        // process B: trigger via task argument
join
// Both done and done_too refer to the same object.
// Triggering done via the task unblocks the waiter on done_too.

Persistent triggered Property — Eliminating the Race

In Verilog, if the trigger fires before the waiter reaches @ev, the waiter is permanently blocked. SV solves this with ev.triggered — a Boolean that stays true for the entire time step after the trigger fires.

@ operator — can miss the trigger

fork
  ->ev;        // P1: triggers ev
  @ev;         // P2: if P1 ran first, P2 BLOCKS FOREVER
join

wait(ev.triggered) — never misses

fork
  ->blast;                  // P1: triggers
  wait(blast.triggered);   // P2: unblocks even if P1 ran first
join                         // always terminates
// triggered persists through the current time step then resets.
// Use @ev when you want edge-triggered (new trigger) semantics.
// Use wait(ev.triggered) when you want level-sensitive (was it triggered this step?) semantics.

// Common testbench pattern: wait for reset to be released
event reset_done;

// In the driver: wait safely for reset even if it already fired
wait(reset_done.triggered);
start_driving();

// In the reset monitor: trigger when reset de-asserts
always @(negedge rst_n) ->reset_done;

📋 wait_order() — Event Sequencing

wait_order(a, b, c) suspends the calling process until events a, b, and c have all triggered in that exact left-to-right order. If any event fires out of order, the construct either generates a runtime error (no else clause) or executes the fail statement.

event a, b, c;

// Wait for a → b → c in order; runtime error if out of order
wait_order(a, b, c);

// With a fail statement: display message on failure, no hard error
wait_order(a, b, c) else $display("Error: events out of order");

// Capture success/failure in a variable
bit success;
wait_order(a, b, c) success = 1; else success = 0;

// Practical: verify a three-phase handshake fires in the right order
// Phase 1 (req) → Phase 2 (grant) → Phase 3 (ack)
wait_order(ev_req, ev_grant, ev_ack)
  $display("Handshake completed correctly");
else
  $error("Protocol violation: events out of order");
Ordering rules: Only the first event in the list uses the persistent .triggered property — so if the first event already fired before wait_order is reached, the construct still works. Later events in the list must fire in order after the construct starts waiting for them. A preceding event can fire again without causing failure — the ordering only applies to subsequent unforced events.

Event Variables — Merging, null, and Comparison

Merging events via assignment

Assigning one event to another makes them share the same underlying synchronisation queue. Triggering either one unblocks waiters on both.

event a, b, c;

a = b;     // a and b now share a synchronisation queue
->a;      // also triggers b
->b;      // also triggers a

a = c;     // a now shares c's queue (a and b are no longer merged)
b = a;     // b now shares a's (= c's) queue
             // -> a  triggers b and c
             // -> b  triggers a and c
             // -> c  triggers a and b
Merging only affects future waits. If a process is already blocked on @E2 when you execute E2 = E1, that process will never unblock — the merge only redirects future @E2 operations. To unblock existing waiters through a merge, the merge must happen before the wait.
// The timing trap: merge AFTER the wait starts
fork
  T1: while(1) @E2;   // blocks on E2's original queue
  T2: while(1) @E1;
  T3: begin
    E2 = E1;            // T1 is ALREADY blocked — merge won't help it
    while(1) ->E2;      // T1 NEVER unblocks; T2 unblocks each time
  end
join

Reclaiming events with null

event E1;
E1 = null;              // detach E1 from its synchronisation queue
@E1;                    // undefined: may block forever or not at all
wait(E1.triggered);    // triggered is false when E1 is null
->E1;                   // no effect (safe to trigger null event)

Event comparison

event E1, E2;
if(E1)                    // 0 if null, 1 otherwise — boolean test
  E1 = E2;
if(E1 == E2)              // true if both share the same synchronisation queue
  $display("E1 and E2 are the same event");
if(E1 != null) ...        // explicitly check for null

// Legal comparison operators: == != === !== (and boolean test)
// Arithmetic and relational operators are NOT legal on event variables

📋 Quick Reference

Semaphore methods

MethodTypeBehaviour
new(N)functionCreate with N initial keys
get(N=1)task (blocking)Acquire N keys; block if unavailable
try_get(N=1)function intReturn 1 if acquired, 0 if not; never blocks
put(N=1)taskReturn N keys; unblock waiting processes

Mailbox methods

MethodTypeBehaviour
new(bound=0)function0=unbounded, N>0=bounded
num()function intCurrent message count
put(msg)task (blocking)Enqueue; blocks if bounded and full
try_put(msg)function int1=success, 0=full; never blocks
get(ref msg)task (blocking)Dequeue; blocks if empty; −error on type mismatch
try_get(ref msg)function int1=ok, 0=empty, −1=type mismatch
peek(ref msg)task (blocking)Copy without removing; blocks if empty
try_peek(ref msg)function int1=ok, 0=empty, −1=type mismatch

Event rules at a glance

  • ->ev — blocking trigger; fires in Active region.
  • ->>ev — non-blocking trigger; fires in NBA region.
  • @ev — edge-sensitive wait; can miss trigger if it already fired.
  • wait(ev.triggered) — level-sensitive; never misses; stays true whole time step.
  • Assign one event to another to merge their synchronisation queues.
  • Merge only affects future waiters — already-blocked processes are not redirected.
  • Assign null to break the association; triggering null is a no-op.
  • Legal comparisons: ==, !=, ===, !==, boolean test (if(ev)).
Coming next: SV-14 covers Section 14 — Scheduling Semantics: the stratified event scheduler, time-slot regions (Preponed, Active, Inactive, NBA, Observed, Reactive, Postponed), and how program blocks, clocking blocks, and assertions interact with the simulation time step.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top