| 18 min

Exceptions & Interrupts: Teaching Your Kernel to Listen

Build an AArch64 exception vector table from scratch, set up the GICv2 Generic Interrupt Controller on QEMU, and write a working ARM Generic Timer interrupt handler. Your kernel finally responds to the hardware.

Diagram showing an exception vector table with 16 entries branching to handlers for Sync, IRQ, FIQ, and SError

In the last post, we enabled the MMU and gave the CPU a proper map of our address space. For the first time, every memory access goes through hardware translation. That’s good because it’s the foundation of memory protection.

Here’s what I haven’t told you yet: we lied by omission. When the MMU encounters a bad address, it raises a fault. This could be a pointer that doesn’t exist in our page tables, a write to a read-only region, or code that tries to execute from the stack. In ARM’s terminology, a fault is a synchronous exception. The CPU looks up its exception vector table to find the handler.

Right now, our kernel doesn’t have an exception handler. If you trigger a fault, the CPU fetches a handler address from wherever VBAR_EL1 points at reset, which is 0x0. That’s unmapped memory. The CPU raises a fault while handling the fault, then another fault while handling that one. The terminal goes silent, and QEMU quietly continues running a processor in an infinite abort spiral. Nothing tells you what went wrong.

This post ends that situation. We are building the exception vector table from scratch, wiring it into the CPU, and by the end, the kernel will respond to real hardware events, such as a timer interrupt, and print them to the UART on every tick.


What We’re Building Today

The end result is a kernel that reacts to the hardware rather than just running through a fixed sequence of function calls. We will add four things:

  • An exception vector table (vectors.S): 16 handlers mapped to a 2 KB-aligned table, installed into VBAR_EL1 before any fault can occur.
  • Synchronous exception handler (exceptions.c): Reads ESR_EL1, ELR_EL1, and FAR_EL1 and prints a diagnostic message before halting. Not recovery, but it beats silent corruption.
  • GIC driver (gic.c): Initialises the GICv2 Generic Interrupt Controller on QEMU’s virt machine; the hardware that routes peripheral interrupts to our CPU.
  • Timer driver (timer.c): Programs the ARM Generic Timer to fire every 500 ms, re-arms itself on each interrupt, and prints a tick counter to the UART.

Here is the full exception entry flow, from the moment a fault or IRQ fires to the moment your C handler runs:

bios@confessions ~/boot-sequence

The CPU suspends the current instruction and saves state automatically. SPSR_EL1 captures the PSTATE before the exception. ELR_EL1 holds the address to return to. FAR_EL1 holds the faulting address (for data and instruction aborts). The CPU does NOT save general-purpose registers — that's your job.

// Hardware writes these automatically:
// SPSR_EL1 = current PSTATE (exception saved state)
// ELR_EL1  = address of faulting / interrupted instruction
// FAR_EL1  = faulting virtual address (aborts only)

Two Kinds of Unexpected

Before writing any code, it’s worth understanding the two distinct categories of exceptional events ARM’s architecture recognises, because they get different entries in the vector table and require different handling strategies.

  • Synchronous exceptions are caused by the instruction the CPU was trying to execute. They happen because of your code. Examples include: accessing an unmapped address (a translation fault), executing an instruction the CPU doesn’t know (an undefined instruction fault), or executing svc to request a syscall. Crucially, ELR_EL1 always points to the instruction that caused the problem. You can re-execute it after you fix the condition.
  • Asynchronous exceptions (IRQs, FIQs, and SError) are caused by hardware events that have nothing to do with the current instruction. A timer fires, a UART receives a byte, a storage controller completes a DMA transfer. The CPU notices at the next interrupt checkpoint and suspends whatever it was doing. ELR_EL1 points to the instruction that would have run next. After the handler completes, that instruction runs as if nothing happened.

This distinction matters for the handler epilogue. A synchronous fault handler generally cannot do a clean eret back to the faulting address unless it has fixed the problem (for example, a page fault handler that loaded the page and re-walked the table). An IRQ handler almost always can. We will start with the IRQ case, which is the clean one.


The Exception Vector Table

The AArch64 vector table is a block of memory with a specific layout enforced by the hardware. The CPU doesn’t look at a list of function pointers; it adds a fixed byte offset to VBAR_EL1 and jumps there. You get to put whatever instructions you like at those offsets. In practice, almost every bare-metal kernel puts a branch instruction that jumps to the real handler.

