Chapter 11: Advanced Core Semantics

Deep Dive: sc_vector Internals and Dynamic Assembly

An exhaustive look into the IEEE 1666-2023 rules and Accellera source code implementation for sc_vector, custom creators, and dynamic port assembly.

How to Read This Lesson

These core semantics are where experienced SystemC engineers earn their calm. We will name the scheduler rule, then show how the source enforces it.

Deep Dive: sc_vector Internals and Dynamic Assembly

When designing large SystemC architectures—like multi-core processors, Network-on-Chip (NoC) routers, or massive memory banks—manually declaring and binding individual ports and submodules becomes unmanageable. To solve this, the IEEE 1666 standard defines sc_vector (Section 8.5 of the LRM), a dedicated container for managing collections of SystemC objects (sc_object).

Unlike std::vector, sc_vector is designed explicitly to participate in the SystemC elaboration hierarchy. Let's dig into the Accellera kernel source code to understand how sc_vector manages naming, sc_object_manager scopes, and dynamic assembly.

Source and LRM Trail

Advanced core behavior should always be checked against Docs/LRMs/SystemC_LRM_1666-2023.pdf before source details. For implementation, read .codex-src/systemc/src/sysc/kernel and .codex-src/systemc/src/sysc/communication, especially the scheduler, events, object hierarchy, writer policy, report handler, and async update path.

The Problem with std::vector in SystemC

You might wonder: Why not just use std::vector<sc_in<int>>?

SystemC relies on the object hierarchy (the sc_object tree) built during elaboration. When you create an sc_object (like a port or module), its constructor pushes an sc_module_name object onto a static stack inside the sc_object_manager. If you create a std::vector of ports, the standard library allocates them contiguously in memory via new[]. The C++ default constructor is invoked, passing no names, which completely bypasses the sc_module_name stack. Your ports end up unnamed and orphans in the kernel hierarchy.

sc_vector solves this by:

  1. Deriving from sc_object itself, becoming a valid node in the hierarchy.
  2. Enforcing that it is populated via an init() method rather than C++ standard allocators.
  3. Dynamically generating names (my_vector_name_0, my_vector_name_1) and explicitly constructing sc_module_name objects for each element.

Source Code Mechanics: How sc_vector Works

If you peek into the Accellera SystemC repository (sysc/utils/sc_vector.h), you'll notice that sc_vector<T> inherits from sc_vector_base. It acts as a wrapper around an internal std::vector<void*>.

When .init(size) is called, the kernel executes a loop. For each index i:

  1. It uses sprintf or std::string concatenation to append "_" and i to the vector's name.
  2. It invokes a creator function.
  3. The creator invokes the T constructor, taking the generated string and casting it into an sc_module_name.
  4. The pointer to the created object is pushed into the underlying std::vector.

By default, it uses a standard creator that just passes the name. However, if your module requires additional constructor arguments (like an ID, a configuration object, or a memory map), you must use a custom creator.

A custom creator is a function object (or a lambda in modern C++) that takes two arguments:

  1. const char* name: The generated name for the element.
  2. size_t index: The index of the element being created.

End-to-End Example: Vector Assembly and Custom Creators

The following code demonstrates an exhaustive, 100% compilable example of:

  1. Creating a custom module (Worker) that requires an id in its constructor.
  2. Using a lambda function as a custom creator for sc_vector<Worker>.
  3. Assembling and binding vectors of ports to vectors of signals.
#include <systemc>
#include <iostream>
#include <vector>
 
// -------------------------------------------------------------------------
// 1. A custom module requiring arguments beyond just a name
// -------------------------------------------------------------------------
class Worker : public sc_core::sc_module {
public:
    sc_core::sc_in<bool> clk;
    sc_core::sc_in<int>  data_in;
    sc_core::sc_out<int> data_out;
 
    int worker_id;
 
    // Notice the constructor takes an ID alongside the name
    Worker(sc_core::sc_module_name name, int id) 
        : sc_core::sc_module(name), worker_id(id) 
    {
        SC_METHOD(process_data);
        sensitive << clk.pos();
        dont_initialize();
    }
 
