Chapter 3: Communication

Ports, Interfaces, Exports, and Channels

How modules connect through interfaces, why ports are typed, and where sc_export fits.

How to Read This Lesson

Think of this chapter as wiring discipline. Ports, exports, interfaces, and channels are not decorative; they are how the model states its contract before time starts moving.

SystemC communication is built around interfaces. A port requires an interface. A channel implements an interface. An export exposes an interface from inside a module.

That separation is one of the most important design choices in SystemC.

Source and LRM Trail

The standard contract lives in Docs/LRMs/SystemC_LRM_1666-2023.pdf around interfaces, ports, exports, primitive channels, hierarchical channels, and predefined channels. The implementation trail is .codex-src/systemc/src/sysc/communication: sc_port, sc_export, sc_interface, sc_prim_channel, sc_signal, sc_fifo, and the writer policy helpers.

Interfaces

An interface is an abstract contract:

#include <systemc>
 
struct BusIf : sc_core::sc_interface {
  virtual int read(unsigned address) = 0;
  virtual void write(unsigned address, int data) = 0;
};

The interface says what can be done. It does not say how storage, timing, arbitration, tracing, or contention are implemented.

Channels

A channel implements the interface. Let's make a complete compilable example of a memory channel. Note how it inherits from sc_core::sc_channel and BusIf:

#include <systemc>
 
struct BusIf : sc_core::sc_interface {
  virtual int read(unsigned address) = 0;
  virtual void write(unsigned address, int data) = 0;
};
 
class SimpleMemory : public sc_core::sc_channel, public BusIf {
public:
  int data[256]{};
 
  SC_CTOR(SimpleMemory) {}
 
  int read(unsigned address) override {
    return data[address & 0xff];
  }
 
  void write(unsigned address, int value) override {
    data[address & 0xff] = value;
  }
};
 
int sc_main(int, char*[]) {
  SimpleMemory mem("mem");
  mem.write(0x0, 42);
  return 0;
}

This channel is untimed and simple. Later you could add wait() in a thread-aware interface, model bus latency, or record accesses for debugging.

Ports and Exports

A module uses a port to call an interface, and an export lets a module provide an interface implemented by a child object. Here is a complete model putting it all together:

#include <systemc>
 
struct BusIf : sc_core::sc_interface {
  virtual int read(unsigned address) = 0;
  virtual void write(unsigned address, int data) = 0;
};
 
class SimpleMemory : public sc_core::sc_channel, public BusIf {
public:
  int data[256]{};
  SC_CTOR(SimpleMemory) {}
  int read(unsigned address) override { return data[address & 0xff]; }
  void write(unsigned address, int value) override { data[address & 0xff] = value; }
};
 
SC_MODULE(CpuModel) {
  sc_core::sc_port<BusIf> bus{"bus"};
 
  SC_CTOR(CpuModel) {
    SC_THREAD(run);
  }
 
  void run() {
    bus->write(0x10, 7);
    int value = bus->read(0x10);
    std::cout << "CPU Read value: " << value << " at time " << sc_core::sc_time_stamp() << "\n";
  }
};
 
SC_MODULE(MemorySubsystem) {
  sc_core::sc_export<BusIf> target{"target"};
  SimpleMemory mem{"mem"};
 
  SC_CTOR(MemorySubsystem) {
    target.bind(mem);
  }
};
 
int sc_main(int, char*[]) {
  CpuModel cpu("cpu");
  MemorySubsystem mem_sys("mem_sys");
  
  cpu.bus.bind(mem_sys.target);
  sc_core::sc_start();
  return 0;
}

The port is typed by the interface, not by the concrete channel. This keeps the CPU model from depending on a particular memory implementation. The parent module exposes target, while the real implementation lives in mem. This is useful for hierarchy: external modules bind to the subsystem, while internal structure remains hidden.

Source-Code Angle

In the reference implementation, ports and exports are part of a binding system layered over C++ pointers and interface references. The kernel validates that a required interface exists and that the final object graph is consistent before simulation begins.

That is why binding errors are usually elaboration errors, not C++ type errors alone.

Under the Hood: Port Binding and Virtual Interfaces

A port (sc_core::sc_port) is fundamentally a safe wrapper around a C++ pointer to an interface (sc_core::sc_interface). When you bind a port p(signal), the sc_port_base::bind() method is invoked (sysc/communication/sc_port.cpp). The kernel does not immediately resolve the binding. Instead, it adds the binding pair to a list. During the complete_binding() phase, the kernel walks through the connections. If multiple ports are bound hierarchically (Port -> Port -> Channel), the kernel traverses the chain until it finds the actual sc_interface implementation. It then caches this interface pointer directly inside the port. Thus, calling p->read() has almost zero overhead during simulation—it is just a standard C++ virtual function call (interface_ptr->read()).

Standard and Source Deep Dive: Port Binding

Port binding is the topological glue of a SystemC model. The IEEE 1666-2023 LRM Sections 4.2.1 (Elaboration) and Section 6.11-6.13 (Ports, Exports, Interfaces) rigidly define how structural connections are made and verified.

Inside the Accellera Source: sc_port_b and sc_port_registry

In src/sysc/communication/sc_port.h/cpp, all specialized sc_port<IF> classes derive from a non-template base class sc_port_b. When you declare sc_port<BusIf> bus{"bus"};, the constructor ultimately calls sc_simcontext::get_port_registry()->insert(this).

The sc_port_registry (located in src/sysc/kernel/sc_simcontext.cpp) is the global list of every port in the simulation.

When you write cpu.bus.bind(subsystem.target); in your C++ code, you are invoking the bind() method on sc_port. However, this does not immediately resolve the C++ pointer! Instead, the port simply stores a generic pointer to the bound object in an internal array (because a port can be bound to multiple channels if the port's N parameter is > 1).

The Elaboration Phase: complete_binding()

The real magic happens when sc_start() is called. Before simulation begins, sc_start() invokes sc_simcontext::elaborate(), which ultimately calls sc_port_registry::complete_binding().

If you trace sysc/kernel/sc_simcontext.cpp, you will see complete_binding() iterate over every single port in the design. For each port:

  1. It traverses the binding tree. If Port A is bound to Port B, and Port B is bound to Channel C, it recursively walks from A -> B -> C to find the actual sc_interface implementation.
  2. Type Checking: It uses C++ RTTI (dynamic_cast) to verify that the target object actually implements the interface required by the port.
    // Abstract representation of the kernel's check:
    sc_interface* target_if = dynamic_cast<sc_interface*>(bound_object);
    if (!target_if) { SC_REPORT_ERROR("Port binding failed: interface mismatch"); }
  3. It resolves the final interface pointer and stores it directly inside the port's m_interface pointer array.

Zero-Overhead Simulation Dispatch

Why delay pointer resolution until complete_binding()? Because once elaboration finishes, the port has an absolute, direct C++ pointer to the implementing channel.

In src/sysc/communication/sc_port.h, the overloaded operator-> is extraordinarily simple:

template <class IF>
inline IF* sc_port<IF>::operator -> () {
    return m_interface;
}

During simulation, when a thread executes bus->write(0x10, data);, there are no map lookups, no string comparisons, and no routing tables. It is exactly equivalent to a direct C++ virtual function call on the channel object.

Comments and Corrections