5. SystemC Tutorial - Events & Notifications

Why this matters

There is a class of SystemC bugs that surfaces only at scale and that almost every team encounters at least once: an event is notified, the simulation continues, the consumer thread that was supposed to wake up does not, and the bug is reproducible only after several seconds of simulated time and only on certain seeds. The engineers spend a day reading their code, tracing the consumer's wait() calls, double-checking the event references, and only when someone pulls up the LRM do they discover that the notification was issued one delta cycle before the consumer reached its wait() — and sc_event is not buffered. The notification fired into nothing; the consumer is waiting for a notification that has already come and gone. This is the single most common pitfall in event-driven SystemC code, and it is also why understanding the three notification forms — immediate, delta, and timed — is non-negotiable.

This post fixes that. By the end you will understand the sc_event primitive at the kernel level; know exactly when each of the three notification forms fires relative to waiters; recognise the "lost event" pattern instantly when you see it; be able to use sc_event_or_list and sc_event_and_list for OR/AND wait conditions; and — in the Advanced section — understand the subtle semantics of triggered() and cancel() that let you build robust timeout and retry patterns.

Prerequisites

Mental model (first principles)

An sc_event is the kernel's primitive for "something happened that some process might care about." It carries no data — only the binary "fired in this delta" / "not fired" state. Processes can wait on an event; other processes (or sc_main) can notify an event to wake the waiters.

                       sc_event mechanics
                       ──────────────────

   Process A (notifier)         Kernel                Process B (waiter)
   ────────────────────         ──────                ──────────────────
                                                     wait(some_event);
                                event.add_waiter(B)  (suspends)

   event.notify(...)            ┌──────────────────┐
                                │ Schedule firing  │
                                │ at appropriate   │
                                │ time per the     │
                                │ notification     │
                                │ form (immediate, │
                                │ delta, or timed) │
                                └──────────────────┘
                                       │
                                       ▼
                                When firing time arrives:
                                fire event → wake all waiters
                                → waiters become runnable
                                → list of waiters cleared
                                       │
                                       ▼
                                                     (wait completes)
                                                     ... continues here

Two crucial facts that everything else follows from:

  1. The waiter list is cleared after firing. A consumer that wants to wait for the next notification must call wait(event) again. There is no persistent subscription; each wait call is a one-shot.
  1. Notifications are not buffered. If notify() is called when the waiter list is empty, the notification fires into nothing. There is no "pending notification" the next wait() will pick up — the notification has happened and is gone. This is the single biggest source of bugs in SystemC event code.

The three notification forms differ only in when the firing happens:

Form Syntax When the firing happens
Immediate event.notify() The current evaluate phase; waiters become runnable for the current delta cycle
Delta event.notify(SC_ZERO_TIME) The next delta cycle, at the same simulation time
Timed event.notify(t) (t > 0) At simulation time now + t, in a new delta cycle

All three forms wake the waiters that are subscribed at the moment of firing. The difference is when "firing" happens — and that affects who is waiting at that moment.

For immediate notification, the firing is essentially synchronous with the notify call — the waiters are added to the runnable set immediately. But — and this matters — the currently running process (the notifier) does not yield until it reaches a wait of its own (for threads) or returns (for methods). So the consumer's resumption happens later in the same evaluate phase, after the producer yields.

For delta notification, the firing is one delta cycle later. The notifier's current evaluate phase completes, the update phase runs, the post-update notification phase identifies that this event has fired, and waiters become runnable for the next evaluate. This is the form to use when you want the consumer to see the producer's committed writes from the same delta.

For timed notification, the firing is at a future simulation time. The kernel queues the firing on its timed-event heap. When the kernel's time-advance step reaches that time, the firing happens and waiters wake.

Beginner: First Principles

The smallest program that exercises an sc_event end-to-end:

// file: first_event.cpp
#include <systemc.h>

SC_MODULE(producer) {
    sc_event* go;

    void run() {
        wait(50, SC_NS);
        std::cout << "[" << sc_time_stamp() << "] producer: notify\n";
        go->notify();
        wait(10, SC_NS);
        std::cout << "[" << sc_time_stamp() << "] producer: done\n";
    }

    SC_CTOR(producer) : go(nullptr) { SC_THREAD(run); }
};

SC_MODULE(consumer) {
    sc_event* go;

    void run() {
        std::cout << "[" << sc_time_stamp() << "] consumer: waiting\n";
        wait(*go);
        std::cout << "[" << sc_time_stamp() << "] consumer: woken\n";
    }

    SC_CTOR(consumer) : go(nullptr) { SC_THREAD(run); }
};

int sc_main(int, char**) {
    sc_event go_event;

    producer p("p");
    consumer c("c");
    p.go = &go_event;
    c.go = &go_event;

    sc_start(200, SC_NS);
    return 0;
}

Build and run:

g++ -std=c++17 first_event.cpp -I$SYSTEMC_HOME/include \
    -L$SYSTEMC_HOME/lib-linux64 -lsystemc -o first_event
./first_event

Expected output:

[0 s] consumer: waiting
[50 ns] producer: notify
[50 ns] consumer: woken
[60 ns] producer: done

Walk through what happened. At time 0 (initialization phase), both threads start. The consumer prints "waiting" and then calls wait(*go) — suspending, adding itself to go_event's waiter list. The producer enters its loop, calls wait(50, SC_NS), suspends. Kernel runnable set is empty. Time advances.

