You already know that a SystemC channel — like sc_signal or a custom channel — implements one or more sc_interface derivatives. But how does a module reach a channel it does not own? And how does a module advertise that it contains a channel to the outside world? That is exactly what sc_port and sc_export answer.

These two constructs split every connection into a requirer side and a provider side. Once you see that division clearly, the binding rules, the hierarchy constraints, and even the elaboration error messages become obvious. This article covers both thoroughly — declaration, binding, multi-binding, hierarchical chains, and the elaboration rules SystemC enforces before sc_start().

1. Two Sides of Every Connection

Every connection between a module and a channel has a direction: someone needs the service, and someone provides it.

The hardware analogy maps cleanly: when board A plugs into board B's socket, board A has the jack (sc_port) and board B has the socket (sc_export). The trace inside board B connecting the socket to the actual component is the internal channel binding.

Direction of visibility. An sc_port is visible to the module's processes — they call through it. An sc_export is visible to the outside — other modules bind to it. Neither is the channel itself; both are typed references to whichever channel gets bound during elaboration.

2. sc_port<IF> — Requiring a Service

Declare an sc_port as a member of SC_MODULE, templated on the interface you need. The module's processes then call through it using the -> operator, which dereferences to the bound interface pointer.

// Interface definition (see sc-interface-in-systemc.html)
struct mem_if : virtual sc_interface {
    virtual uint32_t read (uint32_t addr) = 0;
    virtual void     write(uint32_t addr, uint32_t data) = 0;
};

SC_MODULE(Master) {
    sc_port<mem_if> mem_port;   // requires exactly one binding (default)

    SC_CTOR(Master) { SC_THREAD(run); }

    void run() {
        mem_port->write(0x1000, 0xDEAD);      // -> dereferences to bound interface
        uint32_t val = mem_port->read(0x1000);
        wait(10, SC_NS);
    }
};

The full template signature is sc_port<IF, N, POLICY>. The second parameter, N, controls how many channels can be bound to a single port.

// Exactly 1 binding (default) — sc_port<IF, 1>
sc_port<mem_if>        mem_port;

// Exactly 4 bindings — e.g. four memory banks
sc_port<mem_if, 4>     bank_port;

// Unbounded — any number of bindings allowed
sc_port<mem_if, 0>     multi_port;

When N > 1 or N == 0, each bound channel is accessed by index:

// Access individual bindings by index
uint32_t v0 = bank_port[0]->read(0x0000);   // bank 0
uint32_t v1 = bank_port[1]->read(0x0000);   // bank 1

// Single-binding port: operator-> and [0]-> are equivalent
mem_port->read(0);      // same as mem_port[0]->read(0)
Index access is not bounds-checked at compile time. Accessing port[N] where N >= actual_bindings is undefined behavior. At elaboration time SystemC validates the total binding count, but individual index accesses in process code are your responsibility.

3. sc_export<IF> — Providing a Service

Declare an sc_export as a member of SC_MODULE, templated on the interface it exposes. Inside the module's constructor, bind it to the internal channel that implements that interface. External modules then bind their sc_port directly to this export — they never see the internal channel type.

SC_MODULE(Ram) {
    sc_export<mem_if> mem_export;   // what the outside world sees

    SC_CTOR(Ram) {
        mem_export(m_storage);       // bind export to internal implementation
    }

private:
    RamStorage m_storage;            // implements mem_if, lives inside Ram
};

From the outside, ram.mem_export is the public interface — it is what gets wired in sc_main or by the parent module. The RamStorage type is an implementation detail; callers only need to know about mem_if.

sc_export is the module's public socket. Think of it as a header file for connectivity: it declares what service the module offers without exposing how that service is implemented internally. This is the IMC pattern applied at the structural level — interface, method, channel.

4. Flat Binding — sc_port to Channel

The most common case: both the module with the sc_port and the channel live at the same level of hierarchy, typically in sc_main. Binding uses the function-call syntax: module.port(channel).

int sc_main(int, char**) {
    RamChannel  ram("ram");        // channel — implements mem_if
    Master      master("master");  // has sc_port<mem_if> mem_port

    master.mem_port(ram);           // flat binding: port → channel directly

    sc_start(100, SC_NS);
    return 0;
}

SystemC resolves this binding during the elaboration phase, before sc_start() is called. At that point it walks the module hierarchy, verifies that every port has the required number of bindings, and builds the internal interface pointer table. After elaboration, the binding is fixed — you cannot rebind ports at runtime.

// Multiple sc_port bindings at the same level
sc_port<mem_if, 2> dual_port;   // requires exactly 2 bindings

RamChannel bank0("bank0");
RamChannel bank1("bank1");

master.dual_port(bank0);          // first binding  — index 0
master.dual_port(bank1);          // second binding — index 1

5. Hierarchical Binding — sc_port to sc_export

When the channel lives inside a sub-module, that sub-module wraps it with an sc_export. The parent or sc_main then binds the requirer's sc_port to the sub-module's sc_export. SystemC chains the binding internally so that calls through the port eventually reach the channel.

