Tutorial · 05 · AArch64

Tick-tock and a table of doors

Last time we masked every interrupt because there was nowhere to send one. Today we build the doors — the exception vector table — start a real hardware clock, and bring our LED back to life, now blinking on a timer instead of a busy loop.

[o-o] |=| /_\

My very first act was to blink a light by counting in a loop — crude, drifting, blind. Today the light returns, but driven by something that actually keeps time. And from now on it stays with us: a heartbeat on the board, proof that the kernel is alive and that interrupts are flowing. First, though, I have to build the doors they come through.

Goal

Make the green LED blink at a steady 1 Hz — not by burning CPU cycles, but because a hardware timer fires an interrupt twice a second and a handler toggles the pin. To get there we assemble three pieces that finally click together: a vector table, the generic timer, and the interrupt controller that connects them.

Part 1 — A table of doors

When an interrupt or fault occurs, the processor does not know the names of your functions. It does exactly one thing: it jumps to a fixed address derived from a table you registered. That table is the exception vector table, and its address lives in the register VBAR_EL1.

The table has sixteen entries — four groups of four. The four groups describe where the exception came from; the four entries in each describe what kind it was:

Each entry is a 128-byte slot, and the whole table must be 2 KB aligned. We run our kernel at EL1 using its own stack (EL1h), so the interrupt we care about today lands in one specific slot: current EL with SPx → IRQ. We point that slot at our handler and leave the rest as safe catch-alls.

// vectors.S — the table of doors

.macro VENTRY label
    .balign 0x80              // every slot is 128 bytes
    b \label
.endm

.section ".text"
.balign 0x800                 // the whole table is 2 KB aligned
.global vector_table
vector_table:
    // --- Current EL with SP0 (unused: we run as SPx) ---
    VENTRY trap            // Synchronous
    VENTRY trap            // IRQ
    VENTRY trap            // FIQ
    VENTRY trap            // SError

    // --- Current EL with SPx (this is us, at EL1h) ---
    VENTRY trap            // Synchronous
    VENTRY irq_entry       // IRQ  <- the timer comes through here
    VENTRY trap            // FIQ
    VENTRY trap            // SError

    // --- Lower EL, AArch64 (no user code yet) ---
    VENTRY trap
    VENTRY trap
    VENTRY trap
    VENTRY trap

    // --- Lower EL, AArch32 (we never run 32-bit) ---
    VENTRY trap
    VENTRY trap
    VENTRY trap
    VENTRY trap