At 50 ns, the producer resumes. Prints "notify". Calls go->notify() — an immediate notification. The kernel adds the consumer to the runnable set for the current delta (the producer is currently running). The producer then continues to wait(10, SC_NS) and suspends.

Now the kernel processes the remaining runnable set: the consumer resumes (its wait(*go) returned), prints "woken". Falls off the end of run and the consumer thread terminates.

At 60 ns the producer resumes, prints "done", terminates. Simulation continues until sc_start's 200 ns limit but nothing else is runnable.

Key observation: the consumer needed to be waiting at the moment the producer called notify() for the wakeup to work. If you remove the consumer's first wait(*go) (so the consumer never subscribes), the producer's notify() fires into an empty waiter list and is silently lost — the simulation runs to completion with no "consumer: woken" line.

This is the "lost event" pattern. Read it now, recognize it forever.

Intermediate: How It Really Works

Side-by-side: immediate vs delta vs timed

Three threads waiting on three events; one driver notifies each one with a different form; the output shows when each resumes.

// file: three_notifications.cpp
#include <systemc.h>

SC_MODULE(demo) {
    sc_event ev_imm, ev_delta, ev_timed;

    void consume_imm() {
        wait(ev_imm);
        std::cout << "[" << sc_time_stamp() << "] consumer_imm: woke\n";
    }
    void consume_delta() {
        wait(ev_delta);
        std::cout << "[" << sc_time_stamp() << "] consumer_delta: woke\n";
    }
    void consume_timed() {
        wait(ev_timed);
        std::cout << "[" << sc_time_stamp() << "] consumer_timed: woke\n";
    }

    void driver() {
        wait(10, SC_NS);
        std::cout << "[" << sc_time_stamp() << "] driver: firing all three\n";
        ev_imm.notify();              // immediate
        ev_delta.notify(SC_ZERO_TIME); // delta
        ev_timed.notify(5, SC_NS);     // timed
        wait(100, SC_NS);
        std::cout << "[" << sc_time_stamp() << "] driver: done\n";
    }

    SC_CTOR(demo) {
        SC_THREAD(consume_imm);
        SC_THREAD(consume_delta);
        SC_THREAD(consume_timed);
        SC_THREAD(driver);
    }
};

int sc_main(int, char**) {
    demo d("d");
    sc_start(200, SC_NS);
    return 0;
}

Expected output:

[10 ns] driver: firing all three
[10 ns] consumer_imm: woke
[10 ns] consumer_delta: woke
[15 ns] consumer_timed: woke
[110 ns] driver: done

The driver fires all three notifications at simulation time 10 ns. The immediate consumer wakes in the same delta cycle — but only after the driver yields at its wait(100, SC_NS). The delta consumer wakes in the next delta cycle, still at simulation time 10 ns. The timed consumer wakes at simulation time 15 ns (10 + 5).

Three subtle points worth absorbing.

First, both consumer_imm and consumer_delta print [10 ns] — they are at the same simulation time. The difference is in their delta cycle. The immediate consumer ran in the same delta as the driver; the delta consumer ran one delta later. From the wall-clock perspective the difference is invisible; from the kernel's perspective it is a full evaluate+update+notify pass.

Second, consumer_imm does not run synchronously inside the driver's notify() call. It is made runnable by the notify call, but the kernel cannot run it until the currently running process (the driver) yields. Only when the driver hits wait(100, SC_NS) does the kernel schedule the consumer. If the driver did something else after notify() (say, modified some shared state) the consumer would see that modified state when it runs — because the consumer runs after the driver yields, in the same evaluate phase.

Third, the order of consumer_imm and consumer_delta prints is deterministic in this example because they are in different delta cycles. Within a single delta with multiple runnable consumers, the order is implementation-defined.

The "lost event" demonstration

To make the lost-event pattern concrete:

// file: lost_event.cpp
#include <systemc.h>

SC_MODULE(broken) {
    sc_event go;

    void early_notifier() {
        std::cout << "[" << sc_time_stamp() << "] notifier: firing\n";
        go.notify();  // fires NOW — but is anyone waiting?
    }

    void late_consumer() {
        wait(10, SC_NS);
        std::cout << "[" << sc_time_stamp() << "] consumer: now waiting\n";
        wait(go);     // suspends — but the event already fired!
        std::cout << "[" << sc_time_stamp() << "] consumer: woke (never reached)\n";
    }

    SC_CTOR(broken) {
        SC_THREAD(early_notifier);
        SC_THREAD(late_consumer);
    }
};

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

Expected output:

[0 s] notifier: firing
[10 ns] consumer: now waiting

The consumer hangs forever. sc_start(100, SC_NS) returns at 100 ns; the consumer never wakes. The simulation exits but the consumer's wait(go) was never satisfied.

Two fixes:

Fix A — use delta notification with a slight ordering guarantee. Change go.notify() to something that ensures the consumer is ready. This typically means restructuring the design — for example, the consumer subscribes before the notifier starts running:

void early_notifier() {
    wait(SC_ZERO_TIME);   // let other init-time threads reach their waits
    std::cout << "[" << sc_time_stamp() << "] notifier: firing\n";
    go.notify();
}

