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.
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 intoVBAR_EL1before any fault can occur. - Synchronous exception handler (
exceptions.c): ReadsESR_EL1,ELR_EL1, andFAR_EL1and 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:
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
svcto request a syscall. Crucially,ELR_EL1always 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_EL1points 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:
| Group | When |
|---|---|
| SP_EL0 | Exception taken from current EL while the stack pointer for EL0 was active (unusual) |
| SP_EL1 | Exception taken from current EL while SP_EL1 was active (our normal case) |
| Lower EL, AArch64 | Exception from a lower EL running in 64-bit mode (future: user space) |
| Lower EL, AArch32 | Exception 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 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 :
| Offset | Group | Exception type |
|---|---|---|
0x000 | SP_EL0 | Synchronous |
0x080 | SP_EL0 | IRQ |
0x100 | SP_EL0 | FIQ |
0x180 | SP_EL0 | SError |
0x200 | SP_EL1 | Synchronous |
0x280 | SP_EL1 | IRQ ← this is our normal IRQ entry |
0x300 | SP_EL1 | FIQ |
0x380 | SP_EL1 | SError |
0x400 | Lower AArch64 | Synchronous |
0x480 | Lower AArch64 | IRQ |
| … | … | … |
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.
.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.
›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:
| EC | Value | Meaning |
|---|---|---|
0b000000 | 0x00 | Unknown reason; ESR is invalid for this exception type |
0b000001 | 0x01 | Trapped WF* instruction (wait for interrupt/event) |
0b010001 | 0x11 | SVC (syscall) from AArch32 lower EL |
0b010101 | 0x15 | SVC (syscall) from AArch64 lower EL |
0b100000 | 0x20 | Instruction Abort from lower EL |
0b100001 | 0x21 | Instruction Abort from current EL |
0b100100 | 0x24 | Data Abort from lower EL |
0b100101 | 0x25 | Data Abort from current EL ← the MMU is angry |
0b111100 | 0x3C | BRK 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 at0x08000000on 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 at0x08010000. 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 toGICC_EOIR(End Of Interrupt) to tell the GIC it’s done.
Here’s the physical memory map now that we’ve added the GIC:
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:
| Register | Purpose |
|---|---|
CNTFRQ_EL0 | Read-only. The counter frequency in Hz. QEMU sets this to 62 500 000. |
CNTP_TVAL_EL0 | Write the countdown value here. The timer fires when the counter has advanced by this many ticks. |
CNTP_CTL_EL0 | Control: 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:
| Bit | Name | Meaning when 1 |
|---|---|---|
D | Debug | Debug exceptions masked |
A | SError | SError interrupts masked |
I | IRQ | IRQs masked ← the one we clear |
F | FIQ | FIQs 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
- AArch64 Exception Vector Table — ARM Developer Documentation - Official ARM documentation on the AArch64 exception vector table structure, entry offsets, and VBAR_EL1 requirements.
- Learn the Architecture — AArch64 Exception Model - ARM’s learning path guide to the full AArch64 exception model, covering synchronous vs asynchronous exceptions, ESR_EL1 fields, and the DAIF mask register.
- VBAR_EL1: Vector Base Address Register — Jon’s ARM Reference - Concise ARM register reference for VBAR_EL1, covering alignment requirements and reset behaviour.
- CNTP_CTL_EL0 — ARM Developer Documentation - ARM reference for the physical timer control register, covering ENABLE, IMASK, and ISTATUS bit definitions.
- ARM Generic Interrupt Controller Architecture Specification — Cambridge SRG - The GICv2 architecture specification, covering GICD and GICC register maps, priority encoding, and the acknowledge/end-of-interrupt cycle.
- baremetal-arm — Interrupt Handling (Chapter 7) - A practical bare-metal ARM tutorial with concrete GIC initialization sequences and interrupt handler patterns in C.
- QEMU virt machine documentation - QEMU’s documentation for the virt machine, covering GICv2 base addresses, timer INTIDs, and memory map for the virtual platform.
- AArch64 Generic Timer — GTS3 reference - Detailed guide to the ARM Generic Timer, counter registers, and programming model for AArch64.
- arm-trusted-firmware: gicv2_main.c - Production GICv2 driver from ARM Trusted Firmware — the reference implementation for GIC initialisation sequences.
- Bare-Metal Boot Code for ARMv8-A — UMD ECE - Academic course materials covering exception levels, vector table setup, and AArch64 boot code — a useful companion reference.
- How I Debugged VBAR_EL1 Setup in an ARM64 Kernel — Medium - A 2025 post-mortem of the common VBAR_EL1 alignment and MMU transition bugs — worth reading before you hit them yourself.