1. SystemC Tutorial - Modules, Ports & Signals

Introduction

Before the first gate switches on a new chip, engineers at ARM, Intel, and SiFive are already running software on it. Not on silicon — on a SystemC virtual platform. ARM built the virtual platforms for the Cortex-A series using SystemC, allowing firmware teams to boot Linux and validate drivers months before tape-out. Intel's pre-silicon validation flow for modern SoCs relies on Transaction-Level Modeling (TLM) in SystemC to catch architectural bugs that RTL simulation would find only much later — and at far greater cost. Western Digital's SweRV RISC-V core and SiFive's FU540 were both validated using SystemC models before a single gate was committed to silicon.

SystemC is how the industry thinks in hardware, but works in software.

This series will build a complete RV32I RISC-V CPU from scratch, one component at a time across 34 posts. By the end you will have a working processor model, a UVM-SystemC verification environment, and the skills to read and write the kind of SystemC you will encounter in a real pre-silicon validation team. This is not a toy project — RISC-V is running in billions of devices right now: T-HEAD's XuanTie C906 cores power Android tablets and set-top boxes, RISC-V MCUs from GigaDevice and WCH are displacing ARM Cortex-M0 in embedded products, and major hyperscalers are evaluating RISC-V for custom silicon.

This first post builds the skeleton. Every module in our CPU — the ALU, the Register File, the Instruction Decoder, the Load/Store Unit — will be structured exactly like the pass_through module we write here. Get this pattern locked in and the rest of the series flows naturally.


Prerequisites

Before diving in, you will need:

If you can run g++ --version and cmake --version without errors, and SYSTEMC_HOME points to your SystemC install, you are ready to go.


Concept Explanation

The Translation Table

If you are coming from C++ or SystemVerilog, you already know most of this — the concepts just have different names:

Concept If you know C++ If you know SystemVerilog
SC_MODULE class with simulation awareness module
sc_in<T> read-only member for external data input port
sc_out<T> write-only member for external data output port
sc_inout<T> bidirectional member inout port
sc_signal<T> thread-safe shared variable wire / logic
SC_CTOR constructor with simulator registration initial/always setup block
sc_start() "run the simulation engine" start of simulation time
sensitive << port "re-run this method when port changes" always @(port)

The big mental shift for C++ engineers: you are not writing a program that executes top-to-bottom. You are describing concurrent hardware processes that respond to signal changes. The simulator controls execution order, not you.

The big mental shift for SystemVerilog engineers: ports are typed C++ objects with member functions (read(), write()), not bare wires. The type parameter <T> can be bool, int, sc_uint<8>, sc_bv<32>, or any user-defined type.


SC_MODULE — What the Macro Actually Does

SC_MODULE is a preprocessor macro. Writing:

SC_MODULE(pass_through) {
  // ...
};

...expands to approximately:

struct pass_through : public sc_module {
  typedef pass_through SC_CURRENT_USER_MODULE;
  pass_through(::sc_core::sc_module_name)
  // ...
};

Two things matter here:

  1. It inherits from sc_module, which registers the module with the simulation kernel. The kernel knows this object exists, can schedule its processes, and will include it in hierarchy dumps.
  2. The name string (you pass "dut" at instantiation time) becomes the hierarchical name in simulation output — exactly like the instance name in SystemVerilog. If something goes wrong, dut.in_data in an error message tells you exactly which instance is misbehaving.

You will see SC_MODULE used consistently throughout this series. In later posts we will also write modules as plain classes inheriting from sc_module directly — useful when templates or multiple inheritance are involved — but SC_MODULE is the idiomatic form for straightforward hardware blocks.


Port Types: sc_in, sc_out, sc_inout

Ports in SystemC are typed objects, not raw connections:

sc_in<sc_uint<8>>   in_data;   // 8-bit input
sc_out<sc_uint<8>>  out_data;  // 8-bit output
sc_inout<sc_uint<8>> bidir;    // bidirectional (rare in RTL models)

sc_uint<8> is a SystemC unsigned integer with exactly 8 bits — part-select, bit-select, and arithmetic all behave like hardware. For our RISC-V CPU we will use:
- sc_uint<32> for data buses (the RV32I data width)
- sc_uint<32> for instruction words
- sc_bv<32> when we care about individual bits and do not need arithmetic
- bool for control signals (reset, enable, valid)

