sc_fifo buffers data between a producer and a consumer. But sometimes the problem is different: two processes need to share the same resource — a shared register file, a bus, a memory bank — and only one of them should touch it at a time. That is an access-control problem, not a buffering problem.

SystemC provides two synchronization primitives for this: sc_mutex for mutual exclusion (exactly one thread at a time) and sc_semaphore for counting access (up to N threads at a time). Both are built-in channels, declared at the module or sc_main level, and work only with SC_THREAD processes for their blocking calls.

1. The Shared Resource Problem

Consider two initiator threads that both write to a shared configuration register. Without coordination, their writes interleave in simulation time and corrupt each other — even if each write takes zero time.

// Without synchronization — race condition
// Thread A: reg = reg | FLAG_A   (read: 0x00, write: 0x01)
// Thread B: reg = reg | FLAG_B   (read: 0x00, write: 0x02)  ← reads stale 0x00
// Result:   reg == 0x02          ← FLAG_A is lost

The problem here is not ordering of data — it is ordering of operations. You need a way to say: "while I am touching this register, no one else can." That is exactly what sc_mutex provides.

// With sc_mutex — safe read-modify-write
// Thread A: lock → read 0x00, write 0x01 → unlock
// Thread B: lock (blocks) → read 0x01, write 0x03 → unlock
// Result:   reg == 0x03          ← both flags set correctly

2. sc_mutex — Mutual Exclusion

sc_mutex implements the sc_mutex_if interface. At most one thread can hold the lock at any point in simulation time. Any other thread that calls lock() while it is held will block until the owner releases it.

#include <systemc.h>

// Declare at module scope or in sc_main
sc_mutex bus_lock;                         // anonymous
sc_mutex reg_lock("reg_lock");            // named — preferred

The core API has three methods:

// ── lock() ───────────────────────────────────────────────────
// Acquires the mutex.
// If already locked by another thread: BLOCKS until unlocked.
// Returns 0 on success.
reg_lock.lock();

// ── unlock() ─────────────────────────────────────────────────
// Releases the mutex.
// Any thread blocked on lock() is woken up.
// Returns 0 on success, non-zero if not currently locked.
reg_lock.unlock();

// ── trylock() ────────────────────────────────────────────────
// Non-blocking attempt to acquire.
// Returns  0 if lock was acquired.
// Returns -1 if already locked (does NOT block).
if (reg_lock.trylock() == 0) {
    // got the lock — do work, then unlock
    reg_lock.unlock();
} else {
    // resource busy — try again later
}
sc_mutex is not recursive. If a thread that already holds the lock calls lock() again on the same mutex, it will block waiting for itself — an instant deadlock. There is no owner-check or re-entry counter. Design your critical sections so a thread never tries to acquire a mutex it already holds.

3. sc_mutex in Practice — Protected Register Access

The canonical pattern is: lock, do the critical work, unlock. Wrapping the unlock in a cleanup path is good practice, but in SystemC simulations a thrown exception halts the simulation anyway — so a simple lock/unlock pair is usually fine.

#include <systemc.h>

SC_MODULE(SharedReg) {
    sc_mutex   lock;
    uint32_t   reg_val = 0;

    void thread_a() {
        while (true) {
            wait(10, SC_NS);
            lock.lock();
            reg_val |= 0x01;           // set FLAG_A
            cout << "A wrote: 0x" << std::hex << reg_val << "\n";
            lock.unlock();
        }
    }

    void thread_b() {
        while (true) {
            wait(10, SC_NS);
            lock.lock();
            reg_val |= 0x02;           // set FLAG_B
            cout << "B wrote: 0x" << std::hex << reg_val << "\n";
            lock.unlock();
        }
    }

    SC_CTOR(SharedReg) : lock("lock"), reg_val(0) {
        SC_THREAD(thread_a);
        SC_THREAD(thread_b);
    }
};

Both threads wake at the same simulation time (10 ns). One of them acquires the lock first (simulator picks an order), completes its read-modify-write, and unlocks. The other then proceeds. The final value is always 0x03 — both flags set, no corruption.


4. sc_semaphore — Counting Access