The table has 16 entries, grouped into four sets of four. Each group covers a different source of the exception:

GroupWhen
SP_EL0Exception taken from current EL while the stack pointer for EL0 was active (unusual)
SP_EL1Exception taken from current EL while SP_EL1 was active (our normal case)
Lower EL, AArch64Exception from a lower EL running in 64-bit mode (future: user space)
Lower EL, AArch32Exception from a lower EL running in 32-bit mode (we don’t support this)

Each group has four entries: Synchronous, IRQ, FIQ, and SError. That gives 4×4=164 × 4 = 16 entries total. Each entry is exactly 128 bytes (32 instructions). The entire table is 2,048 bytes (2 KB). The VBAR_EL1 alignment requirement is 2 KB, which is why your linker script must include .balign 2048 before the table. Miss the alignment, and the CPU will use wrong offsets for every single exception class, causing an entertaining cascade of entirely wrong handlers firing on entirely wrong exceptions. Here are the byte offsets of each entry, computed as groupbase+exceptiontype0x80group_base + exception_type * 0x80:

OffsetGroupException type
0x000SP_EL0Synchronous
0x080SP_EL0IRQ
0x100SP_EL0FIQ
0x180SP_EL0SError
0x200SP_EL1Synchronous
0x280SP_EL1IRQ ← this is our normal IRQ entry
0x300SP_EL1FIQ
0x380SP_EL1SError
0x400Lower AArch64Synchronous
0x480Lower AArch64IRQ

Building vectors.S

Add this file to arch/arm64/vectors.S. The exception vector table itself is the first code the CPU runs when any fault or interrupt occurs.

bios@confessions ~/arch/arm64/vectors.S
.section .text.vectors
.global vectors
 
/*
* VBAR_EL1 must be 2048-byte aligned. The hardware adds a fixed
* byte offset to this address to find each exception entry.
* .balign 2048 ensures the label 'vectors' sits at a correctly
* aligned address even if .text.vectors ends up at an odd location.
*/
.balign 2048
vectors:
 
/*
* MACRO: VENTRY <label>
* Emits a single 128-byte vector table entry that branches to <label>.
* .balign 128 keeps each slot at the right offset within the table.
* Any remaining space in the 128-byte window is implicitly zero-filled
* by the assembler (unreachable padding after the branch).
*/
.macro VENTRY label
.balign 128
  b \label
.endm
 
/* ── Group 0: Current EL, SP_EL0 (not used in our kernel) ── */
VENTRY  el1t_sync           /* 0x000 */
VENTRY  el1t_irq            /* 0x080 */
VENTRY  el1t_fiq            /* 0x100 */
VENTRY  el1t_serror         /* 0x180 */
 
/* ── Group 1: Current EL, SP_EL1 (our normal kernel context) ── */
VENTRY  el1h_sync           /* 0x200 — kernel fault */
VENTRY  el1h_irq            /* 0x280 — timer, UART, GIC interrupts */
VENTRY  el1h_fiq            /* 0x300 */
VENTRY  el1h_serror         /* 0x380 */
 
/* ── Group 2: Lower EL, AArch64 (future user-space syscalls) ── */
VENTRY  el0_sync_64         /* 0x400 */
VENTRY  el0_irq_64          /* 0x480 */
VENTRY  el0_fiq_64          /* 0x500 */
VENTRY  el0_serror_64       /* 0x580 */
 
/* ── Group 3: Lower EL, AArch32 (not supported) ── */
VENTRY  el0_sync_32         /* 0x600 */
VENTRY  el0_irq_32          /* 0x680 */
VENTRY  el0_fiq_32          /* 0x700 */
VENTRY  el0_serror_32       /* 0x780 */
 