Ports are not values — they are interfaces. You read a value through them with .read() and write through them with .write(). This distinction matters when we get to pipelined stages in Parts 18–22, where a module reads one port and writes another in a way that is sensitive to pipeline stage boundaries.


sc_signal — The Wire Between Modules

sc_signal<T> is the SystemC equivalent of a net. In the testbench (or in a top-level wrapper) you declare signals and use them to bind ports together:

sc_signal<sc_uint<8>> sig_in, sig_out;
dut.in_data(sig_in);    // bind port to signal
dut.out_data(sig_out);  // bind port to signal

One important behavioral detail: signal writes are not immediately visible. When you call sig_in.write(42), the new value is queued. It becomes visible to all readers only after the simulation kernel processes the delta cycle — a zero-time scheduling step that propagates values and re-runs sensitive processes. This mirrors how combinational logic works in hardware: the output of a gate does not instantly change the input of the next gate in the same simulation timestep.

For our RISC-V CPU, signals will connect the outputs of the Register File to the inputs of the ALU, the ALU output to the writeback logic, and the PC register output to the instruction memory address port. Getting comfortable with the signal/port binding pattern now pays off every time we wire up a new stage.


SC_METHOD and the Sensitivity List

SC_METHOD registers a C++ member function as a combinational process:

SC_CTOR(pass_through) {
  SC_METHOD(pass);       // register pass() as a process
  sensitive << in_data;  // fire when in_data changes
}

This is the SystemC equivalent of always @(in_data) in Verilog. The method runs once at t=0 (initialization) and then re-runs every time any signal in its sensitivity list changes.

Rules for SC_METHOD:
- No wait() calls allowed. If you need to wait for a clock edge, you want SC_THREAD or SC_CTHREAD (covered in Post 3).
- Must complete quickly. The kernel cannot proceed to the next timestep while a METHOD is running.
- Can read any signal, but should only write to signals it owns.

For the RISC-V ALU (Post 5), the execute method will be an SC_METHOD sensitive to the operands and operation code — pure combinational logic, no clock needed.


The Module in Context

Here is where pass_through sits in our overall CPU build and how data flows through it:

graph LR
    SIG_IN["sig_in\nsc_signal<sc_uint<8>>"]
    PT["pass_through\nSC_MODULE\nSC_METHOD: pass()"]
    SIG_OUT["sig_out\nsc_signal<sc_uint<8>>"]

    SIG_IN -->|"in_data (sc_in)"| PT
    PT -->|"out_data (sc_out)"| SIG_OUT

    style PT fill:#06b6d4,color:#fff
    style SIG_IN fill:#1e293b,color:#94a3b8
    style SIG_OUT fill:#1e293b,color:#94a3b8

The signal on the left is driven by the testbench stimulus. The module reads it through in_data, passes it through, and writes to out_data. The signal on the right is read back by the testbench to check the output. This exact topology — testbench drives signals, module reads and writes through ports — is the pattern we will use for every component in the CPU.


Implementation

Below is the complete, compilable implementation for this post. Every CPU component in this series will follow this same structural pattern.

// post01_pass_through.cpp
// SystemC Tutorial Series — Post 1: Modules, Ports & Signals
// Builds the structural skeleton for every RISC-V CPU component in this series.
//
// Compile:
//   g++ -std=c++17 -I$SYSTEMC_HOME/include -L$SYSTEMC_HOME/lib-linux64 \
//       -lsystemc post01_pass_through.cpp -o post01
//
// Or use the provided CMakeLists.txt (see Build & Run section).

#include <systemc.h>

