Source Deep Dive: sc_signal Update Internals
How writes, current values, pending values, update requests, and value-change events work.
How to Read This Lesson
This is a source-reading lesson. We will use the Accellera implementation as a microscope, while keeping the LRM as the portability contract.
sc_signal<T> is one of the best source-reading targets because it demonstrates the SystemC kernel contract for primitive channels, specifically LRM Section 6.15.
The user simply writes sig.write(next_value);, but hardware-like behavior requires more than assigning a C++ variable.
Source and LRM Trail
This lesson is deliberately source-facing. Use Docs/LRMs/SystemC_LRM_1666-2023.pdf to decide what must be portable, then use .codex-src/systemc/src/sysc and .codex-src/systemc/src/tlm_core to see one reference implementation. Treat private members as explanatory, not as APIs your models should depend on.
The Two-Value Model and Primitive Channels
A signal has a current value and a pending value. To understand exactly how the SystemC simulation context manages this, we can write a complete, compilable primitive channel that implements the LRM's request_update() and update() semantics without hiding behind the built-in sc_signal.
#include <systemc>
using namespace sc_core;
// 1. The Interface
template <typename T>
struct custom_signal_if : virtual public sc_interface {
virtual const T& read() const = 0;
virtual void write(const T&) = 0;
virtual const sc_event& default_event() const = 0;
};
// 2. The Primitive Channel mimicking sc_signal
template <typename T>
class SignalInternalsDemo : public sc_prim_channel, public custom_signal_if<T> {
private:
T m_current_value;
T m_new_value;
sc_event m_value_changed;
public:
explicit SignalInternalsDemo(const char* name)
: sc_prim_channel(name), m_current_value(T()), m_new_value(T()) {}
const T& read() const override {
return m_current_value;
}
void write(const T& value) override {
// Check if the value is actually changing
if (value != m_new_value) {
m_new_value = value;
// This tells the simcontext to push this channel into the update queue
request_update();
}
}
const sc_event& default_event() const override {
return m_value_changed;
}
protected:
// 3. The Update Callback (Called by the Kernel)
void update() override {
if (m_current_value != m_new_value) {
m_current_value = m_new_value;
// Notify sensitive processes in the next delta cycle
m_value_changed.notify(SC_ZERO_TIME);
}
}
};
// 4. Test Module
SC_MODULE(SignalTest) {
sc_port<custom_signal_if<int>> port{"port"};
SC_CTOR(SignalTest) {
SC_THREAD(writer_thread);
SC_METHOD(reader_method);
sensitive << port; // Binds to default_event()
dont_initialize();
}
void writer_thread() {
std::cout << "[Time: " << sc_time_stamp() << "] Writing 42\n";
port->write(42);
// Read immediately? It will be the OLD value.
std::cout << "[Time: " << sc_time_stamp() << "] Immediate read: " << port->read() << "\n";
wait(SC_ZERO_TIME); // Advance to next delta
// Read after update phase
std::cout << "[Time: " << sc_time_stamp() << "] Read after delta: " << port->read() << "\n";
}
void reader_method() {
std::cout << "[Time: " << sc_time_stamp() << "] Reader method triggered. New value: " << port->read() << "\n";
}
};
int sc_main(int argc, char* argv[]) {
SignalInternalsDemo<int> sig("sig");
SignalTest test("test");
test.port(sig);
sc_start(10, SC_NS);
return 0;
}The real implementation handles more details (writer policies, tracing, reset integration), but the visible behavior comes exactly from this sequence:
- process writes signal
- signal requests update
- scheduler finishes evaluate phase
- kernel calls
update()on the channel - signal commits pending value and notifies value-change event
- sensitive processes become runnable in a later delta
Writer Checks and Resolved Signals
Signals enforce writer policies (LRM Section 6.15.3). If two processes drive one signal unexpectedly, the implementation reports an error. This is not just politeness; multiple writers can make a hardware model ambiguous.
Resolved signal types (sc_resolved, sc_rv) exist for multi-driver logic. They apply resolution rules to determine the final value. Use resolved signals for real multi-driver semantics (tri-state buses), not to silence accidental multiple-driver bugs.
Debugging Signal Internals
If a signal value appears late:
- the writer may have only set the pending value
- the update phase may not have run yet
- the reader may be sensitive to the value-change event in the next delta
Once you understand the pending/current split and the evaluate-update cycle, most signal timing questions become explainable.
Exhaustive Deep Dive: IEEE 1666-2023 LRM and Accellera Update Phase Source
The two-value evaluate/update paradigm of primitive channels is the cornerstone of modeling concurrent hardware in sequential C++. It is governed strictly by IEEE 1666-2023 LRM Section 4.2 (Simulation semantics) and Section 6.15 (sc_signal).
LRM Section 4.2.1: The Evaluate and Update Phases
According to the LRM, a single delta cycle has two distinct computational steps:
- Evaluation Phase: The kernel executes all runnable processes (
SC_METHOD,SC_THREAD). If a process callssig.write(val), the signal does not immediately change. It instead schedules itself for an update. - Update Phase: Once the runnable queue is empty, the scheduler pauses process execution. It then iterates through all primitive channels that requested an update and calls their
update()virtual method.
This guarantees determinism. No matter the order in which two processes execute during the evaluate phase, they will both read the same "old" signal value.
Source Code: sc_prim_channel and request_update()
If you trace the Accellera source code into src/sysc/communication/sc_signal.h, you'll see that sc_signal<T> inherits from sc_signal_t<T, POL>, which ultimately derives from sc_prim_channel.
When you call write(), it looks like this:
template<class T, sc_writer_policy POL>
inline void sc_signal_t<T, POL>::write(const T& value) {
// 1. Writer policy checks
if (!m_writer_p->check_write(sc_get_current_process_b(), ...)) return;
// 2. Check if pending value changed
m_new_val = value;
if (!(m_new_val == m_cur_val)) {
// 3. Ask the kernel to call our update() later
this->request_update();
}
}What exactly does request_update() do?
In src/sysc/kernel/sc_prim_channel.cpp, the channel accesses the central simulation context (sc_simcontext) and appends itself to an array:
void sc_prim_channel::request_update() {
sc_simcontext* simc = simcontext();
simc->get_update_list()->push_back(this);
}The Scheduler Loop: sc_simcontext::crunch()
To see the update phase in action, you must read the core scheduler loop inside src/sysc/kernel/sc_simcontext.cpp. The method crunch() (which runs delta cycles) contains a loop roughly structured like this:
while (true) {
// --- EVALUATE PHASE ---
while (!m_runnable->is_empty()) {
sc_process_b* p = m_runnable->pop();
p->execute(); // Runs SC_METHODs or resumes SC_THREADs
}
// --- UPDATE PHASE ---
sc_prim_channel_registry* reg = m_prim_channel_registry;
if (reg->pending_updates()) {
reg->perform_update(); // Calls update() on all queued channels
}
// --- DELTA NOTIFICATION PHASE ---
// Move zero-time notifications to the runnable queue for the NEXT delta
// If nothing new is runnable, advance time!
}When perform_update() runs, it calls update() on your sc_signal. The signal copies m_new_val into m_cur_val and calls m_value_changed_event.notify(SC_ZERO_TIME). This event notification pushes sensitive processes onto the runnable queue, triggering another delta cycle!
Thread Safety and async_request_update()
A critical limitation of the sc_simcontext::crunch() loop is that it is purely single-threaded.
LRM Clause 4.2.1.8 introduced async_request_update() specifically to address multi-threading. If an external OS thread (e.g., a POSIX thread reading a network socket) tries to call request_update(), it will cause a race condition on the get_update_list()->push_back(this) logic inside the Accellera kernel, corrupting the simulation state.
To safely inject values from external threads, you must use sc_prim_channel::async_request_update().
In the source code, this function locks a global mutex (sc_host_mutex), adds the channel to a specialized asynchronous update queue, and interrupts the SystemC scheduler using a thread-safe signaling mechanism (often a pipe or event). The scheduler then safely merges the async updates during its standard update phase.
Understanding the deep split between m_cur_val (the present), m_new_val (the future), and the rigidly scheduled request_update() queue is the key to mastering RTL-level modeling in SystemC.
Comments and Corrections