LRM Bridge: Elaboration and Simulation Semantics
The SystemC 1666-2023 model of construction, elaboration, initialization, evaluation, update, delta notification, and timed notification.
How to Read This Lesson
This lesson is an LRM bridge. We translate standard language into the questions you actually ask while debugging and reviewing models.
The IEEE 1666 LRM strictly separates a model's life cycle into elaboration and simulation. This distinction is the key to understanding many API rules and avoiding illegal dynamic structural changes. To fully appreciate this, we must look inside the Accellera SystemC kernel.
Source and LRM Trail
This chapter is the LRM bridge. The primary reference is Docs/LRMs/SystemC_LRM_1666-2023.pdf; the secondary reference is .codex-src/systemc. Read the LRM first for the rule, then read the source to understand why the rule produces the behavior you see in a debugger.
Elaboration
Elaboration is where the structural topology becomes known. Modules are constructed, child objects are named, ports and exports are bound, process macros register behavior, and time resolution is set.
Under the Hood:
The global sc_simcontext singleton maintains the simulation state. During elaboration, creating an sc_module triggers sc_module::sc_module(), which registers the module into sc_simcontext::m_object_manager forming the sc_object hierarchy. Port bindings (port(channel)) push binding requests into a deferred queue (m_bind_info).
You are executing standard C++ constructors, but the SystemC kernel is simultaneously observing and registering the hierarchy. Binding belongs entirely in this phase. Once sc_start() is called, the kernel executes sc_simcontext::elaborate(), iterating over the binding queues to resolve sc_port to sc_interface pointers. Trying to alter topology after this causes an SC_FATAL.
Simulation and the Discrete-Event Scheduler
Simulation begins when control enters the scheduler, normally through sc_core::sc_start(). The LRM describes scheduling through explicit, strictly ordered phases.
In the source code, this is orchestrated by sc_simcontext::initialize() followed by the central sc_simcontext::crunch() and sc_simcontext::next_time() loop.
- Initialization (
initialize()): Processes are made runnable. The kernel iterates over all process handles (sc_method_handle,sc_thread_handle) and pushes them into the runnable queues (m_runnable->push_back()), unlessdont_initialize()was called. - Evaluation (
crunch()part 1): Runnable processes pop from the queue and execute (usingsemantics()for methods, orsuspend_me()/coroutine_resumefor threads) until they yield (wait()) or finish. - Update (
crunch()part 2): Channels commit pending values. The kernel iterates overm_update_list(a vector ofsc_update_if*) and callsupdate()on each channel (e.g.,sc_signal::update()). - Delta Notification (
crunch()part 3): Events with zero-time delay are triggered.m_delta_eventsare processed, which may push new processes intom_runnable. Ifm_runnableis not empty, loop back to Evaluation (Delta Cycle). - Timed Notification (
next_time()): Whenm_runnableandm_delta_eventsare empty,sc_simcontextpeeks atm_timed_events(a standard C++ priority queue sorted by timestamp). It pops the nearest events, updatesm_curr_time, schedules the associated processes intom_runnable, and loops back tocrunch().
End-to-End LRM Example
Here is a complete example demonstrates elaboration, initialization, delta cycles, and timed notification, strictly following IEEE 1666 semantics.
#include <systemc>
// A module demonstrating initialization and evaluation phases
SC_MODULE(SemanticsDemo) {
sc_core::sc_signal<bool> ready{"ready"};
sc_core::sc_event timed_event;
SC_CTOR(SemanticsDemo) {
// Registered during elaboration
SC_METHOD(initialization_method);
// By default, methods are placed in the runnable queue during initialization.
// We do not call dont_initialize() here.
SC_THREAD(simulation_thread);
// Wait for the ready signal to go true
sensitive << ready.pos();
}
// This method runs automatically at time 0 (Initialization Phase)
void initialization_method() {
std::cout << "@" << sc_core::sc_time_stamp()
<< " [Init/Eval Phase] Method executing." << std::endl;
// Write a value. This schedules an Update request, it does NOT change the value instantly.
ready.write(true);
// Schedule a timed event for 10 ns in the future
timed_event.notify(10, sc_core::SC_NS);
// Prevent it from re-running endlessly
next_trigger(timed_event);
}
void simulation_thread() {
while(true) {
// Wakes up when ready becomes true (which happens in the Delta Update phase)
wait();
std::cout << "@" << sc_core::sc_time_stamp()
<< " [Eval Phase] Thread woke up from ready.pos() delta event." << std::endl;
// Suspend until the timed event fires
wait(timed_event);
std::cout << "@" << sc_core::sc_time_stamp()
<< " [Timed Notification] Thread woke up from timed_event." << std::endl;
}
}
};
int sc_main(int argc, char* argv[]) {
// Elaboration Phase
SemanticsDemo demo("demo");
std::cout << "--- Starting Simulation ---" << std::endl;
// Simulation Phase begins
sc_core::sc_start(20, sc_core::SC_NS);
std::cout << "--- Simulation Finished ---" << std::endl;
return 0;
}Initialization Rules
Before time advances, some processes are placed in the runnable queue. That is why SC_METHOD processes often run at time zero. Under the hood, dont_initialize() sets a boolean flag m_dont_init in the sc_process_b base class, which sc_simcontext::initialize() reads to skip pushing it into m_runnable.
For clocked behaviors, you almost always want to prevent this initial time-zero execution:
SC_METHOD(tick);
sensitive << clk.pos();
dont_initialize(); // Crucial to prevent time-zero glitchingWithout dont_initialize(), your "clocked" behavior will execute before the very first clock edge actually occurs.
Delta Cycles vs Timed Notification
Delta cycles let the model settle without advancing physical simulation time. A delta cycle can propagate events from one process to another while sc_time_stamp() remains strictly unchanged. This is not an implementation trick; it is a fundamental pillar of hardware description languages to model concurrency on parallel wires.
Timed Notification occurs only when the delta-event queue is empty. The scheduler jumps time forward to the nearest pending timed event in the m_timed_events priority queue.
Practical Questions to Ask
When debugging LRM-compliant models:
- Is this code executing during a constructor (elaboration) or during
sc_start(simulation)? - Did the process unintentionally initialize at time zero because
dont_initialize()was forgotten? - Is the read value pending an update (in
m_update_list), or has the update phase already committed it? - Did the event notify immediately (
notify()), in a delta (notify(SC_ZERO_TIME)), or at a future time (notify(time))?
Standard and Source Deep Dive: Port Binding
Port binding is the topological glue of a SystemC model. The IEEE 1666-2023 LRM Sections 4.2.1 (Elaboration) and Section 6.11-6.13 (Ports, Exports, Interfaces) rigidly define how structural connections are made and verified.
Inside the Accellera Source: sc_port_b and sc_port_registry
In src/sysc/communication/sc_port.h/cpp, all specialized sc_port<IF> classes derive from a non-template base class sc_port_b.
When you declare sc_port<BusIf> bus{"bus"};, the constructor ultimately calls sc_simcontext::get_port_registry()->insert(this).
The sc_port_registry (located in src/sysc/kernel/sc_simcontext.cpp) is the global list of every port in the simulation.
When you write cpu.bus.bind(subsystem.target); in your C++ code, you are invoking the bind() method on sc_port. However, this does not immediately resolve the C++ pointer! Instead, the port simply stores a generic pointer to the bound object in an internal array (because a port can be bound to multiple channels if the port's N parameter is > 1).
The Elaboration Phase: complete_binding()
The real magic happens when sc_start() is called.
Before simulation begins, sc_start() invokes sc_simcontext::elaborate(), which ultimately calls sc_port_registry::complete_binding().
If you trace sysc/kernel/sc_simcontext.cpp, you will see complete_binding() iterate over every single port in the design. For each port:
- It traverses the binding tree. If Port A is bound to Port B, and Port B is bound to Channel C, it recursively walks from A -> B -> C to find the actual
sc_interfaceimplementation. - Type Checking: It uses C++ RTTI (
dynamic_cast) to verify that the target object actually implements the interface required by the port.// Abstract representation of the kernel's check: sc_interface* target_if = dynamic_cast<sc_interface*>(bound_object); if (!target_if) { SC_REPORT_ERROR("Port binding failed: interface mismatch"); } - It resolves the final interface pointer and stores it directly inside the port's
m_interfacepointer array.
Zero-Overhead Simulation Dispatch
Why delay pointer resolution until complete_binding()? Because once elaboration finishes, the port has an absolute, direct C++ pointer to the implementing channel.
In src/sysc/communication/sc_port.h, the overloaded operator-> is extraordinarily simple:
template <class IF>
inline IF* sc_port<IF>::operator -> () {
return m_interface;
}During simulation, when a thread executes bus->write(0x10, data);, there are no map lookups, no string comparisons, and no routing tables. It is exactly equivalent to a direct C++ virtual function call on the channel object.
Comments and Corrections