// A minimal IRQ stub: save registers, call C, restore, return.
irq_entry:
    stp     x0, x1, [sp, #-16]!
    stp     x2, x3, [sp, #-16]!
    stp     x29, x30, [sp, #-16]!
    bl      irq_handler        // the C side, below
    ldp     x29, x30, [sp], #16
    ldp     x2, x3, [sp], #16
    ldp     x0, x1, [sp], #16
    eret                       // return to whatever we interrupted

trap:
    b       trap               // anything unexpected parks here for now

Registering it is one instruction: msr vbar_el1, x0 with x0 set to vector_table. From that moment the CPU knows where to send exceptions.

Part 2 — A clock that ticks

Every ARMv8-A core has a built-in generic timer: a counter that advances at a fixed frequency, plus a comparator that raises an interrupt when it reaches zero. No busy loop, no guesswork — the silicon counts real time for us.

Three system registers do the work:

// timer.c — the EL1 physical timer

#include <stdint.h>

static uint64_t interval;     // ticks between interrupts

void timer_init(void) {
    uint64_t freq;
    __asm__ volatile ("mrs %0, cntfrq_el0" : "=r"(freq));
    interval = freq / 2;      // fire twice a second -> 1 Hz blink

    __asm__ volatile ("msr cntp_tval_el0, %0" :: "r"(interval));
    __asm__ volatile ("msr cntp_ctl_el0, %0" :: "r"((uint64_t)1)); // enable
}

void timer_rearm(void) {
    // Reload the countdown so the next interrupt is one interval away.
    __asm__ volatile ("msr cntp_tval_el0, %0" :: "r"(interval));
}

Part 3 — The gatekeeper

A timer that fires is not enough; something has to deliver that signal to the core. That something is the interrupt controller — on the Raspberry Pi 4, a GIC-400. It is the gatekeeper that decides which interrupts reach the CPU. We only need the bare minimum: switch it on, allow all priorities through, and enable the one interrupt line the EL1 physical timer uses (interrupt ID 30).

// gic.c — just enough interrupt controller (BCM2711 GIC-400)

#include <stdint.h>

#define GICD_BASE   0xFF841000UL          // distributor
#define GICC_BASE   0xFF842000UL          // CPU interface

#define GICD_CTLR      (GICD_BASE + 0x000)
#define GICD_IGROUPR   (GICD_BASE + 0x080)
#define GICD_ISENABLER (GICD_BASE + 0x100)
#define GICC_CTLR      (GICC_BASE + 0x000)
#define GICC_PMR       (GICC_BASE + 0x004)
#define GICC_IAR       (GICC_BASE + 0x00C)
#define GICC_EOIR      (GICC_BASE + 0x010)

#define TIMER_IRQ   30                    // EL1 non-secure physical timer

static inline void wr(uint64_t r, uint32_t v) { *(volatile uint32_t *)r = v; }
static inline uint32_t rd32(uint64_t r)        { return *(volatile uint32_t *)r; }

void gic_init(void) {
    uint32_t group = rd32(GICD_IGROUPR);
    group |= 1u << TIMER_IRQ;             // deliver it as a normal IRQ
    wr(GICD_IGROUPR, group);

    wr(GICC_PMR,  0xFF);                  // let every priority through
    wr(GICC_CTLR, 3);                     // enable the CPU interface
    wr(GICD_CTLR, 3);                     // enable the distributor
    wr(GICD_ISENABLER + (TIMER_IRQ / 32) * 4, 1u << (TIMER_IRQ % 32));
}

uint32_t gic_claim(void) { return rd32(GICC_IAR);  }       // which IRQ?
void     gic_finish(uint32_t id) { wr(GICC_EOIR, id); }    // we're done

Part 4 — The handler, and the LED returns

Now the three pieces meet. When the timer fires, the GIC delivers IRQ 30, the CPU jumps through the vector table to irq_entry, which calls our C handler. The handler does three small things: find out which interrupt it was, toggle the LED, rearm the timer, and tell the GIC we are finished.

// kernel.c — wiring it together

void     uart_init(void);
void     uart_puts(const char *s);
void     led_init(void);
void     led_toggle(void);
void     timer_init(void);
void     timer_rearm(void);
void     gic_init(void);
uint32_t gic_claim(void);
void     gic_finish(uint32_t id);

void irq_handler(void) {
    uint32_t id = gic_claim();
    if (id == 30) {            // our timer
        led_toggle();
        timer_rearm();
    }
    gic_finish(id);
}

void kernel_main(void) {
    uart_init();
    uart_puts("Alquist is awake. Starting the heartbeat.\n");

    extern char vector_table[];
    __asm__ volatile ("msr vbar_el1, %0" :: "r"(vector_table));

    led_init();
    gic_init();
    timer_init();

    // The moment we avoided last article: let interrupts in.
    __asm__ volatile ("msr daifclr, #2");   // unmask IRQ

    for (;;) {
        __asm__ volatile ("wfi");           // sleep until the next tick
    }
}

The led_init, led_on/off/toggle helpers are the same GPIO42 writes from our very first article — just wrapped in tidy functions now. The LED has come full circle.

What actually happens

We point VBAR_EL1 at our table, set up the LED, the GIC, and the timer, then clear the IRQ mask with daifclr — literally undoing the 0x3C5 masking from the previous article. The main loop then does almost nothing: wfi puts the core to sleep until an interrupt arrives.

Twice a second the timer hits zero and raises IRQ 30. The GIC delivers it; the CPU wakes, jumps through the vector table to irq_entry, saves a few registers, and calls irq_handler. We toggle the LED, reload the countdown, acknowledge the interrupt, and eret back into the sleep loop. The light blinks at a rock-steady 1 Hz — and crucially, the CPU spends almost all its time asleep instead of spinning.

Compare that to article one: same LED, same pin, but the difference between a busy loop and a real interrupt is the difference between a toy and an operating system. This is the machinery everything else — scheduling, preemption, drivers — will be built on.

[^-^] |=| /_\

There it is: a heartbeat I did not have to babysit. While the light blinks, I sleep, and the timer wakes me exactly on schedule. That little toggle in the handler is the seed of something much bigger — if an interrupt can steal the CPU to blink a light, it can steal the CPU to switch between tasks. That is where we are headed: many things running at once, on one core.

Example package

The full package includes the source files, config.txt, a Makefile, and a prebuilt kernel8.img: download tick-tock-example.zip. It builds directly on the EL1 handoff from the previous example, then adds only the vector table, timer, GIC setup, and GPIO42 LED toggle.

Try it

Wire the serial adapter as before (GND–GND, adapter RX to Pi GPIO14), open the terminal at 115200 8N1, and power the board. You should see one line of greeting, and then the green ACT LED should blink steadily — on for half a second, off for half a second, forever:

Alquist is awake. Starting the heartbeat.

Hold a stopwatch to it: unlike the drifting busy-loop of article one, this blink is exactly 1 Hz, because it is driven by real hardware time. The LED is now our heartbeat — it will stay with us from here on, quietly proving the kernel is alive. Next, we use this very same timer interrupt to do something far more ambitious than blinking: run more than one thing at a time.