A semaphore maintains an integer count. wait() decrements it — if the count would go below zero, the caller blocks. post() increments it and wakes a blocked waiter. The initial value controls how many threads can proceed concurrently.

#include <systemc.h>

// Binary semaphore (initial value 1) — behaves like a mutex
sc_semaphore sem_binary("sem_bin", 1);

// Counting semaphore (initial value N) — allows N concurrent holders
sc_semaphore sem_ports("sem_ports", 4);   // 4 DMA channels can run at once

The three methods:

// ── wait() ───────────────────────────────────────────────────
// Decrements count.
// If count was already 0: BLOCKS until another thread calls post().
// Returns 0 on success.
sem_ports.wait();

// ── post() ───────────────────────────────────────────────────
// Increments count.
// If any thread is blocked on wait(): one is woken up.
// Returns 0 on success.
sem_ports.post();

// ── trywait() ────────────────────────────────────────────────
// Non-blocking decrement.
// Returns  0 if count was > 0 (decremented successfully).
// Returns -1 if count was 0 (caller is NOT blocked).
if (sem_ports.trywait() == 0) {
    // got a slot
    sem_ports.post();   // release when done
}

// ── get_value() ──────────────────────────────────────────────
// Returns current count — useful for assertions and debug.
int slots_left = sem_ports.get_value();
Always pair wait() with post(). Unlike sc_mutex, a semaphore has no concept of "owner." Any thread can call post() — even one that never called wait(). This means there is no automatic cleanup if you forget to post. An unmatched wait() will drain the count and eventually deadlock all threads trying to enter.

5. sc_semaphore in Practice — DMA Channel Pool

A common use case is a pool of limited hardware resources: DMA channels, interrupt lines, or memory banks with limited ports. The semaphore tracks how many are free.

#include <systemc.h>

SC_MODULE(DmaPool) {
    sc_semaphore channels;   // 3 DMA channels available

    void requester(int id) {
        while (true) {
            wait(5, SC_NS);

            cout << sc_time_stamp() << " CPU" << id << " waiting for DMA\n";
            channels.wait();                   // block until a channel is free
            cout << sc_time_stamp() << " CPU" << id << " got DMA, channels left: "
                 << channels.get_value() << "\n";

            wait(20, SC_NS);                   // simulate DMA transfer

            channels.post();                   // release channel
            cout << sc_time_stamp() << " CPU" << id << " released DMA\n";
        }
    }

    SC_CTOR(DmaPool) : channels("channels", 3) {
        SC_THREAD(requester); sensitive << SC_ZERO_TIME; dont_initialize();
        // spawn 5 CPU threads competing for 3 channels
        for (int i = 0; i < 5; i++) {
            sc_spawn([this, i]{ requester(i); });
        }
    }
};

With 5 requesters and 3 channels, at most 3 will ever be active simultaneously. The remaining 2 block on channels.wait() until one of the active transfers calls channels.post().


6. Complete Example — Two-Master Shared Bus

Here is a self-contained simulation: two master threads compete for a single shared bus. Only one can drive the bus at a time. The sc_mutex enforces the arbitration.

#include <systemc.h>
#include <iomanip>

SC_MODULE(SharedBus) {
    sc_mutex   bus_arb;
    uint32_t   bus_data = 0;

    void master(const char* name, uint32_t base_addr,
                sc_time period, sc_time xfer_time) {
        uint32_t addr = base_addr;
        while (true) {
            wait(period);

            // --- request bus ---
            cout << std::setw(10) << sc_time_stamp()
                 << "  " << name << ": requesting bus\n";
            bus_arb.lock();

            // --- drive bus ---
            bus_data = addr;
            cout << std::setw(10) << sc_time_stamp()
                 << "  " << name << ": driving 0x"
                 << std::hex << addr << std::dec << "\n";
            wait(xfer_time);

            // --- release bus ---
            bus_arb.unlock();
            cout << std::setw(10) << sc_time_stamp()
                 << "  " << name << ": released bus\n";
            addr += 4;
        }
    }

    SC_CTOR(SharedBus) : bus_arb("bus_arb"), bus_data(0) {
        sc_spawn([this]{
            master("CPU0", 0x1000, sc_time(10, SC_NS), sc_time(15, SC_NS));
        });
        sc_spawn([this]{
            master("DMA",  0x2000, sc_time(12, SC_NS), sc_time(8,  SC_NS));
        });
    }
};

