1. SystemC Tutorial - Modules, Ports & Signals

Why this matters

Rewritten 2026-05-23 with deeper first-principles material.

When ARM's architects ship a new Cortex core, the verification team has been running software on it for months — not on the real die, but on a SystemC virtual platform that lives entirely as C++ objects in a Linux process. Intel does the same for every Xeon generation. SiFive validated the FU540's boot ROM on a SystemC model before the GDS went to the foundry. The reason the industry can do this with confidence is that SystemC gives you a single language for expressing both the structure of a hardware system and the behaviour that runs inside that structure — and the unit of composition for both is the module. Every wire on a die starts life as a port on a module; every block of logic starts life as a process inside one. If you cannot reason fluently about modules, ports, and signals, you cannot read or write the kind of model that gets booted before the first wafer is etched.

That is what this post is for. By the end of it you will have written a single-module SystemC program that compiles, runs, and prints a value you predicted; you will have wired two modules together through an sc_signal; you will know the difference between sc_in<T>, sc_signal<T>, and the more general sc_port<IF>; and — if you stay through the Advanced section — you will know why a write you just performed inside an SC_METHOD is not visible to a read() two lines later in the same function. None of this is exotic. All of it is load-bearing. The rest of the series depends on it.

Prerequisites

This is Part 1 of the SystemC Foundations sub-spine. There are no in-series prerequisites — this is your starting point. You do need:

  • SystemC 2.3.x installed and discoverable. If echo $SYSTEMC_HOME prints a path and that path contains include/systemc.h, you are ready. The installer posts cover this:

- P1 — Installing SystemC 2.3 on Windows - P2 — Configuring SystemC 2.3 Libraries

Linux and macOS users typically install from source using the standard ./configure && make && make install flow; the Accellera tarball is at .

  • A C++17 compiler. GCC 7+, Clang 5+, or MSVC 2017+. C++11 compiles the code in this post too, but the series targets C++17 from Part 4 onward and there is no point holding back.
  • A build system you understand. The examples in this post are single-file; g++ -std=c++17 main.cpp -I$SYSTEMC_HOME/include -L$SYSTEMC_HOME/lib-linux64 -lsystemc -o sim is sufficient. From Part 6 onward we use a small CMake skeleton.

You do not need any prior SystemC experience. You do not need familiarity with SystemVerilog. You do not need to know what UVM is. Comfort with C++ (classes, constructors, references, headers) is assumed throughout.

Mental model (first principles)

The trap most beginners fall into is reading SystemC code and thinking it looks like SystemVerilog with the angle brackets and colons of C++ tacked on. It does not. SystemC is a C++ class library — every construct you will use is a real C++ object with a real C++ type, and the simulation kernel is a real C++ program that schedules calls into those objects. There is no separate language. There is no separate elaborator. There is only int main, which the macro sc_main expands into, calling a kernel that drives your objects according to rules defined in IEEE 1666-2011.

Hold this picture in your head:

                      ┌───────────────────────────────────────┐
                      │           sc_module (C++ class)       │
                      │                                       │
   outside ──port──▶  │  ports: sc_in<T>, sc_out<T>, ...      │
                      │   │                                   │
                      │   ▼ bound to ▼                        │
                      │  channels:  sc_signal<T>, ...         │
                      │   ▲                                   │
                      │   │ owned-by / referenced-by          │
                      │  processes:  SC_METHOD, SC_THREAD     │
                      │                                       │
                      └───────────────────────────────────────┘
                              ▲
                              │  All of the above is built
                              │  during ELABORATION, between
                              │  sc_main entry and sc_start().
                              │
                              │  After sc_start(), the kernel
                              │  takes over and drives the
                              │  processes per the 3-phase loop
                              │  (Evaluate → Update → Notify).

A module is a C++ class derived (via the SC_MODULE macro) from sc_module. Inside it, three kinds of members live together:

1. Ports — the typed interfaces by which the module connects to the world outside it. sc_in<bool> is "an input typed bool," but really it is a sc_port<sc_signal_in_if<bool>> — a pointer-like handle that, during elaboration, gets bound to a channel implementing the matching interface. The port itself owns no storage; the channel does.

2. Channels — the things ports bind to. The simplest channel is sc_signal<T>: one writer, one current value, one pending "next" value, an internal value_changed_event. Channels live wherever you instantiate them — typically inside a parent module that wires sub-modules together, or inside sc_main for the top-level test scaffold. A port is bound to a channel by calling port(channel) or port.bind(channel) during elaboration.

3. Processes — small functions, registered with the kernel inside the module's constructor via SC_METHOD(fn) or SC_THREAD(fn) macros, that the kernel will invoke whenever something they are sensitive to has happened. Sensitivity is declared with sensitive << signal_or_port; immediately after the registration macro. A process is what gives the module its behaviour; without one, a module is just a passive scaffold of ports and channels.

The kernel's job is to enforce the 3-phase per-delta-cycle loop introduced in Part 3 (Delta Cycles): evaluate runnable processes, commit pending channel writes during the update phase, then notify any process whose sensitivity has triggered for the next delta cycle. The kernel does not advance simulation time until the runnable set is empty. You do not need that machinery to write your first module — but knowing it is there, waiting to call your SC_METHOD whenever the port it is sensitive to changes, will keep you from writing the kind of code that "should work but doesn't" because you expected reads and writes to be sequential.

