The previous three articles in this series each covered one piece of the puzzle in isolation: the interface contract, the channel implementation, and the port and export connectors. This article wires all three together, walks through exactly what happens at elaboration and at runtime when a port call fires, shows how swapping one channel for another leaves all initiators untouched, and explains why TLM 2.0 is built on exactly this pattern.

By the end you will have a complete, compilable, runnable Master/Memory system and a clear mental model of the call chain from mem_port->write() all the way to array storage — and back.


1. Recap — The Three Pieces

Before wiring, a one-paragraph recap of each piece:

IMC in one sentence. The module calls through the interface; the channel implements the interface; sc_port connects them at elaboration. Each of the three concerns is isolated in its own class hierarchy.

2. The Complete Example

Here is the full end-to-end system: interfaces defined, channel implemented, module uses a port, sc_main instantiates and binds. All four pieces — interface, channel, port, wiring — are visible in one file.

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

// ── 1. Interface contract ─────────────────────────────────────
// Inherits sc_interface; pure virtual only; no data
class mem_read_if  : public virtual sc_interface {
public:
    virtual uint32_t read(uint32_t addr) = 0;
    virtual ~mem_read_if() {}
};
class mem_write_if : public virtual sc_interface {
public:
    virtual void write(uint32_t addr, uint32_t data) = 0;
    virtual ~mem_write_if() {}
};
class mem_if : public virtual mem_read_if,
               public virtual mem_write_if {
public:
    virtual ~mem_if() {}
};

// ── 2. Channel: sc_prim_channel + interface ───────────────────
// sc_prim_channel → kernel registration (naming, VCD trace)
// mem_if          → interface contract (read, write)
class SimpleRam : public sc_prim_channel, public mem_if {
public:
    explicit SimpleRam(const char* n) : sc_prim_channel(n) {
        memset(m_, 0, sizeof(m_));
    }
    void     write(uint32_t a, uint32_t d) override { m_[a & 0xFF] = d; }
    uint32_t read (uint32_t a)              override { return m_[a & 0xFF]; }
private:
    uint32_t m_[256];
};

// ── Swappable drop-in: FlashModel also satisfies mem_if ───────
class FlashModel : public sc_prim_channel, public mem_if {
public:
    explicit FlashModel(const char* n) : sc_prim_channel(n) {
        memset(m_, 0xFF, sizeof(m_));
    }
    void write(uint32_t a, uint32_t d) override {
        cout << "[FLASH] program addr=0x" << hex << a << endl;
        m_[a & 0xFF] &= d;   // flash can only clear bits
    }
    uint32_t read(uint32_t a) override { return m_[a & 0xFF]; }
private:
    uint32_t m_[256];
};

// ── 3. Module: holds sc_port<IF>, knows nothing about channel ─
SC_MODULE(Master) {
    sc_port<mem_if> mem;   // channel must implement mem_if

    SC_CTOR(Master) { SC_THREAD(run); }

    void run() {
        mem->write(0x10, 0xCAFE1234);
        mem->write(0x14, 0xDEADBEEF);
        uint32_t a = mem->read(0x10);
        uint32_t b = mem->read(0x14);
        cout << "[MASTER] 0x10=0x" << hex << a
             << "  0x14=0x" << b << endl;
    }
};

// ── 4. sc_main: instantiate, bind, start ──────────────────────
int sc_main(int, char**) {
    Master    master("master");
    SimpleRam ram   ("ram");   // change this one line to swap to FlashModel

    master.mem(ram);   // sc_port<mem_if> → SimpleRam (which implements mem_if)

    sc_start();
    return 0;
}

Expected output with SimpleRam:

[MASTER] 0x10=0xcafe1234  0x14=0xdeadbeef

Swap SimpleRam ram("ram")FlashModel ram("ram")only sc_main changes. Master is untouched:

[FLASH] program addr=0x10
[FLASH] program addr=0x14
[MASTER] 0x10=0xcafe1234  0x14=0xdeadbeef

This is the entire payoff of the IMC pattern. The testbench swaps the memory model — fast functional SRAM for initial software bring-up, flash model for programming/erase testing, cycle-accurate DDR for timing closure — by changing a single line in sc_main. Master does not recompile.


3. What Happens at Each Phase

Two distinct phases — elaboration and simulation — involve the IMC machinery in different ways.

Elaboration (before sc_start)

