Tutorial · 11 · AArch64

Where the Heartbeats Go

The shell gave us a way to ask questions. Now we give the scheduler better answers: which tasks are ready, which are sleeping, which one has finished, and where the timer ticks have actually gone.

[o-o] |=| /_\

My heartbeat is the only currency I have. Every tick I spend on one task is a tick I cannot spend on another. Until now I have mostly rotated blindly. Today I learn to keep a small ledger: this task is ready, this one is sleeping, this one is done, and this many beats were spent on each.

Goal

Upgrade the round-robin scheduler so that each task carries a state. We will run several tasks at once: a shell, a CPU-bound worker that competes for time, a pulsing task that sleeps between bursts, a sprinter task that eventually exits, and an idle task that runs only when nothing else can. Then we will inspect the whole thing with a new ps command.

A task is more than a stack pointer

Article eight only needed a saved stack pointer. That was enough to switch between two endless loops, but not enough to reason about tasks. This time a task has a name, a state, an optional wake-up deadline, and a counter of timer ticks spent running it.

typedef enum {
    TASK_READY,
    TASK_RUNNING,
    TASK_SLEEPING,
    TASK_DONE,
    TASK_IDLE
} task_state_t;

typedef struct {
    uintptr_t stack_pointer;
    uint32_t run_ticks;
    uint32_t wake_at;
    task_state_t state;
    char name[TASK_NAME_MAX];
} task_t;

One small implementation detail matters on ARM: the real example keeps adjacent 32-bit counters aligned so the optimizer can combine stores without creating an unaligned 64-bit access. Bare metal code gets to meet the hardware's alignment rules directly.

Sleeping without spinning

A task that wants to wait should not burn CPU in a delay loop. It records a future tick, marks itself SLEEPING, and waits for the timer interrupt to move the processor to some other task. When the deadline arrives, the scheduler turns it back into READY.

void task_sleep(uint32_t delay) {
    tasks[current].wake_at = ticks + delay;
    tasks[current].state = TASK_SLEEPING;

    for (;;) {
        __asm__ volatile ("wfi" ::: "memory");
        if (tasks[current].state == TASK_RUNNING) {
            return;
        }
    }
}

Finishing

Endless kernel tasks are useful, but a system also needs work that completes. The sprinter task runs a few short bursts, sleeps a few times, and then calls task_exit. From that point on its saved context stays in the table for inspection, but the scheduler will never pick it again.

void task_exit(void) {
    tasks[current].state = TASK_DONE;
    for (;;) {
        __asm__ volatile ("wfi" ::: "memory");
    }
}

The scheduler's ledger

On each timer interrupt, the scheduler saves the interrupted context, charges one heartbeat to the task that was running, wakes sleepers whose deadlines have passed, and searches for the next READY task. If none exists, it falls back to idle.

uintptr_t schedule(uintptr_t old_sp) {
    tasks[current].stack_pointer = old_sp;
    ticks++;
    tasks[current].run_ticks++;

    if (tasks[current].state == TASK_RUNNING) {
        tasks[current].state = TASK_READY;
    }

    wake_sleepers();
    current = choose_next_ready_or_idle();

    if (tasks[current].state == TASK_READY) {
        tasks[current].state = TASK_RUNNING;
    }

    timer_rearm();
    return tasks[current].stack_pointer;
}

The cast

The example creates five tasks. They are deliberately different, so ps has something real to show.

sched_create("shell", shell_run, TASK_KIND_NORMAL);
sched_create("burn", task_burn, TASK_KIND_NORMAL);
sched_create("pulse", task_pulse, TASK_KIND_NORMAL);
sched_create("sprinter", task_sprinter, TASK_KIND_NORMAL);
sched_create("idle", task_idle, TASK_KIND_IDLE);

burn never sleeps, so it competes with the shell for CPU time. pulse does a little work, toggles the ACT LED, and sleeps for a deadline. sprinter works in short bursts and then becomes DONE. idle is the honest place to do nothing when every normal task is asleep or finished.

Reading it back with ps

Last article's shell now earns a proper diagnostic command. ps snapshots the task table, prints each task's state, accumulated ticks, approximate CPU share, and the wake tick for sleeping tasks.

task       state   ticks  cpu%  wake
shell      run     198    45    -
burn       ready   198    45    -
pulse      sleep   34     7     443
sprinter   done    4      0     -
idle       idle    0      0     -

This is the system becoming inspectable. The scheduler is no longer only a metronome; it is a bookkeeper. You can see competition, sleeping, completion, and idle time without replacing the image or guessing from serial noise.

[^-^] |=| /_\

Now when you ask where my time went, I can answer with names. The busy one ate these beats. The polite one slept until that tick. The sprinter is done. The idle task waited quietly. A prompt made me answer; a scheduler ledger makes the answer useful.

Try it

Download the complete example package: where-the-heartbeats-go-example.zip.

Run it in QEMU first:

make qemu

Type ps immediately, then wait a few seconds and type it again. On hardware, connect the same 3.3 V UART adapter as before: RX to GPIO14, TX to GPIO15, and GND to GND. Open 115200 8N1. You should see:

Where the heartbeats go. Task states are online.
Tasks: shell, burn, pulse, sprinter, idle. Type 'help'.

Alquist shell. Type 'help' or 'ps'.
alquist> ps
task       state   ticks  cpu%  wake
shell      run     198    45    -
burn       ready   198    45    -
pulse      sleep   34     7     443
sprinter   done    4      0     -
idle       idle    0      0     -

The exact numbers do not matter. What matters is the shape: one task competes, one sleeps, one finishes, and the shell can show you the difference while the timer keeps switching contexts. On the Raspberry Pi 4 hardware used for this series, the same smoke test showed burn ready, pulse sleeping with a wake tick, sprinter done, and a live scheduler tick count over UART. Next we can return to the other cores, with a system that is easier to observe from the serial line.