SystemC IMC — Interface, Channel, and Port Working Together
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:
- sc_interface — the abstract root of every channel. It declares no domain methods; only two kernel hooks:
register_port()(called on binding) anddefault_event()(called onsensitive << port). You inherit from it to define your API contract — pure virtual methods only, no data. - sc_prim_channel — the lightweight kernel base for a channel. Gives the channel a name in the simulation hierarchy, makes it visible in VCD traces, and gives access to the evaluate-update protocol (
request_update()/update()) for channels that need deferred value propagation. For a simple array-backed channel likeSimpleRam, it provides registration only —request_update()is never called. - sc_port<IF> — the initiator side of the IMC pattern. A module holds a port templated on an interface type. At elaboration, the port is bound to a channel that implements that interface. After binding,
port->method()resolves to a raw pointer call into the bound channel — no virtual dispatch through sc_port, just C++ vtable dispatch through the interface pointer.
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:
- Checks that
SimpleRamimplementsmem_if— a compile-time check on the template argument. - Stores a pointer to the
mem_ifsubobject ofSimpleRaminside the port. - Calls
SimpleRam::register_port(port, "mem_if")— the channel can enforce binding constraints here (e.g. single-writer checks).SimpleRamuses the default empty body, so nothing happens. - 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.
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
- The IMC pattern has three independent pieces — interface (contract), channel (implementation), port (connector) — each in its own class hierarchy. Separating them lets you swap any piece without touching the others.
- Elaboration is when the kernel binds the port to the channel, stores the interface pointer, and calls
register_port(). This happens once, beforesc_start(). - At runtime,
port->method()is a raw C++ vtable call through the interface pointer — zero kernel overhead for direct-binding channels. The kernel only re-enters forwait()orrequest_update(). - Multiple initiators can bind to the same channel through different interface types — access control is enforced at compile time by the port's template parameter.
- TLM 2.0 is this pattern applied to transaction-level modelling.
tlm_fw_transport_ifis a customsc_interface; TLM sockets aresc_port/sc_exportpairs bound to it.
Channels & Interfaces — 4-part series