One more piece of mental scaffolding: elaboration vs simulation. Elaboration is everything that happens between the start of sc_main and the first call to sc_start(). That is when modules get constructed, ports get bound, processes get registered. After sc_start(), the kernel takes over and the hierarchy is frozen — you cannot add a port, cannot bind a new channel, cannot register a new process. Everything you want the simulator to know structurally must be in place by the time sc_start returns control to the kernel.

Beginner: First Principles

Let us write the simplest SystemC program that does something visible. A single module, one input port, one output port, one process that copies the input to the output. Save this as pass_through.cpp:

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

SC_MODULE(pass_through) {
    sc_in<bool>  in;
    sc_out<bool> out;

    void copy() {
        out.write(in.read());
        std::cout << "[" << sc_time_stamp() << "] copy: in=" << in.read()
                  << " writing out=" << in.read() << "\n";
    }

    SC_CTOR(pass_through) {
        SC_METHOD(copy);
        sensitive << in;
    }
};

int sc_main(int, char**) {
    sc_signal<bool> stim_to_dut;
    sc_signal<bool> dut_to_obs;

    pass_through pt("pt");
    pt.in(stim_to_dut);
    pt.out(dut_to_obs);

    // Drive a few transitions
    stim_to_dut.write(false);
    sc_start(1, SC_NS);

    stim_to_dut.write(true);
    sc_start(1, SC_NS);

    stim_to_dut.write(false);
    sc_start(1, SC_NS);

    std::cout << "[" << sc_time_stamp() << "] final out = "
              << dut_to_obs.read() << "\n";
    return 0;
}

Build and run:

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

Expected output:

SystemC 2.3.4-Accellera --- ...
[0 s] copy: in=0 writing out=0
[1 ns] copy: in=1 writing out=1
[2 ns] copy: in=0 writing out=0
[3 ns] final out = 0

Now walk through what just happened, line by line, in the order the kernel cares about.

#include <systemc.h> brings in every name we use here: SC_MODULE, SC_CTOR, SC_METHOD, sc_in, sc_out, sc_signal, sc_time, sc_start, sc_main. SystemC keeps the include surface deliberately small at the cost of pulling in a fair amount per header; for now this is the right choice.

SC_MODULE(pass_through) { … }; is a macro. After preprocessing it expands roughly to struct pass_through : public ::sc_core::sc_module { … };. So pass_through is a C++ struct (which in C++ means a class with public default access) deriving from sc_module. Everything inside the braces is normal C++.

sc_in<bool> in; and sc_out<bool> out; declare two ports as ordinary class members. They are objects of type sc_in<bool> (an alias for sc_port<sc_signal_in_if<bool>>) and sc_out<bool> (alias for sc_port<sc_signal_inout_if<bool>, 1, SC_ONE_OR_MORE_BOUND>). They are not bool variables. To get a bool out of in you must call in.read(). To put a bool into out you must call out.write(v).

void copy() { … } is just a member function. The macro SC_METHOD(copy) inside the constructor registers this function with the kernel as a "method process." Method processes have one rule: they must run to completion every time the kernel invokes them. They cannot call wait(). They are the right choice for combinational logic and for tiny stimulus generators.

SC_CTOR(pass_through) { SC_METHOD(copy); sensitive << in; } is another macro. It expands to a constructor pass_through(::sc_core::sc_module_name nm) : ::sc_core::sc_module(nm) { … }. The body then registers copy and tells the kernel "every time in has a value-changed event, schedule copy to run."

The kernel also follows a rule called initialization: every method process is run once at the start of simulation regardless of its sensitivity, unless you opt out with dont_initialize(). That is why you see [0 s] copy: in=0 writing out=0 in the output before any time has advanced — the kernel called copy once during initialization, in.read() returned the default-constructed bool (false, which prints as 0), and copy wrote false to out. The out.write(false) was a pending write that committed in the same delta cycle's update phase.

After sc_start(1, SC_NS) advances time, the kernel checks whether anything is runnable. stim_to_dut.write(true) queued a pending write. The update phase commits it; value_changed_event fires; copy becomes runnable for the next delta; copy runs at time 1 ns, reads true, writes true to out.

The pattern that took some readers ten paragraphs of explanation in older tutorials is the common confusion here: when you call out.write(in.read()), the call to in.read() returns whatever the kernel committed to in's channel in the previous update phase, and the call to out.write(...) queues a pending value that the kernel will commit in the next update phase. The write does not appear to anyone — including out.read() two lines later — until that update happens. There is no exception, no special case, no "but in my example…". Internalize this and the next 30 posts get easier.

Intermediate: How It Really Works

The single-module example skipped over everything that makes SystemC modules useful at scale: composition, hierarchy, and the careful distinction between an sc_port, an sc_signal, and the constructor mechanics that let modules take parameters beyond their name. Let us add the pieces one at a time.

Two modules connected by a signal

The point of ports is that they make modules composable — instances declared in one scope, wired up in another. Here is a writer and a reader, each its own module, instantiated and wired in sc_main:

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