/*
* MACRO: SAVE_REGS
* Push all general-purpose registers onto the stack before calling C.
* We save x0–x29 (30 registers = 240 bytes), plus x30 (the link
* register). The stack must remain 16-byte aligned at all times on
* AArch64 — stp with #-16 pre-decrement handles this in pairs.
*/
.macro SAVE_REGS
  stp x0,  x1,  [sp, #-16]!
  stp x2,  x3,  [sp, #-16]!
  stp x4,  x5,  [sp, #-16]!
  stp x6,  x7,  [sp, #-16]!
  stp x8,  x9,  [sp, #-16]!
  stp x10, x11, [sp, #-16]!
  stp x12, x13, [sp, #-16]!
  stp x14, x15, [sp, #-16]!
  stp x16, x17, [sp, #-16]!
  stp x18, x19, [sp, #-16]!
  stp x20, x21, [sp, #-16]!
  stp x22, x23, [sp, #-16]!
  stp x24, x25, [sp, #-16]!
  stp x26, x27, [sp, #-16]!
  stp x28, x29, [sp, #-16]!
  str x30,      [sp, #-8]!
.endm
 
.macro RESTORE_REGS
  ldr x30,      [sp], #8
  ldp x28, x29, [sp], #16
  ldp x26, x27, [sp], #16
  ldp x24, x25, [sp], #16
  ldp x22, x23, [sp], #16
  ldp x20, x21, [sp], #16
  ldp x18, x19, [sp], #16
  ldp x16, x17, [sp], #16
  ldp x14, x15, [sp], #16
  ldp x12, x13, [sp], #16
  ldp x10, x11, [sp], #16
  ldp x8,  x9,  [sp], #16
  ldp x6,  x7,  [sp], #16
  ldp x4,  x5,  [sp], #16
  ldp x2,  x3,  [sp], #16
  ldp x0,  x1,  [sp], #16
.endm
 
/* ── Concrete handler stubs ── */
 
el1h_sync:
  SAVE_REGS
  bl  sync_exception_handler
  RESTORE_REGS
  eret
 
el1h_irq:
  SAVE_REGS
  bl  irq_handler
  RESTORE_REGS
  eret
 
/* All unimplemented entries spin — they are bugs we haven't handled yet */
el1t_sync:
el1t_irq:
el1t_fiq:
el1t_serror:
el1h_fiq:
el1h_serror:
el0_sync_64:
el0_irq_64:
el0_fiq_64:
el0_serror_64:
el0_sync_32:
el0_irq_32:
el0_fiq_32:
el0_serror_32:
  b .  /* spin — unrecoverable */

Installing the Vector Table: exceptions_init()

The vector table in memory is useless until you tell the CPU where it lives. Add this to your boot sequence, before anything that could fault:

/* kernel/exceptions.c */
#include <stdint.h>
#include "drivers/uart.h"
#include "kernel/exceptions.h"

/* Defined in vectors.S — the label at the top of the table */
extern void vectors(void);

void exceptions_init(void) {
    /* Load the address of our vector table into VBAR_EL1.
     * Any exception taken after this instruction uses our table.
     * The isb() ensures no subsequent instructions execute before
     * this register write is visible to the CPU's fetch pipeline. */
    asm volatile(
        "msr vbar_el1, %0\n"
        "isb\n"
        :: "r"((uint64_t)vectors)
        : "memory"
    );
}

And also add this to the header:

/* include/kernel/exceptions.h */
#ifndef KERNEL_EXCEPTIONS_H
#define KERNEL_EXCEPTIONS_H

void exceptions_init(void);
void sync_exception_handler(void);
void irq_handler(void);

#endif

Decoding What Went Wrong: The Syndrome Registers

When a synchronous exception fires, three system registers capture the context. These are the first things your handler should read before trying to make sense of what happened.

bios@confessions ~/registers · Syndrome registers — state after a synchronous exception
ESR_EL1 0x0000000096000045 Exception Class [31:26] = 0b100101 → Data Abort from current EL. ISS [24:0] = 0x45 → DFSC = permission fault, level 1
ELR_EL1 0x0000000040001234 The virtual address that caused the fault. For data aborts: the address your code tried to access. For instruction aborts: the PC that caused the prefetch fault.
FAR_EL1 0xDEADBAD0CAFEF00D Address of the instruction that caused the fault. We need to return here after fixing it, or print it for debugging
SPSR_EL1 0x0000000060000000 Saved PSTATE before the exception. Restored by eret. Bits [3:0] = 0b0101 means we were at EL1h (SP_EL1 active, 64-bit AArch64).

ESR_EL1[31:26] is the Exception Class (EC) field, this tells you what kind of fault fired. EC = 0b100101 means Data Abort from current EL (a memory access fault). EC = 0b000000 means the register wasn't valid at all, which usually means you read it from the wrong exception type (e.g., reading ESR_EL1 from an IRQ handler where it's meaningless).

The ESR_EL1 register is structured rather than a flat number. The top 6 bits ([31:26]) are the Exception Class (EC), which tells you the category of exception. The remaining bits form the Instruction-Specific Syndrome (ISS), which varies in meaning by EC. A few EC values worth knowing immediately:

ECValueMeaning
0b0000000x00Unknown reason; ESR is invalid for this exception type
0b0000010x01Trapped WF* instruction (wait for interrupt/event)
0b0100010x11SVC (syscall) from AArch32 lower EL
0b0101010x15SVC (syscall) from AArch64 lower EL
0b1000000x20Instruction Abort from lower EL
0b1000010x21Instruction Abort from current EL
0b1001000x24Data Abort from lower EL
0b1001010x25Data Abort from current EL ← the MMU is angry
0b1111000x3CBRK instruction (software breakpoint)

Here’s the synchronous handler that reads these and gives you something useful when things go wrong:

/* in kernel/exceptions.c */

static void print_hex(uint64_t val) {
    const char hex[] = "0123456789abcdef";
    kprint("0x");
    for (int i = 60; i >= 0; i -= 4) {
        uart_putc(hex[(val >> i) & 0xF]);
    }
}

void sync_exception_handler(void) {
    uint64_t esr, elr, far;

    asm volatile("mrs %0, esr_el1"  : "=r"(esr));
    asm volatile("mrs %0, elr_el1"  : "=r"(elr));
    asm volatile("mrs %0, far_el1"  : "=r"(far));

    uint32_t ec  = (esr >> 26) & 0x3F;
    uint32_t iss = esr & 0x01FFFFFF;

    kprint("\n[SYNC EXCEPTION]\n");
    kprint("  ESR_EL1 = "); print_hex(esr);  kprint("\n");
    kprint("  ELR_EL1 = "); print_hex(elr);  kprint("\n");
    kprint("  FAR_EL1 = "); print_hex(far);  kprint("\n");
    kprint("  EC      = "); print_hex(ec);   kprint("\n");
    kprint("  ISS     = "); print_hex(iss);  kprint("\n");

    /* Spin — we can't recover from an unhandled fault */
    while (1);
}

This won’t win any prizes for elegance, but it transforms a silent hang into a diagnostic that tells you exactly what the CPU was doing when it gave up.


The GIC: Your Kernel’s Switchboard

Synchronous exceptions are straightforward; they come from your own code, and ESR_EL1 tells you why. Asynchronous interrupts are different. A timer, a UART, a network card, basically any peripheral can signal the CPU at any moment. The problem is that there are dozens of peripherals and only one CPU (for now). Something has to manage the routing.

That something is the Generic Interrupt Controller (GIC). On QEMU’s virt machine, this is a GICv2-compatible controller that sits between all interrupt sources and the CPU. Every interrupt has an ID (INTID), and the GIC decides whether to forward it to the CPU and at what priority.

The GIC has two main hardware components:

  • The Distributor (GICD) sits at 0x08000000 on QEMU virt. It’s the global switchboard; it knows about every interrupt source in the system, stores their priority, their target CPU routing, and whether each one is enabled. You configure the Distributor once at boot. It’s shared across all CPUs.
  • The CPU Interface (GICC) sits at 0x08010000. Each core has its own CPU Interface that tells it “the highest-priority pending interrupt right now is INTID X, with priority Y.” When your handler finishes, it writes the INTID back to GICC_EOIR (End Of Interrupt) to tell the GIC it’s done.

Here’s the physical memory map now that we’ve added the GIC:

bios@confessions ~/memory-map · Physical address space — adding the GIC to our map
GIC Distributor (GICD) 0x0008000000 → 0x0008010000 (64 KB)
GIC CPU Interface (GICC) 0x0008010000 → 0x0008020000 (64 KB)
PL011 UART (MMIO) 0x0009000000 → 0x0009001000 (4 KB)
Kernel .text 0x0040000000 → 0x0040010000 (64 KB)
Kernel .rodata 0x0040010000 → 0x0040018000 (32 KB)
Kernel .data/.bss 0x0040018000 → 0x0040028000 (64 KB)
Stack (16 KB) 0x0040028000 → 0x004002C000 (16 KB)
Heap (1 MB) 0x004002C000 → 0x004012C000 (1 MB)
GIC Distributor (GICD)
GIC CPU Interface (GICC)
PL011 UART (MMIO)
Kernel .text
Kernel .rodata
Kernel .data/.bss
Stack (16 KB)
Heap (1 MB)
unmapped

The GIC registers are all MMIO, just like the UART. You read and write them with volatile pointers. Create include/kernel/gic.h and kernel/gic.c:

/* include/kernel/gic.h */
#ifndef KERNEL_GIC_H
#define KERNEL_GIC_H

#include <stdint.h>

/* GICv2 base addresses on QEMU virt */
#define GICD_BASE  0x08000000UL
#define GICC_BASE  0x08010000UL

/* Distributor register offsets */
#define GICD_CTLR       0x000   /* Distributor Control Register    */
#define GICD_ISENABLER  0x100   /* Interrupt Set-Enable Registers  */
#define GICD_IPRIORITYR 0x400   /* Interrupt Priority Registers    */
#define GICD_ITARGETSR  0x800   /* Interrupt Processor Target Regs */
#define GICD_ICFGR      0xC00   /* Interrupt Configuration Regs    */

/* CPU Interface register offsets */
#define GICC_CTLR  0x000   /* CPU Interface Control Register */
#define GICC_PMR   0x004   /* Interrupt Priority Mask Register */
#define GICC_IAR   0x00C   /* Interrupt Acknowledge Register  */
#define GICC_EOIR  0x010   /* End of Interrupt Register       */

/* Helpers */
#define GICD_REG(off) (*(volatile uint32_t *)(GICD_BASE + (off)))
#define GICC_REG(off) (*(volatile uint32_t *)(GICC_BASE + (off)))

void gic_init(void);
void gic_enable_irq(uint32_t irq);
uint32_t gic_acknowledge(void);
void gic_end(uint32_t iar);

#endif
/* kernel/gic.c */
#include "kernel/gic.h"

void gic_init(void) {
    /* 1. Enable the Distributor — forward interrupts to CPU interfaces */
    GICD_REG(GICD_CTLR) = 1;

    /* 2. Set the CPU Interface Priority Mask.
     * A value of 0xFF means "accept any priority level."
     * GIC priorities are backwards: 0x00 = highest, 0xFF = lowest.
     * Without this, all interrupts are masked regardless of configuration. */
    GICC_REG(GICC_PMR) = 0xFF;

    /* 3. Enable the CPU Interface — allow it to signal the CPU */
    GICC_REG(GICC_CTLR) = 1;
}

void gic_enable_irq(uint32_t irq) {
    /* GICD_ISENABLER is an array of 32-bit registers, one bit per IRQ.
     * IRQ n → register index n/32, bit n%32. */
    uint32_t reg_idx = irq / 32;
    uint32_t bit     = irq % 32;
    GICD_REG(GICD_ISENABLER + reg_idx * 4) = (1U << bit);
}

uint32_t gic_acknowledge(void) {
    /* Reading GICC_IAR atomically acknowledges the interrupt and returns
     * the INTID in bits [9:0]. The remaining bits encode the CPU source ID.
     * We return the full IAR value because GICC_EOIR needs it. */
    return GICC_REG(GICC_IAR);
}

void gic_end(uint32_t iar) {
    /* Writing the IAR value back to GICC_EOIR signals to the GIC that
     * we are done handling this interrupt. The GIC can then forward the
     * next pending interrupt of the same priority. Forgetting this write
     * silently prevents all future interrupts of this priority or lower. */
    GICC_REG(GICC_EOIR) = iar;
}

The ARM Generic Timer

Every AArch64 core has a built-in Generic Timer. A timer is a hardware counter that increments at a fixed frequency independent of CPU clock speed. On QEMU’s virt machine, this counter runs at 62.5 MHz (you can read the exact value from CNTFRQ_EL0). The timer can be programmed to fire an interrupt after a given number of ticks.

There are several timer variants. We want the non-secure EL1 physical timer (accessed via CNTP_*_EL0 registers). It generates INTID 30 through the GIC, a PPI (Private Peripheral Interrupt) that’s private to each CPU core.

Three registers control it:

RegisterPurpose
CNTFRQ_EL0Read-only. The counter frequency in Hz. QEMU sets this to 62 500 000.
CNTP_TVAL_EL0Write the countdown value here. The timer fires when the counter has advanced by this many ticks.
CNTP_CTL_EL0Control: bit 0 = ENABLE (1 = running), bit 1 = IMASK (1 = interrupt masked), bit 2 = ISTATUS (read: 1 = condition met).

Create include/kernel/timer.h and kernel/timer.c:

/* include/kernel/timer.h */
#ifndef KERNEL_TIMER_H
#define KERNEL_TIMER_H

#include <stdint.h>

#define TIMER_IRQ  30   /* INTID 30: Non-secure EL1 physical timer (PPI) */

void timer_init(uint32_t interval_ms);
void timer_tick(void);
uint64_t timer_get_ticks(void);

#endif
/* kernel/timer.c */
#include "kernel/timer.h"
#include "kernel/gic.h"
#include "drivers/uart.h"

static volatile uint64_t tick_count = 0;
static uint64_t reload_ticks = 0;   /* saved so tick() can re-arm */

void timer_init(uint32_t interval_ms) {
    /* 1. Read the counter frequency */
    uint64_t freq;
    asm volatile("mrs %0, cntfrq_el0" : "=r"(freq));

    /* 2. Calculate the number of ticks for the desired interval */
    reload_ticks = (freq * (uint64_t)interval_ms) / 1000ULL;

    /* 3. Write the countdown value — timer fires when the counter
     *    has advanced by this many ticks from now. */
    asm volatile("msr cntp_tval_el0, %0" :: "r"(reload_ticks));

    /* 4. Enable the timer and unmask its interrupt (bit 0 = enable,
     *    bit 1 = IMASK=0 means the interrupt is NOT masked) */
    asm volatile("msr cntp_ctl_el0, %0" :: "r"((uint64_t)1));

    /* 5. Enable INTID 30 in the GIC so the timer can signal the CPU */
    gic_enable_irq(TIMER_IRQ);
}

void timer_tick(void) {
    tick_count++;

    /* Print a simple tick message */
    kprint("tick #");
    /* Quick inline decimal print for small numbers */
    uint64_t n = tick_count;
    char buf[20];
    int i = 0;
    if (n == 0) { buf[i++] = '0'; }
    else {
        while (n > 0) { buf[i++] = '0' + (n % 10); n /= 10; }
        /* reverse */
        for (int l = 0, r = i - 1; l < r; l++, r--) {
            char t = buf[l]; buf[l] = buf[r]; buf[r] = t;
        }
    }
    buf[i] = '\0';
    kprint(buf);
    kprint("\n");

    /* Re-arm: write the same countdown so the timer fires again */
    asm volatile("msr cntp_tval_el0, %0" :: "r"(reload_ticks));
}

uint64_t timer_get_ticks(void) {
    return tick_count;
}

Wiring It All Together

The IRQ handler in exceptions.c is the glue between the GIC and the timer:

/* in kernel/exceptions.c */
#include "kernel/gic.h"
#include "kernel/timer.h"

void irq_handler(void) {
    /* Acknowledge the interrupt — this tells the GIC we've seen it and
     * returns the INTID of the highest-priority pending interrupt. */
    uint32_t iar = gic_acknowledge();
    uint32_t irq = iar & 0x3FF;   /* INTID lives in bits [9:0] */

    if (irq == TIMER_IRQ) {
        timer_tick();
    } else {
        /* Unknown interrupt — acknowledge it so the GIC doesn't stall,
         * but log a warning. Future drivers will register handlers here. */
        kprint("[irq] unhandled INTID: ");
        /* (print irq number — omitted for brevity) */
    }

    /* Signal end-of-interrupt. The GIC only forwards the next interrupt
     * of this priority after we write here. Forgetting this call
     * means we receive exactly one interrupt and then silence. */
    gic_end(iar);
}

Now update kernel/main.c to call the new initialisation sequence in the correct order:

/* kernel/main.c */
#include "drivers/uart.h"
#include "kernel/mmu.h"
#include "kernel/exceptions.h"
#include "kernel/gic.h"
#include "kernel/timer.h"

void kernel_main(void) {
    /* 1. MMU first — page tables must be in place before any fault can be
     *    cleanly handled. A fault before VBAR_EL1 is set would still vector
     *    to address 0, but at least the MMU ensures 0 is unmapped cleanly. */
    mmu_init();

    /* 2. UART — we want output as early as possible for debugging */
    uart_init();
    kprint("PurgatoryOS booting...\n");

    /* 3. Exception vectors — install VBAR_EL1 before we unmask interrupts */
    exceptions_init();
    kprint("Exceptions: ready\n");

    /* 4. GIC — enable the distributor and CPU interface */
    gic_init();
    kprint("GIC: ready\n");

    /* 5. Timer — 500 ms interval, fires INTID 30 */
    timer_init(500);
    kprint("Timer: armed\n");

    /* 6. Unmask IRQs at the CPU — from this point, interrupts can fire */
    asm volatile("msr daifclr, #2");   /* clear IRQ mask bit in PSTATE */

    kprint("Interrupts enabled. Waiting for timer...\n");

    while (1) {
        /* wfi: Wait For Interrupt — puts the CPU into a low-power state
         * until an interrupt arrives. Much better than a busy spin. */
        asm volatile("wfi");
    }
}

Update the Makefile to compile the new files:

SRCS = arch/arm64/boot.S \
       arch/arm64/vectors.S \
       drivers/uart/uart.c \
       kernel/main.c \
       kernel/mmu.c \
       kernel/exceptions.c \
       kernel/gic.c \
       kernel/timer.c

Also, update link.ld to include .text.vectors early in the .text section. It should be there after .text.boot, but before anything else. The vectors table needs to be in mapped, executable memory:

.text : {
    KEEP(*(.text.boot))
    KEEP(*(.text.vectors))   /* ← add this line */
    *(.text .text.*)
}

Now build and run:

The timer fires every 500 ms. wfi puts the CPU to sleep between ticks. The kernel is no longer a fixed-sequence program. It is now responding to hardware events.


Understanding the Interrupt Enable Sequence

The order of the steps in kernel_main is not arbitrary. Getting the sequence wrong is one of the most common bugs when first setting up interrupts, and the failure modes are cryptic.

mmu_init()          ← page tables must exist before any exception vectors are used
exceptions_init()   ← install VBAR_EL1 before we enable IRQs
gic_init()          ← enable GIC before individual IRQs
timer_init(500)     ← arm the timer (enables IRQ 30 in GIC) before CPU unmask
msr daifclr, #2    ← unmask at the CPU last of all

The DAIF register controls interrupt masking at the CPU level:

BitNameMeaning when 1
DDebugDebug exceptions masked
ASErrorSError interrupts masked
IIRQIRQs masked ← the one we clear
FFIQFIQs masked

msr daifclr, #2 clears bit 1 of DAIF (the I bit), unmasking IRQs. The 2 in the instruction is a bitmask: bit 1 = IRQ. If you unmask before the GIC is initialised, an interrupt could fire with the GIC’s output still disabled, and either nothing happens (the GIC swallows it) or the wrong handler runs. If you unmask before exceptions_init(), a timer that fires extremely early could vector to address 0 and crash. The order matters.


What Broke (And Why)

The first time I ran this, the timer never fired. Everything compiled. The build output looked right, QEMU started, and the kernel printed “Interrupts enabled. I was waiting for the timer to go off, but nothing happened. I could have waited forever.

The fix was embarrassing in hindsight. The UART output was occurring, indicating the kernel was alive. The GIC was initialised in the right order, and the timer was armed. But I had forgotten one line: msr daifclr, #2.

The second issue was subtler. The timer fired once, printed tick #1, and then never fired again. This is the “forget GICC_EOIR” bug. If you read GICC_IAR to acknowledge an interrupt but never write to GICC_EOIR, the GIC considers that interrupt priority level “active” forever. It will not forward another interrupt of the same priority until the current one is explicitly ended. Since the timer is the only interrupt we have, and all GIC priorities default to the same value, the GIC locked up after the first tick. The fix is in gic_end(); this always writes GICC_EOIR before your handler returns.


What’s Next

We have a kernel that responds to hardware. That’s a real milestone. The exception vector table is permanent infrastructure, and every feature from here out depends on it. The GIC is your interrupt bus. The timer is the heartbeat that will eventually drive the scheduler.

The next post builds the heap allocator. We will write a bump allocator from scratch, prove it works by allocating from kernel_main, and examine what a free list would require. By the end of the next post, we will also briefly look at how Rust’s no_std allocator would handle the same problem with stronger safety guarantees. This is a preview of one of the last posts where Rust enters the kernel for real.


Sources