Chapter 12: Virtual Platform Construction

Peripheral Modeling: GPIO

Modeling General Purpose Input/Output (GPIO) pins combining TLM registers and discrete SystemC signals.

How to Read This Lesson

For virtual platforms, imagine a firmware engineer trying to boot real software on your model. Every abstraction choice should help that person move faster without lying about the hardware.

Peripheral Modeling: GPIO

So far, our Virtual Platform peripherals (RAM, Timer) have communicated entirely through TLM sockets. However, many peripherals interact with the outside world via physical wires. A General Purpose Input/Output (GPIO) peripheral is the perfect example of a bridge between memory-mapped TLM configuration and standard sc_signal hardware pins.

Source and LRM Trail

Virtual platform lessons combine standard TLM behavior with architecture practice. Use Docs/LRMs/SystemC_LRM_1666-2023.pdf for TLM and kernel rules, .codex-src/systemc/src/tlm_core/tlm_2 for sockets and payloads, .codex-src/cci for configurable platforms, and .codex-src/systemc-common-practices for reusable patterns.

The GPIO Architecture

A standard GPIO peripheral exposes:

  1. TLM Target Socket: To receive configuration (Pin Direction) and data (Output Value) from the CPU via memory-mapped registers.
  2. Standard SystemC Ports (sc_out / sc_in): The actual external hardware pins that toggle high or low.

Complete GPIO Peripheral Example & TLM Timing Pitfalls

Here is a complete sc_main model demonstrates an 8-bit GPIO controller. The CPU sets the pin directions (Input or Output) via the DIR register, and drives the pins via the OUT register.

Under the Hood (The LT/DE Sync Problem): There is a massive structural pitfall when mixing LT TLM and discrete-event pins. In the code below, update_hardware_pins() uses pins_out.write(). Because b_transport is called by a temporally decoupled LT initiator, the global sc_time_stamp() might be 0 ns, while the delay argument is 100 ns. If you call pins_out.write() immediately inside b_transport, the standard sc_signal calls request_update() on the sc_simcontext. This schedules the pin to toggle in the immediate Delta cycle at global time 0 ns, effectively happening in the past relative to the CPU's local time. To solve this in production VPs, you cannot call pins_out.write() directly inside b_transport. You must queue the toggle operation using a Payload Event Queue (PEQ) or schedule a dedicated SC_METHOD using sc_event::notify(delay) so the discrete pin toggles exactly at global_time + local_delay.

#include <systemc>
#include <tlm>
#include <tlm_utils/simple_target_socket.h>
 
SC_MODULE(GPIO_Controller) {
    tlm_utils::simple_target_socket<GPIO_Controller> socket;
    
    // External hardware pins (8-bit bus)
    sc_core::sc_out<sc_dt::sc_bv<8>> pins_out{"pins_out"};
 
    // Internal Registers
    uint8_t reg_dir = 0x00; // 1 = Output, 0 = Input
    uint8_t reg_out = 0x00; // The logic levels to drive
 
    SC_CTOR(GPIO_Controller) : socket("socket") {
        socket.register_b_transport(this, &GPIO_Controller::b_transport);
        
        // Drive initial state
        SC_THREAD(init_pins);
    }
 
private:
    void init_pins() {
        wait(sc_core::SC_ZERO_TIME);
        update_hardware_pins();
    }
 
    void update_hardware_pins() {
        // Only drive the bits configured as outputs
        sc_dt::sc_bv<8> current_val = reg_out & reg_dir; 
        
        // WARNING: In a purely Loosely Timed environment, writing here directly
        // causes the toggle to happen at global sc_time_stamp(), ignoring local delay.
        pins_out.write(current_val);
        
        std::cout << "@" << sc_core::sc_time_stamp() 
                  << " [GPIO] Hardware Pins Driven: " << current_val << std::endl;
    }
 
    void b_transport(tlm::tlm_generic_payload& trans, sc_core::sc_time& delay) {
        sc_dt::uint64 addr = trans.get_address();
        unsigned char* ptr = trans.get_data_ptr();
        unsigned int len = trans.get_data_length();
 
        if (len != 1) { // Enforce 8-bit access for this simple peripheral
            trans.set_response_status(tlm::TLM_BURST_ERROR_RESPONSE);
            return;
        }
 
        if (trans.get_command() == tlm::TLM_WRITE_COMMAND) {
            uint8_t val = *ptr;
            if (addr == 0x00) { // DIR Register
                reg_dir = val;
                std::cout << "@" << sc_core::sc_time_stamp() << " [GPIO] DIR Reg = 0x" << std::hex << (int)reg_dir << std::endl;
            } else if (addr == 0x01) { // OUT Register
                reg_out = val;
                std::cout << "@" << sc_core::sc_time_stamp() << " [GPIO] OUT Reg = 0x" << std::hex << (int)reg_out << std::endl;
            }
            // A change in registers triggers a physical pin update
            update_hardware_pins();
        }
 
        delay += sc_core::sc_time(10, sc_core::SC_NS);
        trans.set_response_status(tlm::TLM_OK_RESPONSE);
    }
};
 
int sc_main(int argc, char* argv[]) {
    // 1. Hardware Wires
    sc_core::sc_signal<sc_dt::sc_bv<8>> external_bus("external_bus");
 
    // 2. Instantiate Peripheral
    GPIO_Controller gpio("gpio");
    gpio.pins_out(external_bus); // Bind to external hardware
 
    // 3. Mock CPU Transaction (Configure and Drive)
    tlm::tlm_generic_payload trans;
    sc_core::sc_time delay = sc_core::SC_ZERO_TIME;
    uint8_t data;
 
    // Configure lower 4 bits as Outputs
    data = 0x0F;
    trans.set_command(tlm::TLM_WRITE_COMMAND);
    trans.set_address(0x00);
    trans.set_data_ptr(&data);
    trans.set_data_length(1);
    gpio.socket->b_transport(trans, delay);
 
    // Drive 0xA on the output pins
    data = 0x0A;
    trans.set_address(0x01);
    gpio.socket->b_transport(trans, delay);
 
    sc_core::sc_start();
    return 0;
}

By combining TLM sockets for high-speed software control and standard sc_signal objects for physical interactions (with proper delay scheduling), SystemC effectively models the boundary between the processor subsystem and the external printed circuit board.

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