Skip to content

SystemVerilog · Module 13

Semaphores

SystemVerilog semaphores — the counting concurrency primitive. Binary semaphore as a mutex; counting semaphore as a credit pool; get / put / try_get semantics; atomic multi-key acquisition; deadlock causes and four prevention strategies; the leaked-put bug; AXI / DDR / DMA real-world patterns; and the failure modes every verification engineer ships at least once.

Module 13 · Page 13.3

A semaphore is the IPC primitive for resource control — a counter that limits how many processes may hold a shared resource simultaneously. With one key it is a mutex; with N keys it is a credit pool. The semantics are simple — get(), put(), try_get() — but the failure modes (leaked keys, deadlocks, overcount) are silent and ship at regression scale before they ship in silicon. This page walks the three methods, the canonical patterns (binary, counting, credit-based, semaphore-of-zero), and the bugs that catch every team that does not enforce a discipline around them.

1. Engineering Problem — Why Semaphores Exist

A concurrent SystemVerilog testbench has dozens of threads — driver, monitor, scoreboard, sequencer, channel agents — all running at the same simulation time. Many of them touch shared state: a single APB bus, a register model, an AXI master that can only have one outstanding request per channel, a DMA engine with two physical channels.

Without protection, the first two threads to reach a bus_lock_unprotected block race. The symptoms are scheduling-order dependent: tests pass on VCS, hang on Questa; coverage gaps appear only at high seed counts; the same bug surfaces in silicon as a corrupted protocol handshake months later.

Events (the previous lesson) signal timing — "this just happened." They do not gate access. Mailboxes carry data — they do not enforce exclusion. The third IPC primitive — the semaphore — fills the gap: a counter the simulator manages atomically that lets exactly N processes proceed and queues every additional caller until a slot frees.

2. Mental Model — A Bucket of Keys

The picture every engineer carries:

A semaphore is a bucket of keys. You create the bucket with N keys (new(N)). A process that wants the protected resource must first get() a key — take one from the bucket. When it is done, it put()s the key back. If the bucket is empty, get() blocks the caller until another process returns a key. The bucket never lets more than N processes hold keys at once.

Three invariants this picture preserves:

  • The bucket is the only authority. No process can "see" the protected resource is free without going through get(). The semaphore is a contract, not a flag.
  • get() is atomic. s.get(3) either takes all three keys at once or blocks until three are simultaneously available — never two-then-wait-for-one. This is the rule that prevents partial-acquisition deadlocks.
  • The bucket has no fairness guarantee. When put() releases a key, IEEE 1800 does not specify which waiter is unblocked. Two simulators may pick differently — a test that passed on VCS can deadlock on Questa under heavy contention.

3. Visual Explanation — Keys, Holders, and the Wait Queue

Counting semaphore — 4 keys, 2 held, 3 waiters

semaphore
Counting semaphore — 4 keys, 2 held, 3 waiterssemaphore · 4 keyskk2 held · 2 freep0p1waitingw0w1w2
Two processes (p0, p1) hold keys; three waiters (w0, w1, w2) are queued. As each holder calls put(), the simulator unblocks one waiter — but IEEE 1800 does not specify which one, so the order is tool-dependent.

The binary case (new(1)) is the same picture with one key — at most one holder, every other caller queues. The all-busy state (4 held, 0 free) is where the simulation appears to hang; until a put() arrives the bucket stays empty and every get() blocks indefinitely.

4. Syntax & Semantics — new(), get(), put(), try_get()

SystemVerilog — semaphore declaration and all three methods
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ── Declaration and construction ──────────────────────────────
semaphore s = new(1);   // 1 key  — binary semaphore (mutex)
semaphore s = new(4);   // 4 keys — counting semaphore (up to 4 concurrent)
semaphore s = new(0);   // 0 keys — starts locked; another process must put() first
 
// ── get(k): acquire k keys — BLOCKING ─────────────────────────
s.get();       // take 1 key — blocks if none available
s.get(2);      // take 2 keys atomically — blocks until BOTH are available
 
// ── put(k): return k keys — never blocks ──────────────────────
s.put();       // return 1 key — unblocks one waiting process
s.put(2);      // return 2 keys — may unblock multiple waiting processes
 
// ── try_get(k): non-blocking acquire ──────────────────────────
if (s.try_get()) begin
    // key acquired — proceed with protected section
    do_work();
    s.put();