int sc_main(int, char**) {
    SharedBus dut("dut");
    sc_start(100, SC_NS);
    return 0;
}

Sample output — notice CPU0 blocks at 20 ns waiting for DMA to release the bus:

     10 ns  CPU0: requesting bus
     10 ns  CPU0: driving 0x1000
     12 ns  DMA:  requesting bus    ← DMA arrives while CPU0 holds lock
     25 ns  CPU0: released bus
     25 ns  DMA:  driving 0x2000   ← DMA proceeds immediately after unlock
     33 ns  DMA:  released bus

7. sc_mutex vs sc_semaphore — When to Use Which

Propertysc_mutexsc_semaphore
Concurrent holdersExactly 1Up to N (set by initial value)
Typical useExclusive access to one shared resourcePool of N identical resources
Ownership conceptYes — only the locker should unlockNo — any thread can post()
Recursive lockingDeadlocks — not supportedN/A — no ownership
Non-blocking calltrylock() returns -1 if lockedtrywait() returns -1 if count == 0
Binary modeAlways binaryBinary when initial value = 1
Query stateNot availableget_value() returns current count
Blocking calllock() — SC_THREAD onlywait() — SC_THREAD only

Use sc_mutex when there is one shared thing and one thread must own it exclusively. Use sc_semaphore when you have a pool of N resources and want to allow up to N concurrent consumers with blocking when all are busy.


8. Common Pitfalls

Deadlock from missing unlock

If a thread acquires a mutex and then calls wait() on a signal or timer without ever unlocking, any other thread that later calls lock() will block forever. The simulator will eventually report no runnable processes.

// BAD — deadlock if signal never fires
void thread_a() {
    lock.lock();
    wait(done_signal);   // ← still holding lock — thread_b can never enter
    lock.unlock();
}

// GOOD — unlock before the long wait
void thread_a() {
    lock.lock();
    do_critical_work();
    lock.unlock();       // ← release before sleeping
    wait(done_signal);
}

Deadlock from recursive locking

This is the most common bug with sc_mutex. If funcA() locks a mutex and then calls funcB() which also tries to lock the same mutex, the thread blocks waiting for itself.

// BAD — recursive lock → instant deadlock
void funcA() { lock.lock(); funcB(); lock.unlock(); }
void funcB() { lock.lock(); /* DEADLOCK */ lock.unlock(); }

Semaphore count going negative — impossible, but blocking looks like it

The count never goes below zero in the semaphore itself — when the count is zero, wait() blocks the calling thread instead of decrementing. If you see all threads blocked, check that every wait() is eventually matched with a post(). Use get_value() in a watcher thread to assert the count stays in the expected range.

// Debug watcher — assert semaphore count never exceeds initial value
void watcher() {
    while (true) {
        wait(1, SC_NS);
        assert(sem.get_value() <= 4);   // initial value was 4
        assert(sem.get_value() >= 0);
    }
}

API Reference

MethodTypeBehaviour
sc_mutex::lock()BlockingAcquires mutex — blocks if held by another thread; returns 0
sc_mutex::unlock()Non-blockingReleases mutex; returns 0 on success, non-zero if not locked
sc_mutex::trylock()Non-blockingReturns 0 if acquired, -1 if already locked
sc_semaphore::wait()BlockingDecrements count — blocks if count is 0; returns 0
sc_semaphore::post()Non-blockingIncrements count and wakes a waiter if any; returns 0
sc_semaphore::trywait()Non-blockingReturns 0 if count > 0 (decremented), -1 if count was 0
sc_semaphore::get_value()Non-blockingReturns current count — useful for debug assertions

Summary


📬 Get new articles in your inbox

Deep dives on SystemC, C++, and embedded systems — no spam, unsubscribe any time.

No spam, unsubscribe any time. Privacy Policy

Aditya Gaurav

Aditya Gaurav

Embedded systems engineer specializing in SystemC, ARM architecture, and C/C++ internals. Writing deep technical dives for VLSI and embedded engineers.

Part of the SystemC Foundations guide Browse all SystemC guides →