Source Deep Dive: Ports, Signals, and TLM Sockets
How the library turns interface binding, deferred updates, and transaction sockets into usable APIs.
How to Read This Lesson
This is a source-reading lesson. We will use the Accellera implementation as a microscope, while keeping the LRM as the portability contract.
Three source areas explain most user-facing SystemC behavior: ports, signals, and TLM sockets. This section looks at the mechanics defined by the IEEE 1666 standard and how they map to C++.
Source and LRM Trail
This lesson is deliberately source-facing. Use Docs/LRMs/SystemC_LRM_1666-2023.pdf to decide what must be portable, then use .codex-src/systemc/src/sysc and .codex-src/systemc/src/tlm_core to see one reference implementation. Treat private members as explanatory, not as APIs your models should depend on.
Ports Are Typed Access Points
At the API level, a port looks like a simple templated object, but at elaboration time, that port must bind to a channel implementing the specified interface. The LRM dictates strict port binding rules to ensure the topological integrity of the system before simulation begins.
Exports Turn Hierarchy Inside Out
An export exposes an interface from a module boundary to its parent or peers, but the actual implementation of that interface is provided by a child module or channel instantiated within.
Signals Are Deferred-Update Channels
To understand how sc_signal provides deferred updates in accordance with LRM semantics, we can build a complete, custom primitive channel that mimics its evaluate-update behavior.
#include <systemc>
using namespace sc_core;
// 1. Define the Interface
template <typename T>
struct custom_signal_if : virtual public sc_interface {
virtual const T& read() const = 0;
virtual void write(const T&) = 0;
virtual const sc_event& default_event() const = 0;
};
// 2. Implement the Primitive Channel
template <typename T>
class custom_signal : public sc_prim_channel, public custom_signal_if<T> {
private:
T m_current_value;
T m_new_value;
sc_event m_value_changed;
public:
explicit custom_signal(const char* name) : sc_prim_channel(name), m_current_value(T()), m_new_value(T()) {}
const T& read() const override {
return m_current_value;
}
void write(const T& val) override {
if (val != m_new_value) {
m_new_value = val;
request_update(); // Register with the kernel for the update phase
}
}
const sc_event& default_event() const override {
return m_value_changed;
}
protected:
void update() override {
// Called by the kernel during the update phase
if (m_current_value != m_new_value) {
m_current_value = m_new_value;
m_value_changed.notify(SC_ZERO_TIME); // Delta notification
}
}
};
// 3. Test Module
SC_MODULE(TestModule) {
sc_port< custom_signal_if<int> > port{"port"};
SC_CTOR(TestModule) {
SC_THREAD(run);
}
void run() {
port->write(42);
// Notice that read() still returns 0 here because update() hasn't happened yet!
std::cout << "Immediate read: " << port->read() << "\n";
wait(SC_ZERO_TIME);
// After a delta cycle, the update phase has run
std::cout << "Read after delta: " << port->read() << "\n";
}
};
int sc_main(int, char*[]) {
custom_signal<int> sig("sig");
TestModule mod("mod");
mod.port(sig);
sc_start();
return 0;
}The real sc_signal handles writer policies, tracing, reset integration, and specialized logic types, but the core LRM mechanism relies directly on sc_prim_channel, request_update(), and the virtual update() callback.
TLM Sockets Package Binding Patterns
TLM sockets are built to make transaction-level binding ergonomic. They inherit from both a port (to make outbound calls) and an export (to receive inbound calls).
Utility sockets such as tlm_utils::simple_target_socket let you register a member function as the transport callback. We can demonstrate this wrapping inside a complete compilable example.
#include <systemc>
#include <tlm>
#include <tlm_utils/simple_initiator_socket.h>
#include <tlm_utils/simple_target_socket.h>
using namespace sc_core;
SC_MODULE(Memory) {
tlm_utils::simple_target_socket<Memory> socket{"socket"};
SC_CTOR(Memory) {
socket.register_b_transport(this, &Memory::b_transport);
}
void b_transport(tlm::tlm_generic_payload& trans, sc_time& delay) {
trans.set_response_status(tlm::TLM_OK_RESPONSE);
std::cout << "Target received TLM command at " << sc_time_stamp() << "\n";
}
};
SC_MODULE(Cpu) {
tlm_utils::simple_initiator_socket<Cpu> socket{"socket"};
SC_CTOR(Cpu) { SC_THREAD(run); }
void run() {
tlm::tlm_generic_payload trans;
sc_time delay = SC_ZERO_TIME;
trans.set_command(tlm::TLM_WRITE_COMMAND);
socket->b_transport(trans, delay);
}
};
int sc_main(int, char*[]) {
Cpu cpu("cpu");
Memory mem("mem");
cpu.socket.bind(mem.socket);
sc_start();
return 0;
}That single bind() line hides a lot of structure. The socket has to expose the target interface, receive calls from an initiator, and dispatch them to your callback with the payload and delay, complying with the IEEE 1666-2023 standard for TLM-2.0 core interfaces.
Under the Hood: Multi-Port Binding Policies
The sc_port class template takes three arguments: sc_port<IF, N, POL>.
IF: The interface being bound.N: The maximum number of channels that can be bound to this port. Default is 1.POL: The binding policy. Insysc/communication/sc_port.h, the policySC_ONE_OR_MORE_BOUND(default) ensures that if the port is not bound by the end of elaboration, an exception is thrown. If you change it toSC_ZERO_OR_MORE_BOUND, the port can safely be left dangling. WhenN > 1, the port internally stores astd::vectorof interface pointers, allowing you to index into them:port[0]->read(),port[1]->read().
IEEE 1666-2023 LRM: Interfaces, Ports, and Channels
The separation of computation and communication is central to SystemC. The LRM formalizes this via interfaces, ports, and channels.
Interfaces (LRM Section 5.11)
An interface in SystemC is defined as an abstract C++ class derived from sc_interface. According to LRM Section 5.11.2, an interface class declares a set of pure virtual methods. It contains no state and no implementation.
In the Accellera implementation, sc_interface is defined in sysc/communication/sc_interface.h. Its only functional member is a virtual method register_port(). This method allows the interface (when implemented by a channel) to track which ports are connected to it, enabling static rule checking during elaboration.
Channels (LRM Section 5.12)
A channel is a class that implements one or more interfaces. The LRM distinguishes between two types:
- Primitive Channels (LRM 5.12.2): Inherit from
sc_prim_channel. They do not have visible structure (no sub-modules or ports). They are permitted to use therequest_update()andupdate()mechanism to interact directly with the scheduler's evaluate-update phases.sc_signal,sc_mutex, andsc_fifoare predefined primitive channels. - Hierarchical Channels (LRM 5.12.3): Inherit from
sc_module(orsc_channel). They have structure (they can contain ports, sub-modules, and processes). They cannot directly userequest_update().
Ports (LRM Section 5.13)
A port is the mechanism by which a module requires an interface. It is implemented via the sc_port template.
The LRM dictates strict Binding Rules (LRM 4.1.3):
- A port must be bound to a channel that implements the port's interface type, OR to another port of a parent module (hierarchical binding), OR to an export.
- Binding occurs during elaboration. Once simulation starts, port bindings cannot change.
- The port binding policy (
SC_ONE_OR_MORE_BOUND,SC_ZERO_OR_MORE_BOUND,SC_ALL_BOUND) determines whether elaboration succeeds based on how many channels are bound to the port.
sc_port Internals (sysc/communication/sc_port.cpp)
When you call port.bind(channel) or use port(channel), you are invoking sc_port_b::bind(). Internally, the Accellera kernel maintains a linked list of binding deferred actions. Actual resolution of bindings happens recursively just before the end_of_elaboration callback. The kernel traces the binding hierarchy: if Port A binds to Port B, and Port B binds to Channel C, the kernel ultimately resolves Port A directly to the interface pointer of Channel C.
When you invoke a method via a port (e.g., port->read()), the overloaded operator-> simply returns the cached interface pointer to the bound channel. This makes port indirection extremely fast during simulation—it's just a virtual function call.
Exports (LRM Section 5.14)
An export (sc_export) allows a module to provide an interface to its parent or peers, forwarding calls to a channel instantiated inside the module.
Unlike ports, where the caller lives inside the module and the channel is outside, an export receives calls from outside and forwards them inside. This is essential for TLM targets.
The binding mechanism for exports (sc_export::bind()) resolves similarly to ports during elaboration. Ultimately, an external port bound to a module's export is resolved directly to the internal channel's interface pointer, eliminating any runtime overhead from crossing the module boundary.
Signals and the sc_signal Family (LRM Section 6.4)
sc_signal<T> is the fundamental predefined primitive channel. It implements both sc_signal_in_if<T> and sc_signal_inout_if<T>.
Writer Policies (LRM 6.4.4)
The LRM defines sc_writer_policy to handle multiple processes writing to the same signal:
SC_ONE_WRITER(Default): Only one process is allowed to write to the signal during the entire simulation. The kernel enforces this by tracking the process ID of the first writer and throwing an exception if another process attempts to write.SC_MANY_WRITERS: Multiple processes can write. However, if they write in the same delta cycle, the last write wins, which is often a race condition.SC_UNCHECKED_WRITERS: The kernel performs no writer checking, optimizing performance but leaving you vulnerable to hard-to-debug race conditions.
Source Implementation of sc_signal::write()
In sysc/communication/sc_signal.cpp, the write() function looks conceptually like this:
template <class T, sc_writer_policy POL>
inline void sc_signal<T, POL>::write( const T& value_ ) {
// 1. Writer policy check (throws if violated)
bool result = policy_type::check_write( this, sc_get_current_process_b() );
// 2. Value comparison
if ( !(m_new_val == value_) ) {
m_new_val = value_;
// 3. Request update from kernel
this->request_update();
}
}And the corresponding update() called by the kernel:
template <class T, sc_writer_policy POL>
inline void sc_signal<T, POL>::update() {
// 1. Writer policy check (throws if violated)
policy_type::update();
if ( !(m_val == m_new_val) ) {
m_val = m_new_val;
// 2. Notify events
m_value_changed_event.notify_delayed();
m_default_event.notify_delayed();
}
}TLM-2.0 Sockets (LRM Chapter 16)
The TLM-2.0 standard is built on top of the SystemC core interface/port/export mechanism.
What is a Socket?
A TLM-2.0 socket is a structural object that groups together multiple interfaces required for transaction-level modeling. Specifically, an initiator socket acts as an sc_port for the forward path (b_transport, nb_transport_fw) and an sc_export for the backward path (nb_transport_bw). A target socket is the inverse.
Core Interfaces (LRM 11.1)
tlm_fw_transport_if: Implemented by targets. Containsb_transport,nb_transport_fw,get_direct_mem_ptr, andtransport_dbg.tlm_bw_transport_if: Implemented by initiators. Containsnb_transport_bwandinvalidate_direct_mem_ptr.
Socket Binding (LRM 16.1.4)
When you write initiator_socket.bind(target_socket), the socket internally performs two SystemC core bindings:
- The initiator's internal
sc_portis bound to the target's internalsc_export(Forward path). - The target's internal
sc_portis bound to the initiator's internalsc_export(Backward path).
Convenience Sockets: simple_target_socket
The standard sockets (tlm_initiator_socket and tlm_target_socket) require the module to inherit from the transport interfaces and implement all virtual methods. This is verbose.
The tlm_utils::simple_target_socket (defined in tlm_utils/simple_target_socket.h) solves this. It inherits from tlm_target_socket but automatically implements the tlm_fw_transport_if itself. When the initiator calls b_transport on the socket's export, the socket's internal implementation forwards the call to a user-registered callback via a function pointer or std::function.
// Conceptual view of simple_target_socket internal forwarding
void b_transport(tlm::tlm_generic_payload& trans, sc_time& delay) override {
if (m_b_transport_functor) {
// Call the user's registered function
(*m_b_transport_functor)(trans, delay);
} else {
// Error: No callback registered
SC_REPORT_ERROR("TLM-2", "b_transport not implemented");
}
}This brilliant use of C++ abstraction turns a strict, virtual-interface-heavy LRM standard into a simple callback registration API, hiding the complexity of export resolution and interface implementation from the user.
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:
- 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_interfaceimplementation. - 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"); } - It resolves the final interface pointer and stores it directly inside the port's
m_interfacepointer 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