SystemVerilog Mailbox and Semaphore: IPC Guide

In a SystemVerilog testbench, a driver, a generator, a monitor and a scoreboard all run as concurrent processes inside fork...join. The moment two of these processes try to hand data to each other through an ordinary variable, you hit a race: the writer might overwrite a value before the reader has read it, or the reader might read stale data. You need a channel that is safe to share between processes and that knows how to make a process wait when data is not ready. That is exactly what the built-in mailbox and semaphore classes give you.

Both are part of SystemVerilog’s inter-process communication (IPC) toolkit. A mailbox moves data between processes as an ordered queue of messages. A semaphore moves no data at all; it hands out a fixed number of keys that gate access to a shared resource. This guide builds both from first principles, with code you can run yourself.

Why not just use a shared variable?

Imagine a generator that writes a transaction into a shared variable and a driver that reads it. Nothing coordinates the two. If the generator produces three transactions before the driver wakes up, two of them are simply lost, because the variable only holds the latest write. There is no backlog, no ordering guarantee, and no blocking. A mailbox fixes all three: it buffers messages in order, and get() blocks the consumer until a message actually exists.

The mailbox: put, get, num

A mailbox is created with new(). Declaring it as mailbox #(T) makes it typed, so the compiler checks that every message is of type T. The core methods are:

  • put(msg) — append a message. Blocks only if the mailbox is bounded and full.
  • get(ref msg) — remove and return the oldest message. Blocks until one is available.
  • peek(ref msg) — copy the oldest message without removing it.
  • num() — the current number of queued messages.
  • try_get() / try_put() — non-blocking variants that return 0 instead of waiting.

Here is the classic producer-consumer handoff. The producer emits a value every 5 time units; the consumer blocks on get() and wakes the instant a value arrives. Notice the top module is named tb and the two processes run inside fork...join.

module tb;
  mailbox #(int) mbx = new();
  task automatic producer();
    for (int i = 1; i <= 4; i++) begin
      #5;
      mbx.put(i * 10);
      $display("[%0t] producer sent %0d", $time, i*10);
    end
  endtask
  task automatic consumer();
    int item;
    repeat (4) begin
      mbx.get(item);
      $display("[%0t] consumer got  %0d", $time, item);
    end
  endtask
  initial begin
    fork producer(); consumer(); join
    $display("done, msgs left=%0d", mbx.num());
    $finish;
  end
endmodule
[5] producer sent 10
[5] consumer got  10
[10] producer sent 20
[10] consumer got  20
[15] producer sent 30
[15] consumer got  30
[20] producer sent 40
[20] consumer got  40
done, msgs left=0

The log shows producer and consumer interleaving at the same timestamps: as soon as a message lands, the blocked get() returns. At the end num() reports 0, confirming no message was dropped.

Bounded mailbox: built-in backpressure

Pass a positive integer to new(N) and the mailbox becomes bounded to N slots. Now put() also blocks: if the mailbox is full, the producer stalls until the consumer frees a slot. This is how you model a real hardware FIFO or any resource with limited buffering, and it keeps a fast producer from running infinitely ahead of a slow consumer.

In the example below the mailbox holds only 2 messages. The producer tries to push one per time unit, but the consumer drains slowly (one every 4 units). Watch the occupancy pin at 2/2 while the producer waits.

module tb;
  mailbox #(int) mbx = new(2);
  task automatic producer();
    for (int i = 1; i <= 5; i++) begin
      mbx.put(i);
      $display("[%0t] put %0d (occupancy now %0d/2)", $time, i, mbx.num());
      #1;
    end
  endtask
  task automatic consumer();
    int v;
    #3;
    repeat (5) begin
      mbx.get(v);
      $display("[%0t] got %0d", $time, v);
      #4;
    end
  endtask
  initial begin
    fork producer(); consumer(); join
    $display("all drained, left=%0d", mbx.num());
    $finish;
  end
endmodule
[0] put 1 (occupancy now 1/2)
[1] put 2 (occupancy now 2/2)
[3] got 1
[3] put 3 (occupancy now 2/2)
[7] got 2
[7] put 4 (occupancy now 2/2)
[11] got 3
[11] put 5 (occupancy now 2/2)
[15] got 4
[19] got 5
all drained, left=0

The first two puts happen immediately. The third put cannot proceed until the consumer’s first get at time 3 opens a slot — from then on the producer advances in lockstep with the consumer. That self-throttling is the whole point of a bounded mailbox; you get flow control for free, with no explicit wait logic.

peek versus get

peek() is get() without the removal. A monitor often wants to inspect the head of a queue, decide something, and leave the message in place for another process to consume. Mailboxes also carry class handles, not just scalars, which is how real testbenches move transaction objects around.

The example queues one packet object, peeks it (depth stays 1), then gets it (depth drops to 0):

class packet;
  int addr;
  int data;
  function new(int a, int d);
    addr = a;
    data = d;
  endfunction
endclass

module tb;
  mailbox #(packet) mbx = new();

  task automatic monitor();
    packet p;
    #1;
    mbx.peek(p);
    $display("[%0t] monitor PEEK addr=%0h  depth=%0d", $time, p.addr, mbx.num());
    mbx.get(p);
    $display("[%0t] monitor GET  addr=%0h data=%0h  depth=%0d",
             $time, p.addr, p.data, mbx.num());
  endtask

  initial begin
    packet q;
    q = new('h40, 'hBEEF);
    mbx.put(q);
    $display("[%0t] driver PUT  addr=%0h", $time, q.addr);
    monitor();
    $finish;
  end
endmodule
[0] driver PUT  addr=40
[1] monitor PEEK addr=40  depth=1
[1] monitor GET  addr=40 data=beef  depth=0