SC_MODULE(writer) {
    sc_out<int> out;
    // No process body here on purpose — see explanation below.
    // The writer just owns an output port; sc_main drives the
    // channel directly through `bus.write(...)` for now.
    SC_CTOR(writer) {}
};

SC_MODULE(reader) {
    sc_in<int> in;

    void observe() {
        std::cout << "[" << sc_time_stamp() << "] reader sees "
                  << in.read() << "\n";
    }

    SC_CTOR(reader) {
        SC_METHOD(observe);
        sensitive << in;
    }
};

int sc_main(int, char**) {
    sc_signal<int> bus;

    writer w("w");
    reader r("r");
    w.out(bus);
    r.in(bus);

    // Drive the writer manually for a few steps via sc_main.
    // (In Part 4 we'll cover SC_THREAD with a body that drives itself.)
    sc_start(1, SC_NS);  // initialization runs reader once
    bus.write(42);
    sc_start(1, SC_NS);
    bus.write(99);
    sc_start(1, SC_NS);

    return 0;
}

Expected output:

[0 s] reader sees 0
[1 ns] reader sees 42
[2 ns] reader sees 99

Two observations from this. First, the bus.write(42) calls in sc_main are issued from outside any process; they are still legal because sc_signal<int>::write is just a member function and sc_main runs on the kernel thread before any process is active. The kernel still respects the pending-write semantics: when we call sc_start(1, SC_NS) after bus.write(42), the kernel runs an update phase that commits the pending value, fires value_changed_event, and schedules reader::observe. Second, the writer module above is a deliberately empty example — it owns an sc_out<int> port but has no process, so it does nothing on its own. The role of writer here is just to show that you can declare a module with an output port without yet driving it from a process; sc_main drives the channel directly via bus.write(...). We will return to driving outputs from a real SC_THREAD in Part 4.

A typed adder

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

SC_MODULE(adder8) {
    sc_in<sc_uint<8>>  a, b;
    sc_out<sc_uint<8>> y;

    void eval() {
        y.write(a.read() + b.read());
    }

    SC_CTOR(adder8) {
        SC_METHOD(eval);
        sensitive << a << b;
    }
};

int sc_main(int, char**) {
    sc_signal<sc_uint<8>> sa, sb, sy;

    adder8 dut("dut");
    dut.a(sa); dut.b(sb); dut.y(sy);

    sa.write(3); sb.write(4);
    sc_start(1, SC_NS);
    std::cout << "3 + 4 = " << sy.read() << "\n";

    sa.write(200); sb.write(80);
    sc_start(1, SC_NS);
    std::cout << "200 + 80 = " << sy.read() << "\n";

    sa.write(255); sb.write(2);
    sc_start(1, SC_NS);
    std::cout << "255 + 2 (8-bit wrap) = " << sy.read() << "\n";

    return 0;
}

Expected output:

3 + 4 = 7
200 + 80 = 24
255 + 2 (8-bit wrap) = 1

Two things to notice here. First, sc_uint<8> is a SystemC type — not a C++ built-in — and arithmetic on it respects the declared bit-width (255 + 80 in 8 bits is 24; 255 + 2 is 1). When you want hardware-accurate arithmetic, you use sc_uint<W>, sc_int<W>, sc_bv<W>, or sc_lv<W>. When you want fast C++ arithmetic and don't care about bit-width semantics, plain int is fine. The choice matters more than it looks: a sc_in<int> port and a sc_in<sc_uint<32>> port are incompatible types and cannot be bound to the same channel.

Second, the sensitivity list now has two entries: sensitive << a << b. Each operator<< on the sensitive object adds the right-hand object to the static sensitivity list. The eval method will be scheduled whenever either port has a value_changed_event in the current delta. This is the canonical combinational pattern: list every input.

Hierarchy: a parent module instantiating two children

This is where modules earn their keep. A top module that contains an internal signal connecting two children, with the children's ports bound during the parent's constructor:

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

SC_MODULE(producer) {
    sc_out<int> out;
    int counter;

    void drive() {
        out.write(counter);
        std::cout << "  [prod " << sc_time_stamp() << "] wrote "
                  << counter << "\n";
        counter++;
    }

    SC_CTOR(producer) : counter(10) {
        SC_METHOD(drive);
        // No sensitivity list -> runs once at initialization only.
        // That's enough for this structural illustration.
    }
};

SC_MODULE(consumer) {
    sc_in<int> in;

    void observe() {
        std::cout << "  [cons " << sc_time_stamp() << "] saw "
                  << in.read() << "\n";
    }

    SC_CTOR(consumer) {
        SC_METHOD(observe);
        sensitive << in;
        dont_initialize();   // skip the init-time run; only fire on value changes
    }
};

SC_MODULE(top) {
    producer prod;
    consumer cons;
    sc_signal<int> internal;   // not visible outside top

    SC_CTOR(top) : prod("prod"), cons("cons") {
        prod.out(internal);
        cons.in(internal);
    }
};

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

Expected output:

  [prod 0 s] wrote 10
  [cons 0 s] saw 10