Now at time 0 init, both threads run to their first wait. The notifier waits a delta. The consumer reaches wait(10, SC_NS). The notifier resumes in the next delta — but the consumer is still waiting for 10 ns, not for go. So this fix doesn't help here.

Fix B — restructure so the consumer subscribes before any possible notification. Move the wait(10, SC_NS) to after the wait(go):

void late_consumer() {
    std::cout << "[" << sc_time_stamp() << "] consumer: now waiting\n";
    wait(go);      // subscribe BEFORE the notifier fires
    wait(10, SC_NS);
    std::cout << "[" << sc_time_stamp() << "] consumer: woke\n";
}

Now the consumer subscribes at init time, the notifier fires immediately, both run in the same delta. The lost-event problem is avoided by changing the sequence of operations, not by changing the event itself. This is the general lesson: events don't queue, so design your protocol so the consumer always subscribes before the producer notifies.

Producer/consumer with sc_event

The bread-and-butter event-using pattern:

// file: producer_consumer_event.cpp
#include <systemc.h>
#include <queue>

SC_MODULE(pc) {
    sc_event item_available;
    std::queue<int> items;

    void producer() {
        for (int i = 1; i <= 5; ++i) {
            wait(20, SC_NS);
            items.push(i * 10);
            item_available.notify();  // immediate — consumer is waiting
            std::cout << "[" << sc_time_stamp() << "] produced " << (i*10) << "\n";
        }
    }

    void consumer() {
        while (true) {
            if (items.empty()) {
                wait(item_available);
            }
            int v = items.front();
            items.pop();
            std::cout << "[" << sc_time_stamp() << "] consumed " << v << "\n";
        }
    }

    SC_CTOR(pc) {
        SC_THREAD(producer);
        SC_THREAD(consumer);
    }
};

int sc_main(int, char**) {
    pc p("p");
    sc_start(200, SC_NS);
    return 0;
}

Expected output:

[20 ns] produced 10
[20 ns] consumed 10
[40 ns] produced 20
[40 ns] consumed 20
[60 ns] produced 30
[60 ns] consumed 30
[80 ns] produced 40
[80 ns] consumed 40
[100 ns] produced 50
[100 ns] consumed 50

The consumer enters its loop at init, sees items is empty, waits. The producer waits 20 ns, pushes, notifies. The consumer wakes (in the same delta — immediate notification), drains the queue (which now has one item), iterates back to the top, sees the queue empty, waits again. Producer waits 20 ns, repeat.

This works because the consumer always reaches wait(item_available) before any notification. The pattern is self-sustaining.

Handshake: req/ack with two events

// file: handshake.cpp
#include <systemc.h>

SC_MODULE(hs) {
    sc_event req, ack;

    void initiator() {
        for (int i = 0; i < 3; ++i) {
            wait(20, SC_NS);
            std::cout << "[" << sc_time_stamp() << "] initiator: req\n";
            req.notify();
            wait(ack);
            std::cout << "[" << sc_time_stamp() << "] initiator: got ack\n";
        }
    }

    void responder() {
        while (true) {
            wait(req);
            std::cout << "[" << sc_time_stamp() << "] responder: got req, working\n";
            wait(5, SC_NS);
            std::cout << "[" << sc_time_stamp() << "] responder: ack\n";
            ack.notify();
        }
    }

    SC_CTOR(hs) {
        SC_THREAD(initiator);
        SC_THREAD(responder);
    }
};

int sc_main(int, char**) {
    hs h("h");
    sc_start(200, SC_NS);
    return 0;
}

Expected output:

[20 ns] initiator: req
[20 ns] responder: got req, working
[25 ns] responder: ack
[25 ns] initiator: got ack
[45 ns] initiator: req
[45 ns] responder: got req, working
[50 ns] responder: ack
[50 ns] initiator: got ack
... (etc.)

The pattern works because each side reaches its wait before the other side notifies. Both events use immediate notification; the kernel handles the wake correctly because the waiter has already subscribed.

sc_event_or_list — wait for any of N events

// file: or_list.cpp
#include <systemc.h>

SC_MODULE(watcher) {
    sc_event done, error, timeout;

    void watch() {
        std::cout << "[" << sc_time_stamp() << "] watcher: waiting\n";
        wait(done | error | timeout);

        if (done.triggered()) std::cout << "saw DONE\n";
        if (error.triggered()) std::cout << "saw ERROR\n";
        if (timeout.triggered()) std::cout << "saw TIMEOUT\n";
        std::cout << "[" << sc_time_stamp() << "] watcher: exit\n";
    }

    void firer() {
        wait(30, SC_NS);
        std::cout << "[" << sc_time_stamp() << "] firer: notifying done\n";
        done.notify();
    }

    SC_CTOR(watcher) {
        SC_THREAD(watch);
        SC_THREAD(firer);
    }
};

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

Expected output:

[0 s] watcher: waiting
[30 ns] firer: notifying done
saw DONE
[30 ns] watcher: exit

The done | error | timeout expression builds an sc_event_or_list. wait on this list returns when any one of the events fires. After the wait, triggered() on each event tells you which one fired — useful for post-wait branching.

Performance: when notify happens, what does the kernel actually do?

A useful mental model for the cost of each notification form:

Immediate (notify()): O(W) where W is the number of waiters at the moment of the call. The kernel walks the event's waiter list and adds each one to the runnable set. No heap operations, no time-queue insertion. Fastest of the three forms.

