7. SystemC Tutorial - Elaboration & the Kernel Lifecycle

Why this matters

A SystemC simulation does not just "run" — it goes through a precisely defined sequence of phases: elaboration, initialization, simulation, and cleanup. Each phase has different rules about what is legal and what is not. You cannot register a process during simulation. You cannot read sc_time_stamp() meaningfully during elaboration. You cannot call sc_start from inside a process. The kernel enforces these rules with terse error messages that read like Greek if you do not know the phase machinery. Worse: many subtle bugs (test seeds that don't apply, port bindings that "should be there" but aren't, scoreboards that never finalize) trace to running setup code in the wrong phase.

This is the last post in the Foundations section, and it covers the one piece of SystemC infrastructure you've been using without quite seeing: the lifecycle. By the end you will know exactly when each phase fires; which APIs are legal in which phase; how to use the four override hooks (before_end_of_elaboration, end_of_elaboration, start_of_simulation, end_of_simulation) for late-bound initialization; and how to debug "this simulation does not do what I expect" by tracing what the kernel is actually doing at each phase boundary. With this post complete, you have everything you need to build, run, and debug any SystemC model — and Section 2 (RTL-Level Modeling Patterns) is open to you.

Prerequisites

Mental model (first principles)

A SystemC simulation has four distinct phases, with a precisely defined sequence of events between them.

                       SystemC simulation lifecycle
                       ─────────────────────────────

   sc_main entry
       │
       ▼
   ┌───────────────────────────────────────────┐
   │ ELABORATION                                │
   │ - sc_module constructors run               │
   │ - Port binding happens                     │
   │ - SC_METHOD / SC_THREAD registration       │
   │ - sc_signal / sc_event member init         │
   │ Status: SC_ELABORATION                     │
   └───────────────────────────────────────────┘
       │
       │ (first sc_start call)
       ▼
   ┌───────────────────────────────────────────┐
   │ BEFORE END OF ELABORATION                  │
   │ - before_end_of_elaboration() callbacks    │
   │ - Can still create new modules / processes │
   │ Status: SC_BEFORE_END_OF_ELABORATION       │
   └───────────────────────────────────────────┘
       │
       ▼
   ┌───────────────────────────────────────────┐
   │ END OF ELABORATION                         │
   │ - end_of_elaboration() callbacks           │
   │ - Hierarchy now FROZEN                     │
   │ - Can do late binding / introspection      │
   │ Status: SC_END_OF_ELABORATION              │
   └───────────────────────────────────────────┘
       │
       ▼
   ┌───────────────────────────────────────────┐
   │ START OF SIMULATION                        │
   │ - start_of_simulation() callbacks          │
   │ Status: SC_START_OF_SIMULATION             │
   └────────────────────��──────────────────────┘
       │
       ▼
   ┌───────────────────────────────────────────┐
   │ INITIALIZATION + SIMULATION                │
   │ - Every method process runs once (unless   │
   │   dont_initialize())                       │
   │ - Threads start, run to first wait()       │
   │ - Scheduler loop per Part 3                │
   │ Status: SC_RUNNING (alternates with        │
   │         SC_PAUSED between sc_start calls)  │
   └───────────────────────────────────────────┘
       │
       │ (sc_stop called OR sc_start returns final)
       ▼
   ┌───────────────────────────────────────────┐
   │ END OF SIMULATION                          │
   │ - end_of_simulation() callbacks            │
   │ Status: SC_END_OF_SIMULATION               │
   └───────────��───────────────────────────────┘
       │
       │ (sc_main returns)
       ▼
   ┌───────────────────────────────────────────┐
   │ CLEANUP                                    │
   │ - Module destructors run                   │
   │ - Kernel state freed                       │
   │ Status: SC_STOPPED (sort of)               │
   └───────────────────────────────────────────┘
       │
       ▼
   Program exits with sc_main's return value

The phases matter because what you can do depends on which phase you are in:

Phase Can register processes? Can bind ports? Can read sc_time_stamp()? Can call sc_start?
ELABORATION yes yes meaningless (returns 0) no (this IS the call that ends elaboration)
BEFORE_END_OF_ELABORATION yes yes 0 no
END_OF_ELABORATION no no (hierarchy frozen) 0 no
START_OF_SIMULATION no no 0 no
RUNNING no no current sim time no (kernel-callable only, not user-callable from a process)
PAUSED (between sc_start calls) no no current sim time yes (from sc_main)
END_OF_SIMULATION no no final sim time no

The four override callbacks — before_end_of_elaboration, end_of_elaboration, start_of_simulation, end_of_simulation — are virtual functions on sc_module. Override them to do work at specific lifecycle phases that cannot fit inside the constructor (because the rest of the hierarchy is not yet built) or inside a process (because the simulation is already running).

The kernel invokes the callbacks in a defined order: depth-first traversal of the module hierarchy, parents before children for before_end_of_elaboration and end_of_elaboration, and the same depth-first order for start_of_simulation and end_of_simulation. This means a parent module can perform setup that depends on its children's constructors having run, and child modules can do setup that depends on having seen their parent's setup.

Beginner: First Principles

A module that overrides all four lifecycle callbacks and prints from each:

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

