18. SystemC Tutorial - Initiator & Target Sockets

Why this matters

Written 2026-06-05 for the concept-first series.

In Part 2 you wrote a complete loosely-timed system — a traffic generator writing to a memory, reading it back, checking the result — and you bound the two together with a single line: gen.socket.bind(mem.socket). You declared the connection points as tlm_utils::simple_initiator_socket and tlm_utils::simple_target_socket, registered a handler with socket.register_b_transport(this, &Memory::b_transport), and the transactions flowed. That post deliberately treated the sockets as opaque: "bind one initiator socket to one target socket and you have a channel." This post opens the box. By the end you will know exactly what a socket is, what those convenience wrappers actually wrap, why every socket secretly carries two communication paths and not one, and how binding, hierarchical pass-through, and one-target-many-initiators fan-out all work — including the exact elaboration and runtime errors you get when you wire them wrong.

This is the load-bearing infrastructure post of the section. Every model in every later part — the bus fabric that routes by address, the memory-mapped peripheral, the CPU front-end re-fronted with a TLM port in the capstone — is built out of sockets. Get the mental model wrong and you will spend hours staring at (E109) complete binding failed: port not bound without understanding why, or you will reach for a second target socket when you needed a fan-out socket, or you will assume "the data goes through the socket" and be baffled when a payload's data pointer outlives the call. The socket is the single most misunderstood object in TLM-2.0 precisely because it looks like a port (it is declared like one, bound like one) but behaves like a bundled pair of ports carrying function calls in two directions rather than bits in one. This post teaches the socket as it really is: a connector for interface calls, the forward path and the backward path it bundles, the raw tlm_initiator_socket/tlm_target_socket and the tlm_fw_transport_if/tlm_bw_transport_if interface classes underneath, the convenience sockets that pre-write the boilerplate, the binding rules and their diagnostics, and the fan-out sockets for shared targets. You will implement a target's forward interface by hand once — so you see what the convenience socket hides — then switch to the convenience sockets for good, exactly as the rest of the section does.

Prerequisites

  • Part 1 — Why TLM Exists. You need the motivating picture: why transaction-level modeling trades pin-and-cycle accuracy for simulation speed, the loosely-timed (LT) versus approximately-timed (AT) split, and the high-level roles of initiator, target, and socket. This post makes the socket concrete; Part 1 supplies the frame it sits in.
  • Part 2 — The Generic Payload & Blocking Transport. This is the direct prerequisite. You need to be fluent with tlm_generic_payload, the field-by-field anatomy, who-sets-what, and the b_transport(trans, delay) call. This post explains the channel those transactions travel through; Part 2 explained the cargo. We reuse the exact memory and traffic-generator shapes from Part 2 so you can focus on the sockets, not the payload.
  • Part 4 — Processes & Sensitivity (SC_METHOD vs SC_THREAD vs SC_CTHREAD). The initiators here are SC_THREAD processes, because b_transport is a blocking call and only a thread can sit inside one across simulated time. You need to know how an SC_THREAD is registered and why it can block where an SC_METHOD cannot.
  • SystemC 2.3.x or newer with TLM-2.0 headers (bundled since 2.3.0). The tlm and tlm_utils headers ship inside the SystemC distribution from 2.3.0 onward. Every example here compiles with a C++17 compiler against any 2.3.x or 3.0.x build.

One idea from Part 2 is the hinge of this entire post: a transaction is a function call carrying a payload by reference, and the call's return is the transaction's completion. The socket is the mechanism that makes that function call reach across a module boundary and resolve to the right target method. Hold that — "the socket routes a function call, it does not move data" — and the rest follows.

Mental model (first principles)

Start with the most reductive correct statement and build up.

A socket is a connector for function calls, not a wire for bits. When you declare input [31:0] addr in Verilog you are declaring a signal — a bundle of bits that toggles on clock edges and feeds sensitivity lists. A TLM-2.0 socket is a fundamentally different kind of object. It is a binding point that connects one module's interface calls to another module's interface implementations. Nothing toggles. No bits live on the socket. When an initiator "uses" its socket, it calls a method — socket->b_transport(trans, delay) — and that method call is dispatched, through the binding, into a method the target implemented. The socket's whole job is to make that cross-module method call resolve correctly. The bytes being read or written never sit "on" the socket; they ride inside the tlm_generic_payload passed by reference as the call's first argument. Internalize this first: the socket routes calls; the payload carries data.

Every socket bundles two paths, because a transaction protocol needs to talk in both directions. This is the part that surprises people coming from a port mindset. A TLM-2.0 socket is not one port — it is one sc_port plus one sc_export, wired so that binding cross-connects two directions of communication at once:

  • The forward path carries calls from the initiator to the target: b_transport, nb_transport_fw, get_direct_mem_ptr, transport_dbg. These are the methods grouped in the interface class tlm::tlm_fw_transport_if. The target implements this interface; the initiator calls into it.
  • The backward path carries calls from the target back to the initiator: nb_transport_bw and invalidate_direct_mem_ptr. These are grouped in tlm::tlm_bw_transport_if. The initiator implements this interface; the target calls into it.

So an initiator socket contains an sc_port typed on the forward interface (it will call forward methods) and an sc_export providing the backward interface (it will receive backward calls). The target socket is the exact mirror: an sc_export providing the forward interface (it receives forward calls) and an sc_port typed on the backward interface (it will call backward methods). Binding an initiator socket to a target socket cross-connects both pairs in one operation — the initiator's forward port to the target's forward export, and the target's backward port to the initiator's backward export.

If the sc_port/sc_export pairing is hazy, the one-sentence version is: a port is the end that makes calls and needs something to call into; an export is the end that provides an implementation to be called. Forward calls flow from the initiator's forward port into the target's forward export; backward calls flow from the target's backward port into the initiator's backward export. Each socket therefore carries one of each — it is simultaneously a caller (on one direction) and a callee (on the other). That dual nature is precisely why a socket can stand in for a whole bidirectional protocol in a single declaration, and why two sockets of the same kind cannot be wired together: two initiator sockets would both want to call forward and neither would provide the forward implementation, so there is nothing to call into.

