Chapter 7: SystemC 1666-2023 LRM

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.

  1. 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()), unless dont_initialize() was called.
  2. Evaluation (crunch() part 1): Runnable processes pop from the queue and execute (using semantics() for methods, or suspend_me()/coroutine_resume for threads) until they yield (wait()) or finish.
  3. Update (crunch() part 2): Channels commit pending values. The kernel iterates over m_update_list (a vector of sc_update_if*) and calls update() on each channel (e.g., sc_signal::update()).
  4. Delta Notification (crunch() part 3): Events with zero-time delay are triggered. m_delta_events are processed, which may push new processes into m_runnable. If m_runnable is not empty, loop back to Evaluation (Delta Cycle).
  5. Timed Notification (next_time()): When m_runnable and m_delta_events are empty, sc_simcontext peeks at m_timed_events (a standard C++ priority queue sorted by timestamp). It pops the nearest events, updates m_curr_time, schedules the associated processes into m_runnable, and loops back to crunch().

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 glitching

Without 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:

  1. 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_interface implementation.
  2. 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"); }
  3. It resolves the final interface pointer and stores it directly inside the port's m_interface pointer 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