    void process_data() {
        int val = data_in.read();
        // Simple operation: multiply input by worker ID
        data_out.write(val * worker_id);
        std::cout << "@" << sc_core::sc_time_stamp() << " " 
                  << name() << " processed data: " << val 
                  << " -> " << (val * worker_id) << std::endl;
    }
};
 
// -------------------------------------------------------------------------
// 2. The Top-level module managing vectors
// -------------------------------------------------------------------------
class Top : public sc_core::sc_module {
public:
    sc_core::sc_in<bool> clk;
 
    // Vectors of signals
    sc_core::sc_vector<sc_core::sc_signal<int>> sig_in;
    sc_core::sc_vector<sc_core::sc_signal<int>> sig_out;
 
    // Vector of submodules
    sc_core::sc_vector<Worker> workers;
 
    SC_HAS_PROCESS(Top);
 
    Top(sc_core::sc_module_name name, size_t num_workers)
        : sc_core::sc_module(name), 
          // Initialize signal vectors with default creators
          sig_in("sig_in", num_workers), 
          sig_out("sig_out", num_workers),
          // Defer initialization of workers (no size passed here)
          workers("workers")
    {
        // Use a lambda as a custom creator for the workers vector
        // The LRM specifies the signature: T* creator(const char* name, size_t index)
        auto worker_creator = [](const char* n, size_t i) -> Worker* {
            // We pass the index 'i' + 1 as the worker ID
            // The string 'n' is safely cast to an sc_module_name inside Worker
            return new Worker(n, i + 1);
        };
 
        // Initialize the workers vector with the size and the custom creator
        workers.init(num_workers, worker_creator);
 
        // Dynamic Assembly (Binding)
        // sc_vector allows binding a vector of ports to a vector of signals
        // using the sc_assemble_vector utility or manual iteration.
        for (size_t i = 0; i < num_workers; ++i) {
            workers[i].clk(clk);
            workers[i].data_in(sig_in[i]);
            workers[i].data_out(sig_out[i]);
        }
 
        SC_THREAD(stimulus);
    }
 
    void stimulus() {
        for (int i = 0; i < 3; ++i) {
            // Write distinct data to each worker's input signal
            for (size_t w = 0; w < workers.size(); ++w) {
                sig_in[w].write((i + 1) * 10);
            }
            wait(10, sc_core::SC_NS); // Wait for processing
        }
        sc_core::sc_stop();
    }
};
 
// -------------------------------------------------------------------------
// 3. sc_main execution
// -------------------------------------------------------------------------
int sc_main(int argc, char* argv[]) {
    // Suppress Info messages to keep output clean
    sc_core::sc_report_handler::set_actions("/IEEE_Std_1666/deprecated", sc_core::SC_DO_NOTHING);
 
    sc_core::sc_clock clock("clock", 10, sc_core::SC_NS);
    
    Top top("top_module", 4); // Instantiate with 4 workers
    top.clk(clock);
 
    std::cout << "Starting simulation with sc_vector..." << std::endl;
    sc_core::sc_start();
    std::cout << "Simulation finished." << std::endl;
 
    return 0;
}

sc_assemble_vector Utility

In the example above, we bound ports manually using a for loop. The Accellera source provides sc_assemble_vector, which uses an internal sc_vector_iter and pointer-to-member iterators to extract ports from child modules en masse.

If you have a vector of submodules, and you want to extract a specific port from all of them into a new vector of ports (or bind them directly to a vector of signals), sc_assemble_vector avoids manual loops.

// Example of using sc_assemble_vector (pseudo-code)
sc_core::sc_vector<sc_core::sc_in<int>> aggregated_ports("aggregated_ports");
 
// Extract the 'data_in' port from every worker and aggregate them
// Under the hood, this iterates over workers[i].*&Worker::data_in
sc_core::sc_assemble_vector(workers, &Worker::data_in).bind(aggregated_ports);
 
// Now you can bind the aggregated ports directly to a signal vector!
aggregated_ports.bind(sig_in);

Note: Due to compiler variations in pointer-to-member resolution, explicitly looping as shown in the complete example is often considered the safest and most readable fallback in production IP.

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