end else begin
    // no key available — take an alternative action; do NOT block
    $display("[WARN] resource busy, skipping this attempt");
end
 
// ── Critical-section pattern (the standard idiom) ─────────────
s.get();         // ① acquire — block if another process holds the key
critical_work(); // ② protected section — only one process here at a time
s.put();         // ③ release — unblock next waiting process

5. Simulation View — How the Simulator Handles get / put

5.1 get() blocks; put() does not

s.get() may suspend the calling process until s.put() runs in another process. While suspended, the process is removed from the active scheduler — it consumes no simulation time and does not race with anyone. s.put() never blocks; it adds keys to the counter and the simulator picks one of the queued waiters to unblock.

get(k) with k > 1 is atomic — the simulator waits until all k keys are simultaneously available, then takes all k at once. It never takes k-1 and waits for one more.

5.2 No fairness guarantee

When put() releases a key, the simulator picks one waiter to unblock. IEEE 1800 does not specify which one — VCS, Questa, Xcelium, and Verilator may each pick differently. Production testbenches that depend on get() order across waiters are non-portable and will eventually hit a "passes on simulator X, deadlocks on simulator Y" bug. Never assume FIFO semantics on semaphores; that's mailbox territory.

5.3 Two drivers, one bus — the canonical waveform

The waveform below shows two drivers contending for a semaphore(1) over a shared APB bus. Agent A acquires first, holds for the full transaction, then releases. Agent B's get() blocks while A holds the key; the simulator unblocks B as soon as A's put() lands. The signal bus_owner shows which agent currently holds the key.

Figure — binary semaphore serialises two agents on one APB bus

12 cycles
Figure — binary semaphore serialises two agents on one APB busA.get() succeeds, B.get() blocksA.get() succeeds, B.ge…A.put() → B unblocksA.put() → B unblockspclkbus_ownerFREEAAAABBBBFREEFREEFREEagent_AgetHOLDHOLDHOLDputdonedonedonedonedonedonedoneagent_BgetWAITWAITWAITWAITHOLDHOLDHOLDputdonedonedonepselt0t1t2t3t4t5t6t7t8t9t10t11
A and B both call drive_apb() concurrently. Agent A wins the get() at cycle 1, holds the bus through addr / data / response, then put()s at cycle 5 — at which point Agent B's blocked get() unblocks and proceeds. The bus is never driven by both agents simultaneously.

6. Binary Semaphore — The Mutex

A semaphore created with new(1) is a mutex. At most one process holds the key at any time. This is the standard pattern for protecting any shared resource — a bus, a register model, a coverage database, a logging singleton.

SystemVerilog — binary semaphore protecting a shared APB bus
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ── Shared bus: only one driver can use it at a time ──────────
semaphore bus_lock = new(1);   // 1 key = mutex
 
task automatic drive_apb(string agent, ApbTxn t);
    bus_lock.get();                         // ① acquire bus ownership
    $display("[%s] Driving addr=0x%h at %0t", agent, t.addr, $time);
 
    // ── drive all phases of the APB transaction ────────────────
    @(posedge pclk); psel <= 1; paddr <= t.addr; pwrite <= t.write;
    @(posedge pclk); penable <= 1;
    @(posedge pclk iff pready);             // wait for slave ready
    psel <= 0; penable <= 0;
 
    bus_lock.put();                         // ② release bus ownership
    $display("[%s] Released bus at %0t", agent, $time);
endtask
 
initial fork
    begin : agent_a
        foreach (txns_a[i]) drive_apb("A", txns_a[i]);
    end
    begin : agent_b
        foreach (txns_b[i]) drive_apb("B", txns_b[i]);
    end
join
// Result: A and B interleave cleanly — each full transaction is atomic

6.1 Protecting a shared object — register model

SystemVerilog — mutex inside a register-model class
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
class RegModel;
    semaphore          lock;
    logic [31:0]       regs [256];
 
    function new();
        lock = new(1);                          // one key — single-writer
        foreach (regs[i]) regs[i] = 0;
    endfunction
 
    task read(input int addr, output logic [31:0] data);
        lock.get();  data = regs[addr];  lock.put();
    endtask
 
    task write(input int addr, input logic [31:0] data);
        lock.get();  regs[addr] = data;  lock.put();
    endtask
 
    // Read-modify-write held atomically inside the same critical section
    task set_field(input int addr, input int hi, lo, input logic [31:0] val);
        lock.get();                             // hold lock for the entire RMW
        regs[addr][hi:lo] = val;
        lock.put();
    endtask
