SC_MODULE in SystemC — Ports, Processes, and Hierarchy Explained
SC_MODULE is the fundamental building block of every SystemC design. It is the C++ equivalent of a VHDL entity or a Verilog module — a self-contained unit that combines state, behaviour, and connectivity into one object. Understanding how it works at the C++ level, how its constructor wires things together, and how the kernel invokes its processes is the foundation for everything else in SystemC.
This article unpacks the module from the macro expansion all the way through ports, processes, sensitivity lists, and multi-level hierarchy — finishing with a complete three-module example you can compile and run.
What Is SC_MODULE?
SC_MODULE(Name) is a C preprocessor macro that expands to a struct (publicly inherited from sc_core::sc_module) and a static type-name method. Using struct instead of class means all members are public by default — a deliberate choice to keep port and process declarations visible to parent modules.
/* What you write */
SC_MODULE(Counter) { ... };
/* What the preprocessor generates */
struct Counter : sc_core::sc_module {
typedef Counter SC_CURRENT_USER_MODULE;
/* ... your declarations go here ... */
};
The SC_CURRENT_USER_MODULE typedef is consumed by SC_CTOR and the process-registration macros (SC_METHOD, SC_THREAD, SC_CTHREAD) so they know which class they belong to at compile time.
SC_MODULE is not magic — it is a thin macro layer over standard C++. Every rule of C++ class design applies: constructors, destructors, inheritance, templates. If you can explain the expanded struct, you understand the module.
SC_CTOR and SC_HAS_PROCESS
The module constructor is where the kernel gets told about your processes, their sensitivity lists, and any sub-module port bindings. Two macros handle this:
SC_CTOR — the simple constructor
SC_CTOR(ModuleName) expands to a constructor that accepts only a const sc_module_name & argument. Use it when your module needs no extra configuration parameters.
SC_MODULE(Adder) {
sc_in<int> a, b;
sc_out<int> sum;
SC_CTOR(Adder) {
SC_METHOD(compute);
sensitive << a << b;
}
void compute() { sum.write(a.read() + b.read()); }
};
SC_HAS_PROCESS — the flexible constructor
When you need extra constructor arguments (e.g., a configurable data width or a memory size), SC_CTOR won't work. Use SC_HAS_PROCESS(ModuleName) inside the class body to register the process infrastructure, then write a normal C++ constructor.
SC_MODULE(Memory) {
sc_in<bool> clk;
sc_in<sc_uint<16>> addr;
sc_out<sc_uint<8>> data_out;
SC_HAS_PROCESS(Memory); /* enables SC_METHOD/SC_THREAD macros */
explicit Memory(sc_module_name nm, unsigned size_kb)
: sc_module(nm), m_size(size_kb * 1024)
{
m_mem.resize(m_size, 0);
SC_METHOD(read_mem);
sensitive << clk.pos();
}
void read_mem() {
data_out.write(m_mem[addr.read()]);
}
private:
unsigned m_size;
std::vector<uint8_t> m_mem;
};
SC_CTOR. Extra parameters → use SC_HAS_PROCESS + a normal constructor that calls sc_module(nm) explicitly.
Ports — The Module Interface
Ports are typed connection points declared as member variables. They are bound to sc_signal objects (or other channels) during elaboration — never during simulation.
| Port type | Direction | read() | write() | Typical use |
|---|---|---|---|---|
sc_in<T> | Input | ✅ | ❌ | Clock, data inputs, control signals |
sc_out<T> | Output | ❌ | ✅ | Result, status outputs |
sc_inout<T> | Bidirectional | ✅ | ✅ | Bidirectional buses, shared lines |
sc_in_rv<N> | Resolved input | ✅ | ❌ | Multi-driver resolved logic bus |
Ports are bound in the parent scope using the function-call syntax module_instance.port(signal), or with named binding module_instance.port.bind(signal):
/* in sc_main or a parent module's constructor */
sc_signal<bool> clk_sig, en_sig, q_sig;
Toggle toggle_inst("toggle");
toggle_inst.clk(clk_sig); /* positional binding */
toggle_inst.en(en_sig);
toggle_inst.q(q_sig);
sc_start() has been called) is illegal. Port binding must complete before elaboration ends — i.e., before sc_start().
SC_METHOD — Combinational and Edge-Triggered Logic
SC_METHOD registers a member function as a method process. The kernel calls it every time an event on its sensitivity list occurs. The function runs to completion — it cannot call wait(), and it must return before the kernel can schedule anything else.
This makes it the natural choice for:
- Combinational logic — runs whenever any input changes
- Edge-triggered registers — sensitive to
clk.pos()only - Any process that can be expressed as a pure function of its inputs
SC_MODULE(DFF) {
sc_in<bool> clk, d;
sc_out<bool> q;
SC_CTOR(DFF) {
SC_METHOD(on_clk);
sensitive << clk.pos(); /* rising-edge only */
q.initialize(false);
}
void on_clk() {
q.write(d.read()); /* capture D on rising clock edge */
}
};
dont_initialize()
By default, every method process runs once at the start of simulation (time 0) to establish initial output values. Call dont_initialize() in the constructor right after the sensitivity declaration to suppress this:
SC_CTOR(DFF) {
SC_METHOD(on_clk);
sensitive << clk.pos();
dont_initialize(); /* do NOT run on_clk at time 0 */
}
SC_THREAD — Sequential Processes with wait()
SC_THREAD registers a member function as a thread process. Unlike SC_METHOD, a thread can suspend itself by calling wait(). It runs exactly once from its entry point — so an infinite loop with wait() inside is the idiomatic pattern for modelling hardware that operates continuously over time.
Typical uses:
- Clock generators — drive a signal high/low on a repeating schedule
- Testbenches — apply stimulus, wait for a response, check results
- Protocol state machines — sequences of operations separated by clock edges or time delays
SC_MODULE(ClockGen) {
sc_out<bool> clk;
SC_CTOR(ClockGen) {
SC_THREAD(gen_clk);
/* no static sensitivity — thread uses wait() for timing */
clk.initialize(false);
}
void gen_clk() {
while (true) {
clk.write(false);
wait(5, SC_NS); /* low half-period */
clk.write(true);
wait(5, SC_NS); /* high half-period → 10 ns period, 100 MHz */
}
}
};
wait() — even once — will spin forever. The simulator hangs, consuming 100% CPU with no simulation time advancing. Always ensure every code path through a thread loop eventually hits a wait().
Sensitivity Lists — Static and Dynamic
A sensitivity list tells the kernel which events should wake a process. SystemC supports two kinds:
Static sensitivity
Declared in the constructor using the sensitive object. Fixed for the lifetime of the simulation — cannot be changed after elaboration ends.
SC_CTOR(Mux2) {
SC_METHOD(select);
sensitive << a << b << sel; /* any change on a, b, or sel */
}
/* Edge modifiers */
sensitive << clk.pos(); /* rising edge */
sensitive << clk.neg(); /* falling edge */
sensitive << clk; /* both edges (value change event) */
Dynamic sensitivity
Used inside SC_THREAD (and SC_CTHREAD). The wait() call accepts an event, a list of events, or a time duration — making the sensitivity dynamic per-invocation.
void run() {
while (true) {
wait(clk.posedge_event()); /* wait for next rising edge */
wait(10, SC_NS); /* wait 10 nanoseconds */
wait(done_event | timeout_event); /* wait for either event */
}
}
| wait() form | Suspends until… |
|---|---|
wait() | Any event on the static sensitivity list |
wait(event) | The specified sc_event fires |
wait(e1 | e2) | Either event fires (OR) |
wait(e1 & e2) | Both events fire (AND — same delta) |
wait(N, SC_NS) | N nanoseconds of simulation time pass |
wait(N, SC_NS, event) | Event fires OR N ns elapses (timeout) |
Module Hierarchy — Composing the Design
Real designs nest modules inside other modules. The parent creates sub-module instances, declares the signals that connect them, and binds ports in its constructor. This is identical to how you instantiate components in VHDL or modules in Verilog.
SC_MODULE(Top) {
/* ── Sub-module instances ── */
ClockGen clkgen;
Counter counter;
/* ── Internal signals ── */
sc_signal<bool> clk_sig;
sc_signal<sc_uint<8>> count_sig;
SC_CTOR(Top)
: clkgen("clkgen"), counter("counter") /* pass name strings */
{
/* ── Port binding ── */
clkgen.clk(clk_sig);
counter.clk(clk_sig);
counter.count(count_sig);
}
};
Key rules for hierarchy:
- Sub-module member variables must be declared before the constructor initialiser list runs — so they are declared as class members, not local variables.
- Every module instance must receive a unique
sc_module_namestring. The SystemC kernel uses these to build its internal object hierarchy and for tracing. - Ports on sub-modules can be bound to signals declared in the parent, or to ports of the parent itself (transparent pass-through).
- All port bindings must be complete before
sc_start()is called. Unbound mandatory ports cause a run-time error during elaboration.
sc_main — The Three Stages
Every SystemC program's entry point is sc_main(). It is not main() — the SystemC library provides its own main() that calls sc_main() after internal initialisation.
int sc_main(int argc, char* argv[]) {
/* ── 1. Elaboration ────────────────────────────────────────── */
/* Instantiate modules, declare signals, bind ports. */
/* All constructors run here. The kernel is NOT yet simulating. */
sc_signal<bool> clk, en;
sc_signal<sc_uint<8>> count;
ClockGen clkgen ("clkgen");
Counter counter("counter");
clkgen.clk(clk);
counter.clk(clk);
counter.en(en);
counter.count(count);
en.write(true);
/* ── 2. Simulation ─────────────────────────────────────────── */
/* sc_start() hands control to the kernel scheduler. */
/* Processes execute in response to events until time runs out */
/* or sc_stop() is called from inside a process. */
sc_start(200, SC_NS);
/* ── 3. Post-processing ────────────────────────────────────── */
/* sc_start() has returned. Read results, check pass/fail. */
/* Module destructors have NOT yet run — signals are still valid.*/
std::cout << "Final count: " << count.read() << std::endl;
return 0;
}
sc_start() returns is post-simulation read-only inspection. Writing to signals in stage 3 does nothing useful — the kernel is no longer running. Use stage 3 only for checking results and printing reports.
Complete Example — ClockGen, Counter, and Testbench
Here is a self-contained three-file design that demonstrates every concept covered: a clock generator, an enable-gated 8-bit counter, and a testbench that enables the counter, runs it, then disables it mid-simulation.
/* ════════════════════════════════════════════════════════════════
File: clock_gen.h
════════════════════════════════════════════════════════════════ */
#include <systemc.h>
SC_MODULE(ClockGen) {
sc_out<bool> clk;
SC_CTOR(ClockGen) {
SC_THREAD(gen);
clk.initialize(false);
}
void gen() {
while (true) {
wait(5, SC_NS);
clk.write(!clk.read());
}
}
};
/* ════════════════════════════════════════════════════════════════
File: counter.h
════════════════════════════════════════════════════════════════ */
SC_MODULE(Counter) {
sc_in<bool> clk;
sc_in<bool> en; /* count only when high */
sc_out<sc_uint<8>> count;
SC_CTOR(Counter) {
SC_METHOD(on_clk);
sensitive << clk.pos();
dont_initialize();
count.initialize(0);
}
void on_clk() {
if (en.read())
count.write(count.read() + 1);
}
};
/* ════════════════════════════════════════════════════════════════
File: tb.h — testbench
════════════════════════════════════════════════════════════════ */
SC_MODULE(Testbench) {
sc_in<sc_uint<8>> count;
sc_out<bool> en;
SC_CTOR(Testbench) {
SC_THREAD(run);
}
void run() {
en.write(false);
wait(15, SC_NS);
std::cout << "[TB] Enabling counter at " << sc_time_stamp() << std::endl;
en.write(true);
wait(80, SC_NS); /* let it count for 8 clock cycles */
std::cout << "[TB] Count after 8 cycles: " << count.read() << std::endl;
en.write(false);
wait(20, SC_NS);
std::cout << "[TB] Count frozen at: " << count.read() << std::endl;
sc_stop();
}
};
/* ════════════════════════════════════════════════════════════════
File: main.cpp
════════════════════════════════════════════════════════════════ */
int sc_main(int, char**) {
sc_signal<bool> clk_sig, en_sig;
sc_signal<sc_uint<8>> count_sig;
ClockGen clkgen ("clkgen");
Counter counter("counter");
Testbench tb ("tb");
clkgen.clk(clk_sig);
counter.clk(clk_sig);
counter.en(en_sig);
counter.count(count_sig);
tb.count(count_sig);
tb.en(en_sig);
sc_start(); /* run until sc_stop() */
return 0;
}
Expected output (approximate — clock edges at 5 ns intervals):
[TB] Enabling counter at 15 ns [TB] Count after 8 cycles: 8 [TB] Count frozen at: 8
SC_MODULE Quick Reference
| Construct | What it does | When to use |
|---|---|---|
SC_MODULE(Name) | Declares a struct inheriting sc_module | Every module |
SC_CTOR(Name) | Standard constructor (name arg only) | No extra ctor params |
SC_HAS_PROCESS(Name) | Enables process macros for custom ctor | Extra ctor params needed |
SC_METHOD(fn) | Registers a method process | Combinational / edge-triggered logic |
SC_THREAD(fn) | Registers a thread process | Sequential behaviour, testbenches |
sensitive << port | Static sensitivity (any value change) | Combinational inputs |
sensitive << port.pos() | Static sensitivity (rising edge) | Clock-triggered method |
dont_initialize() | Skip time-0 method invocation | Registers (avoid spurious output) |
wait(N, SC_NS) | Suspend thread for N nanoseconds | Timed delays in threads |
wait(event) | Suspend thread until event fires | Event-driven sequencing |
sc_start(T, SC_NS) | Run simulation for T nanoseconds | Bounded simulation runs |
sc_stop() | Halt simulation from inside a process | Testbench end-of-test |