Skip to content

06. Interrupts and Exception Handling

Interrupts are the foundation of responsive, event-driven operating systems. Let's implement ARM exception handling and timer interrupts to enable preemptive multitasking.

What are Interrupts?

Interrupts allow hardware to signal the CPU that an event has occurred, causing the CPU to pause current execution and handle the event immediately.

Use Cases

  • Hardware events: Keyboard press, network packet arrival
  • Error handling: Memory access violations, undefined instructions

The Flow of an Interrupt

sequenceDiagram
    participant HW as Hardware (Timer/Button)
    participant GIC as GIC (Secretary)
    participant CPU as CPU (CEO)
    participant Code as Handler (Code)

    HW->>GIC: "Hey! Something happened!"
    GIC->>GIC: Check Priority & Mask
    GIC->>CPU: "Excuse me, Sir. Important Event."
    CPU->>CPU: Pause Hardware Task
    CPU->>Code: Jump to Vector Table
    Code->>Code: Save State (Context)
    Code->>Code: Handle Event
    Code->>Code: Restore State
    Code->>CPU: Return (eret)
    CPU->>CPU: Resume Hardware Task

ARM Exception Levels

ARM CPUs have 4 Exception Levels (privilege levels):

Level Name Use
EL0 User Unprivileged applications
EL1 Kernel Operating system (we run here)
EL2 Hypervisor Virtualization
EL3 Secure Monitor Trusted execution environment

Our bare-metal OS runs at EL1 (kernel mode).

Exception Types

ARM defines 4 types of exceptions:

Type Description Example
Synchronous Caused by instruction execution Undefined instruction, data abort
IRQ Interrupt Request (normal interrupts) Timer, GPIO, UART
FIQ Fast Interrupt Request (high priority) Critical hardware events
SError System Error (asynchronous abort) Memory errors

IRQ vs FIQ

  • IRQ: Standard interrupts, can be masked
  • FIQ: Faster response (dedicated registers), higher priority

For most use cases, IRQ is sufficient.

Exception Vector Table

The ARM exception vector table defines where the CPU jumps when an exception occurs. It contains 16 entries (4 exception types × 4 sources):

block-beta
    columns 2

    block:T1
        text1["0x000"]
        text2["Current EL with SP0 (Sync/IRQ/FIQ/Error)"]
    end
    space

    block:T2
        text3["0x200"]
        text4["Current EL with SPx (We use this!)"]
        text5["0x280: IRQ Handler"]
    end
    space

    block:T3
        text6["0x400"]
        text7["Lower EL (AArch64)"]
    end
    space

    block:T4
        text8["0x600"]
        text9["Lower EL (AArch32)"]
    end
    space

Each entry must be 128 bytes (0x80) apart.

Implementing the Vector Table

Assembly: boot/vectors.S

.balign 0x800
.global vector_table
vector_table:

    // Current EL with SP0 (not used)
    .balign 0x80
    b   exception_hang  // Synchronous
    .balign 0x80
    b   exception_hang  // IRQ
    .balign 0x80
    b   exception_hang  // FIQ
    .balign 0x80
    b   exception_hang  // SError

    // Current EL with SPx (kernel mode)
    .balign 0x80
    b   sync_el1_handler  // Synchronous
    .balign 0x80
    b   irq_el1_handler   // IRQ ← Our handler
    .balign 0x80
    b   fiq_el1_handler   // FIQ
    .balign 0x80
    b   serror_el1_handler // SError

    // ... (Lower EL entries)

IRQ Handler (Assembly)

The IRQ handler must: 1. Save all registers 2. Call C handler 3. Restore all registers 4. Return from exception (eret)

Analogy: Game Save/Load (Context Switching)

Imagine you are playing a game (executing code) and mom calls you for dinner (Interrupt).

  1. Save Game (stp): You pause and save your progress (registers) so you don't lose your place.
  2. Dinner (bl irq_handler): You go eat dinner (handle the event).
  3. Load Game (ldp): You return and load your save code.
  4. Resume (eret): You continue playing exactly where you left off.