Three structural points here. First, the parent module top declares its two children prod and cons as plain member objects of type producer and consumer. Their constructors run when top's constructor runs — and top's constructor runs when top t("t"); is declared in sc_main. SystemC hierarchy is just C++ object containment.

Second, the constructor initializer list : prod("prod"), cons("cons") passes the names through. Every sc_module-derived class takes an sc_module_name as its first constructor argument; the SC_CTOR macro hides this for the cases where you don't need to pass anything else. The kernel uses the name string for diagnostics and for hierarchical name composition (t.prod, t.cons).

Third, the internal sc_signal<int> internal is declared as a member of top and bound to prod.out and cons.in inside top's constructor body. After top is constructed, the binding cannot change. This is how you build closed structural sub-systems that expose only a curated set of ports to the outside.

SC_CTOR vs SC_HAS_PROCESS

When you need to pass parameters to a module beyond its name — for example, a bus width or a memory size — SC_CTOR is no longer enough. You write your own constructor and use SC_HAS_PROCESS to tell the kernel about the process-registration machinery:

SC_MODULE(memory) {
    sc_in<sc_uint<32>>  addr;
    sc_in<sc_uint<32>>  wdata;
    sc_in<bool>         we;
    sc_out<sc_uint<32>> rdata;

    std::vector<sc_uint<32>> mem;
    const size_t depth;

    void eval() {
        if (we.read()) {
            mem[addr.read().to_uint()] = wdata.read();
        }
        rdata.write(mem[addr.read().to_uint()]);
    }

    memory(sc_module_name n, size_t depth_words)
        : sc_module(n), mem(depth_words, 0), depth(depth_words) {
        SC_HAS_PROCESS(memory);
        SC_METHOD(eval);
        sensitive << addr << wdata << we;
    }
};

// Instantiation now takes the depth parameter:
//     memory dram("dram", 1024);

The decision is mechanical:

You need… Use
Only the module name, nothing else SC_CTOR(name) { … }
Constructor parameters beyond the name Custom constructor + SC_HAS_PROCESS(name); as first line of body
To inherit from a custom sc_module-derived base class Custom constructor + SC_HAS_PROCESS(name);
A templated module Custom constructor + SC_HAS_PROCESS(name); (the macro form does not play nicely with templates)

SC_HAS_PROCESS itself expands to typedefs and friend declarations that SC_METHOD/SC_THREAD need; it does not register anything. You still must call SC_METHOD(fn) for each process.

Port binding: () syntax vs .bind() syntax

You have seen pt.in(stim_to_dut) and prod.out(internal). The equivalent forms are:

pt.in(stim_to_dut);     // operator() form, idiomatic
pt.in.bind(stim_to_dut); // explicit bind, identical effect

Both call the same sc_port::bind underneath. The () form is shorter and reads like "connect this port to that signal." Use it for normal binding. The .bind() form is occasionally clearer when chaining or when the port object itself is the result of an expression (module->in.bind(sig);).

Translation table for SystemVerilog engineers

If you are coming from SystemVerilog/UVM, the mental remap is mostly mechanical. Here are the canonical pairs:

Concept SystemVerilog SystemC Note
Module declaration module foo(input logic a, output logic b); … endmodule SC_MODULE(foo) { sc_in<bool> a; sc_out<bool> b; … }; SystemC ports are typed C++ objects, not language built-ins
Module instantiation foo u_foo (.a(sig_a), .b(sig_b)); foo u_foo("u_foo"); u_foo.a(sig_a); u_foo.b(sig_b); SystemC requires explicit binding statements
Wire / signal logic [7:0] bus; sc_signal<sc_uint<8>> bus; sc_signal is buffered (current + next); logic is a pure value
always_comb / sensitivity always_comb begin y = a & b; end SC_METHOD(eval); sensitive << a << b; SystemC sensitivity is explicit; no inference
Module body Procedural code in always/initial Member function registered as SC_METHOD/SC_THREAD Behaviour and structure are both C++ here
parameter int W = 8; Module-level parameter template<int W> SC_MODULE(foo) { … }; or constructor argument Templates are the parameterization mechanism
Hierarchical reference top.foo.sig top.foo.sig works only at C++ scope; for runtime introspection use the kernel API SystemC has no cross-hierarchy reference syntax

The biggest cognitive shift: in SystemVerilog the simulator infers a sensitivity list from always_comb body reads; in SystemC you write it explicitly. The biggest pleasant shift: in SystemC, your "stimulus generator" is just a C++ class. Anything you can express in C++, you can put in a module — including STL containers, exceptions, lambdas, and templates.

Performance note

Ports have a small runtime cost: each port.read() dispatches through a virtual function on the bound channel. For combinational paths driven thousands of times per simulation second, this is generally fine. For very hot inner loops (e.g., per-cycle activity in a complex bus model), it is worth knowing that sc_signal<T>::read() does effectively no work beyond a member access on the channel object — so the bottleneck, if you have one, is rarely here. Almost every performance complaint about SystemC traces to something else (delta-cycle explosions, log spam, oversized TLM payloads). We will return to performance modeling explicitly in the Advanced section.

A primer on process kinds (forward link to Part 4)