endclass
// Any number of concurrent sequences can use RegModel safely

7. Counting Semaphore — Credit Pool

A semaphore created with new(N) where N > 1 models "at most N concurrent X." This is credit-based flow control — the canonical pattern for AXI outstanding-request limits, DDR command queues, DMA channels, and any shared resource pool.

SystemVerilog — counting semaphore: AXI outstanding-read limit
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ── Allow at most 4 outstanding read requests at a time ───────
semaphore read_slots = new(4);   // 4 concurrent reads allowed
 
task automatic issue_read(int addr);
    read_slots.get();               // claim one slot (blocks if all 4 busy)
    fork
        begin
            send_read_request(addr);
            wait_for_response(addr);
            read_slots.put();       // release slot when response arrives
        end
    join_none                       // launch read asynchronously
endtask
 
// ── Issuing 20 reads — at most 4 in-flight at any time ────────
initial begin
    for (int i = 0; i < 20; i++) begin
        issue_read(i * 4);          // blocks on get() once 4 are in-flight
    end
end
 
// ── DMA engine: limit to 2 active DMA channels ────────────────
semaphore dma_channels = new(2);
 
task automatic start_dma(DmaDesc d);
    dma_channels.get();             // wait for a channel to be free
    launch_dma_transfer(d);
    wait_dma_complete(d.id);
    dma_channels.put();             // release the channel
endtask

8. try_get() — Non-Blocking Acquire

try_get() attempts to acquire a key but never blocks. Returns 1 on success, 0 if no key is available. Use it for timeouts, alternative paths, and best-effort access.

SystemVerilog — try_get() patterns: skip / timeout / priority
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
semaphore s = new(1);
 
// ── Pattern 1: try once, skip on failure ──────────────────────
if (s.try_get()) begin
    protected_work();
    s.put();
end else
    $display("[WARN] Resource busy — skipping this request");
 
// ── Pattern 2: try with timeout ───────────────────────────────
task automatic get_with_timeout(semaphore sem, int timeout_ns, output bit success);
    int elapsed = 0;
    success = 0;
    while (elapsed < timeout_ns) begin
        if (sem.try_get()) begin
            success = 1;
            return;
        end
        #1;                           // wait 1 time unit and retry
        elapsed++;
    end
    $warning("Semaphore get timeout after %0d ns", timeout_ns);
endtask
 
// ── Pattern 3: priority — high-priority path tries first ──────
task automatic high_priority_access();
    if (s.try_get()) begin            // try immediately
        do_high_prio_work();
        s.put();
    end else begin
        s.get();                      // fall back to blocking if unavailable
        do_high_prio_work();
        s.put();
    end
endtask

9. Semaphore Initialised to Zero — Reverse Synchronisation

new(0) creates a semaphore with no keys. The first get() blocks immediately; another process must call put() to provide the first key. This gives reliable per-item synchronisation that — unlike an event — never loses signals.

SystemVerilog — semaphore(0): driver provides keys for scoreboard
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
semaphore pkt_ready = new(0);  // starts with 0 keys — scoreboard blocks first
 
initial fork
 
    begin : driver
        repeat (10) begin
            send_packet();
            pkt_ready.put();         // provide a key — "packet is ready"
        end
    end
 
    begin : scoreboard
        repeat (10) begin
            pkt_ready.get();         // wait for a key (blocks if driver is slow)
            check_output();          // guaranteed: exactly one packet was sent
        end
    end
 
join

10. Deadlock — Causes and Prevention

A deadlock is two or more processes each waiting for a resource held by the other. Semaphore deadlocks are silent — the simulator hangs with no message, no error, no waveform anomaly. The four canonical prevention strategies:

SystemVerilog — classic two-semaphore deadlock and four ways to prevent it
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ── Classic deadlock: opposite acquisition orders ─────────────
semaphore s1 = new(1), s2 = new(1);
 
initial fork
    begin : proc_A
        s1.get();   // A takes s1
        #1;
        s2.get();   // A waits for s2 — but B holds it
        do_work_A();
        s2.put();   // never reached
        s1.put();   // never reached
    end
 
    begin : proc_B
        s2.get();   // B takes s2
        #1;
        s1.get();   // B waits for s1 — but A holds it
        do_work_B();
        s1.put();   // never reached
        s2.put();   // never reached
    end