irq_el1_handler:
    // Save all general-purpose registers
    stp x0, x1, [sp, #-16]!
    stp x2, x3, [sp, #-16]!
    // ... (x4-x30)

    // Call C interrupt handler
    bl  irq_handler

    // Restore all registers
    ldp x0, x1, [sp], #16
    ldp x2, x3, [sp], #16
    // ... (x4-x30)

    eret  // Return from exception

Installing the Vector Table

1
2
3
4
install_vector_table:
    adr x0, vector_table
    msr vbar_el1, x0  // Set Vector Base Address Register
    ret

C Interrupt Handler

File: kernel/interrupts.c

#include "interrupt.h"
#include "uart.h"

void interrupt_init(void) {
    install_vector_table();
    uart_puts("[IRQ] Vector table installed\n");
}

void enable_irq(void) {
    asm volatile("msr daifclr, #2");  // Clear IRQ mask
}

void irq_handler(void) {
    // Check which interrupt fired
    // For now, we handle timer interrupts

    uart_puts("[IRQ] Interrupt received!\n");
}

Timer Interrupts

The ARM Generic Timer can generate interrupts when a compare value is reached.

Registers

Register Purpose
CNTV_CVAL_EL0 Compare value (trigger when count reaches this)
CNTV_CTL_EL0 Control (enable, mask, status)
CNTVCT_EL0 Current counter value

Timer Interrupt Setup

void timer_interrupt_init(void) {
    // Read timer frequency (typically 54 MHz)
    uint64_t freq;
    asm volatile("mrs %0, cntfrq_el0" : "=r"(freq));

    // Set trigger for 1 second from now
    uint64_t current;
    asm volatile("mrs %0, cntvct_el0" : "=r"(current));

    uint64_t compare = current + freq;
    asm volatile("msr cntv_cval_el0, %0" :: "r"(compare));
}

void timer_interrupt_enable(void) {
    // Enable timer (bit 0 = enable, bit 1 = mask)
    uint32_t ctrl = (1 << 0);  // Enable, don't mask
    asm volatile("msr cntv_ctl_el0, %0" :: "r"(ctrl));

    enable_irq();
}

Handling Timer Interrupts

void irq_handler(void) {
    // Check if timer interrupt
    uint32_t ctrl;
    asm volatile("mrs %0, cntv_ctl_el0" : "=r"(ctrl));

    if (ctrl & (1 << 2)) {  // Bit 2 = interrupt status
        uart_puts("[TICK]\n");

        // Reschedule for next tick
        uint64_t freq, current;
        asm volatile("mrs %0, cntfrq_el0" : "=r"(freq));
        asm volatile("mrs %0, cntvct_el0" : "=r"(current));

        uint64_t compare = current + freq;
        asm volatile("msr cntv_cval_el0, %0" :: "r"(compare));
    }
}

The Problem: Too Many Devices!

We have a Timer, a UART, GPIO buttons, maybe a Network card... all of them want to interrupt the CPU. However, the CPU usually only has one main interrupt line (IRQ).

If everyone screams at once, the CPU won't know who to listen to. We need a manager.

The Solution: Interrupt Controller (GIC)

The Generic Interrupt Controller (GIC) sits between the hardware devices and the CPU.

graph LR
    Timer[Timer] --> GIC
    UART[UART] --> GIC
    GPIO[GPIO] --> GIC

    subgraph "Interrupt Controller"
        GIC["GIC (The Manager)"]
    end

    GIC -->|One IRQ Line| CPU[CPU]

The GIC's job is to: 1. Collect signals from all devices. 2. Prioritize them (e.g., "Timer is more important than Keyboard"). 3. Forward the most important one to the CPU. 4. Tell the CPU exactly who interrupted it (Interrupt ID).

Raspberry Pi 4 (BCM2711) Specifics: GIC

On Raspberry Pi 4, this manager is the GIC-400 (GICv2). Unlike older Raspberry Pi models (which used a simpler custom controller), the GIC is a standard ARM component found in many smartphones and servers.

Analogy: The CEO and the Secretary

  • CPU = Busy CEO: Trying to do actual work (running instructions).
  • GIC = Receptionist/Secretary: Manages incoming calls (interrupts). - Masking: "The CEO is in a meeting, hold calls." - Priority: "The building is on fire! Interrupt immediately!" (FIQ/High Priority) - Routing: "This call is for Core 1, not Core 0."

GIC Initialization

We need to configure the Distributor (GICD) and CPU Interface (GICC) to enable interrupts.

// GIC Registers (Physical Addresses)
#define GIC_BASE            0xFF840000
#define GICD_BASE           (GIC_BASE + 0x1000)
#define GICC_BASE           (GIC_BASE + 0x2000)

void gic_init(void) {
    // 1. Distributor Configuration
    // Enable PPI 27 (Virtual Timer)
    // GICD_ISENABLER0 covers interrupts 0-31. Bit 27 = PPI 27.
    *GICD_ISENABLER = (1 << 27);

    // Enable Distributor (Group 1)
    *GICD_CTLR = 1;

    // 2. CPU Interface Configuration
    // Set Priority Mask to allow all interrupts
    *GICC_PMR = 0xFF;

    // Enable CPU Interface (Group 1)
    *GICC_CTLR = 1;
}

Updated IRQ Handler for GIC

When using GIC, the IRQ handler must: 1. Read Interrupt Acknowledge Register (IAR) to get the Interrupt ID. 2. Handle the interrupt. 3. Write to End of Interrupt Register (EOIR) to signal completion.

void irq_handler(void) {
    // Read IAR to get Interrupt ID
    uint32_t iar = *GICC_IAR;
    uint32_t irq_id = iar & 0x3FF;

    if (irq_id < 1020) {
        // Check if timer interrupt (PPI 27)
        if (irq_id == 27) {
            uart_puts("[TICK]\n");
            // ... reset timer ...
        }

        // Signal End of Interrupt
        *GICC_EOIR = iar;
    }
}

Ensure you call gic_init() from interrupt_init()!

Building and Testing

1. Build

1
2
3
cd simpian-os/build
cmake -DBUILD_EXAMPLES=ON ..
make

2. Deploy

Copy interrupt_demo.img to SD card (rename to kernel8.img).

3. Expected Output

Interrupt Demo - Timer IRQ
==============================

[IRQ] Vector table installed
[IRQ] Timer interrupt configured for 1 Hz
[IRQ] Timer interrupt enabled
Timer interrupt enabled. Waiting for ticks...

[TICK] 1
[TICK] 2
[TICK] 3
...

Messages appear every 1 second, triggered by the timer interrupt!

Wait For Interrupt (WFI)

Instead of busy-waiting, use the wfi instruction to enter low-power mode:

1
2
3
while(1) {
    asm volatile("wfi");  // Wait For Interrupt
}

The CPU sleeps until an interrupt occurs, saving power.

Complete Source Code

Troubleshooting

Problem Solution
No interrupts firing Check vbar_el1 is set correctly
System hangs on exception Verify vector table alignment (0x800)
Timer doesn't trigger Ensure IRQ is enabled (daifclr)
Random crashes Check register save/restore in handler

Important Notes

UART in Interrupts

Using UART (uart_puts) from interrupt context is not ideal for production. It can block and cause timing issues. Use a ring buffer instead.

Exception Level

This code assumes you're running at EL1. Check with: mrs x0, CurrentEL

What's Next?

With interrupts working, we can now: - Implement preemptive multitasking (timer-based task switching) - Add GPIO interrupts for button presses - Handle exceptions properly (page faults, undefined instructions)

The next article covers Framebuffer and Graphics, which will allow us to display output on screen instead of just serial console!