Everything in this post has used SC_METHOD. It is not the only choice. SystemC offers three process kinds, each a different contract with the kernel, and each suited to a different style of modelling. You will use all three eventually; knowing what each one is before you need it will make the module examples in subsequent posts make sense.

SC_METHOD(fn) registers a function the kernel calls every time the static sensitivity list fires. The function must run to completion every invocation — no waiting, no yielding. This is the right choice for combinational logic and for small reactive callbacks. You write the body as straight-line C++ that produces a result and returns. No notion of "time passes inside this function" exists. The pass-through, the adder, the comparator in the hands-on exercise — all SC_METHOD.

SC_THREAD(fn) registers a function the kernel runs on its own user-space stack (a coroutine, in modern C++ terms — actually implemented with QuickThreads or pthreads underneath in the Accellera reference). A thread runs from entry, can call wait() to yield control back to the kernel, and resumes when its wait condition is met. Threads are the right choice for sequential behaviour where multiple distinct "phases" of execution must alternate with kernel time advances — stimulus generators, finite state machines whose state evolves over time, anything you would write in SystemVerilog as an initial or always block with @ event controls inside it.

SC_CTHREAD(fn, clk.pos()) is a specialised thread that is sensitive to a single clock edge. It is older, less general than SC_THREAD, and primarily used in high-level-synthesis (HLS) flows that historically expected clocked-thread style. Modern testbench and RTL-modelling code overwhelmingly prefers SC_THREAD with explicit wait(clk.posedge_event()) over SC_CTHREAD. You will see it in legacy code; you will rarely write it in new code. Part 4 walks through the choice in detail with worked examples of each.

Why mention them all here? Because the constructor pattern is identical:

SC_CTOR(my_module) {
    SC_METHOD(combinational_logic);
    sensitive << in_a << in_b;

    SC_THREAD(stimulus);
    // no sensitivity — thread drives its own wait() calls

    SC_CTHREAD(legacy_clocked_logic, clk.pos());
}

A module can register any number of any kind of process in its constructor; they all coexist, the kernel manages them all in the same scheduler. The only rule is "no wait() in SC_METHOD."

Stimulus from sc_main vs from an internal driver process

You saw both patterns in this post: the pass-through example drove stim_to_dut directly from sc_main using sc_start(t) between writes; the hierarchy example let the producer module drive its own output from an SC_METHOD. Both are valid, and the choice shapes how the simulation reads.

Driving from sc_main is the test scaffold pattern. The simulation runs in discrete chunks: write some stimulus, call sc_start(t) to let the kernel work for t units of simulation time, observe the result, write more stimulus, advance, observe, repeat. This is the simplest possible pattern and the right one for early "does it work at all" sanity checks. It does not scale to anything realistic.

Driving from an internal process is the embedded driver pattern. A dedicated driver module owns the stimulus, drives it according to an internal sequence (a list of values, a randomised sequence, a file-fed trace), and the testbench calls sc_start() once with no time argument — the kernel runs until the driver has done all its work and signals sc_stop(). This is closer to how real testbenches are organised and is what Part 6 builds in detail.

Use sc_main stimulus when you are exploring a new module and want to see what happens turn by turn. Use a driver process when you are building anything you will run repeatedly or want anyone else to read.

Advanced: Edge Cases & LRM Corners

The Beginner and Intermediate sections covered the well-trodden path. The LRM has corners that real users hit in real projects but tutorials usually skip. Five of them belong here.

Corner 1: Unbound ports — what actually happens

Per IEEE 1666-2011 §5.3, every sc_port instance has a binding policy: SC_ONE_OR_MORE_BOUND (the default for most port types), SC_ZERO_OR_MORE_BOUND, or SC_ALL_BOUND. The default for sc_in<T> and sc_out<T> is SC_ONE_OR_MORE_BOUND — at least one channel must be bound by the time elaboration completes (when sc_start is first called). If you forget to bind a port, the kernel raises an sc_report at the start of simulation:

Error: (E109) complete binding failed: port not bound: port 'u_dut.in' (sc_in)
In file: ../../../src/sysc/communication/sc_port.cpp:231

The error includes the full hierarchical name of the port — u_dut.in in the example above — and the type of the port. This is one of the most common errors when refactoring; if you split a child module out of a parent, forget the binding statement and the simulator will tell you exactly which port was orphaned. Treat E109 as a friend.

Corner 2: Multiple drivers on a single signal

sc_signal<T> is a single-writer channel by default. The default policy is SC_ONE_WRITER (see §6.4.4). If two different processes call .write() on the same sc_signal instance, the kernel raises an error of the form:

Error: sc_signal<T> cannot have more than one driver

…at elaboration end, if it can detect the multi-driver statically. (The exact SC_ID_* identifier depends on the Accellera release; the message text above is stable.) This is intentional and matches the hardware semantics of a wire: two drivers means a short circuit. If you genuinely need multi-driver semantics, two paths are available:

1. sc_signal_resolved — uses sc_logic values ('0', '1', 'Z', 'X') and applies a resolution table when more than one driver contributes. Two drivers contributing the same value (both '1', both '0') resolve to that value (this is how wired-AND, wired-OR, and bidirectional buses are modeled). Two drivers contributing different defined values resolve to 'X'. A driver contributing 'Z' and another contributing '0' or '1' resolves to the defined value (high-impedance + drive = drive). 2. sc_signal_rv<W> — resolved vector of width W, each bit treated independently.

