sc_prim_channel in SystemC — Building Custom Channels
You have been using sc_signal and sc_fifo as black boxes. At some point — modelling a custom arbitration bus, a priority mailbox, a write-combining buffer — you need a channel that does exactly what your protocol requires, and neither built-in class fits. That is when you reach for sc_prim_channel.
sc_prim_channel is the base class for all primitive channels in SystemC. It gives you two hooks into the scheduler: request_update() to tell the kernel "I have a pending write" and update() where you apply it. If you have ever wondered how sc_signal manages to show every process a consistent snapshot even when multiple processes write in the same delta — sc_prim_channel is exactly how it works.
1. Why Not Just Use a Struct?
The natural instinct when you need a shared variable is a plain C++ struct. Two processes share a pointer, one writes, the other reads. In a single-threaded environment that is fine. In SystemC it silently breaks your simulation.
Here is the problem. SystemC runs all ready processes to completion before advancing simulation time — that is the run-to-completion rule. Two processes scheduled in the same delta can both run before any time advances. If process A writes directly to a shared struct field and process B reads from it in the same delta, whether B sees the old or new value depends entirely on which process happened to run first. That ordering is scheduler-defined — it can differ between simulators, compiler settings, and even runs.
// WRONG — race condition disguised as working code struct SharedBus { int data = 0; bool valid = false; }; SharedBus bus; // Process A (SC_METHOD, sensitive to clk.pos()) void driver() { bus.data = 42; // writes immediately to shared state bus.valid = true; } // Process B (SC_METHOD, sensitive to clk.pos()) void receiver() { // may see old or new value — undefined by standard if (bus.valid) process(bus.data); }
The evaluate-update protocol exists precisely to avoid this. During the evaluate phase every ready process runs and computes new values, but no channel's visible state changes. After all processes finish, the kernel enters the update phase and channels apply their pending values atomically. Every process that runs in the same delta sees a consistent snapshot — the state from the previous delta.
sc_prim_channel is how you participate in that protocol. It registers your channel with the kernel so that update() is called at the right moment. sc_signal<T> is itself a sc_prim_channel subclass — there is no magic underneath, just the same two-field pattern you are about to implement yourself.
sc_prim_channel makes the correct protocol mandatory.
2. sc_prim_channel vs sc_channel
SystemC provides two base classes for channels, and the choice between them is architectural, not stylistic.
| Base class | Provides | Use when |
|---|---|---|
sc_prim_channel |
request_update(), update() |
Your channel holds a value that processes read and write in the same delta — you need the evaluate-update guarantee |
sc_channel |
Nothing extra beyond sc_object |
Reads and writes are inherently serialised — a blocking FIFO where a writer suspends until a reader consumes, so there is no concurrent-delta hazard |
The rule: if your channel stores a value that one process writes and another process reads within the same delta, use sc_prim_channel. If the write and read are serialised by blocking calls — one side always waits for the other before proceeding — you can use sc_channel (or just inherit from sc_object directly).
sc_fifo is actually a sc_prim_channel despite being a blocking FIFO, because its internal counters (num_available, num_free) must update atomically relative to the evaluate-update cycle. The blocking wait in read() and write() is at the process level — the channel's bookkeeping still participates in the update phase.
sc_prim_channel when you don't strictly need it costs one virtual dispatch per update — negligible. Not using it when you do need it causes silent simulation races that are very hard to track down.
3. The evaluate() / update() Protocol
Understanding this protocol in concrete terms is the prerequisite for writing a correct channel. Here is what the kernel does every delta cycle:
// Conceptual kernel loop — one delta cycle // ───────────────────────────────────────────────────────────── // EVALUATE PHASE // All processes in the run-set execute (run to completion). // Interface methods on channels may be called. // Channels may call request_update() to queue themselves. // No channel's visible state changes yet. // // UPDATE PHASE // Kernel calls update() on every channel that called request_update(). // Channel applies m_new_val → m_cur_val. // Channel notifies value_changed_event() if the value changed. // Newly notified events schedule processes for the NEXT delta. // // ADVANCE DELTA // delta_count++ // Newly scheduled processes join the run-set → back to evaluate.
A concrete timeline with three processes sensitive to the same signal:
// delta 0, evaluate phase: // Process P1 calls sig.write(99) → stores 99 in m_new_val, calls request_update() // Process P2 calls sig.read() → returns m_cur_val = 0 (old value, stable) // Process P3 calls sig.read() → returns m_cur_val = 0 (same stable snapshot) // // delta 0, update phase: // Kernel calls sig.update() // m_cur_val = 99, m_new_val unchanged // value_changed_event notified → P2, P3 scheduled for delta 1 // // delta 1, evaluate phase: // P2 calls sig.read() → returns m_cur_val = 99 (new stable value) // P3 calls sig.read() → returns m_cur_val = 99 (same)
Two things to notice. First, P2 and P3 both see 0 during delta 0 regardless of which order they ran relative to P1. Second, they both see 99 in delta 1 because update() committed before either of them ran again. That is the consistency guarantee.
sc_time_stamp() does not change between deltas — only sc_delta_count() increments. A write followed immediately by a read at the same timestamp still crosses a delta boundary if a channel update is involved.
4. request_update() and update()
These are the only two methods you need to implement. Everything else is interface design on your part.
// ── request_update() ────────────────────────────────────────── // Call this from inside any interface method when a process // writes a new value. It registers *this with the kernel's // update queue. The kernel will call update() exactly once // per delta per channel, regardless of how many times // request_update() was called in that delta. void my_write_method(const T& val) { m_new_val = val; m_written = true; request_update(); // inherited from sc_prim_channel } // ── update() ────────────────────────────────────────────────── // Override this in your channel. The kernel calls it during // the update phase. This is the ONLY place you should modify // the channel's "current" (visible) state. void update() override { if (m_written) { if (m_new_val != m_cur_val) { m_cur_val = m_new_val; m_changed_event.notify(SC_ZERO_TIME); } m_written = false; } }
Three rules to carve into memory:
- Never modify
m_cur_valfrom an interface method. Interface methods run during the evaluate phase. Changing visible state there breaks the consistent-snapshot guarantee for every other process in that delta. - Never call
notify()from an interface method. Notifications during evaluate phase schedule processes for the current delta, not the next one. Other processes that already ran this delta will not see the update, but newly scheduled ones will — creating ordering-dependent behaviour. - Always call
request_update()when you have a pending write. If you forget,update()is never called, and your channel silently drops every write.
update() call. The kernel keeps a set, not a counter. You do not need to guard against calling it multiple times — but you do need to make sure your update() only applies pending writes, not stale ones.
5. Building a Custom Typed Signal
The cleanest way to learn sc_prim_channel is to rebuild sc_signal<T> from scratch. The result is about 60 lines of straightforward C++ and makes every design decision explicit.
First, define a minimal interface that modules will use to bind ports:
#include <systemc.h> // ── Interface ───────────────────────────────────────────────── // Modules bind sc_in/sc_out ports against this interface. // Separate read and write interfaces enforce direction. template<typename T> class MySignalIf : public virtual sc_interface { public: virtual void write(const T&) = 0; virtual const T& read() const = 0; virtual const sc_event& value_changed_event() const = 0; virtual const sc_event& default_event() const { return value_changed_event(); } };
Now the channel itself:
template<typename T> class MySignal : public sc_prim_channel // evaluate-update participation , public MySignalIf<T> // the interface processes use { public: explicit MySignal(const char* name = sc_gen_unique_name("mysig")) : sc_prim_channel(name) , m_cur_val() // value-initialised (0 for arithmetic types) , m_new_val() , m_written(false) {} // ── Write interface (evaluate phase) ────────────────────── void write(const T& val) override { m_new_val = val; m_written = true; request_update(); // tell kernel: call update() this delta } // ── Read interface (evaluate phase) ─────────────────────── const T& read() const override { return m_cur_val; // always the stable, committed value } // ── Convenience operators ────────────────────────────────── operator const T&() const { return read(); } MySignal& operator=(const T& v) { write(v); return *this; } // ── Event accessor ──────────────────────────────────────── const sc_event& value_changed_event() const override { return m_changed_event; } protected: // ── update() — called by kernel in update phase ─────────── void update() override { if (!m_written) return; m_written = false; if (m_new_val != m_cur_val) { m_cur_val = m_new_val; m_changed_event.notify(SC_ZERO_TIME); // schedule next delta } } private: T m_cur_val; // what processes see during evaluate phase T m_new_val; // pending write, staged here until update() bool m_written; // guards against spurious update() calls sc_event m_changed_event; };
The two-field pattern — m_cur_val for readers, m_new_val for the pending write — is the entire mechanism. m_written ensures that if two processes both write in the same delta, the second write wins (last-write-wins semantics, identical to sc_signal), and that calling update() when nothing was written is a no-op.
m_changed_event.notify(SC_ZERO_TIME) schedules the notification for the start of the next delta at the same simulation time. m_changed_event.notify() (no argument) is an immediate notification — it wakes processes in the current delta, before the evaluate phase is fully complete. Always use SC_ZERO_TIME inside update() so that sensitive processes start fresh in the next delta with the fully committed channel state.
6. Complete Example — Priority Buffer Channel
A real-world case: you are modelling an interrupt controller that must dispatch the highest-priority pending interrupt first, regardless of arrival order. A plain FIFO hands items back in insertion order. You need a channel where pop() always returns the item with the lowest priority number (highest urgency).
The channel uses sc_prim_channel so that batch pushes in one delta all appear atomically to the consumer in the next delta — no half-loaded priority queue.
#include <systemc.h> #include <queue> #include <vector> #include <iostream> using namespace std; // ── Item type ───────────────────────────────────────────────── struct IrqItem { int irq_num; int priority; // lower number = higher urgency // For std::priority_queue (max-heap) we invert comparison bool operator<(const IrqItem& o) const { return priority > o.priority; // higher priority = higher in heap } }; // ── Interface ───────────────────────────────────────────────── class IPriorityBuf : public virtual sc_interface { public: virtual void push(int irq, int prio) = 0; virtual IrqItem pop() = 0; virtual bool empty() const = 0; virtual const sc_event& data_ready_event() const = 0; virtual const sc_event& default_event() const { return data_ready_event(); } }; // ── Priority Buffer Channel ──────────────────────────────────── class PriorityBuf : public sc_prim_channel , public IPriorityBuf { public: explicit PriorityBuf(const char* nm = "prio_buf") : sc_prim_channel(nm) {} // Called from evaluate phase — stages new items, defers commit void push(int irq, int prio) override { m_pending.push_back({irq, prio}); request_update(); } // Called from evaluate phase — reads the committed queue IrqItem pop() override { if (m_committed.empty()) SC_REPORT_ERROR("PriorityBuf", "pop() called on empty buffer"); IrqItem top = m_committed.top(); m_committed.pop(); return top; } bool empty() const override { return m_committed.empty(); } const sc_event& data_ready_event() const override { return m_data_ready; } protected: // Called by kernel in update phase — commits staged items void update() override { if (m_pending.empty()) return; for (const IrqItem& item : m_pending) m_committed.push(item); m_pending.clear(); m_data_ready.notify(SC_ZERO_TIME); // wake consumers next delta } private: // Staged in evaluate phase — not yet visible to readers vector<IrqItem> m_pending; // Committed in update phase — safe for readers to access priority_queue<IrqItem> m_committed; sc_event m_data_ready; }; // ── Producer — fires two interrupts at different priorities ──── SC_MODULE(IrqSource) { sc_port<IPriorityBuf> out; SC_CTOR(IrqSource) { SC_THREAD(run); } void run() { wait(5, SC_NS); // Push three IRQs in the same delta — all visible together next delta out->push(7, 3); // IRQ 7, priority 3 (low) out->push(2, 1); // IRQ 2, priority 1 (highest) out->push(5, 2); // IRQ 5, priority 2 (medium) wait(50, SC_NS); out->push(9, 0); // IRQ 9, priority 0 (urgent) } }; // ── Consumer — dispatches highest-priority IRQ each time ─────── SC_MODULE(IrqController) { sc_port<IPriorityBuf> in; SC_CTOR(IrqController) { SC_THREAD(run); } void run() { while (true) { wait(in->data_ready_event()); while (!in->empty()) { IrqItem item = in->pop(); cout << "[CTRL] t=" << sc_time_stamp() << " dispatch IRQ " << item.irq_num << " prio=" << item.priority << endl; wait(10, SC_NS); // dispatch latency } } } }; // ── sc_main ─────────────────────────────────────────────────── int sc_main(int, char**) { PriorityBuf buf("irq_buf"); IrqSource src("src"); IrqController ctrl("ctrl"); src.out(buf); ctrl.in(buf); sc_start(200, SC_NS); return 0; }
Expected output:
[CTRL] t=5 ns dispatch IRQ 2 prio=1 [CTRL] t=15 ns dispatch IRQ 5 prio=2 [CTRL] t=25 ns dispatch IRQ 7 prio=3 [CTRL] t=55 ns dispatch IRQ 9 prio=0
Notice IRQ 2 (priority 1) is dispatched before IRQ 5 (priority 2) and IRQ 7 (priority 3) even though all three were pushed in the same delta at t=5 ns. The update() call at the end of that delta committed all three into the priority queue at once, and the consumer saw them in priority order — regardless of push order.
7. Providing sc_trace Support
If your channel does not provide sc_trace support, it will be silently omitted from VCD waveforms. For channels that hold scalar or structured values you want to inspect in a waveform viewer, you need to implement the free function overload.
// For a scalar channel — add as a free function (not a member) template<typename T> inline void sc_trace(sc_trace_file* tf, const MySignal<T>& sig, const std::string& name) { sc_trace(tf, sig.read(), name); } // For a structured channel — trace individual fields inline void sc_trace(sc_trace_file* tf, const PriorityBuf& ch, const std::string& name) { // Priority queues don't trace well — expose a depth counter instead sc_trace(tf, ch.depth(), name + ".depth"); } // Usage in sc_main: sc_trace_file* tf = sc_create_vcd_trace_file("trace"); sc_trace(tf, my_sig, "my_sig"); sc_start(100, SC_NS); sc_close_vcd_trace_file(tf);
The sc_trace overload is looked up by the tracer using argument-dependent lookup (ADL). Declare it in the same namespace as your channel class. If T is a custom struct, you also need an sc_trace overload for T itself — the scalar overloads for built-in types are already provided by SystemC.
sc_trace after the update phase, so read() returns the just-committed value. This is correct behaviour — the waveform should show the stable value at each simulation time, not mid-delta intermediates. Make sure your read() always returns m_cur_val and not a mix of the two fields.
8. Common Pitfalls
Pitfall 1 — Writing directly to m_cur_val from write()
This is the most dangerous mistake because it compiles and often produces apparently correct results in simple tests. The bug surfaces when two processes are sensitive to the same clock edge: one writes, the other reads, and the outcome depends on execution order. In a large design with tens of concurrent processes, this produces non-deterministic simulation results that are nearly impossible to reproduce.
// WRONG — bypasses evaluate-update, causes glitches void write(const T& val) override { m_cur_val = val; // immediately visible — races with readers m_changed_event.notify(); // immediate notify in evaluate phase } // CORRECT — stage the write, let update() commit it void write(const T& val) override { m_new_val = val; m_written = true; request_update(); }
Pitfall 2 — Calling notify() inside write() instead of update()
Notifying inside an interface method (evaluate phase) wakes processes in the current delta. If those processes run before update() has committed the new value, they will read the old m_cur_val — exactly the race condition you were trying to avoid. Always notify in update() after committing.
Pitfall 3 — Forgetting request_update()
If you store to m_new_val but forget to call request_update(), the kernel never calls update(), m_cur_val never changes, and every write is silently dropped. There is no runtime warning. The symptom is a signal that appears stuck at its initial value despite being driven.
// WRONG — update() never called, writes invisible void write(const T& val) override { m_new_val = val; m_written = true; // forgot request_update() — channel is dead }
Pitfall 4 — Unconditional request_update() inside update()
If your update() calls request_update() regardless of whether there is a real pending write, the kernel schedules another update call, which calls request_update() again — an infinite delta loop. Simulation time stops advancing. The rule is: only call request_update() from an interface method (triggered by a process), never unconditionally from inside update().
// WRONG — infinite delta loop void update() override { m_cur_val = m_new_val; request_update(); // schedules another update → never terminates } // CORRECT — only request update when there is pending work void update() override { if (!m_written) return; // nothing to do m_written = false; if (m_new_val != m_cur_val) { m_cur_val = m_new_val; m_changed_event.notify(SC_ZERO_TIME); } }
Summary
sc_prim_channelgives your channel two kernel hooks:request_update()to queue an update, andupdate()where you commit it.- Use it whenever a channel holds a value that processes may read and write in the same delta — without it, concurrent processes see inconsistent state.
- The two-field pattern —
m_cur_valfor readers,m_new_valfor pending writes — is the entire mechanism.sc_signaluses exactly the same approach. - Never modify visible state from an interface method (evaluate phase). Never call
notify()from an interface method. Always callrequest_update()when staging a write. - Notify sensitive processes with
SC_ZERO_TIMEinsideupdate()— this schedules them for the next delta when the new value is fully committed. - Implement
sc_traceas a free function overload if you want your channel to appear in VCD waveforms. - Infinite delta loops occur when
update()callsrequest_update()unconditionally — guard with a dirty flag.
Channels & Interfaces — 4-part series