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.
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.