SC_MODULE(traced_module) {
    SC_CTOR(traced_module) {
        std::cout << "[" << name() << "] CONSTRUCTOR (sc_get_status=ELABORATION)\n";
        SC_METHOD(on_init);
        // No sensitivity — method runs once at initialization
    }

    void before_end_of_elaboration() override {
        std::cout << "[" << name() << "] before_end_of_elaboration\n";
    }

    void end_of_elaboration() override {
        std::cout << "[" << name() << "] end_of_elaboration\n";
    }

    void start_of_simulation() override {
        std::cout << "[" << name() << "] start_of_simulation\n";
    }

    void on_init() {
        std::cout << "[" << name() << "] INIT method runs at "
                  << sc_time_stamp() << "\n";
    }

    void end_of_simulation() override {
        std::cout << "[" << name() << "] end_of_simulation (sim time "
                  << sc_time_stamp() << ")\n";
    }

    ~traced_module() {
        std::cout << "[" << name() << "] DESTRUCTOR\n";
    }
};

int sc_main(int, char**) {
    std::cout << "sc_main: ENTRY\n";
    {
        traced_module a("a");
        traced_module b("b");
        std::cout << "sc_main: about to call sc_start\n";
        sc_start(10, SC_NS);
        std::cout << "sc_main: sc_start returned\n";
    } // destructors fire as modules go out of scope
    std::cout << "sc_main: returning\n";
    return 0;
}

Build and run:

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

Expected output:

sc_main: ENTRY
[a] CONSTRUCTOR (sc_get_status=ELABORATION)
[b] CONSTRUCTOR (sc_get_status=ELABORATION)
sc_main: about to call sc_start
[a] before_end_of_elaboration
[b] before_end_of_elaboration
[a] end_of_elaboration
[b] end_of_elaboration
[a] start_of_simulation
[b] start_of_simulation
[a] INIT method runs at 0 s
[b] INIT method runs at 0 s
[a] end_of_simulation (sim time 10 ns)
[b] end_of_simulation (sim time 10 ns)
sc_main: sc_start returned
sc_main: returning
[b] DESTRUCTOR
[a] DESTRUCTOR

Walk through what happened.

Modules a and b are constructed in sc_main declaration order. The constructors run during the elaboration phase — at this point, the kernel knows about the modules but the hierarchy is still mutable.

sc_main calls sc_start(10, SC_NS). The kernel notices this is the first call. It transitions from elaboration to the before_end_of_elaboration phase, walks the hierarchy (depth-first, parents before children — in this case both a and b are top-level so order is registration-order), and calls each module's before_end_of_elaboration(). Then it transitions to end_of_elaboration, calls each end_of_elaboration(). Then start_of_simulation, calls each start_of_simulation().

Then the kernel enters the initialization phase: every method process registered with no dont_initialize() runs once. Here, both a.on_init and b.on_init fire at simulation time 0.

The scheduler loop runs (here, with nothing to do because both methods completed and have no sensitivity, the runnable set is empty). Time advances up to the 10 ns limit, but no events fire. sc_start returns control to sc_main.

sc_main is about to exit the scope where a and b are declared. The kernel detects "simulation is ending" — calls end_of_simulation() on every module. Then sc_main exits the scope; module destructors run in reverse construction order (LIFO — b then a).

A common confusion: "why does end_of_simulation fire before the destructors instead of inside them?" Because end_of_simulation is a kernel-driven hook called while the simulation is still in a well-defined state; destructors run in arbitrary order during program teardown and may run when other modules are already half-destroyed. Use end_of_simulation for any end-of-test work that needs the kernel state to be valid; use destructors only for resource release that does not depend on other SystemC objects.

Intermediate: How It Really Works

Late-bound port binding in before_end_of_elaboration

The classic use case for before_end_of_elaboration is binding ports that could not be bound during construction — typically because the binding depends on configuration that arrives late.

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

SC_MODULE(consumer) {
    sc_in<int> data;
    void watch() {
        std::cout << "[" << sc_time_stamp() << "] consumer saw " << data.read() << "\n";
    }
    SC_CTOR(consumer) {
        SC_METHOD(watch);
        sensitive << data;
        dont_initialize();
    }
};

SC_MODULE(producer) {
    sc_out<int> data;
    void drive() {
        wait(10, SC_NS);
        data.write(42);
    }
    SC_CTOR(producer) { SC_THREAD(drive); }
};

SC_MODULE(top) {
    producer p;
    consumer c;
    sc_signal<int> link;

    SC_CTOR(top) : p("p"), c("c") {
        // Cannot bind here if some configuration would have changed the binding
        // (this example does it here for simplicity, but the pattern below is
        // what to use when binding decisions arrive late)
        p.data(link);
        c.data(link);
    }

    // Could also bind in before_end_of_elaboration if needed:
    void before_end_of_elaboration() override {
        // Example: rebind a port conditionally based on config that arrived late.
        // For this example, no late changes — just demonstrate the hook.
        std::cout << "[top] before_end_of_elaboration; binding is finalized\n";
    }
};

int sc_main(int, char**) {
    top t("t");
    sc_start(50, SC_NS);
    return 0;
}

The pattern matters most for TLM models. A bus model that needs to know how many initiators and targets are attached cannot make that decision in the constructor (the parent has not yet attached all children at that point); it does so in before_end_of_elaboration when the full hierarchy is constructed but bindings are still mutable.

Introspection with sc_get_top_level_objects

end_of_elaboration is the right time to introspect the hierarchy — it is fully built, frozen, but the simulation has not started.

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

void dump(const std::vector<sc_object*>& roots, int depth = 0) {
    for (auto* o : roots) {
        std::cout << std::string(depth * 2, ' ')
                  << o->kind() << "  " << o->name() << "\n";
        dump(o->get_child_objects(), depth + 1);
    }
}

