Components in a testbench often need to communicate with each other to exchange data and check output values of the design. A few mechanisms allow components or threads to affect each other's control flow:
| Mechanism | What it's for |
|---|---|
| Events | Different threads synchronize with each other via event handles |
| Semaphores | Different threads take turns accessing the same shared resource |
| Mailbox | Threads or components exchange data — data is put into a mailbox and sent |
8 min read | Intermediate Level
What You'll Learn
- What an event, a semaphore, and a mailbox each solve, and how they differ
- How to trigger and wait for an event across two separate processes
- How a semaphore lets only a limited number of processes access a shared resource at once
- How a mailbox passes data safely from one component to another
What Are Events?
An event is a way to synchronize two or more different processes. One process waits for the event to happen while another process triggers it. When the event is triggered, the process waiting for it resumes execution.
1. Create an event using event
event eventA; // Creates an event called "eventA"
2. Trigger an event using the -> operator
->eventA; // Any process that has access to "eventA" can trigger the event
3. Wait for the event to happen
@eventA; // Use "@" to wait for an event
wait (eventA.triggered); // Or use a wait statement with "eventA.triggered"
4. Pass events as arguments to tasks or functions
module tb_top;
event eventA; // Declare an event handle called "eventA"
initial begin
fork
waitForTrigger (eventA); // Task waits for eventA to happen
#5 ->eventA; // Triggers eventA
join
end
// The event is passed as an argument to this task. It simply waits for the event
// to be triggered
task waitForTrigger (event eventA);
$display ("[%0t] Waiting for EventA to be triggered", $time);
wait (eventA.triggered);
$display ("[%0t] EventA has triggered", $time);
endtask
endmodule
Output
[0] Waiting for EventA to be triggered [5] EventA has triggered ncsim: *W,RNQUIE: Simulation is complete.
SystemVerilog Event covers this in more depth.
Quick Check
Q: If ->eventA; fires, and only 5ns later does a separate process execute @eventA;, will that process see the trigger?
Show Answer
No. An event isn't queued or remembered — a trigger only wakes up processes that are actively waiting for it at that exact moment. A process that starts waiting after the trigger already happened will simply keep waiting for the next trigger, if one ever comes.
What's a Semaphore?
Say you wanted to rent a room in a library for a few hours. The admin desk gives you a key to use the room for the time you've requested. After you're done, you return the key, which is then given to someone else who wants to use the same room — this way, two people never use the room at the same time. The key is a semaphore in this context.
A semaphore is used to control access to a resource and is also known as a mutex (mutually exclusive), because only one entity can hold it at a time:
module tb_top;
semaphore key; // Create a semaphore handle called "key"
initial begin
key = new (1); // Create only a single key; multiple keys are also possible
fork
personA (); // personA tries to get the room and puts it back after work
personB (); // personB also tries to get the room and puts it back after work
#25 personA (); // personA tries to get the room a second time
join_none
end
task getRoom (bit [1:0] id);
$display ("[%0t] Trying to get a room for id[%0d] ...", $time, id);
key.get (1);
$display ("[%0t] Room Key retrieved for id[%0d]", $time, id);
endtask
task putRoom (bit [1:0] id);
$display ("[%0t] Leaving room id[%0d] ...", $time, id);
key.put (1);
$display ("[%0t] Room Key put back id[%0d]", $time, id);
endtask
// This person tries to get the room immediately and puts
// it back 20 time units later
task personA ();
getRoom (1);
#20 putRoom (1);
endtask
// This person tries to get the room after 5 time units and puts it back after
// 10 time units
task personB ();
#5 getRoom (2);
#10 putRoom (2);
endtask
endmodule
Output
[0] Trying to get a room for id[1] ... [0] Room Key retrieved for id[1] [5] Trying to get a room for id[2] ... [20] Leaving room id[1] ... [20] Room Key put back id[1] [20] Room Key retrieved for id[2] [25] Trying to get a room for id[1] ... [30] Leaving room id[2] ... [30] Room Key put back id[2] [30] Room Key retrieved for id[1] [50] Leaving room id[1] ... [50] Room Key put back id[1]
key starts with a single available token. personA grabs it immediately at 0ns. When personB tries at 5ns, key.get(1) blocks — there's nothing left to give — until personA returns the key at 20ns, at which point personB's blocked call immediately succeeds. The same pattern repeats when personA's second call tries to get the key at 25ns while personB still holds it, and has to wait until 30ns.
A few things worth noting about semaphores:
- A semaphore object like key is declared and created with
new(); the argument tonew()sets how many keys exist - You get a key with
get(), which blocks (waits) until one is available - You put a key back with
put()
SystemVerilog Semaphore covers this in more depth.
Quick Check
Q: If key = new(2) instead of new(1), would personB's getRoom(2) call at 5ns still have to wait for personA to return anything?
Show Answer
No. With 2 keys available, both personA (at 0ns) and personB (at 5ns) could each get their own key without blocking — only a third concurrent request, with both keys already out, would have to wait.
What's a Mailbox?
A mailbox is like a dedicated channel for sending data between two components. A mailbox can be created and its handle passed to a data generator and a driver: the generator pushes a data object into the mailbox, and the driver retrieves it and drives the signals onto the bus.
// Data packet in this environment
class transaction;
rand bit [7:0] data;
function display ();
$display ("[%0t] Data = 0x%0h", $time, data);
endfunction
endclass
// Generator class - Generate a transaction object and put into mailbox
class generator;
mailbox mbx;
function new (mailbox mbx);
this.mbx = mbx;
endfunction
task genData ();
transaction trns = new ();
trns.randomize ();
trns.display ();
$display ("[%0t] [Generator] Going to put data packet into mailbox", $time);
mbx.put (trns);
$display ("[%0t] [Generator] Data put into mailbox", $time);
endtask
endclass
// Driver class - Get the transaction object from Generator
class driver;
mailbox mbx;
function new (mailbox mbx);
this.mbx = mbx;
endfunction
task drvData ();
transaction drvTrns = new ();
$display ("[%0t] [Driver] Waiting for available data", $time);
mbx.get (drvTrns);
$display ("[%0t] [Driver] Data received from Mailbox", $time);
drvTrns.display ();
endtask
endclass
// Top Level environment that will connect Gen and Drv with a mailbox
module tb_top;
mailbox mbx;
generator Gen;
driver Drv;
initial begin
mbx = new ();
Gen = new (mbx);
Drv = new (mbx);
fork
#10 Gen.genData ();
Drv.drvData ();
join_none
end
endmodule
Output
[0] [Driver] Waiting for available data [10] Data = 0x9d [10] [Generator] Put data packet into mailbox [10] [Generator] Data put into mailbox [10] [Driver] Data received from Mailbox [10] Data = 0x9d ncsim: *W,RNQUIE: Simulation is complete.
The code's $display statement reads "Going to put data packet into mailbox," but the captured output above shows "Put data packet into mailbox" instead, with "Going to" missing. This looks like the code and the captured log have drifted out of sync somewhere — worth verifying against the source before publishing, since the two don't currently match word for word.
Drv starts waiting on an empty mailbox at 0ns. Gen creates and randomizes a transaction at 10ns, then puts it into the mailbox — which immediately unblocks Drv's pending get() call. Both trns and drvTrns print the exact same data value, because mbx.get(drvTrns) reassigns the drvTrns handle to point at the very same object Gen put in — it's not a copy.
SystemVerilog Mailbox covers this in more depth.
Quick Check
Q: If Drv were removed from the fork block entirely, so nothing ever called mbx.get(), what would happen to Gen's mbx.put(trns) call?
Show Answer
It would still succeed immediately. A mailbox created with new() and no size argument is unbounded, so put() doesn't need a waiting receiver to succeed — the transaction would simply sit in the mailbox, unread, until something eventually calls get().
Where Are These Used in a Real Testbench?
Events are common for synchronizing on things like reset deassertion or a test phase starting. Semaphores show up wherever multiple components share one physical resource, like a single bus that only one driver can use at a time. Mailboxes are the standard way a generator hands transactions off to a driver — the same producer-consumer pattern UVM's sequencer-to-driver connection is built on.
Common Beginner Mistakes
Mistake #1: Assuming an event trigger is remembered if no one's waiting yet
As the Quick Check above shows, a trigger that fires with nobody currently waiting on it is simply lost. Why? Events aren't queued the way mailbox data is — if the timing between the trigger and the wait doesn't line up, the waiting process can end up stuck indefinitely.
Mistake #2: Forgetting to put a semaphore key back
Every get() needs a matching put(), or the key never becomes available again. Why? If a task takes a key and then exits early (through an error path, for instance) without calling put(), every other process waiting for that same key blocks forever — a classic deadlock.
Mistake #3: Assuming mailbox.get() always blocks
get() only blocks when the mailbox is currently empty. Why? If data is already sitting in the mailbox when get() is called, it returns immediately — the blocking behavior only kicks in when there's genuinely nothing there yet.
Practice Exercise
Challenge: Give the Library Two Rooms
Using the semaphore example above, change the library to have 2 rooms available instead of 1, and add a third person, personC, who tries to get a room at time 0, alongside personA.
Hint
Only the argument to new() and the fork list need to change — the getRoom/putRoom tasks themselves don't need any modification.
Solution
module tb_top;
semaphore key;
initial begin
key = new (2); // Two rooms available now
fork
personA ();
personC (); // Tries to get a room at time 0, alongside personA
personB ();
#25 personA ();
join_none
end
// getRoom, putRoom, personA, personB tasks unchanged
task personC ();
getRoom (3);
#15 putRoom (3);
endtask
endmodule
With 2 keys available, both personA and personC can get a room at 0ns without either one blocking — only once both keys are out does a third concurrent request have to wait, exactly like personB did against a single key in the original example.
Summary
In this tutorial, you learned:
- Events synchronize two or more processes — one triggers with
->, another waits with@orwait(event.triggered), but a trigger with nobody waiting is simply lost - Semaphores control access to a shared resource using a limited number of keys, obtained with
get()and returned withput() - Mailboxes pass data between components —
put()andget()hand a class handle across, not a copy of the object mailbox.get()only blocks when the mailbox is empty; a semaphore'sget()only blocks when no key is currently available- Each mechanism solves a different coordination problem — see SystemVerilog Event, SystemVerilog Semaphore, and SystemVerilog Mailbox for the full depth on each