Context Switching Deep Dive¶
Module 1 explained which task the scheduler picks next; this module covers
what actually happens in the microseconds around swapping one task's
execution context for another's — the mechanism, not the policy. On ARM
Cortex-M (the most common FreeRTOS target family), this is the PendSV
exception handler, pxCurrentTCB, and a carefully choreographed sequence of
register saves that has to be correct at the assembly level, because a single
misordered instruction here corrupts every task in the system.
What a context switch actually saves¶
A task's "context" is everything needed to resume it exactly where it left
off: the CPU's general-purpose registers, the stack pointer, and (on
Cortex-M) the floating-point registers if the task used any. FreeRTOS does
not keep this in a separate save area — it pushes it onto the task's own
stack, and the TCB's pxTopOfStack field is simply updated to point at
whatever the current stack pointer is at the moment of the switch:
typedef struct tskTaskControlBlock {
volatile StackType_t *pxTopOfStack; // must be the first member — asm depends on this
ListItem_t xStateListItem;
ListItem_t xEventListItem;
UBaseType_t uxPriority;
StackType_t *pxStack;
char pcTaskName[configMAX_TASK_NAME_LEN];
// ...
} tskTCB;
pxTopOfStack being the first struct member isn't incidental — the
portable assembly context-switch code accesses it via pxCurrentTCB with
zero offset, so it can be reached without knowing the rest of the struct
layout. This is one of the few places in the kernel where C struct layout
and hand-written assembly are load-bearing together.
PendSV: why not do it in the tick ISR directly¶
Cortex-M's PendSV (Pendable Service Call) exception exists specifically for
this. The tick interrupt (SysTick) runs at a fixed, often high priority; the
actual context switch is deferred to PendSV, configured at the lowest
interrupt priority in the system (configKERNEL_INTERRUPT_PRIORITY /
configMIN_INTERRUPT_PRIORITY in FreeRTOSConfig.h — the port sets
NVIC_SetPriority(PendSV_IRQn, 0xFF)-equivalent). The reasons this matters:
- PendSV can be "pended" (requested) from any context — the tick ISR,
another (higher-priority) ISR calling an API's
...FromISRvariant, or a task callingtaskYIELD()— and it only actually fires once the CPU returns to a priority level where PendSV is allowed to run. This means a higher-priority ISR that arrives mid-switch-request simply runs to completion first, and PendSV (and the actual switch) happens exactly once, after all pending higher-priority interrupt work is done — not once per ISR that requested it. - Running the switch at the lowest priority guarantees it never itself delays a higher-priority ISR. If context switching ran directly inside SysTick or another interrupt at elevated priority, a slow switch would add directly to that interrupt's latency and, transitively, to every lower one waiting behind it.
void xPortPendSVHandler(void) {
__asm volatile (
" mrs r0, psp \n" // get task's stack pointer
" isb \n"
" ldr r3, =pxCurrentTCB \n"
" ldr r2, [r3] \n"
" stmdb r0!, {r4-r11} \n" // push r4-r11 onto task's stack
" str r0, [r2] \n" // save new SP into pxCurrentTCB->pxTopOfStack
" stmdb sp!, {r3, r14} \n"
" mov r0, %0 \n"
" msr basepri, r0 \n"
" bl vTaskSwitchContext \n" // pick the new pxCurrentTCB
" mov r0, #0 \n"
" msr basepri, r0 \n"
" ldmia sp!, {r3, r14} \n"
" ldr r1, [r3] \n" // pxCurrentTCB (now the NEW task)
" ldr r0, [r1] \n" // its saved pxTopOfStack
" ldmia r0!, {r4-r11} \n" // pop r4-r11 from new task's stack
" msr psp, r0 \n"
" bx r14 \n" // return — hardware pops r0-r3,r12,LR,PC,xPSR
::"i"(configMAX_SYSCALL_INTERRUPT_PRIORITY):"memory");
}
Note what's not explicitly saved: r0-r3, r12, LR, PC, and xPSR.
Cortex-M's exception entry/exit hardware automatically stacks and unstacks
those on every exception, including PendSV — the handler only needs to
manually save the registers hardware doesn't already handle (r4-r11,
and s16-s31 if configUSE_FPU_SUPPORT and the task actually used the
FPU, tracked via the lazy-stacking FPCA bit). This split is exactly why
Cortex-M's exception model was attractive for RTOS use in the first place —
it halves the manual save/restore work compared to architectures with no
hardware exception-stacking convention.
vTaskSwitchContext: policy lives in C, mechanism lives in asm¶
The line bl vTaskSwitchContext is the entire connection point between
Module 1's list-based scheduling policy and this module's register-save
mechanism. It is ordinary, portable C:
void vTaskSwitchContext(void) {
if (uxSchedulerSuspended != (UBaseType_t) 0U) {
xYieldPending = pdTRUE; // scheduler locked — defer, don't switch now
} else {
xYieldPending = pdFALSE;
taskSELECT_HIGHEST_PRIORITY_TASK(); // finds new pxCurrentTCB from ready lists
}
}
It never touches a register or a stack pointer directly — it only updates
which TCB pxCurrentTCB points at. All the actual register shuffling
happens in the assembly wrapper around it. This separation is why porting
FreeRTOS to a new architecture (Module 6) means rewriting the small assembly
layer, not the scheduling algorithm.
Measuring switch overhead — verified on this machine¶
The POSIX simulator port (Level 2 Module 5) doesn't do real register-context
switching — it uses pthread + signals — so it cannot measure real Cortex-M
cycle counts. But it can measure wall-clock overhead per
scheduler-invoked switch on this host, which is a legitimate way to sanity-check
switch-count-driven overhead in a design before hardware exists. This was
built and run on this machine:
#define N_SWITCHES 20000
static volatile int toggle = 0;
static int count = 0;
void taskA(void *pv) {
for (;;) {
while (toggle != 0) taskYIELD();
count++;
if (count >= N_SWITCHES) { /* report elapsed time, vTaskEndScheduler() */ }
toggle = 1;
taskYIELD();
}
}
void taskB(void *pv) {
for (;;) { while (toggle != 1) taskYIELD(); toggle = 0; taskYIELD(); }
}
Built against tasks.c, queue.c, list.c, timers.c, event_groups.c,
stream_buffer.c, portable/MemMang/heap_3.c, and
portable/ThirdParty/GCC/Posix/{port.c,utils/wait_for_event.c} with
-lpthread, exactly as in Level 2 Module 5. Output from the actual run:
Read this number correctly. ~2 microseconds per switch here reflects this host's pthread-signal-based simulated switch — not a Cortex-M register-save cost, and it includes general-purpose OS scheduling noise. On real Cortex-M hardware, a PendSV-based switch is typically single-digit microseconds or less at moderate clock speeds (dominated by ~16-20 pushed/ popped registers plus fixed hardware exception entry/exit overhead, roughly constant regardless of task count) — the shape of the result (switching has a small, roughly fixed per-switch cost you can budget for) transfers; the absolute number does not.
How It Actually Works (beyond the register save)¶
PendSV is deliberately the lowest-priority exception on ARM Cortex-M
specifically so a context switch never preempts a higher-priority hardware
interrupt that's still being serviced — if the tick ISR requests a switch
while a UART ISR is mid-flight, PendSV is left pending and only fires once
every higher-priority exception has finished, which keeps interrupt latency
bounded independent of scheduling activity. vTaskSwitchContext runs
before the register save/restore, as plain C, and its only job is to pick
which TCB's stack pointer pxCurrentTCB should point to next — the actual
save/restore that follows is architecture-specific assembly that doesn't
know or care about priorities at all, it just pushes registers onto whatever
stack the outgoing task was using and pops registers from the incoming
task's stack. This split (policy in C, mechanism in asm) is exactly what
lets the same scheduler logic run on Cortex-M0, Cortex-M4F (which must also
lazily save FPU registers, deferred until a task actually touches
floating-point, to avoid paying that cost on every switch), Xtensa, and the
POSIX simulator, with only the small port layer rewritten per architecture.
Traps¶
- Assuming task switch time scales with the number of tasks. It doesn't
—
vTaskSwitchContextcost is dominated by the O(1) ready-list lookup (Module 1) plus a fixed register save/restore; it is not a function of how many total tasks exist in the system. - Believing PendSV "runs immediately" when pended. It runs at the lowest priority by design — if higher-priority interrupts are active or pending, PendSV (and the switch) is deferred until the CPU returns to a priority it's allowed to run at. This is correct and intentional, not a bug, but it means a system saturated with high-priority interrupt work can delay task switching arbitrarily long.
- Forgetting
pxTopOfStackmust remain the first TCB member if you ever touch a fork/vendor variant oftasks.h— the hand-written assembly context-switch routines on every port assume this offset is zero. - Not accounting for FPU lazy-stacking cost on
configUSE_FPU_SUPPORTports — a task that touches floating point addss16-s31save/restore to every switch it's involved in; a system with one float-heavy task among many integer-only tasks pays this cost only when that task participates in a switch, which can produce inconsistent, hard-to-predict switch timing if not accounted for in WCET budgets (Level 4 Module 2). - Trusting POSIX-port switch timing as a stand-in for real hardware numbers, as called out above — use it only to validate switch-count driven design decisions, never absolute latency budgets.
Cheat sheet¶
| Concept | Where it lives | Key fact |
|---|---|---|
| Task context | Pushed onto the task's own stack | Not a separate global save area |
pxTopOfStack |
First member of TCB | Assembly depends on zero offset |
| PendSV | Lowest interrupt priority | Deferred switch mechanism, not immediate |
vTaskSwitchContext |
Portable C | Only updates pxCurrentTCB; no register access |
| Hardware-saved regs (Cortex-M) | r0-r3, r12, LR, PC, xPSR | Auto stacked/unstacked by exception hardware |
| Manually-saved regs | r4-r11 (+ s16-s31 if FPU used) | Explicit stmdb/ldmia in the port asm |
| Measured here (POSIX sim) | 2.014 us/switch | Host-OS artifact — shape, not magnitude, transfers |
Exercise¶
- Reproduce the measurement above: clone
FreeRTOS/FreeRTOS-Kernel, build the two-task yield-ping-pong program against the POSIX port, and confirm you get a similar per-switch overhead order of magnitude on your own machine — then explain in your own words why this number cannot be quoted as a Cortex-M switch time. - Find a real Cortex-M port's
port.c(e.g.GCC/ARM_CM4F) and count the exact register pushes/pops inxPortPendSVHandlerfor a task that does not use the FPU versus one that does — compute the instruction-count delta directly from the assembly. - Explain, from first principles (not by looking it up), why PendSV is
configured at the lowest priority rather than, say, the same priority
as the tick interrupt — what specifically would break if it ran at a
higher priority than an application ISR that calls a
...FromISRAPI?