Cross-reference: the pilot post (Part 3, Delta Cycles & Event-Driven Semantics) walks through sc_signal_resolved in its Advanced Corner 2 with a complete runnable example. Read that when you encounter the multi-driver case in earnest.

Corner 3: Reading a signal before any write

You will reach for this sooner than you expect — typically in a testbench when you want a "ready" signal to start false until your driver asserts it. The behaviour is well-defined: an sc_signal<T> constructed without explicit initialization holds the default-constructed value of T. For arithmetic types that is zero; for bool it is false; for sc_uint<W> it is zero; for a user-defined class type it is the result of T().

The corollary: do not write code that reads sc_signal<int> x; once at time zero and tests it against a sentinel value to detect "has anyone written this yet?" — there is no sentinel. If you need that semantics, use a separate sc_signal<bool> x_valid; companion signal, or use sc_event with notify() (see Part 5: Events & Notifications). The two-signal "value plus valid" idiom is the SystemC equivalent of std::optional<T> and is the right pattern when you really do need to distinguish "uninitialized" from "zero".

Corner 4: sc_export — when a child wants to publish its own channel as a port

A child module might internally own a channel that it wants its parent to expose to the outside as if it were a port. The mechanism is sc_export<IF>: declared in the child like a port, but bound to a channel inside the same module, not to a channel outside. The parent then sees the sc_export as if it were a port and binds it to an external channel.

This is the pattern you reach for when modeling, say, a FIFO that internally contains an sc_fifo<T> and wants to expose the FIFO's interface — nb_read(), nb_write(), data_written_event() — without the parent needing to know there is an sc_fifo inside. Per §5.4, sc_export participates in elaboration just like sc_port, with the same binding-policy semantics.

We will give sc_export its own treatment in the TLM section, where exports of tlm_initiator_socket and tlm_target_socket are the canonical pattern. For now, recognise the keyword if you see it; do not write your own sc_export until then.

Corner 5: Dynamic vs static module hierarchy

The hierarchy you build during elaboration is static in a particular sense: after sc_start first returns control to the kernel, you cannot add new sc_module instances, you cannot bind new ports, you cannot register new processes. The set of objects the kernel knows about is frozen.

You can, of course, allocate objects in C++ that the kernel does not know about. You can new an sc_module-derived class during elaboration as long as you do it before sc_start and as long as you arrange for its constructor to register processes and bind ports the normal way. Some patterns rely on this — for example, building a tree of identical sub-modules whose count is known only at runtime:

SC_MODULE(crossbar) {
    std::vector<std::unique_ptr<channel_block>> blocks;

    crossbar(sc_module_name n, int num_ports) : sc_module(n) {
        for (int i = 0; i < num_ports; ++i) {
            std::string name = "block_" + std::to_string(i);
            blocks.emplace_back(
                std::make_unique<channel_block>(name.c_str()));
        }
        SC_HAS_PROCESS(crossbar);
        // bind ports of each block as needed
    }
};

Two notes. First, the children are owned by std::unique_ptr because sc_module is not copyable; you cannot put them directly in a std::vector<channel_block>. Second, the kernel sees each channel_block as an ordinary sc_module — it does not know or care that they live in a vector.

What you cannot do is build a channel_block after sc_start. The kernel's runtime data structures (the process-runnable set, the sensitivity maps) are not designed to grow at simulation time. If you genuinely need a runtime-variable structure (a TLM bus that adds peripherals on the fly), that is what TLM-2.0 generic payloads and dynamic socket binding are for — and that is Part 18 territory.

Corner 6: The port template system underneath sc_in<T>

sc_in<bool> looks like a single, monolithic type — but it is a typedef for a specialisation of a more general construct. Per IEEE 1666-2011 §5.3.1:

template <class IF, int N = 1, sc_port_policy P = SC_ONE_OR_MORE_BOUND>
class sc_port;

IF is the interface class the port expects to be bound to. N is the maximum number of channels that can be bound (defaults to 1; setting it greater than 1 makes a port array, which we will see in a moment). P is the binding policy. The familiar sc_in<T>, sc_out<T>, and sc_inout<T> are derived classes (not type aliases) layered on top of sc_port<sc_signal_in_if<T>> and sc_port<sc_signal_inout_if<T>>, adding convenience methods like value_changed_event() and default_event(). The structural shape is:

// Conceptual shape — actual Accellera headers use derived classes,
// not the `using` form, but the type relationships are:
//   sc_in<T>    ≈ sc_port<sc_signal_in_if<T>>      + helpers
//   sc_out<T>   ≈ sc_port<sc_signal_inout_if<T>>   + helpers
//   sc_inout<T> ≈ sc_port<sc_signal_inout_if<T>>   + helpers

Two consequences. First, when you bind pt.in(stim_to_dut), the kernel is checking that sc_signal<bool> — the dynamic type of stim_to_dut — implements sc_signal_in_if<bool>. It does (and sc_signal_inout_if<bool> too, hence why sc_signal accepts both inputs and outputs from the same channel). If you tried to bind an sc_in<bool> to an sc_fifo<bool>, the bind would fail at compile time because sc_fifo<bool> implements sc_fifo_in_if<bool>, not sc_signal_in_if<bool>. The type system is doing real work for you here.