Why two paths when Part 2 only ever used one? Because b_transport is synchronous. The initiator calls it, control goes into the target, the target does the work and returns, and the answer is sitting in the payload when control comes back. The target never needs to "call the initiator back" — the return is the reply. So a purely loosely-timed model that only uses b_transport exercises the forward path and leaves the backward path idle. That is exactly why the convenience target socket lets you register only register_b_transport and ignore everything else. The backward path earns its keep in the non-blocking (approximately-timed) world, where a transaction is split into phases — BEGIN_REQ, END_REQ, BEGIN_RESP, END_RESP — and the target signals progress by calling nb_transport_bw on the initiator between the initiator's forward calls. That is the subject of Part 4 — Non-Blocking Transport. For this post we stay in LT and use b_transport, but you should understand why the second path exists even while it sits unused: the socket is built to carry a full bidirectional protocol, not just a one-shot call.

%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#dbeafe', 'primaryTextColor': '#1e293b', 'primaryBorderColor': '#3b82f6', 'lineColor': '#64748b', 'secondaryColor': '#f1f5f9'}}}%%
flowchart LR
    subgraph Init["Initiator socket"]
      IP["sc_port(fw)
calls b_transport"] IE["sc_export(bw)
impls nb_transport_bw"] end subgraph Tgt["Target socket"] TE["sc_export(fw)
impls b_transport"] TP["sc_port(bw)
calls nb_transport_bw"] end IP -- "forward calls" --> TE TP -- "backward calls" --> IE

Read the diagram as two arrows, not one. The forward arrow (initiator's port into the target's export) carries b_transport and the other forward methods. The backward arrow (target's port into the initiator's export) carries nb_transport_bw. A single initiator.bind(target) wires both arrows. In an LT model only the top arrow is ever traversed, but both are connected.

The raw sockets versus the convenience sockets. There are two layers here, and keeping them distinct prevents most confusion. The raw sockets — tlm::tlm_initiator_socket<BUSWIDTH> and tlm::tlm_target_socket<BUSWIDTH> — are the standard, IEEE-1666-defined connectors. They bind to a full implementation of the relevant interface: a raw target socket must be bound to an object implementing tlm_fw_transport_if, i.e. an object that defines b_transport, nb_transport_fw, get_direct_mem_ptr, and transport_dbg. The convenience sockets — tlm_utils::simple_initiator_socket and tlm_utils::simple_target_socket — derive from the raw sockets and supply a pre-written interface implementation that simply forwards each method to a callback you register. register_b_transport(this, &M::b_transport) says "when a forward b_transport arrives, call this method." Everything else stays unregistered (and errors only if actually called). The convenience socket is not a different kind of connector — it is the raw socket plus a generic forwarding shim so you do not have to hand-write an interface class for every target. We will see both, raw first.

Beginner: First Principles

We will build the simplest socketed system twice. First with the raw tlm_target_socket, implementing the forward interface by hand, so you see every method the protocol expects a target to have. Then with the convenience simple_target_socket, which collapses that whole interface class into one register_b_transport line. The two programs do exactly the same thing — a write and a read-back through one socket pair — so the only difference you study is the socket plumbing.

The raw target: implementing the forward interface by hand

A raw tlm::tlm_target_socket<> does not know what to do with an incoming b_transport. It needs to be bound to an object that implements tlm_fw_transport_if — the forward interface. So the target module inherits from that interface and defines all four of its methods. Three of them are stubs (an LT target does not use non-blocking transport, DMI, or debug transport), but they must exist, because the interface is a contract: bind a target socket and you promise the whole forward interface is implemented.

// file: sock_raw_target.cpp
// Build: g++ -std=c++17 -DSC_ALLOW_DEPRECATED_IEEE_API \
//          -I/usr/local/systemc-3.0.0/include sock_raw_target.cpp \
//          -L/usr/local/systemc-3.0.0/lib-macosarm64 -lsystemc -o sock_raw_target
// Run:   DYLD_LIBRARY_PATH=/usr/local/systemc-3.0.0/lib-macosarm64 ./sock_raw_target

#include <systemc.h>
#include <tlm.h>
#include <tlm_utils/simple_initiator_socket.h>
#include <iostream>

// Raw target: implement the FORWARD interface by hand. No convenience socket.
class RawMemory : public sc_module,
                  public tlm::tlm_fw_transport_if<>      // forward interface
{
public:
  tlm::tlm_target_socket<> socket;     // raw target socket
  unsigned char mem[256];

  SC_HAS_PROCESS(RawMemory);
  RawMemory(sc_module_name n) : sc_module(n), socket("socket") {
    for (int i = 0; i < 256; i++) mem[i] = 0;
    socket.bind(*this);                // bind the socket to THIS interface impl
  }

  // 1. blocking transport — the one method an LT model actually uses
  void b_transport(tlm::tlm_generic_payload& trans, sc_time& delay) override {
    sc_dt::uint64  addr = trans.get_address();
    unsigned int   len  = trans.get_data_length();
    unsigned char* ptr  = trans.get_data_ptr();
    if (trans.get_command() == tlm::TLM_WRITE_COMMAND)
      for (unsigned i = 0; i < len; i++) mem[addr + i] = ptr[i];
    else
      for (unsigned i = 0; i < len; i++) ptr[i] = mem[addr + i];
    delay += sc_time(5, SC_NS);
    trans.set_response_status(tlm::TLM_OK_RESPONSE);
  }

  // 2. non-blocking forward — required by the interface, stubbed (LT target)
  tlm::tlm_sync_enum nb_transport_fw(tlm::tlm_generic_payload&,
                                     tlm::tlm_phase&, sc_time&) override {
    SC_REPORT_FATAL("RawMemory", "nb_transport_fw not implemented");
    return tlm::TLM_ACCEPTED;
  }

  // 3. debug transport — required, stubbed to "moved zero bytes"
  unsigned int transport_dbg(tlm::tlm_generic_payload&) override {
    return 0;
  }

  // 4. DMI — required, stubbed to "denied"
  bool get_direct_mem_ptr(tlm::tlm_generic_payload&,
                          tlm::tlm_dmi&) override {
    return false;
  }
};

SC_MODULE(Gen) {
  tlm_utils::simple_initiator_socket<Gen> socket;
  SC_CTOR(Gen) : socket("socket") { SC_THREAD(run); }
  void run() {
    tlm::tlm_generic_payload trans;
    sc_time delay = SC_ZERO_TIME;
    unsigned int w = 0xABCD1234, r = 0;
    trans.set_command(tlm::TLM_WRITE_COMMAND);
    trans.set_address(0x40);
    trans.set_data_ptr(reinterpret_cast<unsigned char*>(&w));
    trans.set_data_length(4);
    trans.set_streaming_width(4);
    trans.set_byte_enable_ptr(nullptr);
    trans.set_response_status(tlm::TLM_INCOMPLETE_RESPONSE);
    socket->b_transport(trans, delay);

    trans.set_command(tlm::TLM_READ_COMMAND);
    trans.set_data_ptr(reinterpret_cast<unsigned char*>(&r));
    trans.set_response_status(tlm::TLM_INCOMPLETE_RESPONSE);
    socket->b_transport(trans, delay);

    std::cout << "read back 0x" << std::hex << r
              << " resp=" << trans.get_response_string() << "\n";
    std::cout << "total annotated delay = " << delay << "\n";
  }
};

int sc_main(int, char*[]) {
  Gen gen("gen");
  RawMemory mem("mem");
  gen.socket.bind(mem.socket);          // initiator socket -> raw target socket
  sc_start();
  return 0;
}

Expected output:

read back 0xabcd1234 resp=TLM_OK_RESPONSE
total annotated delay = 10 ns

Study the target. The class inherits from sc_module and tlm::tlm_fw_transport_if<>. That second base class is the forward interface; inheriting it is the promise to implement b_transport, nb_transport_fw, get_direct_mem_ptr, and transport_dbg. In the constructor, socket.bind(*this) binds the raw target socket to the interface implementation — and *this is that implementation, because the module itself defines those four methods. (For the initiator we still used the convenience simple_initiator_socket, because writing a raw initiator that hand-implements the backward interface would double the noise without teaching anything new; the asymmetry is deliberate.)

The four methods are the whole forward interface. Only b_transport does real work — it is the LT path. nb_transport_fw is the AT entry point; this LT target does not support it, so it traps loudly if ever called. transport_dbg is the side-channel for debuggers to peek at memory without consuming simulation time (a later part covers it); a do-nothing stub returns 0, meaning "I moved zero debug bytes." get_direct_mem_ptr is the DMI fast-path request; returning false means "no direct access, always go through b_transport." The point of showing all four is not that you will write them — you will not, after this section — but that you see what is under the convenience socket. When you later type register_b_transport, you are filling in exactly the b_transport slot of this interface and accepting library-provided defaults for the other three.

Note The annotated delay totals 10 ns — the target adds 5 ns per access and the initiator did two accesses without resetting delay between them. The same timing-annotation rules from Part 2 apply unchanged; sockets do not alter how delay works.

The convenience target: one line replaces the interface class

Now the same system with tlm_utils::simple_target_socket. The convenience socket is an object that implements tlm_fw_transport_if — a pre-written one that forwards each forward method to a registered callback. So instead of inheriting the interface and writing four methods, the module owns a simple_target_socket and registers one callback for the only method it cares about.

// file: sock_convenience.cpp
// Build: g++ -std=c++17 -DSC_ALLOW_DEPRECATED_IEEE_API \
//          -I/usr/local/systemc-3.0.0/include sock_convenience.cpp \
//          -L/usr/local/systemc-3.0.0/lib-macosarm64 -lsystemc -o sock_convenience
// Run:   DYLD_LIBRARY_PATH=/usr/local/systemc-3.0.0/lib-macosarm64 ./sock_convenience

#include <systemc.h>
#include <tlm.h>
#include <tlm_utils/simple_initiator_socket.h>
#include <tlm_utils/simple_target_socket.h>
#include <iostream>

SC_MODULE(Memory) {
  tlm_utils::simple_target_socket<Memory> socket;   // wraps the raw target socket
  unsigned char mem[256];

  SC_CTOR(Memory) : socket("socket") {
    for (int i = 0; i < 256; i++) mem[i] = 0;
    // one line replaces the hand-written interface class:
    socket.register_b_transport(this, &Memory::b_transport);
  }

  void b_transport(tlm::tlm_generic_payload& trans, sc_time& delay) {
    sc_dt::uint64  addr = trans.get_address();
    unsigned int   len  = trans.get_data_length();
    unsigned char* ptr  = trans.get_data_ptr();
    if (trans.get_command() == tlm::TLM_WRITE_COMMAND)
      for (unsigned i = 0; i < len; i++) mem[addr + i] = ptr[i];
    else
      for (unsigned i = 0; i < len; i++) ptr[i] = mem[addr + i];
    trans.set_response_status(tlm::TLM_OK_RESPONSE);
  }
};

SC_MODULE(Gen) {
  tlm_utils::simple_initiator_socket<Gen> socket;   // wraps the raw initiator socket
  SC_CTOR(Gen) : socket("socket") { SC_THREAD(run); }
  void run() {
    tlm::tlm_generic_payload trans;
    sc_time delay = SC_ZERO_TIME;
    unsigned int w = 0xC0FFEE, r = 0;
    trans.set_command(tlm::TLM_WRITE_COMMAND);
    trans.set_address(0x08);
    trans.set_data_ptr(reinterpret_cast<unsigned char*>(&w));
    trans.set_data_length(4);
    trans.set_streaming_width(4);
    trans.set_byte_enable_ptr(nullptr);
    trans.set_response_status(tlm::TLM_INCOMPLETE_RESPONSE);
    socket->b_transport(trans, delay);              // operator-> dispatches to target

    trans.set_command(tlm::TLM_READ_COMMAND);
    trans.set_data_ptr(reinterpret_cast<unsigned char*>(&r));
    trans.set_response_status(tlm::TLM_INCOMPLETE_RESPONSE);
    socket->b_transport(trans, delay);

    std::cout << "round-trip 0x" << std::hex << r
              << " resp=" << trans.get_response_string() << "\n";
  }
};

int sc_main(int, char*[]) {
  Gen gen("gen");
  Memory mem("mem");
  gen.socket.bind(mem.socket);          // initiator.bind(target)
  sc_start();
  return 0;
}

Expected output:

round-trip 0xc0ffee resp=TLM_OK_RESPONSE

Compare the two targets line for line. The raw version inherited tlm_fw_transport_if<>, called socket.bind(*this), and defined four methods. The convenience version inherits nothing extra, owns a simple_target_socket<Memory>, and calls register_b_transport(this, &Memory::b_transport) — one line — then defines exactly one method. The three stubs are gone; the convenience socket supplies library defaults for nb_transport_fw, get_direct_mem_ptr, and transport_dbg (the non-blocking default errors if called, matching our hand-written trap; DMI defaults to denied; debug defaults to zero bytes). The behavior is identical. This is the trade the tlm_utils sockets make: a tiny amount of magic (a templated forwarding shim) in exchange for never hand-writing an interface class again. From here to the end of the section, every target uses simple_target_socket and register_b_transport, and every initiator uses simple_initiator_socket. The raw socket was shown once, on purpose, so the convenience socket is never mysterious.

The initiator side is worth one note. socket->b_transport(trans, delay) uses operator->, which the initiator socket overloads to dereference through the binding to the target's registered method. It looks like a pointer dereference; it is a virtual dispatch through the forward interface to Memory::b_transport. There is no kernel involvement, no delta cycle, no signal update — it is a C++ call on the initiator's thread stack, exactly as Part 2 described. The socket is what makes that call land in the right place.

Intermediate: How It Really Works

The Beginner section bound exactly one initiator to one target and everything worked. Real platforms have many modules, nested hierarchy, and — inevitably — wiring mistakes. This section covers the binding rules precisely: what bind actually does, what elaboration checks, the exact diagnostics you get for the two most common errors, and how binding passes through module boundaries (hierarchical binding). Every diagnostic quoted here is from a real run on SystemC 3.0.1.

What bind does, and why direction does not equal data direction

gen.socket.bind(mem.socket) performs a cross-connection. It binds the initiator's forward sc_port to the target's forward sc_export (so the initiator's b_transport calls reach the target), and simultaneously binds the target's backward sc_port to the initiator's backward sc_export (so any future nb_transport_bw reaches the initiator). One call, two wires. You can also write it as gen.socket(mem.socket) using operator() — identical effect.

Here is the first thing that trips people up: bind is symmetric for a socket pair. gen.socket.bind(mem.socket) and mem.socket.bind(gen.socket) do exactly the same cross-connection. The library defines the bind overloads on both socket kinds so that either end may initiate the binding. This means "binding backwards" — writing target.bind(initiator) instead of initiator.bind(target) — is not an error and not a bug; it compiles, elaborates, and runs identically. If you came here expecting that mistake to be caught, it is not, because there is nothing wrong with it.

Why is it symmetric, mechanically? Because a binding is a statement of which two endpoints are connected, and that fact is the same regardless of which endpoint's bind method you happen to call. Under the hood, both socket kinds know how to connect their internal sc_port/sc_export pairs to a peer socket of the opposite kind; the overload that runs simply reaches across and cross-wires the four internal objects the same way either direction. Contrast this with an sc_port-to-sc_export binding in plain SystemC, where the port is the consumer and the export is the provider and the asymmetry is real — a socket hides that asymmetry inside itself by carrying both a port and an export, so at the socket level the two ends are peers. The practical upshot is that you should pick one convention for readability (this series always writes initiator.bind(target), mirroring the data-flow direction you usually care about), but you should never debug by flipping a bind, because flipping it changes nothing.

The genuinely meaningful direction lives elsewhere. The roles are fixed by socket type: a simple_initiator_socket is an initiator no matter who calls bind, and a simple_target_socket is a target. The data direction — whether bytes flow into or out of the target — is set by the payload's command field (TLM_READ_COMMAND vs TLM_WRITE_COMMAND), which has nothing to do with which end said bind. So "binding direction equals data direction" is a triple confusion: bind is symmetric, roles come from socket type, and data direction comes from the payload command. Keep those three separate.

Elaboration: when binding is checked, and the unbound-socket error

SystemC runs in two phases: elaboration (constructors run, modules instantiate, sockets bind) and simulation (sc_start runs processes). Binding happens during elaboration. At the end of elaboration, just before simulation starts, the kernel checks that every sc_port is bound — including the ports hidden inside every socket. A socket you forgot to bind fails this check. Here is a system with a deliberately unbound pair:

// file: sock_unbound.cpp  — NOTE: the bind line is intentionally missing
#include <systemc.h>
#include <tlm.h>
#include <tlm_utils/simple_initiator_socket.h>
#include <tlm_utils/simple_target_socket.h>

SC_MODULE(Memory) {
  tlm_utils::simple_target_socket<Memory> socket;
  unsigned char mem[256];
  SC_CTOR(Memory) : socket("socket") {
    socket.register_b_transport(this, &Memory::b_transport);
  }
  void b_transport(tlm::tlm_generic_payload& t, sc_time&) {
    t.set_response_status(tlm::TLM_OK_RESPONSE);
  }
};
SC_MODULE(Gen) {
  tlm_utils::simple_initiator_socket<Gen> socket;
  SC_CTOR(Gen) : socket("socket") {}
};
int sc_main(int, char*[]) {
  Gen gen("gen");
  Memory mem("mem");
  // BUG: forgot  gen.socket.bind(mem.socket);
  sc_start();
  return 0;
}

Actual output (the simulation aborts before any process runs):

Error: (E109) complete binding failed: port not bound: port 'mem.socket_port_0' (sc_port)
In file: ../../../src/sysc/communication/sc_port.cpp:235

Read the diagnostic. (E109) complete binding failed: port not bound is the elaboration kernel reporting that some sc_port never got a peer. The named port — mem.socket_port_0 — is the sc_port inside the target socket (its backward-path port, here), confirming that a socket is a composite of ports under the hood: when the socket is unbound, it is one of those internal ports that the checker names. The lesson is operational: an unbound socket is caught before simulation, not at the first b_transport. If you see (E109) ... port not bound naming a *_socket_port_*, you forgot a bind (or bound the wrong pair). Search your sc_main and module constructors for the socket named in the message.

The forgot-to-register error

The other classic mistake is binding correctly but never calling register_b_transport. The convenience target socket is bound, so elaboration passes — but when the first b_transport arrives at runtime, the forwarding shim has no callback to forward to, and it traps:

// file: sock_noreg.cpp  — bound correctly, but register_b_transport is missing
#include <systemc.h>
#include <tlm.h>
#include <tlm_utils/simple_initiator_socket.h>
#include <tlm_utils/simple_target_socket.h>

SC_MODULE(Memory) {
  tlm_utils::simple_target_socket<Memory> socket;
  unsigned char mem[256];
  SC_CTOR(Memory) : socket("socket") {
    // BUG: missing socket.register_b_transport(this, &Memory::b_transport);
  }
  void b_transport(tlm::tlm_generic_payload& t, sc_time&) {
    t.set_response_status(tlm::TLM_OK_RESPONSE);
  }
};
SC_MODULE(Gen) {
  tlm_utils::simple_initiator_socket<Gen> socket;
  SC_CTOR(Gen) : socket("socket") { SC_THREAD(run); }
  void run() {
    tlm::tlm_generic_payload trans;
    sc_time delay = SC_ZERO_TIME;
    unsigned int w = 1;
    trans.set_command(tlm::TLM_WRITE_COMMAND);
    trans.set_address(0); trans.set_data_ptr((unsigned char*)&w);
    trans.set_data_length(4); trans.set_streaming_width(4);
    trans.set_byte_enable_ptr(nullptr);
    trans.set_response_status(tlm::TLM_INCOMPLETE_RESPONSE);
    socket->b_transport(trans, delay);
  }
};
int sc_main(int, char*[]) {
  Gen gen("gen");
  Memory mem("mem");
  gen.socket.bind(mem.socket);
  sc_start();
  return 0;
}

Actual output:

Error: /OSCI_TLM-2/simple_socket: mem.socket: no blocking transport callback registered
In file: ../../../src/tlm_utils/convenience_socket_bases.cpp:42
In process: gen.run @ 0 s

Two things distinguish this from the unbound error. First, it fires at runtime (In process: gen.run @ 0 s), not at elaboration — the binding was fine, so the kernel let simulation start; the trap sprang only when a transaction actually arrived. Second, the message comes from tlm_utils (/OSCI_TLM-2/simple_socket), not the core kernel — it is the convenience socket's forwarding shim complaining that it has nowhere to forward. no blocking transport callback registered is the exact phrase to grep for. Fix: call register_b_transport(this, &Memory::b_transport) in the constructor. This is also why the raw socket from the Beginner section cannot hit this error — it has no shim and no registration step; the methods are simply virtual functions that exist or do not at compile time.

Hierarchical binding: passing a socket through a module boundary

Real platforms nest. A "subsystem" module might contain a CPU initiator inside it and need to expose that initiator's socket at the subsystem's own boundary, so the top level can bind the subsystem to a memory without reaching inside. This is hierarchical binding: a child socket bound up to a parent socket of the same kind, which then binds out to the eventual peer. The parent socket is a pure pass-through — no relay process, no extra b_transport hop. The clean way to write a pass-through initiator port is with the raw tlm_initiator_socket, which supports the child-to-parent (initiator-to-initiator) bind directly.

// file: sock_hierarchical.cpp
#include <systemc.h>
#include <tlm.h>
#include <tlm_utils/simple_initiator_socket.h>
#include <tlm_utils/simple_target_socket.h>
#include <iostream>

// Leaf initiator (inside the subsystem)
SC_MODULE(Gen) {
  tlm_utils::simple_initiator_socket<Gen> socket;
  SC_CTOR(Gen) : socket("socket") { SC_THREAD(run); }
  void run() {
    tlm::tlm_generic_payload trans;
    sc_time delay = SC_ZERO_TIME;
    unsigned int w = 0x5A5A;
    trans.set_command(tlm::TLM_WRITE_COMMAND);
    trans.set_address(0x10); trans.set_data_ptr((unsigned char*)&w);
    trans.set_data_length(4); trans.set_streaming_width(4);
    trans.set_byte_enable_ptr(nullptr);
    trans.set_response_status(tlm::TLM_INCOMPLETE_RESPONSE);
    socket->b_transport(trans, delay);
    std::cout << "leaf write resp=" << trans.get_response_string() << "\n";
  }
};

// A subsystem that CONTAINS an initiator and exposes its socket upward.
SC_MODULE(Subsystem) {
  tlm::tlm_initiator_socket<> socket;       // raw pass-through port at the boundary
  Gen gen;
  SC_CTOR(Subsystem) : socket("socket"), gen("gen") {
    gen.socket.bind(socket);                // child initiator -> parent initiator
  }
};

SC_MODULE(Memory) {
  tlm_utils::simple_target_socket<Memory> socket;
  unsigned char mem[256];
  SC_CTOR(Memory) : socket("socket") {
    socket.register_b_transport(this, &Memory::b_transport);
  }
  void b_transport(tlm::tlm_generic_payload& t, sc_time&) {
    unsigned int len = t.get_data_length(); sc_dt::uint64 a = t.get_address();
    unsigned char* p = t.get_data_ptr();
    for (unsigned i = 0; i < len; i++) mem[a + i] = p[i];
    t.set_response_status(tlm::TLM_OK_RESPONSE);
  }
};

int sc_main(int, char*[]) {
  Subsystem sub("sub");
  Memory mem("mem");
  sub.socket.bind(mem.socket);  // top level binds the exported subsystem socket
  sc_start();
  return 0;
}

Expected output:

leaf write resp=TLM_OK_RESPONSE

Trace the binding chain. Inside the subsystem constructor, gen.socket.bind(socket) binds the child initiator socket up to the parent's initiator socket — initiator-to-initiator, the hierarchical case. At the top level, sub.socket.bind(mem.socket) binds the parent initiator socket out to the memory's target socket. The two binds compose into one continuous forward path: Gen → (subsystem boundary) → Memory. When Gen::run calls socket->b_transport(...), the call traverses the pass-through and lands directly in Memory::b_transport — no intermediate process, no extra delta, no b_transport re-implementation in the subsystem. The subsystem socket is a conduit, not a relay.

One practical note that this example encodes: use the raw tlm_initiator_socket for the pass-through boundary port. The convenience simple_initiator_socket is engineered for the leaf case (it owns the backward-interface implementation), and using it as a hierarchical pass-through is fiddlier than using the raw socket, which is built precisely for binding initiator-to-initiator. The same applies to a target pass-through: use a raw tlm_target_socket as the boundary port and bind the inner target up into it. The leaf modules still use convenience sockets; only the boundary conduits are raw.

Advanced: Edge Cases & LRM Corners

The Beginner and Intermediate sections covered the one-to-one case that the great majority of models use. This section is the part the tutorials skip: what happens when one target must serve several initiators, why you cannot do that by binding two sockets of the same kind, the tagged and multi-passthrough sockets that solve it, socket lifetime and ownership rules, and the BUSWIDTH parameter that must match across a binding.

Corner 1: you cannot bind two sockets of the same kind

A one-to-one socket's sc_export may be bound exactly once. The instinct, when two initiators need to reach one memory, is to bind both initiator sockets to the one target socket — or, worse, to bind two targets together. Both fail, and the diagnostic is worth seeing. Here are two targets bound to each other:

// file: sock_two_targets.cpp  — illegal: two target sockets bound together
#include <systemc.h>
#include <tlm.h>
#include <tlm_utils/simple_target_socket.h>
SC_MODULE(Memory) {
  tlm_utils::simple_target_socket<Memory> socket;
  SC_CTOR(Memory) : socket("socket") {
    socket.register_b_transport(this, &Memory::b_transport);
  }
  void b_transport(tlm::tlm_generic_payload& t, sc_time&) {
    t.set_response_status(tlm::TLM_OK_RESPONSE);
  }
};
int sc_main(int, char*[]) {
  Memory a("a"), b("b");
  a.socket.bind(b.socket);   // two TARGETS — both provide a forward export
  sc_start();
  return 0;
}

Actual output:

Error: (E126) sc_export instance already bound: a.socket
In file: /usr/local/systemc-3.0.0/include/sysc/communication/sc_export.h:181

(E126) sc_export instance already bound is the kernel reporting that a socket's internal sc_export got a second binding (or, as here, that two exports collided). The same error appears if you try to bind two initiators to one one-to-one target socket: the target's single forward export accepts the first initiator and rejects the second. The rule: a one-to-one socket pair is exactly that — one initiator, one target. Fan-out and fan-in need a different socket. If you see (E126) ... already bound naming a socket, you tried to connect more than two endpoints to a one-to-one socket.

Corner 2: tagged and multi-passthrough sockets for fan-in

When one target legitimately serves several initiators — a shared memory, a bus arbiter — you replace the one-to-one target socket with a multi-passthrough target socket. It accepts many bindings and, crucially, passes a connection index to your callback so the target knows which initiator each call came from. This is the "tagged" idea: the socket tags every incoming call with the index of the binding it arrived on.

// file: sock_tagged.cpp  — two initiators into one target via a multi socket
// Build: g++ -std=c++17 -DSC_ALLOW_DEPRECATED_IEEE_API \
//          -I/usr/local/systemc-3.0.0/include sock_tagged.cpp \
//          -L/usr/local/systemc-3.0.0/lib-macosarm64 -lsystemc -o sock_tagged
#include <systemc.h>
#include <tlm.h>
#include <tlm_utils/simple_initiator_socket.h>
#include <tlm_utils/multi_passthrough_target_socket.h>
#include <iostream>

SC_MODULE(Gen) {
  tlm_utils::simple_initiator_socket<Gen> socket;
  unsigned int tag; sc_dt::uint64 addr;
  SC_HAS_PROCESS(Gen);
  Gen(sc_module_name n, unsigned int t, sc_dt::uint64 a)
    : sc_module(n), socket("socket"), tag(t), addr(a) { SC_THREAD(run); }
  void run() {
    tlm::tlm_generic_payload trans;
    sc_time delay = SC_ZERO_TIME;
    unsigned int w = 0x100 + tag;
    trans.set_command(tlm::TLM_WRITE_COMMAND);
    trans.set_address(addr); trans.set_data_ptr((unsigned char*)&w);
    trans.set_data_length(4); trans.set_streaming_width(4);
    trans.set_byte_enable_ptr(nullptr);
    trans.set_response_status(tlm::TLM_INCOMPLETE_RESPONSE);
    socket->b_transport(trans, delay);
  }
};

SC_MODULE(Memory) {
  // ONE target socket that accepts MANY initiators; each call carries its index.
  tlm_utils::multi_passthrough_target_socket<Memory> socket;
  unsigned char mem[256];
  SC_CTOR(Memory) : socket("socket") {
    for (int i = 0; i < 256; i++) mem[i] = 0;
    socket.register_b_transport(this, &Memory::b_transport);
  }
  // NOTE the extra leading int: which initiator (the connection index)
  void b_transport(int id, tlm::tlm_generic_payload& t, sc_time&) {
    unsigned int len = t.get_data_length(); sc_dt::uint64 a = t.get_address();
    unsigned char* p = t.get_data_ptr();
    for (unsigned i = 0; i < len; i++) mem[a + i] = p[i];
    std::cout << "served initiator #" << id << " @0x" << std::hex << a << "\n";
    t.set_response_status(tlm::TLM_OK_RESPONSE);
  }
};

int sc_main(int, char*[]) {
  Gen g0("g0", 0, 0x00);
  Gen g1("g1", 1, 0x40);
  Memory mem("mem");
  g0.socket.bind(mem.socket);   // becomes connection index 0
  g1.socket.bind(mem.socket);   // becomes connection index 1
  sc_start();
  return 0;
}

Expected output:

served initiator #0 @0x0
served initiator #1 @0x40

The differences from the one-to-one target are exactly two. First, the socket type is multi_passthrough_target_socket<Memory> instead of simple_target_socket<Memory> — that is what lets two initiators bind to it without the (E126) collision. Second, the registered callback takes an extra leading int — the connection index. g0 bound first, so its transactions arrive with id == 0; g1 bound second, so it is id == 1. The target can route, arbitrate, or simply log per-initiator using that tag. There is a matching multi_passthrough_initiator_socket for the fan-out case (one initiator socket bound to several targets), and tagged single-bind variants (simple_target_socket_tagged) when you want the index but not the many-binding. For the bus fabric in Part 6 the fan-out initiator socket is the natural fit, because a bus has one upstream port and many downstream targets; we develop it there. Treat the multi/tagged sockets as the standard tool the moment a connection stops being strictly one-to-one — do not fight the (E126) error by stacking one-to-one sockets.

Corner 3: socket lifetime and ownership

A socket is a member sub-object of its module, constructed in the module's constructor initializer list (socket("socket")) and destroyed with the module. Two ownership rules follow. First, the socket must outlive every transaction that uses it. Because binding is structural (fixed at elaboration) and modules live for the whole simulation, this is automatic for sockets declared as module members — which is the only correct way to declare them. Never heap-allocate a socket with a lifetime shorter than the module, and never bind a socket that is about to go out of scope. Second, the socket does not own the payload or its data buffer. The socket routes the b_transport call; the tlm_generic_payload and the buffer its data pointer names are owned by the initiator (as Part 2 established). The socket has no part in payload memory management — it neither allocates nor frees the payload, and the convenience socket's forwarding shim passes the payload reference straight through untouched. Conflating "the socket" with "the transaction" is a category error: the socket is permanent structure fixed at elaboration; the transaction is transient data created and destroyed during simulation.

Corner 4: BUSWIDTH and TYPES must match across a binding

Sockets are templated on a BUSWIDTH (default 32) and a protocol TYPES traits bundle (default tlm_base_protocol_types, which is what makes tlm_generic_payload the carried payload). Both ends of a binding must agree. A simple_initiator_socket<M, 64> will not bind to a simple_target_socket<T, 32>; the compiler rejects the bind with a template-mismatch error, because the two sockets are instantiated on different tlm_fw_transport_if types. The error message points at mismatched interface template instantiations and is not always self-evident, so the practical rule is: specify BUSWIDTH explicitly and identically on every socket in a connected fabric. All examples in this section use the default 32 (the template argument omitted), which is internally consistent; the moment you move to a 64-bit fabric, set 64 on every socket at once. The BUSWIDTH constrains the data-word width the protocol assumes; it does not itself move data, which still travels through the payload's byte buffer — so widening the bus is a matter of consistency across bindings, not of changing how bytes are carried.

Version differences

The socket classes, the forward/backward interface split (tlm_fw_transport_if / tlm_bw_transport_if), the bind/operator() semantics, hierarchical binding, and the tlm_utils convenience, tagged, and multi_passthrough_* sockets are identical across SystemC 2.3.0, 2.3.1, 2.3.3, 2.3.4, and 3.0.x. The headers ship bundled with the kernel from 2.3.0 onward. SystemC 3.0.x tracks IEEE 1666-2023, which deprecates the SC_HAS_PROCESS(name) macro used by the raw-socket example here (3.0 prefers declaring processes via SC_CTOR/SC_THREAD directly) and emits a deprecation note if you use it, but none of the socket semantics changed. Every example compiles and runs identically on any 2.3.x or 3.0.x build; the diagnostics quoted in this post are verbatim from the SystemC 3.0.1 build the examples were checked against.

Hands-on exercise

Build a two-initiator, one-target system using a tagged (multi-passthrough) target socket, and prove the target can tell its initiators apart.

The target models a 256-byte memory with a multi_passthrough_target_socket. Its b_transport callback takes the leading connection-index int. On every access it:

  • Services the read or write against its byte array exactly like the Beginner memory.
  • Range-checks address + length against 256 and returns TLM_ADDRESS_ERROR_RESPONSE (without touching memory) on overflow — the response discipline from Part 2 still applies.
  • Prints the connection index, the command, and the address, so you can see which initiator each transaction came from.
  • Sets TLM_OK_RESPONSE on success.

Drive it with two SC_THREAD initiators, each owning a simple_initiator_socket, bound to the target so they become connection indices 0 and 1. Have initiator 0 write a known word to the low half of memory and read it back; have initiator 1 write a different known word to the high half and read it back. Each initiator checks its own round-trip and prints PASS/FAIL. Confirm from the output that the index printed by the target matches the initiator that issued each access, and that neither initiator's data corrupts the other's (because they target different addresses).

When that works, extend it three ways:

  1. Add a deliberate out-of-range access from one initiator (e.g. a 4-byte access at address 0xFE) and confirm the target returns TLM_ADDRESS_ERROR_RESPONSE and the initiator catches it with is_response_error().
  2. Wrap both initiators inside a single Subsystem module that exposes the two leaf initiator sockets through raw tlm_initiator_socket pass-through ports at its boundary, and bind those to the memory at the top level. This combines hierarchical binding with the tagged target.
  3. Make the target arbitrate: if both initiators were to access overlapping addresses, have it log a warning using the connection index to name the offender. (You will need to give each initiator a distinct, deterministic access pattern to trigger or avoid the overlap on purpose.)

Predict, before running: which connection index does the second-bound initiator get? What happens to the index numbering if you bind the initiators in the opposite order in sc_main?

Hints

  • The target callback signature is void b_transport(int id, tlm::tlm_generic_payload& trans, sc_time& delay) — the leading int is mandatory for a multi-passthrough socket and is the thing that makes this exercise different from the one-to-one targets.
  • Connection index is assigned in bind order: the first g.socket.bind(mem.socket) in sc_main is index 0, the second is index 1. Reordering the binds renumbers the indices.
  • Reuse the Beginner memory's read/write/range-check body verbatim inside the tagged callback; only the signature and the index print are new.
  • For the hierarchical extension, the subsystem's boundary ports are raw tlm::tlm_initiator_socket<> (one per leaf initiator), each bound up from a leaf with leaf.socket.bind(boundary_socket) in the subsystem constructor, then out with sub.boundary_socket.bind(mem.socket) at the top level — exactly the pattern from the Intermediate hierarchical example, repeated twice.
  • Keep the response-status discipline from Part 2: initiator sets TLM_INCOMPLETE_RESPONSE before each call and checks after; target sets a definite status before returning, and returns before any memory access on a range error.

No solution is provided. The understanding lives in getting the tagged-callback signature, the bind-order index numbering, and the hierarchical pass-through right yourself.

Common mistakes

  • Assuming the data travels "through the socket." The socket routes the b_transport call; the bytes ride in the tlm_generic_payload passed by reference. A read does not "return data over the socket" — the target writes into the buffer the initiator's data pointer names, and the initiator reads it after the call returns. Treating the socket as a data channel leads to confusion about buffer ownership and lifetime. Fix: socket carries calls, payload carries data — keep them mentally separate.
  • Forgetting register_b_transport on a convenience target socket. Binding succeeds, elaboration passes, and then the first transaction trips no blocking transport callback registered at runtime (In process: ... @ 0 s). The socket's forwarding shim has nowhere to forward. Fix: call socket.register_b_transport(this, &M::b_transport) in the target's constructor; grep the exact phrase if you see the error.
  • Leaving a socket unbound. An unbound socket fails the end-of-elaboration port-binding check with (E109) complete binding failed: port not bound, naming a *_socket_port_*. This fires before any process runs. Fix: ensure every socket gets exactly one bind (or hierarchical pass-through); search sc_main and constructors for the named socket.
  • Trying to bind two sockets of the same kind, or two initiators to one one-to-one target. A one-to-one socket's sc_export binds exactly once; a second binding raises (E126) sc_export instance already bound. Fix: use a multi_passthrough_target_socket (fan-in) or multi_passthrough_initiator_socket (fan-out) the moment a connection stops being strictly one-to-one — do not stack one-to-one sockets.
  • Expecting "binding backwards" to be an error. target.bind(initiator) is symmetric with initiator.bind(target) and is equally legal — it is not a bug and is not flagged. The roles come from socket type, and the data direction comes from the payload's command field, neither of which depends on which end called bind. Fix: do not hunt for a "wrong bind direction" bug; look at socket types and the payload command instead.
  • Using a convenience socket as a hierarchical pass-through. simple_initiator_socket/simple_target_socket are built for the leaf case (they own the opposite-direction interface implementation). For a boundary conduit that just passes a socket up through a module, use the raw tlm_initiator_socket/tlm_target_socket, which support initiator-to-initiator and target-to-target binding directly. Fix: leaf modules use convenience sockets; boundary pass-through ports use raw sockets.
  • Mismatched BUSWIDTH (or TYPES) across a binding. A 64-bit initiator socket will not bind to a 32-bit target socket; the compiler rejects it with a template-instantiation mismatch that is hard to read. Fix: set BUSWIDTH explicitly and identically on every socket in a connected fabric.

Recap

After working through this post you can now:

  • State the core abstraction — a TLM-2.0 socket is a connector for interface calls, not a wire for bits — and explain why the data rides in the payload, not the socket.
  • Describe the two paths every socket bundles: the forward path (initiator→target: b_transport, nb_transport_fw, get_direct_mem_ptr, transport_dbg, grouped in tlm_fw_transport_if) and the backward path (target→initiator: nb_transport_bw, invalidate_direct_mem_ptr, grouped in tlm_bw_transport_if), and explain why LT models exercise only the forward path while AT needs both.
  • Implement a target's forward interface by hand against a raw tlm_target_socket, then collapse it to one register_b_transport line with a simple_target_socket, and say precisely what the convenience socket hides.
  • Bind an initiator to a target, explain that bind is symmetric and that direction-of-bind equals neither role nor data direction, and bind sockets through a module boundary with hierarchical pass-through.
  • Diagnose the three load-bearing socket errors on sight from their real messages: (E109) ... port not bound (unbound socket, at elaboration), no blocking transport callback registered (forgot register_b_transport, at runtime), and (E126) sc_export instance already bound (tried to over-connect a one-to-one socket).
  • Serve several initiators from one target with a multi_passthrough_target_socket, read the connection index in the callback, and reach for the tagged/multi sockets instead of stacking one-to-one sockets.
  • Reason about socket lifetime and ownership: sockets are permanent module members fixed at elaboration; the payload and its buffer are owned by the initiator, not the socket.

Further reading

Standards

  • IEEE Std 1666-2011, IEEE Standard for Standard SystemC® Language Reference Manual, §10.2 (transport interfaces — the forward/backward tlm_fw_transport_if / tlm_bw_transport_if split), §10.5 (sockets: the sc_port/sc_export membership, binding and hierarchical-binding semantics), and §10.3 (generic payload, for the call argument the socket routes).

Vendor and consortium documents

  • Aynsley (Doulos), OSCI TLM-2.0 Language Reference Manual (JA32) — the canonical narrative on sockets, the convenience-socket pattern, and the forward/backward interface pair.
  • Doulos TLM-2.0 Tutorial — worked initiator/target/interconnect examples and the binding diagrams.
  • Accellera Systems Initiative, SystemC 3.0.0 distribution, include/tlm_core/tlm_2/tlm_sockets/ (raw tlm_initiator_socket / tlm_target_socket) and include/tlm_utils/ (simple_*, multi_passthrough_*, tagged variants) — authoritative source for the socket classes and the convenience forwarding shim.

Textbooks

  • Grötker, Liao, Martin, and Swan, System Design with SystemC — transaction-level modeling chapters; the port/export foundation that sockets are built on.

Next in this section

→ Part 4: Non-Blocking Transport & the AT Coding Style — where the backward path finally earns its keep: nb_transport_fw and nb_transport_bw, the four-phase BEGIN_REQ/END_REQ/BEGIN_RESP/END_RESP handshake, and how a transaction split into phases lets initiators keep multiple requests in flight. Read it here: 19. SystemC Tutorial — Non-Blocking Transport.

Author
Mayur Kubavat
DV engineer working on SoC verification. Writes here about UVM, PCIe, SystemVerilog, and the everyday craft of getting designs to tape-out.

Comments (0)

Leave a Comment