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.
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 x0–x30, 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 preemptive: 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.