join
// RESULT: simulation hangs silently at #1. A and B wait forever.
 
// ── Prevention 1: consistent lock ordering ────────────────────
// Always acquire semaphores in the same order everywhere.
begin : proc_A_fixed
    s1.get();   // ALWAYS: s1 first, then s2
    s2.get();
    do_work_A();
    s2.put();   // release in reverse order
    s1.put();
end
begin : proc_B_fixed
    s1.get();   // ALWAYS: s1 first, then s2 — same order as A
    s2.get();
    do_work_B();
    s2.put();
    s1.put();
end
 
// ── Prevention 2: get both atomically with one semaphore ──────
semaphore both = new(2);   // 2 keys = both resources
both.get(2);               // take both atomically — no partial acquisition
do_work();
both.put(2);
 
// ── Prevention 3: try_get with rollback ───────────────────────
if (s1.try_get()) begin
    if (s2.try_get()) begin
        do_work();
        s2.put();
        s1.put();
    end else begin
        s1.put();          // rollback: release s1 since s2 is unavailable
        // retry later or take an alternative action
    end
end
 
// ── Prevention 4: watchdog timer to detect hangs ──────────────
fork
    s1.get();              // the real get
    #1_000_000 $fatal(1, "Semaphore timeout — possible deadlock at %0t", $time);
join_any
disable fork;              // cancel the watchdog if get() succeeded

11. Synthesis — Not Applicable

semaphore 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 credit counter with a request / grant handshake (e.g. AXI4 outstanding-transaction tracking, DDR command queue full / empty flags). Those are covered in the protocol and credit-flow lessons, not under IPC semaphores.

12. Verification View — Real Patterns That Ship

12.1 Shared AXI master — serialising address channels

SystemVerilog — semaphores serialise AW / AR channels on a shared AXI master
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
class Axi4Master;
    semaphore aw_lock;   // one outstanding AW request at a time
    semaphore ar_lock;   // one outstanding AR request at a time
 
    function new();
        aw_lock = new(1);
        ar_lock = new(1);
    endfunction
 
    task write(input [31:0] addr, data);
        aw_lock.get();                   // serialise write-address phase
        drive_aw_channel(addr);
        drive_w_channel(data);
        wait_b_channel();
        aw_lock.put();
    endtask
 
    task read(input [31:0] addr, output [31:0] data);
        ar_lock.get();                   // serialise read-address phase
        drive_ar_channel(addr);
        wait_r_channel(data);
        ar_lock.put();
    endtask
endclass
// Any sequence can call write() or read() concurrently;
// the semaphores ensure AW and AR are never driven simultaneously.

12.2 Credit-based flow control

SystemVerilog — credit-based flow control via a counting semaphore
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// Receiver grants N credits; sender uses one per packet
semaphore tx_credits = new(0);   // starts locked — receiver must grant first
 
initial fork
 
    begin : receiver
        repeat (8) begin
            @(posedge clk);
            tx_credits.put();    // grant one credit
        end
        // ...refill credits as buffer drains...
    end
 
    begin : transmitter
        forever begin
            tx_credits.get();    // block until a credit is available
            send_packet();       // guaranteed: receiver has buffer space
        end
    end
 
join

13. Industry Usage — Where Semaphores Land in Real Verification

  • AMBA bus VIPs — every APB / AHB / AXI master VIP carries an internal mutex per channel so multiple concurrent sequences can call write() / read() without colliding. UVM agents typically wrap this in a sequencer-level lock.
  • AXI4 outstanding-transaction limitsAWLEN / ARLEN credits are modelled as counting semaphores in the master VIP. The DUT spec ("at most 4 outstanding reads") becomes semaphore = new(4).
  • DDR command queue — DDRn controllers expose a finite per-bank command-queue depth. Verification testbenches model the controller's back-pressure with a counting semaphore.
  • DMA / scatter-gather engines — N physical DMA channels become semaphore = new(N); sequences competing for channel allocation contend on it.
  • UVM register model uvm_reg::lock_model() — the canonical method for serialising register access from multiple sequences uses a semaphore internally.
  • UVM sequencer arbitration locksuvm_sequence::lock() / grab() use a semaphore-like mechanism to serialise sequence access to a sequencer; understanding raw semaphores is the prerequisite to debugging UVM lock contention.
  • License manager mocks — verification environments that model tool-license behaviour (e.g. one EDA license per host) use counting semaphores keyed to license counts.