SC_MODULE(child) {
    sc_in<int>  in;
    sc_out<int> out;
    SC_CTOR(child) {}
};

SC_MODULE(parent) {
    child c1, c2;
    sc_signal<int> s1, s2;

    SC_CTOR(parent) : c1("c1"), c2("c2") {
        c1.in(s1); c1.out(s2);
        c2.in(s2); c2.out(s1);
    }

    void end_of_elaboration() override {
        std::cout << "Hierarchy at end_of_elaboration:\n";
        dump(sc_get_top_level_objects());
    }
};

int sc_main(int, char**) {
    parent p("p");
    sc_start(SC_ZERO_TIME);
    return 0;
}

Expected output:

Hierarchy at end_of_elaboration:
sc_module  p
  sc_module  p.c1
    sc_in  p.c1.in
    sc_out  p.c1.out
  sc_module  p.c2
    sc_in  p.c2.in
    sc_out  p.c2.out
  sc_signal  p.s1
  sc_signal  p.s2

This is the structural inventory of the entire simulation. For complex models — TLM virtual platforms with dozens of peripherals, for example — dumping this at end_of_elaboration is the fastest way to verify the hierarchy is built the way you expected before any simulation behaviour begins.

Phase-aware modules with sc_get_status

A module can guard its public APIs against being called in the wrong phase:

SC_MODULE(config_holder) {
    int threshold = 0;

    void set_threshold(int t) {
        if (sc_get_status() == SC_RUNNING || sc_get_status() == SC_PAUSED) {
            SC_REPORT_ERROR("config_holder",
                "set_threshold called after simulation started");
            return;
        }
        threshold = t;
    }

    int get_threshold() const { return threshold; }

    SC_CTOR(config_holder) {}
};