Delta (notify(SC_ZERO_TIME)): O(W) + O(1) — same waiter-list walk, plus appending one entry to the post-update notification queue (a simple list). The notifications fire at the end of the current delta. Slightly more bookkeeping than immediate, still O(1) for the queue side.

Timed (notify(t) with t > 0): O(log N) where N is the number of pending timed notifications across the entire simulation. The kernel inserts into a min-heap keyed by time. When the kernel advances time, it pops from the heap. For models with many pending timed events (a memory controller modelling thousands of in-flight transactions, for example), this O(log N) is the dominant cost.

For most testbench-level code, the cost difference is invisible — all three notification forms are dwarfed by the actual work the processes do. For very hot kernels (TLM models with millions of notifications per second), preferring immediate over delta and preferring delta over short-timed can give measurable speedup, but only after profiling has identified the kernel as the bottleneck.

Notification within sc_main vs. within a process

A subtle but useful distinction. From within a running process, an immediate notify() adds waiters to the runnable set for the current delta (after the running process yields). From within sc_main (between sc_start calls, so no process is currently running), an immediate notify() schedules waiters to wake when the next sc_start resumes — which happens to be the initialization phase of that sc_start.

int sc_main(int, char**) {
    sc_event ev;
    consumer c("c"); c.set_event(&ev);

    sc_start(SC_ZERO_TIME);   // run init — consumer subscribes
    ev.notify();              // immediate from sc_main scope
    sc_start(1, SC_NS);       // consumer wakes here, in init phase
    return 0;
}

The pattern is occasionally useful for testbench scaffolding — driving "one-shot" events from sc_main to kick off testbench sequences. For most cases, prefer to drive events from a real SC_THREAD-based driver, which gives you cleaner timing relationships.

sc_event_and_list — wait for all of N events

// file: and_list.cpp
#include <systemc.h>

SC_MODULE(barrier) {
    sc_event a_done, b_done, c_done;

    void coord() {
        std::cout << "[" << sc_time_stamp() << "] coord: waiting for all 3\n";
        wait(a_done & b_done & c_done);
        std::cout << "[" << sc_time_stamp() << "] coord: all done, proceeding\n";
    }

    void worker_a() { wait(10, SC_NS); a_done.notify(); }
    void worker_b() { wait(30, SC_NS); b_done.notify(); }
    void worker_c() { wait(20, SC_NS); c_done.notify(); }

    SC_CTOR(barrier) {
        SC_THREAD(coord);
        SC_THREAD(worker_a);
        SC_THREAD(worker_b);
        SC_THREAD(worker_c);
    }
};

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

Expected output:

[0 s] coord: waiting for all 3
[30 ns] coord: all done, proceeding

The a_done & b_done & c_done expression builds an sc_event_and_list. wait returns when all three have fired — in any order, at any time. The list is latched: once all three have fired, the wait completes regardless of how spread out in time those firings were.

A full worked example: timed-event-driven traffic light

Putting the patterns together — a traffic-light controller driven entirely by events, with a sc_main that observes phase transitions:

// file: traffic_light.cpp
#include <systemc.h>

SC_MODULE(traffic_light) {
    enum phase_t { GREEN, YELLOW, RED };
    phase_t phase = GREEN;
    sc_event phase_changed;

    void controller() {
        while (true) {
            switch (phase) {
                case GREEN:
                    wait(30, SC_NS);
                    phase = YELLOW;
                    phase_changed.notify();
                    break;
                case YELLOW:
                    wait(5, SC_NS);
                    phase = RED;
                    phase_changed.notify();
                    break;
                case RED:
                    wait(25, SC_NS);
                    phase = GREEN;
                    phase_changed.notify();
                    break;
            }
        }
    }

    void observer() {
        while (true) {
            wait(phase_changed);
            const char* name = phase == GREEN ? "GREEN" :
                               phase == YELLOW ? "YELLOW" : "RED";
            std::cout << "[" << sc_time_stamp() << "] phase = " << name << "\n";
        }
    }

    SC_CTOR(traffic_light) {
        SC_THREAD(controller);
        SC_THREAD(observer);
    }
};

int sc_main(int, char**) {
    traffic_light tl("tl");
    sc_start(200, SC_NS);
    return 0;
}

Expected output:

[30 ns] phase = YELLOW
[35 ns] phase = RED
[60 ns] phase = GREEN
[90 ns] phase = YELLOW
[95 ns] phase = RED
[120 ns] phase = GREEN
[150 ns] phase = YELLOW
[155 ns] phase = RED
[180 ns] phase = GREEN

The pattern is illuminating. The controller drives state changes; the observer wakes on each change. The observer subscribes once at start, and because the controller always reaches wait(time) after the notify, the observer is always waiting when the controller calls phase_changed.notify(). The lost-event pitfall is avoided by the natural protocol design — the controller pauses in wait(time) while the observer is reset to wait for the next change.

If we had structured it differently — for example, if the observer did some work that took longer than the controller's next wait(time) — the observer might miss a notification because it would not be waiting yet. The fix would be to use sc_event_queue or to add explicit handshaking; but for this simple case the temporal structure makes the protocol safe.

Decision table: which notification form?

