Part 3 · Firmware Systems42 min read
RTOS Fundamentals — Tasks and the Context Switch
An RTOS is not magic. Underneath FreeRTOS, Zephyr and every other kernel for Cortex-M is the same mechanism: a few dozen instructions in the PendSV handler that save one task’s registers onto its stack and load another’s. In this lesson you will learn what tasks are, how schedulers decide who runs, and exactly how the Cortex-M4 hardware supports a context switch. Then you will write a complete pre-emptive scheduler in about 200 lines including comments, run it on the Discovery board or in Renode, and map every piece of it onto FreeRTOS and Zephyr. After this, an RTOS is a tool you understand instead of a black box you configure.
When you need an RTOS, and when you do not
Section titled “When you need an RTOS, and when you do not”The event-driven designs from State machines and event-driven firmware handle a surprising amount. Reach for an RTOS when:
- Independent activities have very different time scales. A motor loop every 100 µs, a sensor every 10 ms, a network stack that sometimes needs 50 ms of CPU. Run-to-completion forces the 50 ms job to be chopped into slices by hand; pre-emption does it for you.
- You integrate middleware written for threads. TCP/IP stacks, USB stacks, filesystems and TLS libraries often expect to block. Without a kernel you must rewrite them as state machines or run them in a fragile polling mode.
- Response time must be decoupled from code size. In a superloop, adding a feature changes everyone’s latency. With priorities, a high-priority task’s response time depends only on higher-priority work.
Stay without one when the product is a single control loop, when RAM is a few kilobytes, when you must certify every line (a kernel adds a large body of code to justify), or when a cooperative scheduler with state machines already meets every deadline. An RTOS converts a scheduling problem into a concurrency problem. That trade is worth it only if the scheduling problem is the harder one.
Tasks, TCBs and task states
Section titled “Tasks, TCBs and task states”A task (FreeRTOS’s word; Zephyr says thread) is an independent flow of execution: a function that typically never returns, with its own stack. Each task has a task control block (TCB), a struct holding everything the kernel needs to suspend and resume it: at minimum the saved stack pointer, plus state, priority, and timing data.
Why a separate stack per task? Because a task can be suspended in the middle of a function, three calls deep. Its local variables, saved registers and return addresses all live on its stack, and they must survive untouched while other tasks run. One stack per task, and the saved stack pointer in the TCB is enough to find all of it again.
Every task is in one of a few states:
created | v +----------> READY <---------------------------+ | | ^ | | scheduler | | pre-empted (higher priority | | picks it v | ready, or time slice ended) | | RUNNING | | | | | | waits: delay, queue, semaphore | | v | | BLOCKED ----------------------------+ | event arrives or timeout | +-- resumed -- SUSPENDED (explicitly parked; no timeout)- Running: executing on the CPU. On a single-core MCU exactly one task is running.
- Ready: able to run, waiting for the CPU.
- Blocked: waiting for something (a time, a queue item, a semaphore), usually with a timeout. Blocked tasks cost no CPU at all. This is the point: a task that waits for a UART byte by blocking uses zero cycles until the byte arrives.
- Suspended: removed from scheduling until another task explicitly resumes it. Rarely needed.
Scheduling policies
Section titled “Scheduling policies”The scheduler is the code that picks which ready task runs. Three policies matter:
- Pre-emptive priority scheduling. Every task has a priority; the highest-priority ready task always runs. If an ISR makes a higher-priority task ready, the switch happens as soon as the ISR returns, mid-way through whatever the lower-priority task was doing. This is what FreeRTOS and Zephyr do by default, and it is what makes response times predictable.
- Round-robin. Tasks of equal standing take turns, each running for a time slice (usually one tick). FreeRTOS applies round-robin among tasks of the same priority (
configUSE_TIME_SLICING). - Cooperative. A task runs until it voluntarily blocks or yields. No pre-emption means fewer races, but one task that forgets to yield freezes the system. Zephyr’s negative-priority threads behave this way.
Kernels count time in ticks, generated by a periodic interrupt (SysTick on Cortex-M, typically 1 kHz). Each tick the kernel checks whether any delayed task’s time has come. A tickless kernel stops the periodic interrupt when the system is idle, programs a timer for the next actual deadline, and sleeps until then; Power management covers it.
How a context switch works on Cortex-M
Section titled “How a context switch works on Cortex-M”Switching tasks means saving every register the outgoing task might care about, and loading the incoming task’s saved registers, so that each resumes as if nothing happened. The Cortex-M was designed for this, and three hardware features do most of the work.
Two stack pointers
Section titled “Two stack pointers”The core has two physical stack pointers. MSP (main stack pointer) is used by all exception handlers and, after reset, by thread code. PSP (process stack pointer) can be selected for thread mode by setting CONTROL.SPSEL. Which one sp refers to depends on the mode:
| Mode | Stack used |
|---|---|
| Handler mode (any ISR or exception) | always MSP |
Thread mode, CONTROL.SPSEL = 0 | MSP |
Thread mode, CONTROL.SPSEL = 1 | PSP |
An RTOS runs every task in thread mode on PSP, with PSP pointing into that task’s own stack. Interrupts always switch to MSP. Two consequences: switching tasks is just changing PSP, and ISRs never consume task stack beyond the frame the hardware pushes on entry. You size the MSP for the deepest interrupt nesting once, instead of adding ISR headroom to every task’s stack.
What the hardware saves for you
Section titled “What the hardware saves for you”On exception entry the core automatically pushes eight registers onto the stack that was in use, PSP for a task. These are exactly the registers the AAPCS calling convention says a called function may clobber, which is what lets an ISR be an ordinary C function:
basic frame (8 words, 32 bytes) extended frame (26 words, 104 bytes) used when the task has live FPU state
higher addresses higher addresses +------------+ +------------+ | xPSR | sp + 0x1C | (reserved) | sp + 0x64 | PC | sp + 0x18 | FPSCR | sp + 0x60 | LR | sp + 0x14 | S15 | | R12 | sp + 0x10 | ... | | R3 | sp + 0x0C | S0 | sp + 0x20 | R2 | sp + 0x08 | xPSR | sp + 0x1C | R1 | sp + 0x04 | PC .. R0 | (as basic frame) | R0 | sp + 0x00 <- PSP | R0 | sp + 0x00 <- PSP +------------+ +------------+ lower addressesThen the core puts a special value called EXC_RETURN in LR and jumps to the handler. When the handler executes bx lr with that value, the core recognizes it as an exception return, pops the frame from the indicated stack, and resumes. The low bits of EXC_RETURN record where to return:
EXC_RETURN | Returns to | Stack | Frame |
|---|---|---|---|
0xFFFFFFF1 | Handler mode | MSP | basic |
0xFFFFFFF9 | Thread mode | MSP | basic |
0xFFFFFFFD | Thread mode | PSP | basic |
0xFFFFFFE1 | Handler mode | MSP | extended (FPU) |
0xFFFFFFE9 | Thread mode | MSP | extended (FPU) |
0xFFFFFFED | Thread mode | PSP | extended (FPU) |
Bit 2 selects PSP, bit 3 selects thread mode, and bit 4 is 0 when the frame is extended. The scheduler tests bit 4 to know whether the task has floating-point state.
What software must save
Section titled “What software must save”The hardware frame leaves out R4 to R11: the callee-saved registers. An ordinary C function preserves them, so an ISR that returns to the same task need not touch them. A context switch returns to a different task, so the switch code must save the outgoing task’s R4 to R11 and load the incoming task’s. The same logic applies to the FPU: the hardware frame holds S0 to S15 and FPSCR (caller-saved), so the switch code saves S16 to S31 (callee-saved) when the task uses the FPU. Finally, the switch code saves the outgoing task’s EXC_RETURN, because it says whether that task’s frame is basic or extended.
Why PendSV
Section titled “Why PendSV”You could switch tasks directly in the SysTick handler. The problem is nesting. If SysTick pre-empted another ISR, the code must return to that ISR, not to a task; switching PSP there and returning to thread mode with an ISR still active is illegal (it raises a UsageFault). The switch must happen only when the processor is about to return to thread mode.
PendSV (pendable service call) is a system exception that exists for exactly this. Software makes it pending by writing SCB->ICSR = SCB_ICSR_PENDSVSET_Msk. Configured at the lowest priority, it runs only after every other active ISR has finished, by tail-chaining straight from the last ISR, and its pending bit is a single flag, so ten requests during a burst of interrupts produce one switch. SysTick, a UART ISR that releases a waiting task, or a task that blocks all just pend PendSV; the switch itself always happens in one place, at the right moment.
FPU lazy stacking
Section titled “FPU lazy stacking”The Cortex-M4F has 32 single-precision registers. Pushing 18 extra words on every interrupt would be expensive, so the core is lazy about it. When a task executes its first FPU instruction, CONTROL.FPCA is set, marking that it has live FP state. On exception entry with FPCA set (and lazy stacking enabled, which is the reset default in FPCCR), the core reserves space for the extended frame but does not write S0 to S15 and FPSCR. It records the reserved address and sets a flag (LSPACT). If the handler never touches the FPU, the registers are never written. If the handler executes any FP instruction, the core stalls and fills in the reserved space first.
For the scheduler this has a neat consequence. When PendSV saves S16 to S31 with a vstmdb instruction, that is itself an FP instruction, so it triggers the lazy save of S0 to S15 into the outgoing task’s reserved frame. Both halves of the FP state end up on the correct task’s stack, and tasks that never use the FPU pay nothing.
A minimal scheduler
Section titled “A minimal scheduler”Here is the complete scheduler: three user tasks plus an idle task, pre-emptive round-robin on a 1 kHz SysTick, with blocking delays. It uses the CMSIS core definitions (SCB, SysTick, NVIC_SetPriority, __set_BASEPRI and friends) that ST’s stm32f4xx.h pulls in from core_cm4.h; compile with -DSTM32F407xx so the header selects the right device.
#ifndef MINIRTOS_H#define MINIRTOS_H
#include <stdint.h>
typedef void (*task_fn_t)(void *arg);
void os_init(void);int os_task_create(task_fn_t fn, void *arg, const char *name);void os_start(void) __attribute__((noreturn));void os_delay(uint32_t ticks); /* block for 1 tick or more */uint32_t os_stack_unused_bytes(uint32_t index);
#endif#include <stddef.h>#include <stdint.h>#include "stm32f4xx.h"#include "minirtos.h"
#define MAX_TASKS 4u /* idle + 3 user tasks */#define STACK_WORDS 256u /* 1 KB per task */#define STACK_PAINT 0xA5A5A5A5u#define CPU_HZ 16000000u /* HSI after reset; change if you configured the PLL */#define TICK_HZ 1000u#define IDLE_INDEX 0u
typedef enum { TASK_READY, TASK_BLOCKED } task_state_t;
typedef struct { uint32_t *sp; /* saved PSP: MUST be the first member */ task_state_t state; uint32_t wake_tick; const char *name;} tcb_t;
_Static_assert(offsetof(tcb_t, sp) == 0, "the assembly reads tcb->sp at offset 0");
static tcb_t tcbs[MAX_TASKS];static uint32_t stacks[MAX_TASKS][STACK_WORDS] __attribute__((aligned(8)));static uint32_t task_count;static uint32_t current_index;static volatile uint32_t tick_count;
/* Used by name from the assembly, so external linkage; 'used' keeps it under LTO. */tcb_t *volatile current_tcb __attribute__((used));void os_switch_context(void);
static void task_exit_trap(void){ for (;;) { } /* a task function returned: park here for the debugger */}
static void idle_task(void *arg){ (void)arg; for (;;) { __DSB(); __WFI(); /* sleep until the next interrupt */ }}
int os_task_create(task_fn_t fn, void *arg, const char *name){ if (task_count >= MAX_TASKS) { return -1; } uint32_t *stk = stacks[task_count]; for (uint32_t i = 0; i < STACK_WORDS; i++) { stk[i] = STACK_PAINT; }
uint32_t *sp = &stk[STACK_WORDS]; /* one past the top; full-descending */
/* Hardware frame, popped by exception return. */ *--sp = 0x01000000u; /* xPSR: Thumb bit (bit 24) only */ *--sp = (uint32_t)(uintptr_t)fn & ~1u; /* PC: entry point, bit 0 clear */ *--sp = (uint32_t)(uintptr_t)task_exit_trap; /* LR: if the task ever returns */ *--sp = 0u; /* R12 */ *--sp = 0u; /* R3 */ *--sp = 0u; /* R2 */ *--sp = 0u; /* R1 */ *--sp = (uint32_t)(uintptr_t)arg; /* R0: first argument */
/* Software frame, popped by ldmia {r4-r11, lr} in PendSV/SVC. */ *--sp = 0xFFFFFFFDu; /* EXC_RETURN: Thread, PSP, basic frame */ for (int r = 11; r >= 4; r--) { *--sp = 0u; /* R11 down to R4 */ }
tcbs[task_count] = (tcb_t){ .sp = sp, .state = TASK_READY, .wake_tick = 0u, .name = name }; task_count++; return (int)(task_count - 1u);}
void os_init(void){ task_count = 0u; (void)os_task_create(idle_task, NULL, "idle"); /* always index 0 */}
/* Called from PendSV_Handler with interrupts disabled. */__attribute__((used)) void os_switch_context(void){ uint32_t i = current_index; for (uint32_t n = 0; n < task_count; n++) { i = (i + 1u) % task_count; if (i != IDLE_INDEX && tcbs[i].state == TASK_READY) { current_index = i; current_tcb = &tcbs[i]; return; } } current_index = IDLE_INDEX; /* nothing else can run */ current_tcb = &tcbs[IDLE_INDEX];}
void SysTick_Handler(void){ tick_count++; for (uint32_t i = 0; i < task_count; i++) { if (tcbs[i].state == TASK_BLOCKED && (int32_t)(tick_count - tcbs[i].wake_tick) >= 0) { tcbs[i].state = TASK_READY; } } SCB->ICSR = SCB_ICSR_PENDSVSET_Msk; /* time slice over: request a switch */}
void os_delay(uint32_t ticks){ __disable_irq(); /* SysTick also writes these fields */ current_tcb->wake_tick = tick_count + ticks; current_tcb->state = TASK_BLOCKED; __enable_irq(); SCB->ICSR = SCB_ICSR_PENDSVSET_Msk; /* give up the CPU now */ __DSB(); __ISB();}
uint32_t os_stack_unused_bytes(uint32_t index){ const uint32_t *stk = stacks[index]; uint32_t n = 0; while (n < STACK_WORDS && stk[n] == STACK_PAINT) { n++; } return n * 4u;}
__attribute__((naked)) void PendSV_Handler(void){ __asm volatile ( " mrs r0, psp \n" /* r0 = outgoing task's stack pointer */ " ldr r3, =current_tcb \n" /* r3 = ¤t_tcb */ " ldr r2, [r3] \n" /* r2 = outgoing TCB */ " tst lr, #0x10 \n" /* EXC_RETURN bit 4 clear = FPU frame */ " it eq \n" " vstmdbeq r0!, {s16-s31} \n" /* save s16-s31 (forces lazy s0-s15) */ " stmdb r0!, {r4-r11, lr} \n" /* save r4-r11 and EXC_RETURN */ " str r0, [r2] \n" /* outgoing->sp = r0 */ " push {r3, lr} \n" /* keep r3; two regs keep MSP aligned */ " cpsid i \n" " bl os_switch_context \n" /* updates current_tcb */ " cpsie i \n" " pop {r3, lr} \n" " ldr r1, [r3] \n" /* r1 = incoming TCB */ " ldr r0, [r1] \n" /* r0 = incoming->sp */ " ldmia r0!, {r4-r11, lr} \n" /* restore r4-r11 and its EXC_RETURN */ " tst lr, #0x10 \n" " it eq \n" " vldmiaeq r0!, {s16-s31} \n" /* restore s16-s31 if it had FPU state */ " msr psp, r0 \n" /* PSP -> incoming hardware frame */ " bx lr \n" /* exception return into incoming task */ " .ltorg \n" /* literal pool for the ldr = above */ );}
__attribute__((naked)) void SVC_Handler(void){ __asm volatile ( " ldr r3, =current_tcb \n" " ldr r1, [r3] \n" /* r1 = first task's TCB */ " ldr r0, [r1] \n" /* r0 = its prepared sp */ " ldmia r0!, {r4-r11, lr} \n" /* lr = 0xFFFFFFFD */ " msr psp, r0 \n" " isb \n" " movs r0, #0 \n" " msr basepri, r0 \n" /* unmask SysTick and PendSV */ " bx lr \n" /* exception return: Thread mode, PSP */ " .ltorg \n" );}
void os_start(void){ NVIC_SetPriority(PendSV_IRQn, 15u); /* lowest: never pre-empts an ISR */ NVIC_SetPriority(SysTick_IRQn, 14u); NVIC_SetPriority(SVCall_IRQn, 0u); /* highest: not masked by BASEPRI below */
/* Mask priorities 14 and 15 (SysTick, PendSV) until the first task is running. */ __set_BASEPRI(14u << (8u - __NVIC_PRIO_BITS));
SysTick->LOAD = CPU_HZ / TICK_HZ - 1u; SysTick->VAL = 0u; SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk | SysTick_CTRL_TICKINT_Msk | SysTick_CTRL_ENABLE_Msk;
current_index = (task_count > 1u) ? 1u : IDLE_INDEX; current_tcb = &tcbs[current_index];
__set_CONTROL(0u); /* MSP, privileged, FPCA = 0 */ __ISB(); __enable_irq(); /* SVC while PRIMASK=1 escalates to HardFault */ __asm volatile ("svc 0"); for (;;) { } /* never reached */}That is the whole kernel. The rest of this section walks through it in the order the hardware experiences it.
Building a task’s first stack
Section titled “Building a task’s first stack”A new task has never run, so there is no saved context to restore. os_task_create forges one: it writes onto the empty stack exactly what PendSV would have saved if the task had been interrupted at the first instruction of its function. When the scheduler first switches to it, the restore path cannot tell the difference.
address contents written by stk[255] xPSR = 0x01000000 hardware frame (exception return pops these 8) stk[254] PC = fn & ~1 stk[253] LR = task_exit_trap stk[252] R12 = 0 stk[251..249] R3, R2, R1 = 0 stk[248] R0 = arg stk[247] EXC_RETURN = 0xFFFFFFFD software frame (ldmia pops these 9) stk[246..240] R11 .. R5 = 0 stk[239] R4 = 0 <- tcb.spThe values that matter:
- xPSR =
0x01000000. Bit 24 is the T (Thumb) bit. Cortex-M only executes Thumb code, and exception return loadsEPSR.Tfrom this word. Leave it zero and the first instruction of the task raises an INVSTATE UsageFault, which escalates to HardFault if UsageFault is not enabled. This is the most common bug in hand-written schedulers. - PC =
fn & ~1. Function pointers to Thumb code have bit 0 set (that is howbxknows to stay in Thumb state). The stacked return address is loaded straight into the PC on exception return, and the architecture requires bit 0 of it to be clear. - LR =
task_exit_trap. If the task function ever returns, it returns here, into an infinite loop you can spot in the debugger, instead of jumping to address 0. - R0 =
arg. AAPCS passes the first argument inR0, so the task function receivesargas its parameter. - EXC_RETURN =
0xFFFFFFFD. Return to thread mode, use PSP, basic frame. A fresh task has no FP state.
The 2D stacks array is aligned to 8 bytes and each row is 1024 bytes, so every task’s initial hardware frame starts 8-byte aligned, as the AAPCS expects of a stack at a function boundary. The forged xPSR has bit 9 clear, telling the core that no alignment padding word was inserted.
PendSV_Handler, line by line
Section titled “PendSV_Handler, line by line”PendSV runs in handler mode on MSP, so it can freely use PSP as a data pointer. The function is naked: GCC emits no prologue or epilogue, because a prologue would push registers onto MSP and change lr before we save it. Only basic asm is reliable in a naked function, which is why the TCB pointer is reached through its symbol name.
| Instruction | What it does and why |
|---|---|
mrs r0, psp | Read the outgoing task’s stack pointer. The hardware has already pushed its basic or extended frame there. |
ldr r3, =current_tcb | Load the address of the current_tcb variable from the literal pool (placed by .ltorg at the end). |
ldr r2, [r3] | r2 = the outgoing TCB. |
tst lr, #0x10 | lr holds EXC_RETURN. Bit 4 clear means the hardware stacked an extended frame: this task has live FPU state. |
it eq / vstmdbeq r0!, {s16-s31} | Only for FPU tasks: push S16 to S31 below the hardware frame. As an FP instruction it also triggers the lazy save of S0 to S15 into the reserved space. |
stmdb r0!, {r4-r11, lr} | Push R4 to R11 and EXC_RETURN: 9 words. stmdb stores the lowest register at the lowest address, matching the forged layout. |
str r0, [r2] | Save the final stack pointer in outgoing->sp. The outgoing task is now completely frozen in its own stack. |
push {r3, lr} | r3 is caller-saved and we need it after the call. Pushing two registers keeps MSP 8-byte aligned for the C call, as AAPCS requires. |
cpsid i … cpsie i | Mask interrupts while the C code reads task states, because SysTick (priority 14) can pre-empt PendSV and modify them. Unconditional cpsie is safe here: PendSV cannot have pre-empted code that had PRIMASK set. |
bl os_switch_context | The C scheduler picks the next task and writes current_tcb. All policy lives here; the assembly is pure mechanism. |
pop {r3, lr} | Restore r3 (the lr value is about to be replaced anyway). |
ldr r1, [r3] / ldr r0, [r1] | r1 = incoming TCB, r0 = its saved stack pointer. |
ldmia r0!, {r4-r11, lr} | Restore its R4 to R11 and its own EXC_RETURN. |
tst / it eq / vldmiaeq r0!, {s16-s31} | If the incoming task’s frame is extended, restore S16 to S31. |
msr psp, r0 | PSP now points at the incoming task’s hardware frame. |
bx lr | Exception return. The core sees 0xFFFFFFFD or 0xFFFFFFED, pops R0 to R3, R12, LR, PC, xPSR (and S0 to S15, FPSCR for extended frames) from PSP, and resumes the incoming task exactly where it was. |
The outgoing and incoming tasks can be the same; the code simply saves and restores the same context. Including the 12-cycle exception entry and the return, the whole switch is well under a hundred instructions.
Starting the first task
Section titled “Starting the first task”At os_start, the CPU is in thread mode on MSP, running main. Nothing has been saved yet, and PendSV’s save path would write into whatever PSP happens to contain (its reset value is architecturally unknown). The first task must be started differently.
This scheduler uses an SVC (supervisor call): svc 0 raises the SVCall exception synchronously. SVC_Handler performs only the restore half of PendSV: it loads the first task’s forged stack, pops the software frame (so lr becomes 0xFFFFFFFD), sets PSP, and returns. The exception return switches thread mode onto PSP (it sets CONTROL.SPSEL) and pops the hardware frame, so the CPU lands at the first instruction of the task function with R0 = arg.
Three details make the start race-free:
- BASEPRI masks SysTick and PendSV until the task is running. SysTick is enabled before
svc, so a tick could fire between__enable_irq()andsvc 0, pend PendSV, and PendSV would try to save a task that does not exist.__set_BASEPRI(14 << 4)blocks every exception with priority value 14 or 15. SVCall is at priority 0, so it is not blocked.SVC_Handlersets BASEPRI back to 0 just before returning. - Interrupts must be enabled for
svc. An SVC executed whilePRIMASKis set cannot be taken, so the core escalates it to a HardFault. Hence__enable_irq()beforesvc, which is safe because BASEPRI holds back the dangerous exceptions. CONTROLis cleared. Ifmainexecuted any FPU instruction,CONTROL.FPCAis set and the SVC exception would stack an extended frame. Clearing it means the start-up frame on MSP is a plain basic frame.
Other kernels start the first task directly from thread mode (set PSP, set CONTROL.SPSEL, isb, branch to the function) or set PSP to 0 and let PendSV skip the save when it sees 0. FreeRTOS’s Cortex-M4F port also reloads MSP from vector table entry 0 before the SVC, reclaiming the stack main was using, since nothing will ever return to it. That is a worthwhile refinement once the basic version works.
SysTick, blocking and the idle task
Section titled “SysTick, blocking and the idle task”SysTick_Handler increments the tick count, moves every blocked task whose wake time has arrived back to ready, and pends PendSV to end the current time slice. The wake check uses the wraparound-safe (int32_t)(now - deadline) >= 0 comparison, so it works across the 32-bit tick counter rollover.
os_delay marks the calling task blocked with a wake time, then pends PendSV and executes DSB and ISB. Because thread mode has lower priority than any exception, PendSV is taken immediately, and the barriers make sure that happens before the next instruction rather than a few instructions later. When this task is eventually switched back in, it resumes right after the ISB and returns from os_delay. The two writes to the TCB happen with interrupts disabled because SysTick writes the same fields.
os_switch_context scans round-robin from the task after the current one and picks the first ready user task. If none is ready, it picks the idle task, which executes WFI in a loop. The idle task must always be ready; it is what guarantees the scheduler always has something to run, and it is where the CPU sleeps.
Running it
Section titled “Running it”#include <stdint.h>#include "stm32f4xx.h"#include "minirtos.h"
#define LED_GREEN 12u#define LED_ORANGE 13u#define LED_BLUE 15u
typedef struct { uint32_t pin; uint32_t period_ms;} blink_cfg_t;
static blink_cfg_t green = { LED_GREEN, 250u };static blink_cfg_t orange = { LED_ORANGE, 500u };static volatile uint32_t spin_count;
static void led_toggle(uint32_t pin){ const uint32_t mask = 1u << pin; if (GPIOD->ODR & mask) { GPIOD->BSRR = mask << 16; /* reset this pin only */ } else { GPIOD->BSRR = mask; /* set this pin only */ }}
static void blink_task(void *arg){ const blink_cfg_t *cfg = arg; for (;;) { led_toggle(cfg->pin); os_delay(cfg->period_ms); }}
static void busy_task(void *arg) /* never blocks: proves pre-emption works */{ (void)arg; for (;;) { spin_count++; if ((spin_count & 0x3FFFFu) == 0u) { led_toggle(LED_BLUE); } }}
int main(void){ RCC->AHB1ENR |= RCC_AHB1ENR_GPIODEN; (void)RCC->AHB1ENR; /* read back so the clock is on before use */ GPIOD->MODER = (GPIOD->MODER & ~(0xFFu << 24)) | (0x55u << 24); /* PD12-PD15 output */
os_init(); (void)os_task_create(blink_task, &green, "green"); (void)os_task_create(blink_task, &orange, "orange"); (void)os_task_create(busy_task, NULL, "busy"); os_start();}Build it with the startup file and linker script from Part 2 plus the CMSIS headers, using the course flags (-mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16 -O2 -g) and -DSTM32F407xx. Your startup code must enable the FPU in CPACR before main, as Startup code and the vector table showed, because the code is compiled for hardware floating point.
What you should see: green toggling every 250 ms, orange every 500 ms, and blue flickering steadily. The busy task never calls os_delay, yet the blink tasks keep exact time, because SysTick pre-empts the busy task every millisecond and the round-robin hands the CPU to any task that just woke. Comment out the SCB->ICSR line in SysTick_Handler and green and orange freeze the moment the busy task first gets the CPU, while blue keeps flickering: without pre-emption the busy task never gives the CPU back.
Each task toggles its own pin through BSRR, never with GPIOD->ODR ^= mask. An ODR read-modify-write in one task can be pre-empted between the read and the write by a task changing another pin, and the first task’s write then undoes the second’s change. Pre-emption turns every shared read-modify-write into a potential race; Concurrency, atomics, and memory ordering treats this properly.
In gdb, a few commands make the mechanism visible:
(gdb) break os_switch_context(gdb) continue(gdb) p current_tcb->name$1 = 0x8000f2c "busy"(gdb) finish(gdb) p current_tcb->name$2 = 0x8000f24 "green"(gdb) x/17xw current_tcb->spAt the breakpoint, current_tcb still names the outgoing task; after finish it names the incoming one. The last command dumps the incoming task’s saved context: eight words of R4 to R11, then EXC_RETURN, then the hardware frame with the PC where the task will resume. Your names and addresses will differ.
Stack sizing
Section titled “Stack sizing”Every task’s stack must hold, at its deepest:
- the deepest call chain the task can execute, including library calls such as
snprintf(often several hundred bytes on its own); - one exception frame pushed when an interrupt arrives: 32 bytes basic, 104 bytes extended for FPU tasks;
- the saved software context while the task is switched out: 36 bytes, or 100 bytes with
S16toS31.
ISR stack usage beyond the hardware frame goes on MSP, not on the task stack.
Estimate the call chain with -fstack-usage, which makes GCC write a .su file per source file listing each function’s frame size, then follow the call graph by hand or with a tool. GCC’s -fcallgraph-info=su (GCC 10 and later) emits the call graph with sizes in a file you can process. Indirect calls through function pointers break automatic analysis; account for them manually.
Then measure. The scheduler paints every stack with 0xA5A5A5A5 at creation; os_stack_unused_bytes counts painted words from the bottom (lowest address) up, which is the high-water mark: how close the task has ever come to overflowing. Run the system through its worst cases (every feature active, every error path) and check that each task keeps a margin of 25 percent or more. FreeRTOS provides the same measurement as uxTaskGetStackHighWaterMark().
What happens on overflow? In this scheduler the stacks are rows of one array, so task 2’s stack sits directly above task 1’s in memory. When task 2 overflows, it writes into the top of task 1’s stack, which is where task 1’s saved context lives while it is switched out. Nothing visible happens until task 1 is switched back in, when PendSV restores a corrupted PC or xPSR and the CPU faults somewhere unrelated to the cause. That delayed, misleading fault is the signature of a stack overflow. Memory protection, stack safety, and robustness shows how the MPU turns it into an immediate fault at the faulting instruction.
Choosing priorities
Section titled “Choosing priorities”This scheduler has no priorities, so a task that wakes may wait for others’ time slices. Real kernels add a priority to each TCB and always run the highest-priority ready task. How should priorities be assigned?
Rate monotonic scheduling (RMS) is the classic answer for periodic tasks: the shorter the period, the higher the priority. Liu and Layland proved that for independent periodic tasks with deadlines equal to their periods, RMS is optimal among fixed-priority schemes, and that the task set is guaranteed schedulable if total utilization is at most n(2^(1/n) - 1): 0.828 for two tasks, 0.780 for three, approaching 0.693 for many.
| Task | Period T | Worst-case execution C | Utilization C/T | RMS priority |
|---|---|---|---|---|
| Motor control | 5 ms | 1 ms | 0.20 | high |
| Sensor fusion | 10 ms | 2 ms | 0.20 | medium |
| Telemetry | 50 ms | 10 ms | 0.20 | low |
Total utilization is 0.60, under the three-task bound of 0.780, so every task meets its deadline. The bound is sufficient, not necessary. For a sharper answer, response time analysis computes each task’s worst-case response time R by iterating until it converges:
R = C_i + sum over higher-priority tasks j of ceil(R / T_j) * C_j
Telemetry: start R = 10 R = 10 + ceil(10/5)*1 + ceil(10/10)*2 = 10 + 2 + 2 = 14 R = 10 + ceil(14/5)*1 + ceil(14/10)*2 = 10 + 3 + 4 = 17 R = 10 + ceil(17/5)*1 + ceil(17/10)*2 = 10 + 4 + 4 = 18 R = 10 + ceil(18/5)*1 + ceil(18/10)*2 = 10 + 4 + 4 = 18 converged: 18 ms, deadline 50 msWhen a task’s deadline is shorter than its period (respond to a button within 2 ms, but presses come at most every 100 ms), assign by deadline instead: deadline monotonic. In practice: order tasks by how soon they must finish, keep high-priority tasks short, and never put a task that can run for a long time above one with a tight deadline. Note that blocking on shared resources breaks the independence assumption; the next lesson’s priority inversion is exactly that.
Ticks and periodic work
Section titled “Ticks and periodic work”A tick-based delay is quantized. A delay of N ticks unblocks at the Nth tick interrupt after the call, but the call can happen anywhere within the current tick period, so the real delay is between N-1 and N tick periods. A delay of 1 tick can be almost zero. If you need at least 5 ms with a 1 ms tick, ask for 6.
The second trap is drift. This task wants to run every 10 ms:
for (;;) { sample_sensor(); /* takes 2 ms, sometimes more if pre-empted */ vTaskDelay(pdMS_TO_TICKS(10));}vTaskDelay is relative to when it is called, so the period is 10 ms plus the execution time plus any pre-emption: about 12 ms, varying from cycle to cycle. vTaskDelayUntil delays until an absolute tick count and advances it by exactly one period:
TickType_t last_wake = xTaskGetTickCount();for (;;) { sample_sensor(); vTaskDelayUntil(&last_wake, pdMS_TO_TICKS(10)); /* wakes at last_wake + 10, exactly */}This is the same “advance from the ideal time” rule as the cooperative scheduler in the previous lesson. Use it for anything periodic: control loops, sampling, heartbeats.
FreeRTOS and Zephyr
Section titled “FreeRTOS and Zephyr”Everything above maps directly onto the kernels you will use professionally.
| Concept | This lesson | FreeRTOS | Zephyr |
|---|---|---|---|
| Create a task | os_task_create | xTaskCreate, xTaskCreateStatic | k_thread_create, K_THREAD_DEFINE |
| Relative delay | os_delay | vTaskDelay | k_sleep(K_MSEC(n)), k_msleep |
| Periodic delay | exercise 4 | vTaskDelayUntil | k_timer, or sleep to an absolute timeout |
| Priorities | none | 0 (idle) to configMAX_PRIORITIES - 1; higher number = more urgent | lower number = more urgent; negative = cooperative |
| Tick | SysTick_Handler | xPortSysTickHandler, configTICK_RATE_HZ | CONFIG_SYS_CLOCK_TICKS_PER_SEC |
| Context switch | PendSV_Handler | xPortPendSVHandler | the arch layer’s PendSV handler |
| Configuration | #defines | FreeRTOSConfig.h | Kconfig (prj.conf) plus device tree |
| Assertions | none | configASSERT | __ASSERT with CONFIG_ASSERT=y |
| Stack checks | painting | uxTaskGetStackHighWaterMark, configCHECK_FOR_STACK_OVERFLOW | CONFIG_THREAD_ANALYZER, MPU stack guards |
FreeRTOS
Section titled “FreeRTOS”#define configUSE_PREEMPTION 1#define configUSE_TIME_SLICING 1#define configCPU_CLOCK_HZ 168000000u#define configTICK_RATE_HZ 1000#define configMAX_PRIORITIES 8#define configMINIMAL_STACK_SIZE 128 /* words, not bytes */#define configTOTAL_HEAP_SIZE (32 * 1024) /* bytes, used by heap_4.c */#define configCHECK_FOR_STACK_OVERFLOW 2#define configASSERT(x) do { if ((x) == 0) { taskDISABLE_INTERRUPTS(); for (;;) { } } } while (0)
#define configKERNEL_INTERRUPT_PRIORITY (15 << 4) /* PendSV, SysTick: lowest */#define configMAX_SYSCALL_INTERRUPT_PRIORITY (5 << 4) /* explained in the next lesson */
#define xPortPendSVHandler PendSV_Handler /* map port handlers to vector names */#define vPortSVCHandler SVC_Handler#define xPortSysTickHandler SysTick_Handler#include "FreeRTOS.h"#include "task.h"#include "app.h" /* bsp_init, sample_sensor */
static void sensor_task(void *arg){ (void)arg; TickType_t last_wake = xTaskGetTickCount(); for (;;) { sample_sensor(); vTaskDelayUntil(&last_wake, pdMS_TO_TICKS(10)); }}
int main(void){ bsp_init(); const BaseType_t ok = xTaskCreate(sensor_task, "sensor", 256u /* words */, NULL, tskIDLE_PRIORITY + 2u, NULL); configASSERT(ok == pdPASS); (void)ok; /* configASSERT may compile to nothing */ vTaskStartScheduler(); /* returns only if there was not enough heap for the idle task */ for (;;) { }}The FreeRTOS Cortex-M4F port contains a PendSV handler that is essentially the one you wrote, with BASEPRI instead of cpsid around the scheduler call. xTaskCreate allocates the TCB and the stack from the FreeRTOS heap; the stack depth argument is in words. You link exactly one heap implementation: heap_4.c is the usual choice, a first-fit allocator that merges adjacent free blocks to limit fragmentation, sized by configTOTAL_HEAP_SIZE. xTaskCreateStatic takes caller-provided buffers and needs no heap at all. configASSERT is your safety net: define it in every build, because the kernel uses it to catch misconfiguration such as calling an API from an interrupt with the wrong priority.
Zephyr
Section titled “Zephyr”#include <zephyr/kernel.h>#include "app.h"
static void sensor_thread(void *p1, void *p2, void *p3){ ARG_UNUSED(p1); ARG_UNUSED(p2); ARG_UNUSED(p3); for (;;) { sample_sensor(); k_sleep(K_MSEC(10)); }}
/* name, stack size, entry, p1, p2, p3, priority, options, start delay (ms) */K_THREAD_DEFINE(sensor_tid, 1024, sensor_thread, NULL, NULL, NULL, 5, 0, 0);CONFIG_THREAD_ANALYZER=yCONFIG_THREAD_NAME=yCONFIG_SYS_CLOCK_TICKS_PER_SEC=1000Zephyr defines threads statically at compile time with K_THREAD_DEFINE, so stacks and thread objects are allocated by the linker. Kernel features are selected with Kconfig options in prj.conf, and hardware (which UART, which pins, which clock) is described in device tree files that generate the configuration your drivers read at build time, replacing the board header from Part 2.
What an RTOS costs
Section titled “What an RTOS costs”- RAM. Every task needs its own stack, typically 512 bytes to 2 KB, plus a TCB on the order of a hundred bytes. Ten tasks can easily consume 10 KB.
- Flash. A FreeRTOS kernel with queues and timers is roughly 5 to 10 KB depending on configuration.
- CPU and latency. The tick interrupt runs every millisecond. A context switch, including the scheduler decision, costs on the order of a few hundred cycles, around a microsecond or two at 168 MHz. Kernel critical sections briefly mask interrupts below a configured priority, adding to their latency.
- Complexity. This is the real cost. Pre-emption creates a new class of bugs: data races between tasks, deadlocks, priority inversion, per-task stack overflows. None of these exist in a single-threaded superloop. The next two lessons are about controlling them.
Common mistakes
Section titled “Common mistakes”- Forgetting the Thumb bit in the forged xPSR. The first switch to the task faults with INVSTATE; always write
0x01000000. - PendSV not at the lowest priority. The switch can run while another ISR is active and corrupt the return; set PendSV to the lowest priority, as every port does.
- Not saving
S16toS31for FPU tasks. Floating-point values change randomly in tasks that use the FPU; testEXC_RETURNbit 4 and save the high FP registers. - Executing
svcwith interrupts disabled. The SVC escalates to a HardFault; enable interrupts and use BASEPRI to hold off the scheduler exceptions instead. - A task function that returns. It jumps to whatever
LRheld; make tasks infinite loops and point the forgedLRat a trap. - Stacks sized by guesswork. Overflow corrupts a neighbor’s saved context and faults later in unrelated code; paint stacks and check high-water marks under worst-case load.
- Relative delays for periodic work. The period stretches by the execution time; use
vTaskDelayUntilor its equivalent.
Exercises
Section titled “Exercises”- Run the scheduler on the board or in Renode. Break on
PendSV_Handler, single-step withstepi, and afterstr r0, [r2]dump the outgoing task’s context withx/17xwon itssp. Identify the savedEXC_RETURNand the stacked PC, and useinfo symbolon the PC to see where the task was interrupted. - Print each task’s
os_stack_unused_bytesover a UART (or read them in gdb) after 10 seconds. Then reduceSTACK_WORDSto 48 and observe how the system fails. Explain the failure using the memory layout of thestacksarray. - Add priorities: give
tcb_tapriofield and changeos_switch_contextto pick the highest-priority ready task, round-robin among equals. Make the busy task the lowest priority and show that the blink tasks still keep time; then make it the highest and explain what you see. - Implement
os_delay_until(uint32_t *last_wake, uint32_t period). Make a task that spends 2 ms in a busy loop and then delays, toggling PD14 each cycle. Measure the toggle rate on a logic analyzer or with the DWT cycle counter: about 83 Hz withos_delay(10), exactly 100 Hz withos_delay_until. - Disassemble the build with
arm-none-eabi-objdump -dand findPendSV_Handler. Confirm there is no compiler-generated prologue, find the literal pool word holding the address ofcurrent_tcb, and check thatvstmdbeqis encoded inside an IT block. Then remove the two FPU lines from both halves of PendSV, write two tasks that each repeatedly compute a float sum and compare it with the expected value, and observe the mismatches.
Check yourself
Section titled “Check yourself”Question 1:Why is PendSV configured at the lowest exception priority in every Cortex-M RTOS port?
A context switch is only legal on the way back to a task. At the lowest priority, PendSV tail-chains after the last active ISR, and multiple switch requests collapse into one.
Question 2:A hand-written scheduler forges each new task's stack with xPSR = 0x00000000. What happens at the first switch to that task?
Cortex-M only executes Thumb code. The T bit (bit 24) is loaded from the stacked xPSR, so it must be set in every forged frame.
Question 3:Inside PendSV_Handler, lr contains 0xFFFFFFED. What does that tell the handler?
Bit 4 of EXC_RETURN is clear for an extended frame. 0xFFFFFFED means thread mode, PSP, with FPU context.
Question 4:Why does PendSV save R4 to R11 manually but not R0 to R3?
Hardware stacking covers the caller-saved registers so ISRs can be C functions. A switch to another task must also preserve the callee-saved set.
Question 5:A FreeRTOS task calls sample() (3 ms) then vTaskDelay(pdMS_TO_TICKS(10)) in a loop, with a 1 kHz tick. What is its period?
vTaskDelay counts from the moment it is called, so execution time adds to the period. vTaskDelayUntil advances from the previous wake time and gives exactly 10 ms.
Question 6:A task's deepest call chain uses 600 bytes. ISRs on this system nest three deep and use 400 bytes of stack together. Roughly how much of that ISR usage must the task's stack also hold?
On exception entry the frame is pushed on the stack in use (PSP for a task), then the handler switches to MSP. ISR locals and nesting live on MSP.
Key takeaways
Section titled “Key takeaways”- A task is a stack plus a TCB; the saved stack pointer in the TCB is enough to freeze and resume it.
- Tasks run on PSP and handlers on MSP; the hardware stacks
R0toR3,R12,LR,PC,xPSR(plusS0toS15,FPSCRwith FPU state), and the switch code savesR4toR11,EXC_RETURNand, for FPU tasks,S16toS31. - Context switches happen in PendSV at the lowest priority, requested by writing
SCB->ICSR = SCB_ICSR_PENDSVSET_Msk. - A new task’s stack is forged to look interrupted: xPSR
0x01000000, PC with bit 0 clear,EXC_RETURN0xFFFFFFFD. - Size stacks from
-fstack-usageplus frame overhead, then verify with painted high-water marks; overflow corrupts a neighbor and faults later. - Assign priorities by rate or deadline, check utilization or response times, and use absolute delays for periodic work.
- FreeRTOS and Zephyr implement exactly this mechanism; their priorities, configuration and APIs map one-to-one onto it.
Next: RTOS synchronization and communication, where tasks share resources safely with mutexes, semaphores, queues and notifications.