Modeling Best Practices: Public API Contracts
How to define stable contracts for reusable SystemC models: sockets, ports, registers, parameters, reports, traces, and ownership.
How to Read This Lesson
This best-practice lesson is written for code reviews. Use it to decide what should be portable standard behavior, what is an implementation detail, and what needs a project rule.
Modeling Best Practices: Public API Contracts
A SystemC model used by other teams has an API even if nobody calls it a library. If you change a parameter name, a report ID, or a TLM socket behavior, you break the build or the simulations of downstream users.
To write industrial-grade Virtual Platform (VP) models, you must define and stabilize your Public API Contracts. Let us examine how these contracts map directly to Accellera kernel structures.
Source and LRM Trail
Best-practice lessons should be traceable. Use Docs/LRMs/SystemC_LRM_1666-2023.pdf, the domain LRMs for AMS/CCI/UVM when relevant, .codex-src/systemc, .codex-src/cci, .codex-src/uvm-systemc, and .codex-src/systemc-common-practices. Mark what is portable, what is source insight, and what is project policy.
The Contract Surfaces
In a professional SystemC IP block, the following elements constitute the public contract and must not be broken or changed without version bumping:
- Module Hierarchy Names: Do not use
sc_gen_unique_name()for top-level objects.- Kernel Reality: The kernel maintains a global hash table in
sc_object_managermapping hierarchical string paths tosc_object*. Automated configuration tools (via CCI) rely on these paths. Generating unique names dynamically breaks reproducible configurations.
- Kernel Reality: The kernel maintains a global hash table in
- Ports and Exports: Ensure the data types and interface types are completely stable.
- TLM Sockets: Which payload extensions are required? What byte enables are supported?
- Register Map: Base offsets, bitfields, and reset behaviors.
- CCI Parameters: Hierarchical paths, metadata, default values, and mutability.
- Kernel Reality: These are registered in the singleton
cci_broker_if. If a parameter name changes, top-level JSON configurations loaded by the broker will fail to apply.
- Kernel Reality: These are registered in the singleton
- Report IDs (
msg_type): The exact strings used inSC_REPORT_WARNINGandSC_REPORT_ERROR.- Kernel Reality: The
sc_report_handlermaintains a rule map comparing the exactmsg_typestring to decide whether toSC_DISPLAY,SC_LOG, orSC_STOP. Breaking the string breaks the user's simulation filters.
- Kernel Reality: The
- DMI / Debug Transport: Whether the model safely supports backdoor memory access without side-effects.
Socket Contracts
For each TLM socket, your Doxygen or Markdown documentation must answer:
- What happens if
get_byte_enable_ptr() != nullptr? - Are
TLM_IGNORE_COMMANDpayloads handled gracefully? - What is the expected streaming width?
- If it is an AT (Approximately Timed) target, which protocol phases does it actively use?
- Memory Management: For
b_transportandnb_transport, does the target adhere totlm_mm_interfacereference counting? Fortransport_dbg, does it safely bypassacquire()andrelease()? (Debug transactions do not use the memory pool).
Complete Example: Designing a Contract-Safe IP Block
Here is a complete sc_main demonstrates an IP block that treats its external surface as a strict contract, validating inputs safely and exposing a clean, documented hierarchy.
#include <systemc>
#include <tlm>
#include <tlm_utils/simple_target_socket.h>
#include <iostream>
// ---------------------------------------------------------
// IP BLOCK WITH STRICT CONTRACTS
// ---------------------------------------------------------
class ContractSafeIP : public sc_core::sc_module {
public:
// Contract 1: Stable Socket Name
tlm_utils::simple_target_socket<ContractSafeIP> target_socket{"target_socket"};
// Contract 2: Stable Port Name
sc_core::sc_out<bool> interrupt_out{"interrupt_out"};
// Contract 3: Documented Register Map
static constexpr uint64_t REG_STATUS = 0x00;
static constexpr uint64_t REG_DATA = 0x04;
SC_HAS_PROCESS(ContractSafeIP);
// Contract 4: Predictable Constructor
ContractSafeIP(const sc_core::sc_module_name& name)
: sc_core::sc_module(name), internal_data(0) {
// Register standard blocking transport callback
target_socket.register_b_transport(this, &ContractSafeIP::b_transport);
// Register standard debug transport callback (No side-effects!)
target_socket.register_transport_dbg(this, &ContractSafeIP::transport_dbg);
}
private:
uint32_t internal_data;
// --- Blocking Transport (Functional Behavior) ---
void b_transport(tlm::tlm_generic_payload& trans, sc_core::sc_time& delay) {
tlm::tlm_command cmd = trans.get_command();
uint64_t adr = trans.get_address();
unsigned int len = trans.get_data_length();
unsigned char* byt = trans.get_byte_enable_ptr();
// Contract Enforcement: No byte enables supported
if (byt != nullptr) {
trans.set_response_status(tlm::TLM_BYTE_ENABLE_ERROR_RESPONSE);
return;
}
// Contract Enforcement: Must be 32-bit access
if (len != 4) {
trans.set_response_status(tlm::TLM_BURST_ERROR_RESPONSE);
return;
}
// Functional side-effects happen here
if (cmd == tlm::TLM_WRITE_COMMAND && adr == REG_DATA) {
memcpy(&internal_data, trans.get_data_ptr(), 4);
interrupt_out.write(true); // Side-effect!
// Contract 5: Stable Report ID.
// The sc_report_handler uses this exact string to filter output.
SC_REPORT_INFO("IP_BLOCK/WRITE", "Data register updated, interrupt asserted.");
}
else if (cmd == tlm::TLM_READ_COMMAND && adr == REG_DATA) {
memcpy(trans.get_data_ptr(), &internal_data, 4);
interrupt_out.write(false); // Side-effect!
}
else {
trans.set_response_status(tlm::TLM_ADDRESS_ERROR_RESPONSE);
return;
}
trans.set_response_status(tlm::TLM_OK_RESPONSE);
delay += sc_core::sc_time(10, sc_core::SC_NS);
}
// --- Debug Transport (No Side Effects!) ---
unsigned int transport_dbg(tlm::tlm_generic_payload& trans) {
if (trans.get_command() == tlm::TLM_READ_COMMAND && trans.get_address() == REG_DATA) {
if (trans.get_data_length() >= 4) {
memcpy(trans.get_data_ptr(), &internal_data, 4);
// Notice: We do NOT clear the interrupt here! Debug is invisible.
// We also do not call trans.acquire() or release() for debug payloads.
return 4;
}
}
return 0;
}
};
// ---------------------------------------------------------
// TESTBENCH
// ---------------------------------------------------------
int sc_main(int argc, char* argv[]) {
sc_core::sc_signal<bool> irq{"irq"};
ContractSafeIP ip_inst("ip_inst");
ip_inst.interrupt_out(irq);
// Dummy payload for testing
tlm::tlm_generic_payload trans;
sc_core::sc_time delay = sc_core::SC_ZERO_TIME;
uint32_t data = 0xABCD;
trans.set_command(tlm::TLM_WRITE_COMMAND);
trans.set_address(ContractSafeIP::REG_DATA);
trans.set_data_ptr(reinterpret_cast<unsigned char*>(&data));
trans.set_data_length(4);
trans.set_response_status(tlm::TLM_INCOMPLETE_RESPONSE);
std::cout << "Sending TLM Write...\n";
ip_inst.target_socket->b_transport(trans, delay);
if (trans.is_response_ok()) {
std::cout << "Write Successful. IRQ State: " << irq.read() << "\n";
}
return 0;
}Explanation of the Execution
Sending TLM Write...
Info: (I804) /IEEE_Std_1666/main: IP_BLOCK/WRITE: Data register updated, interrupt asserted.
Write Successful. IRQ State: 1
By explicitly checking byte enables, length, and addresses, the IP block guarantees it will not crash with a segmentation fault if an integrator misuses it. By separating b_transport from transport_dbg, it allows software debuggers (GDB connected to the VP) to inspect memory without accidentally triggering hardware state machines.
This level of rigor is what transforms "academic SystemC" into "industrial SystemC".
Deep Dive: Accellera Source for sc_signal and update()
The sc_signal<T> channel perfectly illustrates the Evaluate-Update paradigm of SystemC. In the Accellera source (src/sysc/communication/sc_signal.cpp), sc_signal inherits from sc_prim_channel.
The write() Implementation
When you call write(const T&), the signal does not immediately change its value. Instead, it stores the requested value in m_new_val and registers itself with the kernel:
template<class T>
inline void sc_signal<T>::write(const T& value_) {
if( !(m_new_val == value_) ) {
m_new_val = value_;
this->request_update(); // Inherited from sc_prim_channel
}
}The request_update() call appends the channel to sc_simcontext::m_update_list.
The update() Phase
After the Evaluate phase finishes (all ready processes have run), the kernel iterates over m_update_list and calls the update() virtual function on each primitive channel. For sc_signal, this looks like:
template<class T>
inline void sc_signal<T>::update() {
if( !(m_new_val == m_cur_val) ) {
m_cur_val = m_new_val;
m_value_changed_event.notify(SC_ZERO_TIME); // Notify processes sensitive to value_changed_event()
}
}This guarantees that all concurrent processes see the same old value until the delta cycle advances, perfectly mimicking hardware register delays.
Comments and Corrections