Second, you can build your own port-channel pairs by defining your own interface class:

template <typename T>
struct my_interface : virtual sc_interface {
    virtual T peek() const = 0;
    virtual void poke(T v) = 0;
};

template <typename T>
using my_in  = sc_port<my_interface<T>>;
template <typename T>
using my_out = sc_port<my_interface<T>>;

template <typename T>
struct my_channel : sc_module, my_interface<T> {
    T held;
    T peek() const override { return held; }
    void poke(T v) override { held = v; }
    SC_CTOR(my_channel) : held(T()) {}
};

This is exactly how TLM sockets are defined: a tlm_initiator_socket<W> is an sc_port<tlm_fw_transport_if<...>> paired with an sc_export<tlm_bw_transport_if<...>>. You will rarely write your own interface; recognise the pattern when you read TLM.

A port array — sc_in<bool> ins[8]; or sc_port<sc_signal_in_if<bool>, 8> ins; — is a single port that holds up to 8 bound channels. Access individual sub-bindings with ins[i]. This is the SystemC equivalent of a vector port in SystemVerilog and is the right tool when you have, say, an 8-port arbiter where each port should be bound to its own external signal independently:

SC_MODULE(arbiter8) {
    sc_in<bool> req[8];
    sc_out<sc_uint<3>> grant;

    void evaluate() {
        for (int i = 0; i < 8; ++i) {
            if (req[i].read()) {
                grant.write(i);
                return;
            }
        }
        grant.write(0);
    }

    SC_CTOR(arbiter8) {
        SC_METHOD(evaluate);
        for (int i = 0; i < 8; ++i) sensitive << req[i];
    }
};

The sensitive accumulator works on each req[i] individually because each is a separate sc_in<bool> instance (this is the C-array form). The sc_port<IF, N> form bundles them into a single port object — semantically equivalent for binding, slightly different for sensitivity (you write sensitive << ins; and the kernel iterates internally). Both forms are valid; the C-array form is more common in tutorial material.

Corner 7: Hierarchical names, the runtime introspection API, and why your error message points where it does

Every sc_module instance owns a name string — passed in by the constructor's sc_module_name argument and used by the kernel to construct a hierarchical path. When you write top t("t"); and inside top's constructor you have producer prod; initialized via : prod("prod"), the kernel records the producer's name as t.prod. Inside producer if there is a member sc_signal<int> internal; (declared in top but for the sake of illustration imagine it was inside producer), its hierarchical name becomes t.prod.internal. This is what shows up in error messages, in trace files, and in any introspection you do at runtime.

The kernel exposes this introspection through sc_get_top_level_objects() and the sc_object class hierarchy. Every module, port, signal, and event is an sc_object with a name() method that returns the leaf name and a kind() method that returns the type ("sc_module", "sc_in", "sc_signal", "sc_event"). You can walk the entire structural hierarchy from sc_main (or, more usefully, from a dont_initialize-protected diagnostic process) and print it:

// file: hierarchy_dump.cpp (sketch — wire into sc_main after construction)
#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);
    }
}

int sc_main(int, char**) {
    top t("t");               // build the hierarchy from the example above
    sc_start(SC_ZERO_TIME);   // enter and immediately exit
                              // simulation — hierarchy is now frozen
    dump(sc_get_top_level_objects());
    return 0;
}

A typical output looks like:

sc_module t
  sc_module t.prod
    sc_out t.prod.out
    sc_method_process t.prod.drive
  sc_module t.cons
    sc_in t.cons.in
    sc_method_process t.cons.observe
  sc_signal t.internal

Three reasons this matters in practice. First, when you read an error like (E109) port not bound: port 'top.dram.addr', the top.dram.addr is the hierarchical name — you can navigate to exactly that port in your source by following the path. Second, when you write VCD or transaction recording, the trace file uses these names as the signal identifiers; the names you give to your modules and signals become the labels in your waveform viewer. Third, when you eventually integrate with a TLM virtual platform that needs to address peripherals by name, the hierarchical name is the address.

Three small naming rules pay off: keep module names short (dram, cpu, arbiter); match the variable name and the construction-time name (producer prod → prod("prod") not prod("foo")); never use . inside a module name (it confuses the path parser). The third one matters because the kernel constructs paths by concatenating with . as the separator; embedding a . in your name will produce a path the introspection API cannot navigate correctly.

A subtler corner: the sc_module_name parameter is not the same as the module's C++ variable name. You can write:

producer first_one("alice");
producer second_one("bob");

The variables in your C++ scope are first_one and second_one; the names the kernel sees are alice and bob. Hierarchical paths will use alice and bob. This is a feature — it lets you build arrays of structurally identical modules with distinct hierarchical identities — but it is also a foot-gun: if the variable name and the kernel name diverge, debugging gets harder because you cannot grep for the name the kernel reports and expect to find a variable declaration. Discipline: keep them the same unless you have a specific reason not to.

Hands-on exercise