14. Design Review Notes — What a Senior Will Flag

Pattern in the diffWhat review will say
s.get() followed by if (err) return; with no put() on the err path"Key leak — every error condition burns one slot from the pool permanently. Single exit, put() on every path."
Two tasks acquire (s1, s2) and (s2, s1)"Lock-order violation — this is a deadlock waiting to happen. Document the global order in env_pkg and apply it everywhere."
s.put(); called without a prior s.get()"Overcounting — SystemVerilog does not cap the key count at new(N). An extra put() permanently widens the pool and silently breaks the invariant."
s.get(); in test-level code with no timeout"Wrap in fork ... join_any with a watchdog. A silent hang is worse than a failed test."
semaphore s; ... s.get(); with no new(...) call"Null handle — semaphore must be constructed with new(N). Missing the new() raises a null-handle exception at the first get()."
s.put(); inside a loop with no matching get()"Same overcounting bug as above, just harder to spot. If the loop runs N times, the pool grows by N."
repeat (n) s.get(); instead of s.get(n);"Non-atomic — partial acquisition deadlocks under contention. Use the atomic get(k) form."
disable fork while a process is mid-critical-section"If a process holding a key is killed by disable fork, the key is leaked. Either put() before the disable point or avoid disable over semaphore-holding processes."

The single highest-value rule: establish one global lock-acquisition order per environment and enforce it with a lint check. Every multi-semaphore deadlock that ships is a violation of this rule.

15. Debugging Guide — Real Failures, Real Fixes

1

Regression hangs intermittently at 3.2 ms — VCS clean, Questa fails

LOCK-ORDER
Buggy Code
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// Sequence A
task seq_a();
    bus_lock.get();
    reg_lock.get();
    ...
endtask
 
// Sequence B
task seq_b();
    reg_lock.get();
    bus_lock.get();   // opposite order
    ...
endtask
Symptom
Nightly regression on Questa hangs at simv_time = 3.2 ms across 41 of 800 tests, repeatable per-seed. VCS reports clean for two months. Two weeks chasing a phantom simulator bug before the lock order surfaces.
Root Cause
Cross-locked semaphores. IEEE 1800 leaves wake-up order unspecified; VCS happens to schedule the threads in an order that avoids the cycle, Questa does not. The bug always existed — VCS scheduling was masking it.
Fix
Define a global lock order in env_pkg (e.g. "always bus_lock before reg_lock"). Refactor every multi-semaphore task to follow it. Add a lint rule: any task that calls .get() on two named semaphores must call them in the documented order.
2

Driver hangs forever on the second illegal transaction

LEAKED-KEY
Buggy Code
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
task drive_one(Txn t);
    bus.get();
    if (t.illegal) return;   // BUG: key never returned
    drive_apb(t);
    bus.put();
endtask
Symptom
First illegal-input test passes. Every subsequent test using the same bus hangs at the first drive_one() call. Watchdog timer eventually fires; log shows the driver suspended in bus.get().
Root Cause
After the first illegal transaction the bus key is permanently lost — the early return skipped the put(). Every subsequent get() blocks forever; the bucket is empty and no one will refill it.
Fix
Single exit point: bus.put() runs regardless of the branch. Refactor as bus.get(); if (!t.illegal) drive_apb(t); bus.put();. The acquire / release pair must be balanced on every code path.
3

Coverage shows 200% of expected DMA concurrency

OVERCOUNT
Buggy Code
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
semaphore dma = new(2);   // intended: at most 2 concurrent
...
fork
    begin
        dma.get();
        run_dma();
        dma.put();
        dma.put();        // BUG: spurious extra put on success path
    end
join_none
Symptom
Coverage report shows 4 concurrent DMA channels in flight when the spec allows 2. No simulator error; the spurious put() simply inflates the key count. The bug is invisible until a coverage review questions the impossible-looking metric.
Root Cause
SystemVerilog does not cap the key count at new(N). Every extra put() widens the pool permanently. After K iterations the bucket has 2 + K keys.
Fix
Delete the duplicate put(). Add a debug-only assertion that periodically checks the in-flight count against the configured pool size: assert(dma_inflight <= 2). Spec-vs-actual mismatches surface immediately.
4

