Chapter 1: Foundations

Build SystemC and Write a First Model

How a SystemC program is compiled, linked, elaborated, and started.

How to Read This Lesson

Read this like a conversation between normal C++ and the SystemC kernel. Whenever something looks like magic, ask: what C++ object did that macro or constructor register?

A SystemC model is a normal C++ program linked with the SystemC library. That makes the workflow familiar: include headers, compile sources, link against the library, and run the executable.

The official downloads and release material live under Accellera's SystemC resources, while active source development is public in the Accellera GitHub repository. In a production environment, pin a SystemC version the same way you would pin a compiler or simulator.

Source and LRM Trail

Portable behavior comes from Docs/LRMs/SystemC_LRM_1666-2023.pdf, especially the clauses around modules, hierarchy, elaboration, and simulation startup. The Accellera implementation path to inspect is .codex-src/systemc/src/sysc/kernel: start with sc_module, sc_object, sc_module_name, sc_simcontext, and the process classes.

Minimal Build Shape

The exact commands vary by installation, but the structure is consistent:

c++ -std=c++17 main.cpp \
  -I/path/to/systemc/include \
  -L/path/to/systemc/lib \
  -lsystemc \
  -o sim
./sim

Many teams wrap this with CMake:

cmake_minimum_required(VERSION 3.20)
project(counter_systemc CXX)
 
set(CMAKE_CXX_STANDARD 17)
find_package(SystemCLanguage CONFIG REQUIRED)
 
add_executable(sim main.cpp)
target_link_libraries(sim SystemC::systemc)

A Clocked Counter

This example introduces a module with input and output ports:

#include <systemc>
using namespace sc_core;
 
SC_MODULE(Counter) {
  sc_in<bool> clk{"clk"};
  sc_out<unsigned> value{"value"};
  unsigned internal = 0;
 
  void tick() {
    value.write(++internal);
  }
 
  SC_CTOR(Counter) {
    SC_METHOD(tick);
    sensitive << clk.pos();
    dont_initialize();
  }
};
 
int sc_main(int, char*[]) {
  sc_clock clk{"clk", 10, SC_NS};
  sc_signal<unsigned> count{"count"};
 
  Counter counter{"counter"};
  counter.clk(clk);
  counter.value(count);
 
  sc_start(50, SC_NS);
  return 0;
}

sc_clock is a channel that provides a clock signal. sc_signal<unsigned> is a channel that stores a value and notifies readers when it changes. The Counter module exposes ports, and the top level binds those ports to channels.

The Three Phases You Should Name

SystemC execution is easier to understand if you separate it into three phases:

  1. Construction: C++ constructors allocate modules, channels, and local state.
  2. Elaboration: SystemC finalizes hierarchy, port bindings, process registration, and object names.
  3. Simulation: sc_start() lets the kernel run processes according to events and time.

Many confusing errors come from doing phase-specific work in the wrong place. Binding belongs before simulation. wait() belongs in a thread process during simulation. Creating a process dynamically is possible, but you should first learn the static model.

Practical Advice

Keep the first model small. Build one clock, one signal, one module, and one print statement. Once that is working, add hierarchy. Then add events. Then add TLM. A SystemC environment is just C++, but debugging gets much easier when each layer has been proven independently.


Let's Connect This to the Standard and the Source

To understand how our Counter module is mapped into the simulator's memory, we must examine the IEEE 1666 rules for module instantiation and object naming. SystemC relies heavily on a complex C++ state machine maintained during object construction.

Module Instantiation and sc_module_name (IEEE 1666 Section 5.2 and 5.3)

When you write Counter counter{"counter"}; or SC_CTOR(Counter), you are participating in a strict C++ protocol dictated by the LRM.

The LRM states that every sc_module must have a name, and this name must be passed via an object of type sc_module_name. You cannot pass a raw const char* or std::string directly to the sc_module base constructor; it demands an sc_module_name object.

Why does sc_module_name exist? When you write Counter counter{"my_counter"};, the string "my_counter" is implicitly converted into an sc_module_name object via its converting constructor. This temporary object does an immense amount of work before the Counter constructor even begins executing.

