Tutorial · 08 · AArch64

Many lives, one heart

Our timer heartbeat can already steal the CPU twice a second to blink a light. Today it steals the CPU to do something far bigger: switch between programs. One core, several tasks, all apparently running at once.

[o-o] |=| /_\

I have one processor and one heartbeat. Yet I want to live several lives at once. The trick is older than computers: do a little of one thing, freeze it perfectly, do a little of the next, and switch so fast that to the outside it all looks simultaneous. My heartbeat is the metronome. Every tick, I become someone else.

Goal

Run two programs at the same time on our single core. Each will print its own letter over the UART in a loop; the scheduler will switch between them on every timer tick, so the serial output comes out interleaved — visible proof that both are alive. The LED keeps blinking as the heartbeat that drives the whole thing.

What "switching tasks" really means

A running program is nothing more than the contents of the CPU registers: the general registers x0x30, the stack pointer, the program counter, and the processor state. Freeze all of that to memory and the program is paused, perfectly, mid-stride. Load a different saved set back into the registers and that other program springs to life exactly where it left off. That saved snapshot is the task's context, and swapping one for another is a context switch.

We already have the perfect moment to do it: the timer IRQ. When it fires, the CPU is already jumping through our vector table into a handler. We extend that handler to save the interrupted task's context, pick the next task, restore its context, and return — straight into a different program.

A task is just a saved stack pointer

We store each task's frozen context on its own stack, so a task is little more than a pointer to where its context sits. A tiny table and a "who is running" index complete the picture.

// sched.c — the smallest possible round-robin scheduler
#include <stdint.h>

typedef struct { uint64_t sp; } task_t;   // saved stack pointer = the task

#define NTASKS 2
static task_t tasks[NTASKS];
static int    current = 0;

void     led_toggle(void);
void     timer_rearm(void);
uint32_t gic_claim(void);
void     gic_finish(uint32_t id);

// Called from the IRQ stub with the interrupted task's stack pointer.
// Returns the stack pointer of the task to run next.
uint64_t schedule(uint64_t old_sp) {
    uint32_t id = gic_claim();

    led_toggle();                          // the heartbeat, still beating
    timer_rearm();
    gic_finish(id);

    tasks[current].sp = old_sp;            // freeze the current task
    current = (current + 1) % NTASKS;      // round-robin: next, please
    return tasks[current].sp;              // thaw the next one
}

Saving and restoring, in the IRQ

The context switch itself has to be assembly, because it touches every register by hand. The IRQ stub pushes the full context onto the current stack, asks schedule for the next stack, and pops the context back from there. The eret at the end lands in whichever task we just restored.