You want… Use
Consumer to see the producer's writes from the same delta notify(SC_ZERO_TIME) (delta)
Consumer to react in the same delta, before the producer's subsequent writes commit notify() (immediate)
Consumer to wake after a specific simulated time has passed notify(t) (timed)
To cancel a pending notification only works for delta and timed forms; event.cancel()
"Wake when any of these happens" `wait(ev1 \ ev2 \ ev3)`
"Wake when all of these have happened" wait(ev1 & ev2 & ev3)
"Wake on event, but also after a timeout" wait(t, event)

Advanced: Edge Cases & LRM Corners

Corner 1: Multiple notify() calls on the same event — the "earliest wins" rule

If you call notify() multiple times on a single event before it fires, the LRM (§5.10.6) defines the result as the earliest of the requested times. The ordering: immediate > delta > timed-near > timed-far.

event.notify(10, SC_NS);   // timed, fires at +10 ns
event.notify(SC_ZERO_TIME); // delta — supersedes the timed
event.notify();             // immediate — supersedes the delta

After this sequence, the event fires immediately. The earlier-pending notifications are discarded by the kernel.

The corollary: if you have a delta notification pending and then call timed notify with t > 0, the timed notify is ignored (it would fire later than the already-pending delta). This is occasionally surprising — code that says "notify in 10 ns" may silently do nothing if a notify is already pending sooner.

Corner 2: cancel() — only for pending notifications

event.cancel() cancels a pending notification (delta or timed). It has no effect on immediate notifications because immediate has already fired by the time notify() returns. It also has no effect if there is no pending notification.

event.notify(100, SC_NS);   // schedule for 100 ns from now
// If the operation completes early, retract the scheduled notification:
event.cancel();              // remove the pending notify
// The 100-ns firing will not happen.

This is the right primitive for "schedule a timeout, but cancel it if the operation completes early" patterns.

Corner 3: triggered() — true during the delta the event fires in

event.triggered() returns true during the delta cycle in which the event has fired. After that delta passes, it returns false. This is not a persistent flag.

It is primarily useful inside the body of a process that just waited on a composite list:

wait(done | error | timeout);
if (done.triggered())    handle_done();
if (error.triggered())   handle_error();
if (timeout.triggered()) handle_timeout();

Calling triggered() outside this immediate post-wait context is usually wrong — by the next delta the result has changed.

Corner 4: default_event() on ports — what sensitive << port actually means

When you write sensitive << some_signal_port, the << operator calls port->default_event() and registers that event with the static sensitivity list. For sc_in<T> (and sc_signal<T> via default_event()), the default event is the value_changed_event — fired whenever the signal's value changes.

You can also write sensitivity explicitly:

sensitive << port;                       // default: value_changed_event
sensitive << port.value_changed_event(); // same thing, explicit
sensitive << port.pos();                 // posedge only (for bool)
sensitive << port.neg();                 // negedge only (for bool)
sensitive << custom_event;               // your own sc_event

This is why a process sensitive to clk.pos() only fires on rising edges — pos() returns a reference to the posedge_event, not the general value_changed_event.

Corner 5: Composing OR and AND lists

You can build larger lists incrementally:

sc_event_or_list any_failure;
any_failure |= signal_error;
any_failure |= timer_expired;
any_failure |= watchdog_bite;

wait(any_failure);   // wakes when any of the three fires

The compound assignment operators |= (for OR lists) and &= (for AND lists) let you build lists at runtime, possibly conditionally:

sc_event_and_list arrival_barrier;
for (auto* worker : workers) {
    arrival_barrier &= worker->arrived;
}
wait(arrival_barrier);

This pattern scales to any number of events without having to spell out the | or & chain by hand.

Corner 6: AND-list latching semantics

The AND list (sc_event_and_list) is latched: once an event in the list fires, the kernel remembers it. The wait completes when all events in the list have fired, possibly at very different times.

wait(a & b & c);   // returns when a, b, c have ALL fired

If a fires at 10 ns, b at 50 ns, c at 100 ns, the wait returns at 100 ns.

The crucial detail: the latch resets after the wait completes. A subsequent wait(a & b & c) starts a fresh count and waits for all three to fire again.

This is the right primitive for barrier synchronization — wait until all workers have signaled completion — but be aware that the latch state is per-wait, not per-event. You cannot inspect "has a already fired in the current latch" outside of the wait expression.

Corner 7: Combining time and event with wait(t, event)

The two-argument form wait(t, event) (or wait(t, event_list)) returns whichever comes first: the time elapses, or the event fires.

sc_event request;
wait(sc_time(100, SC_NS), request);
if (request.triggered()) {
    handle_request();
} else {
    handle_timeout();
}

This is the standard SystemC timeout pattern. The triggered() check tells you which condition won. There is no separate sc_event_timeout API — wait(t, event) is the LRM-blessed way.

Corner 8: Events as module members vs sc_main-local

In the examples above, events are module members. You can also declare them in sc_main and pass references into modules:

int sc_main(int, char**) {
    sc_event shared_event;
    producer p("p");
    consumer c("c");
    p.set_event(&shared_event);
    c.set_event(&shared_event);
    sc_start(100, SC_NS);
    return 0;
}

This is occasionally useful for events that span multiple subsystems and don't naturally belong to any single module. The trade-off is that module reusability decreases — the module now depends on an external event reference, which must be set before sc_start.