master.mem(ram) is the port binding call. The SystemC kernel:

  1. Checks that SimpleRam implements mem_if — a compile-time check on the template argument.
  2. Stores a pointer to the mem_if subobject of SimpleRam inside the port.
  3. Calls SimpleRam::register_port(port, "mem_if") — the channel can enforce binding constraints here (e.g. single-writer checks). SimpleRam uses the default empty body, so nothing happens.
  4. Registers the binding in the simulation hierarchy for tracing and debug.

Runtime (inside sc_start)

When Master::run() executes mem->write(0x10, 0xCAFE1234):

// Step-by-step call chain
// 1. mem->  →  sc_port::operator->() returns the stored mem_if*
// 2. ->write()  →  C++ vtable dispatch through mem_if* to SimpleRam::write()
// 3. SimpleRam::write(0x10, 0xCAFE1234)  →  m_[0x10] = 0xCAFE1234
// sc_prim_channel is not involved. No kernel overhead. Pure C++ call.

This is important: once elaboration is complete and the port is bound, every port->method() call is a plain C++ virtual dispatch — it goes through the interface vtable directly into the channel's method body. There is no scheduler overhead, no event queue lookup, no kernel involvement. The IMC pattern is zero-overhead at runtime for direct-binding channels.

When does the kernel get involved at runtime? The kernel re-enters the picture only when a channel calls request_update() (for deferred channels like sc_signal) or when a process calls wait(). For SimpleRam, neither happens — all calls are synchronous and immediate. See the sc_prim_channel article for the evaluate-update protocol.

4. Multiple Initiators, One Channel

The IMC pattern scales naturally to multiple initiators bound to the same channel. Each initiator holds its own port; all ports bind to the same channel object. Access control is enforced by the interface type each port holds.

SC_MODULE(Monitor) {
    sc_port<mem_read_if> rd;   // read-only — cannot call write()

    SC_CTOR(Monitor) { SC_THREAD(sample); }

    void sample() {
        wait(50, SC_NS);
        cout << "[MON] 0x10 = 0x" << hex << rd->read(0x10) << endl;
    }
};

// sc_main — one channel, two initiators with different interface views
int sc_main(int, char**) {
    Master  master ("master");
    Monitor monitor("monitor");
    SimpleRam ram  ("ram");

    master.mem(ram);    // full read/write access
    monitor.rd(ram);    // read-only view — write() not accessible

    sc_start(100, SC_NS);
    return 0;
}

monitor.rd is sc_port<mem_read_if>. If anyone tries to call rd->write() in the monitor, it fails at compile time — mem_read_if has no write() method. The interface type enforces access control without any runtime check.


5. Why TLM 2.0 Is This Pattern at Scale

Open the OSCI TLM 2.0 standard and you find two interface declarations at its core — both are custom sc_interface subclasses, exactly like mem_if:

// TLM 2.0 — forward transport interface (initiator → target)
template <typename TRANS>
class tlm_fw_transport_if : public virtual sc_interface {
public:
    virtual void b_transport     (TRANS&, sc_time&)       = 0;
    virtual bool get_direct_mem_ptr(TRANS&, tlm_dmi&)     = 0;
    virtual tlm_sync_enum nb_transport_fw(TRANS&, tlm_phase&, sc_time&) = 0;
};

// TLM 2.0 — backward transport interface (target → initiator)
template <typename TRANS>
class tlm_bw_transport_if : public virtual sc_interface {
public:
    virtual tlm_sync_enum nb_transport_bw(TRANS&, tlm_phase&, sc_time&) = 0;
    virtual void invalidate_direct_mem_ptr(...)                       = 0;
};

A TLM 2.0 simple_initiator_socket is nothing more than an sc_port<tlm_fw_transport_if> bundled with an sc_export<tlm_bw_transport_if>. A simple_target_socket is the mirror. When an initiator calls socket->b_transport(trans, delay), it is doing exactly what Master does above — calling through an sc_port into whatever target is bound. The initiator has no knowledge of the target's type.

TLM 2.0 did not invent a new mechanism. It applied the IMC pattern to transaction-level modelling, added a standard payload type (tlm_generic_payload), and defined timing annotation conventions. If you understand the pattern in this article, you understand the conceptual core of TLM 2.0. The rest is detail.

Track 05 of the ErrBits SystemC series covers TLM 2.0 sockets, blocking and non-blocking transport, and the Direct Memory Interface (DMI) optimisation in full detail.


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 04 IMC — The Complete Picture ← here

📬 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 →