Because a mailbox stores the handle, both the driver’s q and the monitor’s p point at the same object — a subtlety worth remembering when you later randomize or mutate transactions.

The semaphore: counting keys

A semaphore is a bucket of interchangeable keys. new(K) starts it with K keys. A process calls get(n) to acquire n keys — blocking until that many are free — and put(n) to return them. No data crosses the semaphore; it only arbitrates how many processes may touch a shared resource at once. Use it for a shared bus, a limited pool of ports, or any mutual-exclusion lock (a lock is just a semaphore with one key).

The n argument is what makes semaphores interesting: a process can demand several keys atomically. Below, a bus has 2 keys. Two single-key agents grab one each; a third agent needs both keys and must wait for both of the others to finish.

module tb;
  semaphore bus = new(2);
  task automatic drive(string name, int cost);
    $display("[%0t] %s requests %0d key(s)", $time, name, cost);
    bus.get(cost);
    $display("[%0t] %s ACQUIRED %0d key(s)", $time, name, cost);
    #5;
    bus.put(cost);
    $display("[%0t] %s released %0d key(s)", $time, name, cost);
  endtask
  initial begin
    fork
      begin #1; drive("rtl_core", 1); end
      begin #2; drive("dma_eng ", 1); end
      begin #3; drive("burst_io", 2); end
    join
    $display("[%0t] bus idle, keys restored", $time);
    $finish;
  end
endmodule
[1] rtl_core requests 1 key(s)
[1] rtl_core ACQUIRED 1 key(s)
[2] dma_eng  requests 1 key(s)
[2] dma_eng  ACQUIRED 1 key(s)
[3] burst_io requests 2 key(s)
[6] rtl_core released 1 key(s)
[7] dma_eng  released 1 key(s)
[7] burst_io ACQUIRED 2 key(s)
[12] burst_io released 2 key(s)
[12] bus idle, keys restored

At time 3 burst_io requests 2 keys but both are held, so it blocks. Only after rtl_core (time 6) and dma_eng (time 7) release does it acquire. A semaphore also supports try_get(n), which returns 0 immediately instead of blocking when the keys are unavailable — handy for polling without stalling the process.

Mailbox or semaphore?

Questionmailboxsemaphore
Moves data between processes?Yes, an ordered queue of messagesNo, only keys
Typical useGenerator to driver, monitor to scoreboardShared bus, resource pool, lock
Blocking call that waits for data/keysget() / peek()get(n)
Capacity argument to new()Optional bound (0 = unbounded)Initial key count
Non-blocking varianttry_get, try_puttry_get

Common pitfalls

  • Forgetting new(). A mailbox or semaphore handle is null until constructed; calling a method on null throws a fatal error at runtime.
  • Counting on an unbounded put to block. With new() and no argument, put never waits — a runaway producer can queue millions of messages. Use new(N) when you want backpressure.
  • Mismatched key accounting. Every get(n) must be balanced by a put(n). Leak keys and the resource eventually deadlocks; return too many and you break mutual exclusion.
  • Assuming get order equals fairness. Mailbox messages are strictly FIFO, but several processes blocked on the same semaphore are not guaranteed any particular wake order.
  • Peeking then assuming exclusivity. peek leaves the message in the queue, so another process can get it before you do; use a semaphore if you need exclusive access.

Mailboxes and semaphores are the plumbing under every layered verification environment, including UVM’s TLM ports and config locks. Once you can reason about who blocks, when, and on what, the rest of the testbench architecture follows naturally.

Quick check

What is the difference between a bounded and an unbounded mailbox?

An unbounded mailbox (created with new()) lets put() always succeed immediately, so the queue can grow without limit. A bounded mailbox (created with new(N)) caps the queue at N messages; once full, put() blocks until a get() frees a slot, giving you automatic backpressure.

Why does get() block but a plain shared-variable read does not?

get() is defined to wait until a message is actually available, so the consumer process suspends and resumes only when data exists. A shared-variable read returns whatever is there right now — possibly stale or default data — with no synchronization, which is how races and lost messages occur.

When would you use peek() instead of get()?

Use peek() when a process needs to inspect the oldest message without removing it, leaving it in the mailbox for another process to consume. A monitor that looks at the head of a queue to make a decision, but lets the driver actually consume the message, is a typical case.

A semaphore starts with 2 keys. Three processes call get(1), get(1), and get(2). What happens?

The first two get(1) calls each take one key, emptying the bucket. The get(2) call blocks because 2 keys are not free. It only proceeds after both earlier processes return their keys with put(1), at which point 2 keys are available again.

Have questions about this topic?
Share your question in comments or talk to our mentor team for batch guidance.
▶ Try it in the ChipXpert Lab

Compile and simulate this in the browser — no install. Edit the code and re-run instantly.

Open in Verification Lab

Learn Verilog with mentors, labs and placement support

This free series is maintained by the ChipXpert training team in Hyderabad & Bengaluru. Go deeper with instructor-led courses on real EDA tools.

Explore VLSI Courses
Next batch starts 18th November 2026
Start your VLSI career with ChipXpert

Live online and classroom batches in Hyderabad & Bengaluru. Fill in your details and a counsellor will call you back.

  • Real EDA tools in your browser: industry-standard EDAReal EDA tools in your browser
  • Recorded sessions on the elearn portalRecorded sessions
  • Placement assistance: resume, mock interviewsPlacement assistance
  • Merit scholarship up to 60% · EMI optionsScholarship up to 60%
Hi! Ask me about courses, fees, batches or discounts. ×
BENGALURU
HYDERABAD