For events that are intrinsic to a single module's protocol, declare them as members. For events that coordinate between modules at the top level, declare them in the parent module (or in sc_main) and pass references.

Corner 9: sc_event_queue — when you actually want buffered notifications

sc_event does not buffer. If your protocol genuinely needs "every notify should eventually wake exactly one consumer," sc_event is the wrong primitive — you want sc_event_queue (defined in <sysc/communication/sc_event_queue.h>).

sc_event_queue eq;

// Producer: notify multiple times in rapid succession
eq.notify(SC_ZERO_TIME);
eq.notify(SC_ZERO_TIME);
eq.notify(SC_ZERO_TIME);

// Consumer (sees three separate firings):
while (true) {
    wait(eq.default_event());
    // handle one item
}

sc_event_queue buffers pending notifications and replays them across delta cycles, so the consumer sees three distinct wakeups rather than one merged wakeup. This is the right primitive for "every event must be processed" patterns — typically transaction-level modelling where you cannot afford to drop transactions.

The trade-off: more memory (the queue stores pending notifications) and slightly higher kernel overhead. Use sc_event_queue deliberately; for most synchronization patterns the discipline of "always have the consumer waiting" with sc_event is sufficient and lighter-weight.

Corner 10: sc_event and tracing

sc_event is not directly traceable to VCD. The reason: an event is a momentary firing, not a sustained state, and VCD records value changes over time. To trace event firings, the conventional pattern is to mirror them to a sc_signal<bool>:

sc_event ev;
sc_signal<bool> ev_trace;

// In a method sensitive to ev:
void on_event() {
    ev_trace.write(!ev_trace.read());   // toggle each firing
}

// In sc_main:
sc_trace(tf, ev_trace, "ev_firings");

The trace shows a toggle on each event firing. This is enough to debug "did the event fire" questions in the waveform viewer.

Corner 11: The dynamic event member of sc_module — sc_event lifetime considerations

sc_event is a normal C++ object. It is constructed when its enclosing scope is entered and destroyed when that scope is exited. If you declare an sc_event as a local variable inside a process body, it is destroyed when the process exits — which is almost certainly not what you want.

// ANTI-PATTERN: event has process-local lifetime
void my_thread_body() {
    sc_event local_ev;          // constructed on stack
    other_module->set_event(&local_ev);   // share pointer
    wait(some_signal);
    other_module->trigger();    // triggers local_ev — fine while we're here
    wait(local_ev);
    // local_ev destroyed when this function returns; if it's called again
    // and other_module still holds the old pointer, undefined behaviour.
}

The right pattern is to declare events as module members (sc_event ev; directly in SC_MODULE(...) { ... };) so they share the module's lifetime — i.e., from elaboration through end of simulation.

Corner 12: sc_event is non-copyable and non-movable

The Accellera sc_event declares its copy constructor and assignment operator as private (in older versions) or deleted (in newer ones). You cannot copy or assign events. This matters when you try to store events in standard containers:

std::vector<sc_event> events(5);          // FAILS — sc_event is non-copyable
std::vector<sc_event*> event_ptrs(5);     // works — store pointers
std::vector<std::unique_ptr<sc_event>> event_owners(5);   // works — owning pointers

The same restriction applies to sc_signal and sc_module for the same reason — these classes register themselves with the kernel at construction, and a copy would create a second registration with conflicting identity. The fix is always to store pointers or use indirection via a containing module.

Corner 13: Event ordering across modules at the same delta cycle

When two modules fire events in the same delta cycle, the order in which waiters in other modules wake up is implementation-defined. The Accellera PoC uses registration order, but other simulators may differ.

// Module A's thread:
ev_a.notify();

// Module B's thread:
ev_b.notify();

// Module C's thread:
wait(ev_a | ev_b);
// Which one's triggered() returns true first? Both — they're an OR list.
// But which event_handler runs first if both fired? Implementation-defined.

The practical guidance: if your test correctness depends on the order of event firings within a single delta, you have a race condition. Either use distinct delta cycles to serialize, or pick the form that doesn't expose the order to the consumer (e.g., AND-list rather than OR-list semantics where applicable).

Corner 13a: Dynamic-vs-static sensitivity interaction inside threads

Inside an SC_THREAD, the sensitivity list (sensitive << ... after registration) is consulted only when wait() is called with no arguments. Any wait(specific_event), wait(time), or wait(event_list) ignores the static list entirely and uses the explicit condition.

SC_CTOR(my_module) {
    SC_THREAD(my_thread);
    sensitive << signal_a << signal_b;   // static sensitivity
}

void my_thread() {
    wait();                          // suspends on signal_a or signal_b
    wait(signal_c.value_changed_event());   // suspends only on signal_c
    wait();                          // back to signal_a/signal_b (static)
    wait(10, SC_NS);                 // suspends on time only
    wait();                          // signal_a/signal_b again
}

The static list is the default used by bare wait(). Explicit waits temporarily override; subsequent bare wait() reverts. There is no "permanently change my static sensitivity" API — if you want different sensitivity for different parts of a thread, use explicit wait(...) calls throughout.

For an SC_METHOD, there is no equivalent — next_trigger(...) overrides only the next invocation. After that single invocation completes (without another next_trigger), subsequent invocations revert to the static sensitivity list.