Build a 2-bit comparator module from scratch. The interface:

  • sc_in<sc_uint<2>> a, b;
  • sc_out<bool> eq, gt, lt;

The semantics: eq is true when a == b; gt is true when a > b; lt is true when a < b. Exactly one of the three outputs is true at any time (the three are mutually exclusive and exhaustive over unsigned comparison).

Write sc_main to instantiate the comparator, declare five signals (sa, sb, seq, sgt, slt), bind them, and exercise the four combinations of inputs that demonstrate eq once, gt once, lt once, and the eq case at the boundary (try a = b = 3).

Before you run it, predict the output values for each combination. Then run it and check. If your prediction is wrong, do not change the prediction — change the code. Then ask yourself what about your mental model of SC_METHOD semantics led you to predict wrong.

Hint: you only need one SC_METHOD with a sensitivity list of two inputs. The body is straight C++; no SystemC-specific math required.

Common mistakes

  • Forgetting .read() and .write(). Writing out = in; instead of out.write(in.read()); is a compile error. The error message ("no operator= for sc_in<bool>") is not always obvious; the fix always is.
  • Putting SC_METHOD outside SC_CTOR / SC_HAS_PROCESS-covered constructor body. The SC_METHOD macro expands to code that references private members of sc_module; it only compiles inside the constructor. Putting it elsewhere produces an error about a missing helper class — confusing if you do not know the macro internals.
  • *Binding an sc_in<T> and an sc_out<T> to two different sc_signal<T> instances expecting them to be connected. They are not. Ports communicate only through the channel they are bound to. The two signals are independent; the output value written into one will never appear on the other. Bind both ports to the same* signal when you want them connected.
  • Declaring sc_in<int> in one module and sc_in<sc_uint<32>> in another and trying to bind them to the same signal. The signal's value type must match. sc_signal<int> and sc_signal<sc_uint<32>> are different types; ports declared for one cannot bind to the other. The kernel reports incompatible interface at elaboration.
  • Adding ports or registering processes after sc_start. Compiles fine, then the kernel reports (E110) insert process failed: insert outside initialization (or similar) and aborts. The structural hierarchy must be complete before sc_start is called.
  • Calling wait() inside an SC_METHOD. Method processes must run to completion; calling wait() produces a runtime error. If you need wait(), you need SC_THREAD. Part 4 covers the choice.
  • Mismatching the module's C++ variable name and its sc_module_name constructor argument. producer prod_a("alice"); is legal — the kernel sees alice, your code references prod_a. Error messages and trace files will show alice; grepping your source for alice will find nothing useful. The runtime cost is zero; the maintenance cost is the next engineer wondering where alice is defined. Discipline: keep the variable name and the construction string identical unless you have an explicit reason — an array of structurally identical modules with role-distinguishing names, for example — to diverge.
  • Initialising sc_signal<T> with a constructor argument expecting the kernel to drive that value at time zero. sc_signal<int> s(SC_NS, 5); does not work; sc_signal does not take an initial-value constructor argument. The default value at time zero is always the default-constructed value of T. To seed a specific value, write s.write(5) in sc_main before sc_start — and remember from the pilot post (Part 3) that this seed is a pending write committed in the initialization update phase, not an immediate set.

Recap

After this post, you can:

  • Write an SC_MODULE with input and output ports, and explain what each piece of the macro expansion is doing in plain C++ terms.
  • Declare an sc_signal<T> and bind it to one writer and one or more readers.
  • Choose between SC_CTOR and SC_HAS_PROCESS based on whether your constructor takes parameters beyond the module name.
  • Build a parent module that owns child modules and wires them through internal signals.
  • Read a translation table from SystemVerilog to SystemC and identify the cognitive shifts each row represents.
  • Predict, before running a small SystemC program, what its output will be — including the initialization run of every method process and the update-phase delay between write() and a subsequent read().
  • Recognise common elaboration-time errors (E109, E110, E115) and trace them to the structural mistakes that cause them.

Further reading

  • IEEE 1666-2011, §5 (modules), §5.3 (ports), §5.4 (sc_export, brief), §6.4 (sc_signal), §6.6 (resolved signals). The LRM is the ground truth for everything in this post. Spend an hour reading §5 end-to-end at least once.
  • Accellera SystemC User's Guide — the chapters on modules and channels. The User's Guide is more readable than the LRM and is the right starting point if a particular section of the LRM is opaque.
  • Accellera SystemC PoC source: src/sysc/kernel/sc_module.h and src/sysc/communication/sc_signal.h. Reading the macro expansions is the fastest way to demystify SC_MODULE and SC_CTOR. The kernel source is well-commented.
  • Doulos SystemC Golden Reference Guide — the modules and ports chapters. Available as a free PDF from Doulos.
  • Black, Donovan, and Tahar, SystemC: From the Ground Up (2nd ed.) — the early chapters on modules and process registration are the most accessible book-length treatment.
  • Bhasker, A SystemC Primer — the modules chapter has an idiosyncratic but clear take on "what is a port really" if you want a second voice.

Next in this section

→ Part 2: Simulation Time & Clocks — how sc_time works, what sc_start(t) actually does, and how to model clocked behaviour cleanly before we introduce delta-cycle subtleties in Part 3.

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