Chapter 12: Virtual Platform Construction

Interrupt Controller & System Events

Modeling an Interrupt Controller (GIC/NVIC) and bridging hardware IRQs into TLM software interrupts.

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.

Interrupt Controllers & System Events

In a real System-on-Chip (SoC), peripherals do not expect the CPU to constantly poll them. When a Timer expires or a UART receives data, it asserts a hardware interrupt line (IRQ).

The Interrupt Controller (like the ARM GIC or Cortex-M NVIC) receives dozens of these raw hardware lines, prioritizes them, and signals the CPU. In a Virtual Platform, we must model this exact behavior.

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 Architecture

  1. Peripheral: Asserts a standard sc_signal<bool> representing the IRQ line.
  2. Interrupt Controller (INTC): Contains a TLM socket (for the CPU to read status/acknowledge) and standard sc_in<bool> ports for the incoming IRQ lines. It evaluates priority and asserts a single sc_out<bool> to the CPU.
  3. CPU ISS: A thread monitoring the sc_in<bool> from the INTC, triggering an asynchronous exception routine in the simulated software.

Complete Interrupt Controller Example

Here is a complete sc_main demonstrates a peripheral generating an interrupt, the controller routing it, and the CPU responding.

#include <systemc>
#include <tlm>
#include <tlm_utils/simple_target_socket.h>
 
// 1. Mock Peripheral (Timer that fires an IRQ)
SC_MODULE(Timer_IRQ) {
    sc_core::sc_out<bool> irq_out{"irq_out"};
 
    SC_CTOR(Timer_IRQ) {
        SC_THREAD(run);
    }
    void run() {
        wait(20, sc_core::SC_NS);
        std::cout << "@" << sc_core::sc_time_stamp() << " [Timer] Firing IRQ." << std::endl;
        irq_out.write(true); // Assert Interrupt
    }
};
 
// 2. The Interrupt Controller
SC_MODULE(InterruptController) {
    tlm_utils::simple_target_socket<InterruptController> socket;
    
    sc_core::sc_in<bool>  irq_in{"irq_in"};
    sc_core::sc_out<bool> cpu_irq{"cpu_irq"};
 
    bool irq_pending = false;
 
    SC_CTOR(InterruptController) : socket("socket") {
        socket.register_b_transport(this, &InterruptController::b_transport);
        SC_METHOD(eval_interrupts);
        sensitive << irq_in;
    }
 
private:
    void eval_interrupts() {
        if (irq_in.read() == true) {
            std::cout << "@" << sc_core::sc_time_stamp() << " [INTC] IRQ Received. Forwarding to CPU." << std::endl;
            irq_pending = true;
            cpu_irq.write(true);
        }
    }
 
    void b_transport(tlm::tlm_generic_payload& trans, sc_core::sc_time& delay) {
        // Mocking the CPU acknowledging and clearing the interrupt
        if (trans.get_command() == tlm::TLM_WRITE_COMMAND && trans.get_address() == 0x10) { // 0x10 = Clear Reg
            std::cout << "@" << sc_core::sc_time_stamp() << " [INTC] CPU Cleared IRQ via TLM." << std::endl;
            irq_pending = false;
            cpu_irq.write(false);
        }
        trans.set_response_status(tlm::TLM_OK_RESPONSE);
    }
};
 
// 3. Mock CPU
SC_MODULE(MockCPU_IRQ) {
    tlm_utils::simple_initiator_socket<MockCPU_IRQ> socket;
    sc_core::sc_in<bool> irq_in{"irq_in"};
 
    SC_CTOR(MockCPU_IRQ) : socket("socket") {
        SC_THREAD(cpu_loop);
        // Under the hood: This maps to the sc_signal::m_posedge_event
        sensitive << irq_in.pos(); 
    }
 
    void cpu_loop() {
        while(true) {
            wait(); // Wait for IRQ
            std::cout << "@" << sc_core::sc_time_stamp() << " [CPU] INTERRUPT DETECTED! Jumping to ISR." << std::endl;
            
            // Send TLM transaction to INTC to clear the interrupt
            tlm::tlm_generic_payload trans;
            sc_core::sc_time delay = sc_core::SC_ZERO_TIME;
            uint32_t val = 1;
 
            trans.set_command(tlm::TLM_WRITE_COMMAND);
            trans.set_address(0x10);
            trans.set_data_ptr(reinterpret_cast<unsigned char*>(&val));
            trans.set_data_length(4);
            trans.set_response_status(tlm::TLM_INCOMPLETE_RESPONSE);
 
            socket->b_transport(trans, delay);
            wait(delay); // Advance time for bus latency
        }
    }
};
 
int sc_main(int argc, char* argv[]) {
    // Hardware wires
    sc_core::sc_signal<bool> timer_irq_wire;
    sc_core::sc_signal<bool> cpu_irq_wire;
 
    // Instantiate Modules
    Timer_IRQ timer("timer");
    InterruptController intc("intc");
    MockCPU_IRQ cpu("cpu");
 
    // Bind Wires
    timer.irq_out(timer_irq_wire);
    intc.irq_in(timer_irq_wire);
    intc.cpu_irq(cpu_irq_wire);
    cpu.irq_in(cpu_irq_wire);
 
    // Bind TLM Socket
    cpu.socket.bind(intc.socket);
 
    sc_core::sc_start(100, sc_core::SC_NS);
    return 0;
}

LT Temporal Decoupling vs Hardware Interrupts

There is a severe synchronization issue when mixing TLM Loosely Timed (LT) initiators with discrete hardware events.

The Problem: In an LT CPU using a tlm_quantumkeeper, the CPU is accumulating local_time natively within a for() loop, running ahead of global sc_time_stamp(). If the CPU is currently at local time 40 ns (but the global scheduler is at 0 ns), and a timer fires a physical IRQ at global time 20 ns, the CPU has technically "overshot" the interrupt! The software state has already executed instructions past the point where it should have been preempted by the ISR.

The Solution: Production CPU wrappers (like open-source RISC-V ISS models or QEMU-SystemC bridges) must implement a quantum-break mechanism. When irq_in.pos() is triggered inside the hardware domain, the CPU wrapper must immediately assert a flag (m_async_irq_pending). The LT execution loop must check this flag after every instruction. If asserted, it forces an immediate wait(local_time) to sync back with the sc_simcontext, processes the ISR, and resets the quantum.

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