// MemorySubsystem hides its internal channel behind an export
SC_MODULE(MemorySubsystem) {
    sc_export<mem_if> mem_export;   // public socket

    SC_CTOR(MemorySubsystem) {
        mem_export(m_ram);           // wire export to internal channel
    }
private:
    RamChannel m_ram;                // hidden from the outside
};

int sc_main(int, char**) {
    MemorySubsystem mem_sys("mem_sys");
    Master          master("master");

    // Hierarchical binding: sc_port → sc_export → internal channel
    master.mem_port(mem_sys.mem_export);

    sc_start(100, SC_NS);
    return 0;
}

The full chain at runtime is: master.mem_portmem_sys.mem_exportmem_sys.m_ram. From the master's perspective, mem_port->read(addr) calls m_ram.read(addr) with zero overhead after elaboration — the interface pointer is resolved once and stored directly.

The master never sees RamChannel. This is the entire point of sc_export: encapsulation. You can swap RamChannel for a different implementation — a model with latency, a TLM socket, a stub — without changing any code in Master or sc_main. Only MemorySubsystem's constructor changes.

6. sc_export to sc_port — Connecting Inside a Hierarchy

Hierarchical binding works in both directions. A parent module can also forward connections between its own ports/exports and those of its child modules. There are two patterns:

Pattern A — forwarding an inner sc_port outward as an sc_export. The parent module declares an sc_export and binds it to a child's sc_port — effectively promoting the child's requirement to the module boundary so the grandparent can satisfy it.

Pattern B — forwarding an outer sc_port inward to a child sc_port. The parent module has an sc_port (a requirement from its own parent) and wires it directly to a child module's sc_port. This propagates a requirement downward through the hierarchy.

// Pattern B — parent sc_port forwarded to child sc_port
SC_MODULE(Cluster) {
    sc_port<mem_if> mem_port;    // cluster's own requirement
    Core core0, core1;

    SC_CTOR(Cluster) : core0("core0"), core1("core1") {
        // Forward cluster's port to both cores' ports
        core0.mem_port(mem_port);   // sc_port → sc_port (downward)
        core1.mem_port(mem_port);
    }
};

The binding direction rules are:

Never bind sc_export to sc_export at the same level. sc_export can only delegate to a child's sc_export (going deeper into the hierarchy) or bind directly to a channel. Binding two sibling sc_exports together is a structural error — only one of them can be the actual provider.

7. Elaboration Binding Rules

SystemC's elaboration phase runs after all module constructors complete but before sc_start() hands control to the scheduler. During elaboration, the kernel calls end_of_elaboration() on every module and then verifies that every port is correctly bound.

The policy is controlled by the third template parameter of sc_port:

// SC_ONE_OR_MORE_BOUND — default
// Requires exactly N bindings (where N is the second parameter).
// Unbound port triggers sc_report_error before sc_start().
sc_port<mem_if, 1, SC_ONE_OR_MORE_BOUND>  mem_port;   // default

// SC_ZERO_OR_MORE_BOUND — optional port
// Binding check is skipped entirely. Access without binding is undefined.
sc_port<mem_if, 1, SC_ZERO_OR_MORE_BOUND> opt_port;

// SC_ALL_BOUND — all N slots must be filled
// The port accepts exactly the N bindings declared and no more.
sc_port<mem_if, 4, SC_ALL_BOUND>          bank_port;

In practice, SC_ZERO_OR_MORE_BOUND is used for optional connections — for example, a debug tap port that is only wired in certain configurations. Always guard access to such a port with a null check:

// Safe access for an optional port
if (debug_port.size() > 0) {
    debug_port->dump(addr);
}

An sc_export that is declared but never bound to a channel inside its module also triggers an elaboration error. The kernel cannot call through an export that has no backing implementation.


8. Complete Example — Memory Subsystem with Hidden Internals

This example shows the full picture: a MemorySubsystem module containing separate read-only and read-write channels, wrapped behind exports, with a Master that uses both without knowing what is inside.

#include <systemc.h>
#include <iostream>
using namespace std;

// ── Interfaces ────────────────────────────────────────────────
struct mem_read_if : virtual sc_interface {
    virtual uint32_t read(uint32_t addr) = 0;
};
struct mem_write_if : virtual sc_interface {
    virtual void write(uint32_t addr, uint32_t data) = 0;
};

// ── Internal channels (implementation details) ───────────────
struct RomChannel : sc_module, mem_read_if {
    uint32_t rom[256] = {};
    explicit RomChannel(sc_module_name n) : sc_module(n) {
        rom[0] = 0xBAADF00D;   // pre-loaded firmware word
    }
    uint32_t read(uint32_t addr) override {
        return rom[addr & 0xFF];
    }
};

struct RamChannel : sc_module, mem_read_if, mem_write_if {
    uint32_t ram[256] = {};
    explicit RamChannel(sc_module_name n) : sc_module(n) {}
    uint32_t read(uint32_t addr) override { return ram[addr & 0xFF]; }
    void     write(uint32_t addr, uint32_t data) override {
        ram[addr & 0xFF] = data;
    }
};

