The puzzle
The sensor must be sampled every 2 ms, the control loop run every 10 ms, the display refreshed every 20 ms. A superloop that runs everything on every pass wastes time and gives each job a latency equal to the whole pass. You could buy an RTOS, but many products ship without one. How far can you get with a timer, a table and a loop, and where exactly does it stop working?
STEP 1
From the superloop to a task table
Unit 7, lesson 6 built a superloop: every pass calls every task, and each task checks the time itself. Its worst-case latency is one whole pass, the sum of all the tasks. A cooperative scheduler organises the same idea around a table:
typedef struct { void (*run)(void); uint32_t period, left; volatile bool due; } task_t;
static task_t tasks[] = { /* most urgent first; periods in ticks of 1 ms */
{ sample_sensor, 2, 1 },
{ control_loop, 10, 1 },
{ update_display, 20, 1 },
};
void tick_1ms(void) { /* timer interrupt: only marks tasks due */
for (size_t i = 0; i < N_TASKS; i++)
if (--tasks[i].left == 0) {
tasks[i].left = tasks[i].period;
if (tasks[i].due) overruns++; /* still due from last time: one run is lost */
tasks[i].due = true;
}
}
int main(void) {
for (;;) {
bool ran = false;
for (size_t i = 0; i < N_TASKS && !ran; i++)
if (tasks[i].due) { tasks[i].due = false; tasks[i].run(); ran = true; }
if (!ran) __WFI(); /* nothing due: sleep until the next interrupt */
}
}
The interrupt stays tiny (unit 9, lesson 6): it only counts down and sets flags. The loop runs the most urgent due task to completion, then scans again from the top, so a more urgent task that became due meanwhile goes next. On the pico-sdk the tick can be a repeating timer, whose callback runs from an IRQ handler. Each due flag is a single aligned variable, set by the interrupt and cleared by the loop (unit 9, lesson 5). If a task is still due when its next period starts, setting the flag again loses one run, so the tick counts it as an overrun. (A tick that lands between the scan and __WFI() is not lost: its flag is set, and the loop sees it after the next interrupt wakes it, at most one tick later. The standard way to avoid even that delay is to mask interrupts with __disable_irq(), scan, execute __WFI(), which still wakes on a pending interrupt, then __enable_irq().)
STEP 2
Nobody runs until the current task returns
“Cooperative” means that tasks give the CPU back voluntarily, by returning. The scheduler cannot take it from them. A task that becomes due waits for whatever is running to finish, however unimportant that is. For the most urgent task, the worst start delay is therefore the longest single run of any other task:
↑ This step uses the figure at the top of the page.
Priorities decide who goes next, not who may interrupt. Zephyr describes its cooperative threads the same way: once running, such a thread “remains the current thread until it performs an action that makes it unready”, so lengthy computation delays even threads of higher priority. FreeRTOS has a cooperative mode too (configUSE_PREEMPTION set to 0), in which waking a more urgent task does not cause a switch; the switch happens when the running task blocks or yields.
STEP 3
Make every run short
The fix is the one from lesson 1: split long work into steps that each return quickly. A display task that redraws a few rows per call and keeps its position in a static variable is a state machine with one state per chunk. Protothreads (from the Contiki operating system) let you write the same thing as straight-line code: PT_WAIT_UNTIL(pt, condition) records the current line in a variable and returns from the function while the condition is false; the next call jumps back to that line with a switch. Two consequences follow directly from that implementation: local variables on the stack do not survive a wait (keep them in a structure or static), and a wait must not sit inside another switch statement.
STEP 4
The cyclic executive
When timing must be exactly repeatable, the schedule can be fixed at design time. A cyclic executive divides time into minor frames started by a timer; each frame runs a fixed list of functions from a table, and the list repeats every major cycle. With periods of 5, 10 and 20 ms, a natural choice is a minor frame of 5 ms (the greatest common divisor) and a major cycle of 20 ms (the least common multiple):
A timer interrupt starts a 5 ms minor frame; each frame runs a fixed list of functions from a table, and the table repeats every 20 ms. Task A (1 ms) runs every 5 ms, B every 10 ms and C every 20 ms. Everything must finish before the next frame starts. The CPU may be only half loaded and a frame still overflow, if the table puts B and C in the same frame; placing them in different frames fixes it.
Every frame’s work must finish before the next tick. The average load says little: two jobs placed in the same frame can overflow it while the CPU is idle half the time. Placing work so that frames are balanced is the whole design task, done once, on paper. In exchange, the order of execution is identical in every cycle, so the timing is easy to reason about and to check: each frame’s worst case is the sum of its functions’ worst execution times.
STEP 5
When cooperation stops being enough
Cooperative scheduling works when every task can be written with short runs. It fails when one job cannot be split (a library call that takes 30 ms, a flash erase that blocks the bus), when the shortest deadline is shorter than the longest unavoidable run, or when the code grows and every new function silently becomes part of every other task’s latency. Preemption (lesson 3) removes the dependency: an urgent task no longer waits for a long one to return, at the cost of stacks per task and shared data that needs protection.
STEP 6
Worked example: an 8 ms display update
The sample task (0.2 ms) is due every 2 ms, control (1 ms) every 10 ms, and the display update takes 8 ms every 20 ms. At t = 0 all three are due: sample runs 0–0.2 ms, control 0.2–1.2 ms, then the display 1.2–9.2 ms. The sample task becomes due again at 2 ms but cannot start until the display returns at 9.2 ms, 7.2 ms late. Meanwhile its flag is set again at 4, 6 and 8 ms: three of the ten samples in each 20 ms cycle are lost.
Rewrite the display to do 1 ms of drawing per call. Now the longest run of any task is 1 ms (the control loop, or one display step), so the sample task waits at most
In this schedule it never waits more than 0.8 ms, and no sample is lost. The display still finishes within its 20 ms period: its total work is unchanged, only interleaved.
MYTHS AND FACTS
Common misconceptions
A higher priority means the task is served immediately
Not without preemption: priority only chooses the next task when the current one returns.
The CPU is only 55 % loaded, so the schedule fits
In a cyclic executive each frame must fit on its own; in a cooperative scheduler the longest run sets the latency, whatever the average.
Protothreads are threads
They are functions that return and resume at a recorded line; they have no stack of their own, so locals do not survive a wait.
The tick interrupt should run the tasks
Then every task runs in interrupt context and blocks interrupts of equal and lower priority while it runs; mark them due and run them in the loop.
Check yourself
Answer in your head, then open the card.
Tasks take at most 0.3, 1.5 and 6 ms and are scheduled cooperatively, most urgent first. What is the worst start delay of the most urgent task?
6 ms: it may become due just after the 6 ms task has started, and nothing can interrupt that task.
Tasks with periods 4, 8 and 12 ms are placed in a cyclic executive. Suggest a minor frame and a major cycle.
A minor frame of 4 ms (the greatest common divisor) and a major cycle of 24 ms (the least common multiple), so the table has 6 frames.
A protothread computes a value into a local variable, then waits with PT_WAIT_UNTIL, then uses the value. Why is the result garbage?
PT_WAIT_UNTIL returns from the function while waiting; when the function is called again, its stack frame is new and the local variable has not kept its value. Store it in a static variable or in the protothread's structure.
A cooperative scheduler's tick interrupt finds a task still marked due when its next period starts. What has happened, and what should the scheduler do?
The previous run never started within a whole period: an overrun. (This scheduler clears the flag before running a task, so a run that started but is still going only makes the next one late.) Setting the flag again loses one run silently; the scheduler should count the overrun so it shows up in testing.
Sources (4)
- Zephyr Project documentation, doc/kernel/services/scheduling/index.rst — “Once a cooperative thread becomes the current thread, it remains the current thread until it performs an action that makes it unready. Consequently, if a cooperative thread performs lengthy computations, it may cause an unacceptable delay in the scheduling of other threads, including those of higher priority”; k_yield() lets threads of higher or equal priority run
- FreeRTOS Kernel, tasks.c — with configUSE_PREEMPTION == 0: “If the cooperative scheduler is being used then a yield should not be performed just because a higher priority task has been woken”
- Contiki OS, core/sys/pt.h and core/sys/lc-switch.h (protothreads, Adam Dunkels) —
PT_WAIT_UNTIL(pt, condition)records the position withLC_SETand doesreturn PT_WAITING;while the condition is false; lc-switch.h implements the local continuation with aswitch()and warns that it “does not work if anLC_SET()is done within anotherswitch()statement” - Raspberry Pi Ltd, pico-sdk 1.5.1, common/pico_time/include/pico/time.h — repeating timers: “Generally the callback is called as soon as possible after the time specified from an IRQ handler on the core the alarm pool was created on”; the callback returns true to continue repeating