get(3) hangs while three keys are available

PARTIAL-ACQUISITION-MYTH
Buggy Code
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
semaphore credits = new(8);
...
task send_burst(int len);
    repeat (len) credits.get();   // WRONG: each get() is independent
    send_data(len);
    repeat (len) credits.put();
endtask
Symptom
Two concurrent send_burst(4) calls deadlock occasionally. Each thread acquires 2 or 3 keys, then both wait for the remaining keys the other holds. Looks like a deadlock but does not match any lock-order pattern.
Root Cause
repeat (len) credits.get(); issues len independent get() calls. Each can succeed independently; two threads can each hold a partial set of keys and deadlock waiting for the remainder. The fix is credits.get(len)one atomic call that takes all len keys at once.
Fix
Replace the repeat (len) credits.get(); with credits.get(len); and repeat (len) credits.put(); with credits.put(len);. Atomic multi-key acquire is the entire reason get(k) exists.
5

Watchdog timer fires; no message about which get() blocked

UNGUARDED-GET
Buggy Code
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
initial begin
    bus.get();
    ...
end
// No fork-join_any with timeout — caller hangs invisibly
Symptom
Test times out at the regression watchdog (100,000 cycles). Log shows nothing useful — just "killed by signal 9" from the grid. Hours of $display instrumentation needed to localise the hang to a specific get().
Root Cause
Blocking IPC calls with no timeout produce silent hangs. The test framework cannot tell which get() blocked — only that the test never finished.
Fix
Wrap every test-level blocking get() in fork ... join_any with a $fatal watchdog: fork s.get(); #100_000 $fatal(1, "[%m] s.get() blocked > 100,000 ns"); join_any disable fork;. The next failure reports exactly which semaphore stalled and at what time — debug drops from days to minutes.

16. Interview Insights — What Interviewers Actually Probe

A binary semaphore is new(1) — one key — and behaves as a mutex protecting a single shared resource. Example: a single APB bus serialised across two driver agents. A counting semaphore is new(N) for N > 1 and models "at most N concurrent X" — credit-based flow control. Example: an AXI4 master limited to 4 outstanding read requests, or a DDR controller with 8 in-flight commands per bank.

17. Exercises

1. Design — barrier with semaphore(0) (Foundation)
Implement an N-thread barrier where each thread calls done.put() on completion and a coordinator calls done.get(N) to wait for all of them. Why does this approach win over an event-based barrier when the producer rate may exceed the consumer rate?

2. Debug — the disappearing slot (Intermediate)
A counting semaphore slots = new(4) is supposed to limit AXI outstanding reads to 4. After 10,000 transactions the regression shows 8 in-flight reads in the coverage report and the test still passes. Walk through the diagnostic: which methods on the semaphore should you trace, and what is the canonical bug pattern this matches?

3. Code review — the locking class (Intermediate)
A teammate submits a BusArbiter class with task acquire(); bus.get(); endtask and task release(); bus.put(); endtask and uses them in driver code as arb.acquire(); drive_txn(); if (txn.bad) return; arb.release();. Identify the bugs and propose the canonical fix.

4. Trade-off — atomic get(k) vs sequential get()s (Advanced)
A junior teammate argues that "get(3) is just s.get(); s.get(); s.get(); rolled up — same behaviour, smaller code." Argue the case against. Construct a two-process example where the rolled-up sequential form deadlocks but the atomic form does not, and explain the underlying IEEE 1800 semantics.

18. Summary

A semaphore is a bucket of keys. new(N) creates the bucket; get() takes one (or k, atomically) and blocks if empty; put() returns one (or k); try_get() is the non-blocking variant. Binary (N=1) is a mutex protecting a single resource; counting (N>1) is a credit pool for "at most N concurrent X." new(0) is the reverse-synchronisation form used for handshakes that must not lose signals.

Defaults to memorise. Always pair get() with put() on every exit path — leaked keys silently deadlock the testbench. Establish one global lock-acquisition order per environment and enforce it. Use get(k) atomically when you need k keys — never repeat (k) get(). Wrap test-level blocking get() calls in fork ... join_any with a watchdog. Never assume FIFO unblock semantics — IEEE 1800 leaves the order to the simulator.

Next up: 13.4 — Mailboxes — the IPC primitive for passing typed data between processes. Where events signal moments and semaphores gate access, mailboxes carry items through a typed, optionally bounded FIFO.