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.
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:
- Groups: current EL using
SP0; current EL usingSPx; a lower EL in AArch64; a lower EL in AArch32. - Kinds: Synchronous, IRQ, FIQ, SError.
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:
CNTFRQ_EL0— read-only: how many ticks happen per second.CNTP_TVAL_EL0— write a countdown value; the timer fires when it reaches zero.CNTP_CTL_EL0— the enable switch (bit 0 on, bit 1 mask off).
// 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.