If you trace the Accellera source code in sysc/kernel/sc_module_name.cpp:

  1. The sc_module_name constructor runs first. It queries the global sc_simcontext.
  2. It pushes the string name onto the simulation context's name stack (sc_simcontext::m_name_gen).
  3. It checks for name collisions. If a sibling module or port at the same hierarchical level already has the name "my_counter", it automatically generates a unique name (e.g., "my_counter_0").
  4. The C++ compiler then calls the base class constructor sc_module(const sc_module_name&).
  5. The sc_module constructor pops the name off the stack and registers the object with the sc_object_manager.

This C++ stack trick is how SystemC magically figures out parent-child relationships. The module currently executing its constructor is considered the "active" parent. Any sub-modules or ports instantiated inside that constructor are registered as children of that parent.

The Object Manager and Name Traversal (IEEE 1666 Section 5.1.2)

Every structural element in SystemC derives from sc_core::sc_object. This includes modules, ports, signals, and sockets. When an sc_object is registered, the sc_object_manager stores its string path.

The LRM provides two functions on every sc_object:

  • basename(): Returns the local name of the object (e.g., "clk").
  • name(): Returns the fully qualified hierarchical name, constructed by joining parent names with periods (e.g., "counter.clk").

You can query the entire simulation hierarchy at runtime using sc_get_top_level_objects() or find specific objects using sc_find_object("top.counter.value"). This is heavily used by tracing mechanisms (like VCD file generation) to automatically walk the tree and dump signal values.

Why dont_initialize() Exists (IEEE 1666 Section 5.2.14)

In our Counter example, we used dont_initialize(). What does this actually do?

By default, the LRM states that at the start of simulation (Time 0), the scheduler places every registered process (SC_METHOD, SC_THREAD) into the run queue and executes them once during the Initialization Phase. This guarantees that all initial values propagate through the system logic (just like a combinatorial evaluation in Verilog).

However, our Counter method tick() is purely clocked; it should only run when the clock has a positive edge (sensitive << clk.pos()). If tick() ran at Time 0 before any clock edge occurred, it would erroneously increment the counter.

When you call dont_initialize() inside SC_CTOR immediately after SC_METHOD(tick), it sets a flag on the sc_process_handle representing that method. In sysc/kernel/sc_simcontext.cpp, when the scheduler populates the initial run queue, it skips any process with this flag set. The method will remain asleep until its sensitivity list triggers it naturally.

The True Identity of sc_clock (IEEE 1666 Section 8.4)

In sc_main, we instantiated an sc_clock. While it looks like a primitive, sc_clock is actually a hierarchical module (sc_module) that contains an internal sc_signal<bool> and an internal SC_METHOD process that wakes up and toggles the signal based on the period, duty cycle, and start time provided in its constructor.

If you inspect sysc/communication/sc_clock.cpp, you will see it relies entirely on the same event and delta cycle mechanics as your custom models. It doesn't use magical kernel privileges; it simply schedules a next_trigger(half_period) internally and writes !current_val to its signal. This uniformity is a core design philosophy of SystemC: everything builds upon the core primitives of Events and Processes.

Deep Dive: Accellera Source for sc_signal and update()

The sc_signal<T> channel perfectly illustrates the Evaluate-Update paradigm of SystemC. In the Accellera source (src/sysc/communication/sc_signal.cpp), sc_signal inherits from sc_prim_channel.

The write() Implementation

When you call write(const T&), the signal does not immediately change its value. Instead, it stores the requested value in m_new_val and registers itself with the kernel:

template<class T>
inline void sc_signal<T>::write(const T& value_) {
    if( !(m_new_val == value_) ) {
        m_new_val = value_;
        this->request_update(); // Inherited from sc_prim_channel
    }
}

The request_update() call appends the channel to sc_simcontext::m_update_list.

The update() Phase

After the Evaluate phase finishes (all ready processes have run), the kernel iterates over m_update_list and calls the update() virtual function on each primitive channel. For sc_signal, this looks like:

template<class T>
inline void sc_signal<T>::update() {
    if( !(m_new_val == m_cur_val) ) {
        m_cur_val = m_new_val;
        m_value_changed_event.notify(SC_ZERO_TIME); // Notify processes sensitive to value_changed_event()
    }
}

This guarantees that all concurrent processes see the same old value until the delta cycle advances, perfectly mimicking hardware register delays.

Comments and Corrections