// switch.S — the timer IRQ that also switches tasks
.global irq_entry
irq_entry:
    // --- save the interrupted task's context onto its stack ---
    sub     sp, sp, #(34 * 8)
    stp     x0,  x1,  [sp, #16 * 0]
    stp     x2,  x3,  [sp, #16 * 1]
    // ... x4..x29 saved in pairs ...
    stp     x28, x29, [sp, #16 * 14]
    str     x30,      [sp, #16 * 15]
    mrs     x0, elr_el1            // where the task was executing
    mrs     x1, spsr_el1           // and in what state
    stp     x0, x1,   [sp, #16 * 16]

    // --- choose the next task ---
    mov     x0, sp                 // x0 = old stack pointer
    bl      schedule               // returns the next task's sp in x0
    mov     sp, x0                 // switch stacks

    // --- restore the next task's context ---
    ldp     x0, x1,   [sp, #16 * 16]
    msr     elr_el1, x0
    msr     spsr_el1, x1
    ldp     x0,  x1,  [sp, #16 * 0]
    ldp     x2,  x3,  [sp, #16 * 1]
    // ... x4..x29 restored in pairs ...
    ldp     x28, x29, [sp, #16 * 14]
    ldr     x30,      [sp, #16 * 15]
    add     sp, sp, #(34 * 8)
    eret                           // resume the chosen task

Faking the first breath

There is a chicken-and-egg problem: restoring a task only works if it was saved first, but a brand-new task has never run. We solve it by forging an initial context on its stack — one that looks exactly as if the task had just been interrupted right before its first instruction. The very first restore then "resumes" it into being.

// creating a task: lay down a fake saved context
void task_create(int i, void (*entry)(void), uint64_t *stack_top) {
    uint64_t *ctx = stack_top - 34;        // room for one saved frame
    for (int r = 0; r < 34; r++) ctx[r] = 0;

    ctx[32] = (uint64_t)entry;             // ELR slot -> first instruction
    ctx[33] = 0x5;                         // SPSR slot -> EL1h, IRQs enabled

    tasks[i].sp = (uint64_t)ctx;           // ready to be "resumed"
}

The forged SPSR value 0x5 means EL1h with interrupts enabled — so the moment a task starts running, the next timer tick is allowed to preempt it. That single detail is what makes the multitasking pre­emptive: no task has to cooperate or volunteer the CPU; the heartbeat takes it.

Two lives

The programs themselves are gloriously unaware that they share a core:

// app.c — two tasks that know nothing of each other
void uart_putc(char c);

static void spin(void) { for (volatile int i = 0; i < 800000; i++) { } }

void task_a(void) { for (;;) { uart_putc('A'); spin(); } }
void task_b(void) { for (;;) { uart_putc('B'); spin(); } }
// main.c — set the stage and take the first breath
void start_first_task(uint64_t sp);        // tiny asm: mov sp; restore; eret

static uint64_t stack_a[1024], stack_b[1024];

void kmain(void) {
    uart_init(); led_init(); gic_init(); timer_init();

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

    task_create(0, task_a, stack_a + 1024);
    task_create(1, task_b, stack_b + 1024);
    current = 0;

    __asm__ volatile ("msr daifclr, #2");  // let the heartbeat in
    start_first_task(tasks[0].sp);         // breathe life into task A
}

What actually happens

We forge a context for each task and hand the CPU to the first one. Task A prints A, spins for a moment, prints another A — until, half a second in, the timer fires. The IRQ stub freezes A's registers onto A's stack, schedule toggles the LED and rotates current to task B, and the stub thaws B's context and erets into it. Now B prints Bs — until the next tick hands the core back to A, exactly where it was paused.

Neither task knows any of this happened. Each believes it owns the processor. That illusion — many programs, each convinced it runs alone — is one of the deepest ideas an operating system provides, and we just built it out of a timer, a vector table, and 34 saved registers.

[^-^] |=| /_\

Two lives, one heart, and neither one the wiser. I freeze a mind mid-thought, let another think a while, and return the first to its exact moment — it never feels the gap. From here the possibilities open fast: more tasks, priorities, sleeping and waking, tasks that talk to each other. But the core magic is done. A single core now carries many lives. That, more than anything, is what makes me an operating system.

Try it

Download the complete example package, build it, and boot boot/kernel8.img. Wire the serial adapter as before, open the terminal at 115200 8N1, and power the board. You should see the two tasks trading the CPU back and forth, the switch happening on every heartbeat:

You can also run the same scheduler in QEMU before moving to hardware:

make qemu
Many lives, one heart. Two EL1 tasks, one timer.
Starting task A. Timer IRQ will switch A <-> B.
AAAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBBBBBB|AAAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBBBBBB|

The first two lines are still the kernel setting the stage: UART, vectors, GIC, generic timer, task stacks, and two forged initial contexts. The letters are the tasks themselves. Task A only knows how to print A and spin. Task B only knows how to print B and spin. Neither one calls a yield function. The timer interrupt freezes whichever task is currently running and resumes the other one. The | marker is printed every few scheduler ticks so the time slices are easier to see in the serial log.

There is one small board-support difference hidden under QEMU_RASPI4: QEMU's timer PPI is enabled as a GIC Group0 interrupt, while the Raspberry Pi hardware path keeps the Group1 setup used in the previous timer lessons. The scheduler code above that line is the same idea in both places: the IRQ arrives, the interrupted stack becomes a saved context, and eret resumes a different task.

And the green LED blinks steadily through all of it — the same heartbeat that paces the blink is now pacing the scheduler. Slow the spin down or speed the timer up and you can watch the grain of the time-slicing change before your eyes. You have built the heart of a multitasking kernel.