// ── MemorySubsystem — hides channels behind exports ───────────
SC_MODULE(MemorySubsystem) {
    sc_export<mem_read_if>  rom_export;    // read-only view
    sc_export<mem_read_if>  ram_read_export;
    sc_export<mem_write_if> ram_write_export;

    SC_CTOR(MemorySubsystem)
        : m_rom("rom"), m_ram("ram")
    {
        rom_export(m_rom);             // bind exports to channels
        ram_read_export(m_ram);
        ram_write_export(m_ram);       // same channel, two interface views
    }

private:
    RomChannel m_rom;
    RamChannel m_ram;
};

// ── Master — only knows about the interfaces ──────────────────
SC_MODULE(Master) {
    sc_port<mem_read_if>  rom_port;
    sc_port<mem_read_if>  ram_rd_port;
    sc_port<mem_write_if> ram_wr_port;

    SC_CTOR(Master) { SC_THREAD(run); }

    void run() {
        // Read firmware word from ROM
        uint32_t fw = rom_port->read(0);
        cout << "[MASTER] ROM[0] = 0x" << hex << fw << endl;

        // Write and read back from RAM
        ram_wr_port->write(0x10, 0xCAFE);
        uint32_t rd = ram_rd_port->read(0x10);
        cout << "[MASTER] RAM[0x10] = 0x" << hex << rd << endl;

        wait(10, SC_NS);
    }
};

// ── sc_main ───────────────────────────────────────────────────
int sc_main(int, char**) {
    MemorySubsystem mem("mem");
    Master          master("master");

    // Hierarchical binding: sc_port → sc_export → internal channel
    master.rom_port    (mem.rom_export);
    master.ram_rd_port (mem.ram_read_export);
    master.ram_wr_port (mem.ram_write_export);

    sc_start(50, SC_NS);
    return 0;
}

Expected output:

[MASTER] ROM[0] = 0xbaadf00d
[MASTER] RAM[0x10] = 0xcafe

Note that m_ram is bound to two different exports simultaneously — ram_read_export and ram_write_export. This is perfectly valid: the same channel object implements both mem_read_if and mem_write_if, and each export holds a pointer to its respective interface vtable entry. The master sees them as two independent ports with different types, even though they bottom out at the same object.


9. Common Pitfalls

Pitfall 1 — Binding sc_export to sc_export at the same hierarchy level

sc_export can only delegate downward — to a child module's export or directly to a channel. Two sibling modules cannot wire their exports together: neither is the provider; both are sockets waiting to be filled. The correct fix is to create a channel that both can bind to, or restructure so one is a requirer (sc_port) and the other is a provider (sc_export).

// WRONG — two exports at the same level cannot be bound to each other
mod_a.some_export(mod_b.some_export);   // elaboration error

// CORRECT — create a channel and bind both sides to it
MyChannel ch("ch");
mod_a.some_export(ch);
mod_b.some_port(ch);    // or bind mod_b's export to ch if mod_b provides it

Pitfall 2 — Declaring sc_export but forgetting to bind it inside the module

An sc_export that has no backing channel is an empty socket. SystemC will report an elaboration error when it tries to resolve the interface pointer. The binding must happen in the module constructor, not in before_end_of_elaboration() or later.

SC_MODULE(Broken) {
    sc_export<mem_if> mem_export;
    RamChannel        m_ram;

    SC_CTOR(Broken) : m_ram("ram") {
        // BUG: forgot mem_export(m_ram); — elaboration will fail
    }
};

// Fix: always bind exports in the constructor
SC_CTOR(Fixed) : m_ram("ram") {
    mem_export(m_ram);   // must be here
}

Pitfall 3 — Using sc_port where sc_export is needed

If your module implements a service (contains a channel) and wants to expose it, you need sc_export, not sc_port. Using sc_port declares a requirement — it means "I need something bound to me from outside." An external caller trying to bind their port to your port will get an elaboration type mismatch.

// WRONG — sc_port says "I need a service", not "I provide one"
SC_MODULE(BadRam) {
    sc_port<mem_if> mem_port;   // implies BadRam is a consumer — wrong
    ...
};

// CORRECT — sc_export says "I provide this service"
SC_MODULE(GoodRam) {
    sc_export<mem_if> mem_export;   // correct: GoodRam is a provider
    ...
};

Summary


Channels & Interfaces — 4-part series

01 sc_interface — The Interface Contract 02 sc_prim_channel — Implementing a Channel 03 sc_port & sc_export — Connecting Modules ← here 04 IMC — The Complete Picture

📬 Get new articles in your inbox

Deep dives on SystemC, C++, and embedded systems — no spam, unsubscribe any time.

No spam, unsubscribe any time. Privacy Policy

Aditya Gaurav

Aditya Gaurav

Embedded systems engineer specializing in SystemC, ARM architecture, and C/C++ internals. Writing deep technical dives for VLSI and embedded engineers.

Part of the SystemC Foundations guide Browse all SystemC guides →