// ---------------------------------------------------------------------------
// pass_through: our first SC_MODULE — the skeleton every CPU component uses.
//
// In our RISC-V CPU, every unit (ALU, Register File, Decoder, Load/Store Unit)
// will follow this same pattern:
//   1. Declare input and output ports with typed sc_in / sc_out
//   2. Write a process function (SC_METHOD for combinational, SC_THREAD for clocked)
//   3. Register the process in SC_CTOR with its sensitivity list
// ---------------------------------------------------------------------------
SC_MODULE(pass_through) {

  // Ports — typed and simulation-aware
  sc_in<sc_uint<8>>  in_data;   // 8-bit input  — read with in_data.read()
  sc_out<sc_uint<8>> out_data;  // 8-bit output — write with out_data.write()

  // The combinational process: re-runs whenever in_data changes.
  // No clock needed — this is pure combinational logic.
  // In the RISC-V ALU (Post 5), the execute() process will work the same way.
  void pass() {
    out_data.write(in_data.read());
  }

  // Constructor: register processes and their sensitivity lists.
  // SC_CTOR macro expands to:
  //   pass_through(sc_module_name name) : sc_module(name)
  // Everything inside the braces runs once at construction time,
  // before simulation starts.
  SC_CTOR(pass_through) {
    SC_METHOD(pass);       // register pass() as a METHOD process
    sensitive << in_data;  // re-run when in_data changes (like always @(in_data))
  }
};

// ---------------------------------------------------------------------------
// sc_main — the SystemC entry point, replaces main().
// The simulator calls this after initialising the kernel.
// Everything declared here is part of the "elaboration" phase:
// modules are instantiated, ports are bound, before sc_start() is called.
// ---------------------------------------------------------------------------
int sc_main(int argc, char* argv[]) {

  // Signals are the wires connecting modules together.
  // They live outside any module — in the testbench or top-level wrapper.
  sc_signal<sc_uint<8>> sig_in;   // drives the DUT input
  sc_signal<sc_uint<8>> sig_out;  // captures the DUT output

  // Instantiate the design under test.
  // The string "dut" is the hierarchical instance name — appears in logs.
  pass_through dut("dut");

  // Port binding — connect ports to signals.
  // This is equivalent to: assign sig_in = dut.in_data; (conceptually)
  // The simulator will error if any port is left unbound when sc_start() runs.
  dut.in_data(sig_in);
  dut.out_data(sig_out);

  // --------------------------------------------------------------------------
  // Stimulus and observation
  // --------------------------------------------------------------------------

  // Apply first stimulus value
  sig_in.write(42);
  sc_start(10, SC_NS);  // advance simulation 10 nanoseconds
  std::cout << "t=" << sc_time_stamp()
            << "  in=" << sig_in.read()
            << "  out=" << sig_out.read()
            << std::endl;

  // Apply second stimulus value — 0xAB = 171 decimal
  sig_in.write(0xAB);
  sc_start(10, SC_NS);
  std::cout << "t=" << sc_time_stamp()
            << "  in=" << sig_in.read()
            << "  out=" << sig_out.read()
            << std::endl;

  return 0;
}

A CMakeLists.txt for this post is provided in the GitHub repo. It locates your SystemC install via SYSTEMC_HOME and wires up the include paths and library links correctly across Linux, macOS, and Windows.


Build & Run

# Clone the repo and enter the post01 directory
git clone https://github.com/vlsidesignverification/risc-v-systemc.git
cd risc-v-systemc/section1-foundations/post01-modules-ports-signals

# Configure and build
mkdir build && cd build
cmake .. -DSYSTEMC_HOME=/path/to/your/systemc-install
make

# Run the simulation
./post01

Expected output:

        SystemC 2.3.x --- <date and time>
        Copyright (c) 1996-2017 by all Contributors,
        ALL RIGHTS RESERVED

t=10 ns  in=42  out=42
t=20 ns  in=171  out=171

If you see the two output lines with matching in and out values, the module is working. The SystemC 2.3.x banner is printed by the kernel at startup — you cannot suppress it in 2.3.x (nor should you want to; it confirms the version).

Troubleshooting:

  • error: 'sc_uint' was not declared — your SYSTEMC_HOME is wrong or the include path is not being passed to the compiler. Check cmake -DSYSTEMC_HOME=... and confirm $SYSTEMC_HOME/include/systemc.h exists.
  • error: unbound port at runtime — you forgot a port binding line. Every sc_in and sc_out must be bound to a signal before sc_start().
  • The output shows out=0 for both lines — the sensitivity list is missing (see DV Insight below).

Verification

There is no formal testbench in this post — and that is intentional.

We are learning the language first. Printing stimulus and response values to stdout is enough to confirm the module works as expected at this stage. Starting in Post 5, when we build the ALU, we will introduce a proper SystemC testbench with a separate SC_MODULE driving stimulus, golden reference checking, and pass/fail reporting. By Post 8 we will have a complete self-checking testbench infrastructure that all subsequent modules reuse.