Corner 14: wait() with no arguments inside a thread with empty static sensitivity

If a thread is registered with SC_THREAD(fn) and no sensitive << ... follows, the static sensitivity list is empty. Calling wait() with no arguments inside the thread suspends it on the empty list — meaning it can never wake. The simulation hangs.

This is the same Corner 4 from Part 4 (Processes & Sensitivity), restated here because it bites event-using code particularly often: a thread that uses dynamic wait(some_event) calls usually has no static sensitivity, and a stray bare wait() (perhaps from a refactor or copy-paste) silently deadlocks.

The defence: always use wait(...) with arguments inside threads, OR ensure the thread has explicit static sensitivity. Never write wait() without thinking about what sensitivity list it's consulting.

Hands-on exercise

Below is one focused exercise that exercises most of what this post covered. Take 20-30 minutes to do it carefully before reading the hint.

Build a simple FSM driven by external events:

  • One thread, the controller, holds state in { IDLE, RUNNING, DONE }.
  • Three events: start_ev, done_ev, reset_ev.
  • Transitions: IDLE + start_ev → RUNNING; RUNNING + done_ev → DONE; any state + reset_ev → IDLE.
  • Use wait(start_ev | done_ev | reset_ev) to wait for any of the three.
  • Use triggered() to determine which event fired and choose the next state.

Drive the three events from sc_main with sc_start calls in between, exercising at least one path from IDLE → RUNNING → DONE → (reset_ev) → IDLE. Predict the output before running.

Hint: the entire controller body is one while(true) loop with a switch on the current state and a wait(...) at the top of each branch. The triggered() checks after the wait tell you which transition to take. Beware: if you write wait(start_ev | done_ev | reset_ev) inside the IDLE branch and only handle start_ev there, the IDLE branch will also wake (and silently fall through) when done_ev or reset_ev fires — make sure each branch handles all three possibilities sensibly (reset_ev always to IDLE, irrelevant events ignored or logged).

Common mistakes

  • Calling notify() (immediate) and assuming the consumer wakes up before the producer reaches its next wait. It does NOT — the consumer becomes runnable, but the kernel does not switch away from the producer until it yields. The consumer's resumption happens later in the same evaluate phase, after the producer suspends.
  • Calling notify() when no consumer is waiting yet. The notification is lost. The simulation may appear to deadlock with no error. Always structure your protocol so the consumer subscribes before the producer notifies.
  • Treating notify(SC_ZERO_TIME) as identical to notify(). They are not. Use immediate (notify()) when you need the wakeup in the current delta; use delta (notify(SC_ZERO_TIME)) when you need the consumer to see the producer's committed writes from the same delta.
  • Forgetting to re-wait() after a wakeup. The waiter list is cleared after each firing. A consumer that wants to react to multiple notifications needs while(true) { wait(event); ... }, not a single wait(event) at the top.
  • Using cancel() on a notification that hasn't been scheduled. No-op, but indicates a logic error. Check whether you actually need to cancel or whether the structure can be reorganised.
  • Calling triggered() outside the immediate post-wait branch. The result is false after the delta passes, leading to subtly wrong logic. Limit triggered() checks to the lines immediately following the relevant wait.
  • Sharing event references across modules without ownership clarity. If module A and module B both think they own some_event, the lifetime is unclear. Pick one owner (typically the parent module) and have others hold a reference.
  • Tracing sc_event directly to VCD. Not supported. Mirror to an sc_signal<bool> that toggles on each firing.
  • Storing sc_event in a standard container by value. sc_event is non-copyable; std::vector<sc_event> fails to compile. Use std::vector<sc_event*> or std::vector<std::unique_ptr<sc_event>> if you need a dynamic collection.
  • Declaring an sc_event as a local variable inside a process body. Lifetime is bound to that scope; the event is destroyed when the function returns. Events should be module members or persistent globals.
  • Calling notify() with multiple producers expecting the consumer to wake N times. sc_event fires once per delta regardless of how many producers notify; if you need per-notify wakeups, use sc_event_queue.
  • Using sc_event to communicate values. It carries no data — only "fired" / "not fired". For values, use sc_signal<T> and have the consumer's wait fire on the signal's value-changed event. Or use sc_event to signal "the value is ready" and store the value in a shared module member.
  • Forgetting that AND-list latching resets after each wait. A wait on (a & b & c) completes when all three have fired; a second wait on the same expression starts a fresh count and waits for all three to fire again. The latch is per-wait, not per-event.

Worked recipe: timeout with cancellation

A frequently needed pattern: schedule a timeout, but if the operation completes early, cancel the timeout to prevent the timeout handler from firing unnecessarily.

SC_MODULE(timeout_demo) {
    sc_event op_complete;
    sc_event op_timeout;

    void operation() {
        wait(15, SC_NS);     // simulate work taking 15 ns
        std::cout << "[" << sc_time_stamp() << "] operation completed\n";
        op_complete.notify();
        op_timeout.cancel();  // operation done — cancel the pending timeout
    }

    void controller() {
        op_timeout.notify(20, SC_NS);   // 20 ns timeout
        wait(op_complete | op_timeout);
        if (op_complete.triggered()) {
            std::cout << "[" << sc_time_stamp() << "] handled completion\n";
        } else {
            std::cout << "[" << sc_time_stamp() << "] handled timeout\n";
        }
    }

    SC_CTOR(timeout_demo) {
        SC_THREAD(controller);
        SC_THREAD(operation);
    }
};