This pattern is useful for configuration objects that must be set up during elaboration. Calling set_threshold from within a process after simulation has started would cause confusing bugs (the config object's value would change mid-simulation); the phase check catches it explicitly.

The four override hooks in detail

A reference for when to use each:

Hook Fires when What you can do What you cannot do
before_end_of_elaboration() After all constructors, before hierarchy is frozen Bind ports, register new processes, create child modules Read sc_time_stamp() meaningfully, call wait(), call sc_start
end_of_elaboration() After binding is finalized, before simulation Introspect hierarchy (sc_get_top_level_objects), allocate internal buffers, set up logging Bind ports, register processes
start_of_simulation() Just before the initialization phase Late-bound configuration that depends on hierarchy introspection done in end_of_elaboration Bind ports, register processes
end_of_simulation() After last sc_start returns, before destructors End-of-test reporting, scoreboard finalization, write summary files Continue simulating, modify signals

The conventional pattern: do as much work as possible inside the constructor (the simplest case). Use before_end_of_elaboration only when something genuinely cannot be known until siblings exist. Use end_of_elaboration for hierarchy-wide introspection. Use start_of_simulation rarely — it is mostly for late binding to the simulation that depends on end_of_elaboration having completed. Use end_of_simulation for end-of-test reporting that needs the kernel state alive (rather than putting it in the destructor).

Diagnostic dump on end_of_simulation

A scoreboard that reports at end_of_simulation rather than in its destructor:

SC_MODULE(scoreboard) {
    int passes = 0, failures = 0;

    void record_pass() { ++passes; }
    void record_failure(const std::string& msg) {
        ++failures;
        SC_REPORT_WARNING("scoreboard", msg.c_str());
    }

    void end_of_simulation() override {
        std::cout << "\n========== TEST SUMMARY ==========\n";
        std::cout << "Simulation finished at " << sc_time_stamp() << "\n";
        std::cout << "Passed: " << passes << ", Failed: " << failures << "\n";
        std::cout << "===================================\n";
    }

    SC_CTOR(scoreboard) {}
};

The advantage over the destructor is that end_of_simulation is called from within the kernel's control flow, with all the kernel state still valid — meaning you can call sc_time_stamp(), you can read signals (their final values are still accessible), and the call ordering relative to other modules' end_of_simulation hooks is deterministic. By contrast, destructors run in object-construction-reverse-order during scope exit, with the kernel partially torn down.

Late-bound configuration: a worked example

A common real-world pattern is a configuration object that gets populated during elaboration by some external code (a JSON parser, a command-line argument processor, a test harness) and then consulted by modules during end_of_elaboration to decide what to do.

// file: late_config.cpp
#include <systemc.h>
#include <map>
#include <string>

// Global config object — populated before sc_start
struct config_t {
    int  num_workers = 1;
    bool enable_logging = true;
    std::map<std::string, int> per_module_settings;
};
config_t g_config;

SC_MODULE(worker) {
    int worker_id;
    bool logging;

    worker(sc_module_name n, int id)
        : sc_module(n), worker_id(id), logging(true) {
        SC_HAS_PROCESS(worker);
        SC_THREAD(run);
    }

    void start_of_simulation() override {
        // Consult global config — by now it's populated and frozen
        logging = g_config.enable_logging;
        auto it = g_config.per_module_settings.find(std::string(name()));
        if (it != g_config.per_module_settings.end()) {
            std::cout << "[" << name() << "] custom setting "
                      << it->second << " applied\n";
        }
    }

    void run() {
        if (logging) {
            std::cout << "[" << sc_time_stamp() << "] " << name()
                      << " (id=" << worker_id << ") starting\n";
        }
        wait(10, SC_NS);
        if (logging) {
            std::cout << "[" << sc_time_stamp() << "] " << name()
                      << " done\n";
        }
    }
};

SC_MODULE(pool) {
    std::vector<std::unique_ptr<worker>> workers;

    void before_end_of_elaboration() override {
        for (int i = 0; i < g_config.num_workers; ++i) {
            std::string nm = "worker_" + std::to_string(i);
            workers.emplace_back(std::make_unique<worker>(nm.c_str(), i));
        }
        std::cout << "[pool] created " << workers.size() << " workers\n";
    }

    SC_CTOR(pool) {}
};

int sc_main(int argc, char** argv) {
    // Pretend we parsed command line
    g_config.num_workers = 3;
    g_config.enable_logging = true;
    g_config.per_module_settings["pool.worker_1"] = 42;

    pool p("pool");
    sc_start(50, SC_NS);
    return 0;
}

Two patterns visible here. First, the pool module creates workers in before_end_of_elaboration (not the constructor) because the worker count is determined by config that the constructor has no access to. Second, each worker consults the config in start_of_simulation — after end_of_elaboration (where binding finalizes) but before simulation begins. This pattern is widely used in TLM virtual platforms where the number of peripherals, memory map, and per-peripheral settings come from external configuration.

The crucial discipline: g_config must be fully populated before sc_start is called. Modifying it from inside a process during simulation would be a race condition (the config is a plain global, not synchronized with the kernel).

When NOT to use lifecycle hooks

For simple modules, do not override the lifecycle hooks. The constructor is enough for most setup; the destructor is enough for most cleanup; and unnecessary hook overrides clutter the code without adding value. Reach for before_end_of_elaboration only when you need late binding; for end_of_elaboration only when you need hierarchy introspection; for end_of_simulation only when you need end-of-test reporting that the destructor cannot do correctly.

Advanced: Edge Cases & LRM Corners

Corner 1: sc_get_status() return values across the lifecycle

The full set of return values:

  • SC_ELABORATION — between sc_main entry and the kernel's transition to lifecycle callbacks (still during constructors).
  • SC_BEFORE_END_OF_ELABORATION — during the before_end_of_elaboration callback.
  • SC_END_OF_ELABORATION — during the end_of_elaboration callback.
  • SC_START_OF_SIMULATION — during the start_of_simulation callback.
  • SC_RUNNING — during scheduling (when processes are running).
  • SC_PAUSED — between sc_start calls or after sc_pause().
  • SC_STOPPED — after sc_stop() has triggered termination.
  • SC_END_OF_SIMULATION — during the end_of_simulation callback.

sc_get_status() is callable from any context (no restrictions). Querying it from elaboration code is the canonical way for a module's constructor to verify "I am being constructed in the right phase" — though in practice, only late-bound APIs need this.

Corner 2: Order of callbacks across multiple modules

Per IEEE 1666-2011 §5.2.3, the kernel calls each lifecycle callback on every module in hierarchical depth-first order, with parents before children. Modules at the same hierarchical level are called in registration order (the order their constructors completed).

SC_MODULE(parent) {
    child a, b;
    SC_CTOR(parent) : a("a"), b("b") {}
    void end_of_elaboration() override {
        std::cout << "parent.end_of_elaboration\n";
    }
};

SC_MODULE(child) {
    SC_CTOR(child) {}
    void end_of_elaboration() override {
        std::cout << "  " << name() << ".end_of_elaboration\n";
    }
};

// Output:
// parent.end_of_elaboration
//   parent.a.end_of_elaboration
//   parent.b.end_of_elaboration

The parent callback fires before its children — this is the LRM-specified order. For end_of_simulation, the same depth-first, parents-before-children order is used.

The practical implication: a parent module that wants to set up state that its children depend on should do so in the constructor or before_end_of_elaboration — by end_of_elaboration the children may already be using the state.

Corner 3: Restrictions on what you can do inside each callback

The LRM is explicit about what is and is not legal in each phase. A condensed summary:

Operation Constructor before_eoe eoe start running end_of_sim
SC_METHOD / SC_THREAD ✓ ✓ ✗ ✗ ✗ ✗
port binding (port(channel)) ✓ ✓ ✗ ✗ ✗ ✗
create new sc_module ✓ ✓ ✗ ✗ ✗ ✗
wait() ✗ ✗ ✗ ✗ ✓ (in thread) ✗
sc_start() ✗ ✗ ✗ ✗ ✗ (no call from inside a process) ✗
sc_stop() ✗ ✗ ✗ ✗ ✓ ✓
read signal meaningless (default-constructed) meaningless meaningless meaningless ✓ ✓
write signal queues pending (commits in init update) queues queues queues ✓ not useful

The "meaningless" cells are technically legal but return the default-constructed value — you have not yet run the simulation to give signals real values.

Corner 4: Late-created modules in before_end_of_elaboration

You can create new modules inside before_end_of_elaboration — the hierarchy is not yet frozen. This is useful for templated parents that want to instantiate a runtime-determined number of children:

SC_MODULE(scalable_parent) {
    std::vector<std::unique_ptr<worker>> workers;
    int n_workers;

    scalable_parent(sc_module_name nm, int n) : sc_module(nm), n_workers(n) {
        SC_HAS_PROCESS(scalable_parent);
        // Cannot create children here that depend on something post-construction.
    }

    void before_end_of_elaboration() override {
        for (int i = 0; i < n_workers; ++i) {
            std::string nm = "worker_" + std::to_string(i);
            workers.emplace_back(std::make_unique<worker>(nm.c_str()));
        }
    }
};

The new modules' constructors run during the before_end_of_elaboration phase, and their before_end_of_elaboration and subsequent callbacks fire on the next pass of the lifecycle traversal. The kernel handles the recursion correctly.

Corner 4a: sc_signal::initialize() and elaboration-time value-setting

A subtle alternative to writing a signal's initial value via sc_main before sc_start: the sc_signal<T>::initialize(value) member function. Per LRM §6.4.5, initialize sets the signal's current value directly (bypassing the pending-write mechanism), but only if called before the first sc_start. After the simulation has started, initialize raises an error.

SC_MODULE(top) {
    sc_signal<int> data;
    SC_CTOR(top) {
        // Set the initial value directly — visible from time 0
        data.initialize(42);
    }
};

This differs from data.write(42) in a subtle but important way: write queues a pending update that commits in the initialization update phase, so processes that read data during the initialization evaluate phase see the default-constructed value (0), not 42. initialize sets the current value directly, so the very first read returns 42.

Use initialize when you want signals to have a specific value at time 0 visible to initialization-time process runs. Use write for any value setting after the simulation has started.

Corner 4b: request_update and the kernel update queue

When a signal's write(v) is called, the kernel calls request_update() on the signal, which adds the signal to the kernel's "pending updates" list. At the next update phase, the kernel walks this list and calls update() on each — committing pending writes to current values.

You can intercept this for custom channel types by overriding request_update() and update() in your own primitive channels. This is rare in application code (most people use sc_signal and sc_fifo directly) but is the canonical pattern for writing new primitive channels — TLM tlm_fifo, sc_event_queue, and similar all use this mechanism.

The implication for elaboration: any signal type you write must be careful to participate correctly in the update phase. If you forget to call request_update() from your custom write, your channel will silently lose all writes after simulation start (because the kernel never knows to call your update()).

Corner 5: wait() is not legal in end_of_elaboration

A common mistake: trying to put wait() inside a lifecycle callback. The callback is not a process; the kernel is not in a state where it can suspend the call.

void end_of_elaboration() override {
    wait(100, SC_NS);   // ERROR — wait called outside a process
}

The kernel raises Error: (E523) wait(N, SC_NS) is called by main thread. The fix is structural: if you need to do something at a specific simulation time, put it inside a real SC_THREAD and have the thread wait. The lifecycle callbacks are for setup work, not for simulated-time-aware logic.

Corner 6: sc_stop from inside a lifecycle callback

Calling sc_stop() from start_of_simulation or earlier is legal — it tells the kernel to abort the simulation as soon as sc_start is called. The kernel respects the flag and immediately returns from the first sc_start call. This is occasionally useful for "configuration check failed; refuse to simulate" patterns.

void start_of_simulation() override {
    if (config_is_invalid()) {
        SC_REPORT_ERROR("config", "invalid configuration; aborting");
        sc_stop();
    }
}

Calling sc_stop() from end_of_simulation is a no-op — the simulation is already ending.

Corner 7: Destructor ordering vs. end_of_simulation ordering

Module destructors run in C++ object-destruction order: reverse of construction. For modules declared as members of a parent, the parent's destructor runs after the parent's body finishes — but before that, the kernel has already invoked end_of_simulation in hierarchical depth-first order (parents before children). So the order is:

  1. end_of_simulation on parent
  2. end_of_simulation on child (or both children in reg order)
  3. ~child() destructors (in reverse construction order)
  4. ~parent() destructor

If you need to use SystemC APIs (like sc_time_stamp() or reading a sibling module's state) in cleanup logic, use end_of_simulation — the kernel is still active. By the time destructors run, the kernel may have torn down internal state and these APIs may return invalid values.

Corner 8: end_of_simulation is only called if the simulation actually started

If sc_main exits without ever calling sc_start, the lifecycle callbacks (including end_of_simulation) never fire. Only destructors run. This is occasionally surprising: a configuration error caught in sc_main before sc_start will not trigger end_of_simulation, so any logging in that callback is silently skipped.

For "always-run" cleanup logic, either use the destructor (with the caveat about kernel state) or call your cleanup function explicitly before sc_main returns.

Corner 9: The sc_simcontext and global state

Behind the lifecycle, the kernel maintains a singleton sc_simcontext object. It holds the runnable set, the time-event heap, the process registry, and the lifecycle state. You can get a pointer to it via sc_get_curr_simcontext(), but doing so is rarely useful for application code — it is internal kernel machinery.

The simcontext lives for the entire duration of sc_main. There is conceptually one simulation per process. SystemC supports multiple sequential simulations in the same process (by managing simcontext explicitly), but that is advanced and rare; most code assumes one simulation per program execution.

Corner 9a: Debugging "process is not running" using lifecycle awareness

A common debugging session: "my SC_THREAD prints a startup message at time 0 but never runs again." The diagnosis almost always traces to one of three lifecycle-aware causes.

  1. The thread suspended on an empty static sensitivity list. It ran to its first wait(), the wait has no condition that will ever fire, and the thread is permanently suspended. Diagnosis: check the thread's wait() calls and the sensitive << ... after registration; if both are empty / contradictory, this is the bug.
  1. The thread is waiting on an event that nobody notifies. The waiter list contains the thread, but no producer ever calls notify(). Diagnosis: print from every notifier path or use a kernel-level trace to see whether the event ever fires.
  1. The notifier called notify() before the consumer reached its wait(). The lost-event pattern from Part 5. Diagnosis: add a wait(SC_ZERO_TIME) at the top of the notifier thread to let the consumer reach its wait first; if the bug goes away, you've confirmed the cause.

A useful diagnostic: override end_of_simulation on a module that contains threads of interest and print which threads are still alive (have not exited). Threads that have not exited but also have not advanced past their first wait() are the prime suspects.

Corner 9b: The sc_pause and sc_get_status() interaction

When sc_pause() is called from inside a process, the simulation transitions to SC_PAUSED after the current delta completes. sc_main can then call sc_start again to resume. During the pause, sc_get_status() returns SC_PAUSED. The lifecycle callbacks (end_of_simulation) do not fire — pause is not the same as termination.

void some_thread() {
    wait(10, SC_NS);
    sc_pause();   // return control to sc_main
    // Continues here when sc_main calls sc_start() again.
    wait(20, SC_NS);
    sc_stop();
}

This pattern is useful for cooperative co-simulation or interactive debugging. The simulation has not terminated — it has yielded control to sc_main. Modules continue to exist in their current state; the next sc_start resumes from exactly where it paused.

Corner 10: Lifecycle callbacks and inheritance

If you derive a class from sc_module and want to override a lifecycle callback, declare it override:

class my_special_module : public sc_module {
public:
    SC_HAS_PROCESS(my_special_module);
    my_special_module(sc_module_name n) : sc_module(n) {}

    void end_of_elaboration() override {   // 'override' keyword for safety
        // your logic
        sc_module::end_of_elaboration();   // call base if it does anything
    }
};

The base class's lifecycle callbacks are no-ops in the standard library, so calling the base is optional — but using override is good practice (the compiler warns if the signature does not match).

If you intermediate through your own base class, that class should also chain to its base, and so on up the hierarchy. SystemC's polymorphism uses standard C++ virtual dispatch.

Worked recipe: a phase-tracing parent

A pattern useful in test infrastructure — a top-level "phase tracker" module that logs every phase transition so you have a clear audit trail of what the kernel was doing.

SC_MODULE(phase_tracker) {
    SC_CTOR(phase_tracker) {
        log("CONSTRUCTOR");
    }
    void before_end_of_elaboration() override { log("before_end_of_elaboration"); }
    void end_of_elaboration() override         { log("end_of_elaboration"); }
    void start_of_simulation() override        { log("start_of_simulation"); }
    void end_of_simulation() override          { log("end_of_simulation"); }
    ~phase_tracker()                           { log("DESTRUCTOR"); }
private:
    void log(const char* phase) {
        std::cout << "[phase] " << phase
                  << " (sc_time_stamp=" << sc_time_stamp() << ")\n";
    }
};

Instantiating one of these as the first member of your top module gives you a complete lifecycle log for any simulation. The cost is negligible (six callbacks total, each a one-line print); the diagnostic value when debugging "why isn't this running" issues is high.

Lifecycle and exception handling

If a constructor or a lifecycle callback throws an exception, the kernel propagates it. Constructors throwing means elaboration fails; the kernel calls destructors for already-constructed modules (per C++ rules) and sc_main receives the exception. Callbacks throwing during elaboration means the kernel aborts elaboration and propagates.

SC_MODULE(strict_module) {
    SC_CTOR(strict_module) {
        if (some_config_invalid()) {
            throw std::runtime_error("config validation failed in constructor");
        }
    }
    void end_of_elaboration() override {
        if (binding_count() != expected_count()) {
            throw std::runtime_error("expected N bindings, found M");
        }
    }
};

The standard library does not guarantee the cleanliness of partial-elaboration teardown (some kernel state may be left in an inconsistent state); use exceptions sparingly and for genuine "abort the simulation, no point continuing" cases. For recoverable misconfigurations, prefer SC_REPORT_ERROR with SC_STOP action.

Final check: a sanity-test script

Before running any complex simulation, a five-minute lifecycle sanity test:

  1. Add a phase_tracker to your top module.
  2. Run with sc_start(SC_ZERO_TIME).
  3. Verify the log shows: CONSTRUCTOR → before_end_of_elaboration → end_of_elaboration → start_of_simulation → end_of_simulation → DESTRUCTOR, in that order.
  4. Verify sc_time_stamp() is 0 in every callback before end_of_simulation.

If the sequence is wrong or unexpected, something is wrong in your module structure or your SystemC installation. Fix that before debugging your design — you cannot debug a model on top of a misbehaving simulator.

Hands-on exercise

Build a lifecycle tracer module that prints a single line on every kernel transition — constructor, all four overridable callbacks, and the destructor. Instantiate three such tracers as children of a parent module; have the parent also be a tracer (logging "parent" in each callback). Predict the exact sequence of log lines for a simulation that calls sc_start(20, SC_NS) once. Then run it and compare.

Specifically: predict (a) the order of constructors (parent vs children), (b) the order of before_end_of_elaboration callbacks (parent before or after children?), (c) whether end_of_simulation fires before or after destructors, and (d) the destructor order.

Hint: the hierarchical depth-first, parents-before-children rule applies to all lifecycle callbacks. Destructors follow C++ rules (reverse of declaration order). Constructors follow C++ rules (declaration order, recursing into child constructors first when a parent has child members).

Section-end consolidation: putting Foundations together

You have reached the end of the Foundations section. The seven posts in this section have covered every primitive the SystemC kernel exposes:

  • Part 1 — modules, ports, and signals: the structural skeleton.
  • Part 2 — simulation time and clocks: the time primitive.
  • Part 3 — delta cycles and event-driven semantics: how the kernel schedules.
  • Part 4 — processes (SC_METHOD / SC_THREAD / SC_CTHREAD) and sensitivity: the behaviour primitive.
  • Part 5 — events and notifications: the synchronization primitive.
  • Part 6 — testbenches with driver/monitor/scoreboard: putting structure and behaviour together.
  • Part 7 (this post) — lifecycle and elaboration: the meta-control over when each phase fires.

If you can build a small SystemC model that exercises modules, processes, events, time, and a testbench scoreboard, and trace the lifecycle from sc_main entry through end_of_simulation, you have fully internalized SystemC's kernel machinery. Every subsequent post in this series builds on these primitives — no new kernel mechanics are introduced. What changes is the patterns you compose with them.

Section 2 (RTL-Level Modeling Patterns) applies these foundations to canonical RTL design. The Beginner reader who is shaky on Foundations can come back to it any time; the Intermediate reader who has internalized this section will read Section 2 with the kernel mechanics already in mind, and the patterns there will feel like natural compositions rather than new constructs.

A final piece of advice for everything that follows: always remember which phase you are in. The "I cannot register a process here" or "this signal read returns zero" errors invariably trace to running setup code in the wrong phase. The phase awareness you have built in this post is the difference between an SC engineer who can debug their own code and one who has to ask for help.

Common mistakes

  • Calling wait() from inside a lifecycle callback. Lifecycle callbacks are not processes; wait() raises an error. Move time-dependent logic into a real SC_THREAD.
  • Trying to register processes in end_of_elaboration or later. The hierarchy is frozen. Register in the constructor or before_end_of_elaboration.
  • Reading sc_time_stamp() in the constructor expecting a meaningful value. It returns SC_ZERO_TIME regardless of any pending writes. The simulation has not started.
  • Using end_of_simulation for resource release that depends on other modules' state. While the kernel state is alive, other modules' end_of_simulation may have already run (parents first). Don't assume children are still in their "live" state when your parent's end_of_simulation runs.
  • Forgetting override on lifecycle callbacks. Without override, a typo in the signature (e.g., end_of_elaboratoin) compiles but does not override the base — the kernel calls the base's no-op instead of your code. Always use override.
  • Logging from the destructor and expecting kernel APIs to work. They may not — the kernel may have torn down state. Log from end_of_simulation instead.
  • Calling sc_start from inside a process. Compiles, but the kernel raises an error at runtime. sc_start is sc_main-only.
  • Creating new modules outside elaboration / before_end_of_elaboration. The hierarchy is frozen after end_of_elaboration. Attempting new my_module(...) later produces undefined behaviour (the module gets created as a C++ object but the kernel does not know about it). Use sc_spawn if you need late-bound process creation.
  • Calling lifecycle callbacks manually. They are kernel-invoked; calling my_module.end_of_elaboration() directly from your code skips kernel bookkeeping. Trust the kernel to invoke them at the right time.
  • Putting cleanup logic only in the destructor. Destructors run in arbitrary order during program teardown, with kernel state potentially gone. Use end_of_simulation for cleanup that needs the simulation to still be in a defined state.
  • Mixing sc_start() (no args, run forever) with sc_stop() from one specific process. If that process never reaches the stop point (because of another bug), the simulation runs forever. Add a watchdog sc_start(sc_time(t, unit)) as an upper bound.
  • Confusing end_of_elaboration (kernel-invoked, before sim) with end_of_simulation (kernel-invoked, after sim). Both names sound similar; their meanings are completely different. The first runs before any process; the second runs after all processes have stopped.
  • Reading config files or environment variables inside a callback that the kernel calls many times. Lifecycle callbacks fire once per simulation; if you put expensive I/O there it does not repeat unnecessarily. But if you mistakenly put it in a process body that fires often, you re-read the file on every invocation. Read once, cache the result.

Cross-language hand-off: lifecycle when SystemC is embedded in C++ host code

A pattern increasingly common in modern projects: SystemC modules are part of a larger non-SystemC application (a verification harness, a co-simulation host, a Python-driven framework via DPI-like bridges). The lifecycle semantics still apply but the boundaries get fuzzier.

The conventional shape: a host C++ program calls into a "simulation runner" function that internally constructs SystemC modules, calls sc_start, and tears down. The runner is the moral equivalent of sc_main but the host program owns process-level concerns (signal handling, command-line parsing, top-level logging).

int run_systemc_simulation(const config_t& cfg) {
    // Construct top-level modules
    top t("top");
    t.configure(cfg);

    sc_start();   // until sc_stop()

    return collect_results();   // pull results from modules via member access
}

Two caveats. First, you can call this runner only once per process — the SystemC kernel does not gracefully support sequential simulations in the same process unless you explicitly manage simcontext. Second, any signal-handling, threading, or async I/O that the host does must not interfere with the kernel — SystemC is not thread-safe for concurrent process scheduling, only for the single kernel thread.

For multi-simulation-per-process patterns, see Accellera's documentation on sc_simcontext management — it's an advanced topic and rarely needed in typical projects.

Quick reference: the four lifecycle callback signatures

A condensed cheat sheet:

SC_MODULE(my_module) {
    SC_CTOR(my_module) { /* construction-time setup */ }

    void before_end_of_elaboration() override {
        // Late binding, child module creation, last chance to mutate hierarchy
    }

    void end_of_elaboration() override {
        // Hierarchy frozen; introspect via sc_get_top_level_objects
    }

    void start_of_simulation() override {
        // Final pre-simulation setup; sc_time_stamp() is still 0
    }

    // Processes run here (kernel scheduler loop)

    void end_of_simulation() override {
        // End-of-test reporting; sc_time_stamp() is final simulation time
    }

    ~my_module() { /* destructor cleanup; kernel state may be torn down */ }
};

That is the entire lifecycle API surface. Six override points (including constructor and destructor), each invoked exactly once per simulation. Master these, and you understand SystemC's structural semantics completely.

Recap

After this post — and the entire Foundations section — you can:

  • Name the four phases of a SystemC simulation lifecycle and explain what happens in each.
  • Override the four lifecycle hooks (before_end_of_elaboration, end_of_elaboration, start_of_simulation, end_of_simulation) for late-bound initialization and end-of-test reporting.
  • Use sc_get_status() to make phase-aware decisions in your code.
  • Introspect the module hierarchy at end_of_elaboration using sc_get_top_level_objects().
  • Predict the order of lifecycle callbacks across a multi-module hierarchy.
  • Choose between destructor cleanup and end_of_simulation cleanup based on what APIs you need.
  • Build a complete SystemC model from scratch: modules with ports and signals, processes of the right kind, events for synchronization, a testbench with driver/monitor/scoreboard, VCD tracing, and proper lifecycle hooks.
  • Debug "why isn't this happening?" questions by tracing what phase the simulation is in when the symptom occurs.

The full Foundations section is now behind you. You have the kernel mechanics, the structural primitives, the process semantics, the synchronization primitives, the testbench patterns, and the lifecycle. From here, Section 2 (RTL-Level Modeling Patterns) applies these foundations to canonical RTL design: combinational logic, sequential logic, FSMs, memories, structural composition. The mental machinery you have built in Foundations stays with you for every post that follows.

A retrospective: what the Foundations section was building toward

The seven Foundations posts were structured to build a complete mental model of the SystemC kernel from first principles. Looking back, the dependency graph is:

  • Part 1 (modules, ports, signals) introduces the structural primitives — the C++ classes the kernel operates on.
  • Part 2 (time and clocks) introduces the time primitive — the kernel's notion of when things happen.
  • Part 3 (delta cycles) introduces the scheduling primitive — the kernel's notion of how things happen.
  • Part 4 (processes and sensitivity) introduces the behaviour primitive — the code the kernel runs.
  • Part 5 (events and notifications) introduces the synchronization primitive — how processes coordinate.
  • Part 6 (testbenches) shows how Parts 1–5 compose into a working system.
  • Part 7 (lifecycle, this post) shows the meta-level: when each piece of Parts 1–6 actually happens during a simulation run.

Read in this order, each post extends the model without requiring you to forget anything from earlier ones. Read out of order, you'll find yourself missing context — Part 3 makes much more sense after Part 1 and Part 2, and Part 7 ties everything together.

If you want a one-paragraph summary of the entire Foundations section to bring with you into Section 2:

> SystemC is a C++ class library. A simulation consists of sc_module objects organized hierarchically, with sc_signal channels connecting their sc_port interfaces. Processes registered in module constructors (as SC_METHOD, SC_THREAD, or SC_CTHREAD) execute the actual behaviour. The kernel schedules processes per a 3-phase delta-cycle loop (evaluate → update → post-update notify), advancing simulation time only when the runnable set is empty. Synchronization between processes uses sc_events with three notification forms (immediate, delta, timed) and the AND/OR list combinators. Testbenches are just modules whose role is to drive stimulus and observe results; sc_main is the top-level entry point that constructs the hierarchy and calls sc_start to run the kernel. The simulation goes through four phases — elaboration, initialization, simulation, cleanup — with four override hooks (before_end_of_elaboration, end_of_elaboration, start_of_simulation, end_of_simulation) for late-bound work. Everything else in SystemC is library code built on top of these primitives.

If that paragraph reads as familiar and clear, you have the Foundations. If parts feel hazy, revisit the relevant post — the foundation will support everything that comes next.

Further reading

  • IEEE 1666-2011 §4 (the entire simulation lifecycle chapter). The most important reference for everything in this post. Read §4.1 and §4.4 together for the canonical phase-by-phase description, and §5.2.3 for the callback specifications.
  • §5.2.3 — the lifecycle callback signatures and ordering rules.
  • Accellera SystemC User's Guide — the lifecycle chapter, with worked examples of before_end_of_elaboration for late binding.
  • Accellera SystemC PoC source: src/sysc/kernel/sc_simcontext.h and sc_simcontext.cpp. The lifecycle transitions are explicit in sc_simcontext::initialize and sc_simcontext::simulate.
  • Doulos Golden Reference Guide — the elaboration and simulation phases chapter.
  • Black, Donovan, Tahar, SystemC: From the Ground Up (2nd ed.) — the simulation lifecycle chapter walks through the same material with worked diagnostic examples and broader treatment of multi-simcontext scenarios.
  • The Accellera SystemC distribution includes a unit_test/ directory with small programs exercising lifecycle hooks; these are useful as regression tests if you ever modify the kernel or write a new primitive channel.
  • For practical TLM virtual platforms, the Doulos TLM virtual-platform examples extensively use late-bound binding in before_end_of_elaboration — reading their socket-binding code is a fast way to see the canonical industrial pattern.

Next in this section

→ End of Section 1 — Foundations. This is the closing post of the section. You have completed the SystemC Foundations sub-spine: 7 posts covering modules, time, delta cycles, processes, events, testbench patterns, and lifecycle. The next section is RTL-Level Modeling Patterns — combinational logic, sequential logic, FSMs, memories, and structural composition — where the SystemC kernel mechanics you have learned get applied to canonical RTL design. The RV32I ALU, register file, and decoder return there as worked examples illustrating the patterns, but the structure is now concept-first rather than chip-first.

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