Verification methodology comes after language fluency. Do not skip ahead — the testbench patterns in Post 5 will make much more sense once you have used SC_METHOD, SC_THREAD, and clocked processes in Posts 2–4.


DV Insight

The most common beginner mistake: forgetting the sensitivity list.

Try removing the sensitive << in_data; line and rebuilding. The output will be:

t=10 ns in=42 out=0 t=20 ns in=171 out=0

Without a sensitivity list, SC_METHOD fires exactly once — at t=0 during initialization — and never again. The module appears dead: input changes, output stays at its initial value (zero). This bug is silent. No compiler warning, no runtime error, just wrong output.

In a complex design this can waste hours. Whenever a module output is not tracking its input, check the sensitivity list first. This is the SystemC equivalent of forgetting @(*) in a Verilog always block.


Why port binding matters — and what happens when you skip it.

The line dut.in_data(sig_in) wires the port to the signal. If you omit it, SystemC will not silently proceed — the simulator will abort at sc_start() with a message like:

Error: (E109) complete binding failed: port not bound: port 'dut.in_data' (sc_in)

This is actually helpful behavior. In a large design with many ports, unbound port detection catches wiring mistakes before simulation starts. When you get this error, the message tells you exactly which port on which instance is not connected — much friendlier than hunting for a floating wire in a waveform viewer.

Port binding syntax: instance.port(signal) — the port name is a member of the instance, and you call it like a function passing the signal as the argument. This is sc_port's operator() overload. In the GitHub repo's CMakeLists.txt there is a helper macro to bind ports in bulk for larger modules; we will use it starting in Post 5.


Integration

The pass_through module is deliberately trivial — an 8-bit wire with a name. Its value is structural, not functional. Here is what the same pattern looks like scaled up to a real CPU component (preview for Post 5):

SC_MODULE(alu_32) {
  sc_in<sc_uint<32>>  operand_a;
  sc_in<sc_uint<32>>  operand_b;
  sc_in<sc_uint<4>>   alu_op;
  sc_out<sc_uint<32>> result;
  sc_out<bool>        zero_flag;

  void execute() {
    // ... ALU logic ...
  }

  SC_CTOR(alu_32) {
    SC_METHOD(execute);
    sensitive << operand_a << operand_b << alu_op;
  }
};

Same structure. More ports. Richer process body. The SC_METHOD / sensitive << / port binding pattern is identical.

Where we are in the full CPU build:

[Part 1: Skeleton ✓] → [Part 5: ALU] → [Parts 7-12: Single-Cycle CPU] → [Parts 18-22: Pipeline] → [Parts 23-34: UVM-SystemC VIP]

Every post from here to Part 34 builds on this post. When something breaks in Post 18 (pipeline hazard logic), the first thing you will reach for is this module skeleton to isolate the failing stage — a clean, minimal SC_MODULE with the minimum ports needed to reproduce the problem.

The pass_through module also appears directly in the RISC-V CPU as a placeholder during the incremental build process. In Posts 7–9 (single-cycle CPU integration), we will stub out unimplemented stages with pass_through variants while wiring the datapath, so the simulation compiles and runs even before every unit is complete. This is a standard technique in pre-silicon model development at companies like ARM and SiFive — build the interconnect first, fill in the logic unit by unit.


What's Next

Post 2: Simulation Time & Clocks

The pass_through module has no clock — it is purely combinational. Real CPU components are clocked: registers latch on a rising edge, pipelines advance one stage per cycle, and memory reads take multiple cycles.

In Post 2 we will:
- Learn how SystemC models hardware time with sc_time and sc_clock
- Build the clock generator module that all our sequential CPU components will use
- Introduce SC_THREAD — the process type that can call wait(), letting us write clocked behavior in a natural way
- Build a D flip-flop in SystemC and verify it holds its value across clock edges

After Post 2, our modules will have a heartbeat. After Post 3 (resets and initialization), they will be safe to wire together into multi-module hierarchies. The single-cycle CPU starts taking shape in Post 7.

Code for Post 2: GitHub — section1/post02

Author
Mayur Kubavat
VLSI Design and Verification Engineer sharing knowledge about SystemVerilog, UVM, and hardware verification methodologies.

Comments (0)

Leave a Comment