Two scenarios. If operation completes in 15 ns (less than the 20 ns timeout), the controller wakes via op_complete, prints "handled completion", and the operation's op_timeout.cancel() ensures the timeout never fires later. If operation took 25 ns instead (longer than the timeout), the controller would wake via op_timeout at 20 ns, print "handled timeout", and the eventual completion at 25 ns would no longer have any waiter to wake.

This pattern is the SystemC equivalent of select with timeouts in Unix-style systems programming, or WaitForMultipleObjectsEx with a timeout in Windows. The cancel() call is the load-bearing piece — without it, the timeout event would fire at 20 ns regardless, even after the operation had already completed, potentially causing a spurious wakeup if the controller restarts the wait.

Naming conventions for events

A small convention that pays off in code reviews: name events as verbs or past-tense verbs describing what happened. request_arrived, data_ready, transaction_complete, error_detected read naturally; req, flag, signal are vague and force the reader to look up the event's role.

Module-member events that signal one side of a handshake often pair: request + acknowledge, start + done, enq_request + enq_complete. The reader can predict which side fires which.

For events that fire repeatedly (like our phase_changed in the traffic light), past-tense or stative naming makes it clear that the event is a recurring signal rather than a one-shot. phase_changed reads as "the phase has just changed" which is the right semantic.

When to use sc_event vs. sc_signal<bool> for cross-process signalling

A practical question that comes up often: when should I use an sc_event and when should I use a sc_signal<bool> that pulses high then low?

Use sc_event when:

  • The signalling has no value; it's pure "something happened" semantics.
  • You want the natural "one-firing-per-delta" semantics.
  • The producer always reaches its notify after the consumer reaches its wait (avoids the lost-event problem).
  • You don't need waveform tracing of the firings (or you're willing to mirror to a signal for tracing).

Use sc_signal<bool> (held high for a cycle, then low) when:

  • You want VCD tracing without the mirror trick.
  • The signalling is naturally part of an RTL-style protocol (e.g., a valid signal in a handshake).
  • The consumer may temporarily miss the rising edge but the high level lets it catch up.
  • The signal value itself is part of the protocol's meaning (e.g., enable that stays high while operation is active).

For internal SystemC testbench mechanics, sc_event is usually the right primitive. For modelling actual hardware wires in an RTL-level model, sc_signal<bool> is usually right. The distinction is "is this a kernel-level coordination primitive or is it a model of a real signal in hardware."

Recap

After this post, you can:

  • Explain the three notification forms (immediate, delta, timed) and predict when each wakes its waiters relative to the notify call.
  • Recognise and avoid the "lost event" pattern — notification fired when no consumer is waiting.
  • Build producer/consumer, handshake, and barrier patterns using sc_event.
  • Use sc_event_or_list to wait for any of N events.
  • Use sc_event_and_list to wait for all of N events with latching semantics.
  • Use triggered() correctly in the immediate post-wait context.
  • Use cancel() to retract a pending notification, and know that immediate notifications cannot be cancelled.
  • Choose between immediate and delta notification based on whether the consumer must see the producer's committed writes.
  • Compose OR/AND lists incrementally for runtime-variable wait conditions.
  • Mirror an event to an sc_signal<bool> for VCD tracing.

Further reading

  • IEEE 1666-2011 §5.10 — the entire chapter. The "earliest wins" rule, the triggered() semantics, and the AND-list latch are all worth reading carefully in the LRM directly. The annex of the LRM also contains a clarifying worked example of the immediate notification semantics that is worth comparing against your mental model after reading this post.
  • Accellera SystemC User's Guide — the events chapter walks through the same patterns with worked examples.
  • Accellera SystemC PoC source: src/sysc/kernel/sc_event.h. The implementation is well-commented; reading it clarifies the kernel-level mechanics behind the LRM semantics.
  • Doulos SystemC Golden Reference Guide — the events chapter is brief but covers all the common patterns.
  • Black, Donovan, Tahar, SystemC: From the Ground Up (2nd ed.) — the events chapter includes additional examples of OR/AND list composition and discusses common patterns where event-driven code is the natural fit versus where signal-based code wins out.
  • The Accellera proof-of-concept distribution includes example files under examples/sysc/ that exercise notifications directly — examples/sysc/pkt_switch/ and examples/sysc/2.1/scx_pipe/ are worth reading for realistic event-driven testbench patterns at a manageable size.
  • For the sc_event_queue deep dive, the kernel header src/sysc/communication/sc_event_queue.h is short and explains the buffering semantics by example.

Next in this section

→ Part 6: Your First SystemC Testbench — now that you can build multi-process models with events and notifications, the next post shows how all of the Foundations primitives compose into a working testbench: a driver that issues stimulus, a monitor that observes outputs, and a scoreboard that compares against expected values. Part 6 also introduces VCD tracing so you can inspect signal behaviour visually after a run. After Part 6 you will be ready for Part 7 (Elaboration & the Kernel Lifecycle), which completes the Foundations section.

Author
Mayur Kubavat
DV engineer working on SoC verification. Writes here about UVM, PCIe, SystemVerilog, and the everyday craft of getting designs to tape-out.

Comments (0)

Leave a Comment