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:
- TLM Target Socket: To receive configuration (Pin Direction) and data (Output Value) from the CPU via memory-mapped registers.
- 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