Context Switch
Teaching One CPU to Run Many Tasks
On ARM there is no parallel execution — at any given instant, exactly one task is running. A context switch is the act of handing responsibility from one task to another without either of them noticing the handoff happened.
This part covers the actual mechanism behind TamgaOS's fixed-priority preemptive scheduler (FPPS): the PendSV exception, what the hardware saves automatically versus what software must save by hand, the stack frame layout byte-for-byte against the ARMv7-M architecture manual, and why BASEPRI is the right tool instead of disabling every interrupt in the system.
— Chapter I: The Mechanism —
The captain analogy
Think of it like a ship changing captains mid-voyage. When the first captain goes to rest, the next one must continue from the exact same condition — same speed, same heading — not start over from zero. The ship never knows the captain changed.
A task on TamgaOS is the same. It has no idea it was ever interrupted. It resumes from the exact instruction, with the exact register values, it had the moment it was paused.
Trigger
A context switch generally starts from the SysTick handler or an SVC call. At that point the scheduler decides whether the current task has used up its time slice, and if a switch is needed, it sets the PendSV pending bit rather than switching immediately.
PendSV is deliberately not handled inline — it's set to the lowest priority in the system, so it only actually runs once every other, more urgent interrupt has finished. This is what keeps hardware interrupts (UART, timers) from ever being delayed by a scheduling decision.
Automatic frame
When the PendSV handler is entered, the Cortex-M4 hardware has already pushed eight registers onto the current task's stack, with zero software involvement: xPSR, PC, LR, R12, R3, R2, R1, R0. This happens for every exception entry — it's not something PendSV does specially.
// On exception entry, hardware pushes (high to low address): xPSR // Program Status Register PC // Return address — where to resume LR // Link Register at time of interrupt R12 R3 R2 R1 R0 // SP now points here // PendSV_Handler's very first instruction runs AFTER this.
Saving context
Our job starts here. ARM does not save R4-R11 automatically — those are callee-saved registers, and the hardware leaves that responsibility to software. So the handler must push them onto the current task's stack by hand.
After pushing R4-R11, we read the resulting Process Stack Pointer (PSP) value and store it into g_current_task->sp. This is the one piece of state that lets us find our way back: next time this task runs, we'll know exactly which point in its execution to resume from.
MRS R0, PSP ; R0 = current task's stack pointer STMDB R0!, {R4-R11} ; manually push callee-saved regs LDR R3, =g_current_task LDR R2, [R3] STR R0, [R2] ; g_current_task->sp = R0
Task selection
With the old task's context fully saved, the scheduler looks at which task should run next and performs the handoff at the pointer level:
g_current_task = g_next_task;
In TamgaOS's FPPS scheduler, g_next_task is set by sched_tick() — it scans every READY task and picks the one with the numerically lowest priority field (0 = highest priority). If that task isn't already the one running, PendSV gets triggered.
Restore context
Restoring is the save sequence run backward. First we read the new task's saved stack pointer out of its Task Control Block (TCB). From that address, R4-R11 get popped back into the CPU registers. Last, we update PSP to point at the new task's stack.
LDR R1, =g_next_task LDR R2, [R1] STR R2, [R3] ; g_current_task = g_next_task LDR R0, [R2] ; R0 = new task->sp LDMIA R0!, {R4-R11} ; restore callee-saved regs MSR PSP, R0 ; PSP now points into the new task's stack
Exception return
BX LR is the magic instruction. When it executes, the CPU recognizes the special 0xFXXXXXXX value in LR as an EXC_RETURN code rather than a branch address, and automatically pops the remaining frame — R0-R3, R12, LR, PC, xPSR — from whatever stack EXC_RETURN points to. The new task continues exactly from where it left off, as if nothing happened.
Throughout the switch, interrupts stay disabled so the stacks stay consistent — but as covered in the priority section below, that doesn't mean every interrupt in the system. PendSV's own priority already keeps it from preempting anything urgent; a short, scoped mask is enough.
With this mechanism, the system stays real-time. After we change context, a pipeline flush is also required — with ISB (Instruction Synchronization Barrier) the instruction pipeline is cleared so the new register values are actually visible to subsequent instructions. This costs cycles, but skipping it risks the CPU executing stale instructions fetched under the old context.
One rule that matters more than it sounds: every task must have its own stack. Context switch literally means writing different data to different stacks — if two tasks shared one, their state would corrupt each other on every switch.
— Chapter II: Stack Frame, Byte by Byte —
Figure B1-2 — stack frame layout (no FPU)
The ARMv7-M Architecture Reference Manual's Figure B1-2 defines exactly what the hardware frame looks like on exception entry, without the FP extension. This is the ground truth I checked stack_init() against, offset by offset.
Offset 0x04 — the "Reserved" slot — only carries meaning once the FP extension is involved, where it holds FPSCR. Without FPU, it's simply unused padding that keeps the frame 8-byte aligned.
Mapping it to stack_init()
Maybe you noticed here EXC_RETURN address is without FPU address :) Why ? using M4 (have FPU support) because first I will implent code without FPU support later when ı will be sure everything is working as expected ı will update with FPU support.
This is the part that made the architecture manual click for me: stack_init() is constructing exactly Figure B1-2, by hand, before the task has ever run once. Each write corresponds to one offset in the table above.
*(--stack_top) = 0x01000000UL; // Offset 0x24 — xPSR, T-bit set *(--stack_top) = (uint32_t)func; // Offset 0x20 — PC, task entry point *(--stack_top) = 0xFFFFFFFDUL; // Offset 0x1C — LR slot, EXC_RETURN padding *(--stack_top) = 0x00000000UL; // Offset 0x18 — R12 *(--stack_top) = 0x00000000UL; // Offset 0x14 — R3 *(--stack_top) = 0x00000000UL; // Offset 0x10 — R2 *(--stack_top) = 0x00000000UL; // Offset 0x0C — R1 *(--stack_top) = 0x00000000UL; // Offset 0x08 — R0 // Offset 0x04 (Reserved) — not used yet, will hold FPSCR once FPU lands // Offset 0x00 (Original SP) — implicit, stack_top's value before this call // ─── below this point: software frame, PendSV_Handler / STMDB ─── *(--stack_top) = 0x00000000UL; // R11 *(--stack_top) = 0x00000000UL; // R10 *(--stack_top) = 0x00000000UL; // R9 *(--stack_top) = 0x00000000UL; // R8 *(--stack_top) = 0x00000000UL; // R7 *(--stack_top) = 0x00000000UL; // R6 *(--stack_top) = 0x00000000UL; // R5 *(--stack_top) = 0x00000000UL; // R4 return stack_top; // new SP — task->sp will point here
This was the moment the figure actually meant something instead of being an abstract diagram: this init function is the exact same table, just written as memory writes instead of a picture, with the order reversed because the stack grows down and each --stack_top moves toward lower addresses.
Reading the EXC_RETURN tables
Tables B1-8 and B1-9 of the architecture manual define what each EXC_RETURN value actually means on return — which mode you go back to, and critically, which stack the frame comes from.
Without FP extension — Table B1-8
| EXC_RETURN | Return to | Return stack |
|---|---|---|
0xFFFFFFF1 | Handler mode | Main |
0xFFFFFFF9 | Thread mode | Main |
0xFFFFFFFD | Thread mode | Process |
With FP extension — Table B1-9
| EXC_RETURN | Return to | Return stack | Frame type |
|---|---|---|---|
0xFFFFFFE1 | Handler mode | Main | Extended |
0xFFFFFFE9 | Thread mode | Main | Extended |
0xFFFFFFED | Thread mode | Process | Extended |
0xFFFFFFF1 | Handler mode | Main | Basic |
0xFFFFFFF9 | Thread mode | Main | Basic |
0xFFFFFFFD | Thread mode | Process | Basic |
Why this matters: the entries that say Main stack use MSP, the Main Stack Pointer — the stack the CPU uses in handler mode, shared across the whole system. Those addresses are not what we want for task switching. We need the Process entries, because every task has its own stack, and PSP is what lets each task's stack stay private and independent.
For TamgaOS, that means every task starts with 0xFFFFFFFD in its frame's LR slot — Thread mode, Process stack, Basic frame, no FPU. When FPU support lands later, that becomes 0xFFFFFFED instead, and the frame grows to include S0-S15 and FPSCR.
MSP vs PSP — why R13 has two banks
R13 is special: architecturally it's a single register name, but it's actually two banked physical registers — MSP (Main Stack Pointer) and PSP (Process Stack Pointer) — and only one is active at a time, selected by CONTROL[1].
MSP — Main Stack
Used in Handler mode (all exceptions/interrupts) and, by default, in privileged Thread mode before an RTOS switches things over. One shared stack for the whole exception-handling side of the system.
PSP — Process Stack
Used by RTOS tasks running in Thread mode once CONTROL[1] is set. Each task gets a private region of RAM and its own PSP value — this is what makes "every task has its own stack" actually true in hardware.
So the short version: MSP is the OS/kernel's own stack, PSP is what TamgaOS hands to each task. Context switch logic only ever touches PSP — MSP stays put, used for handler-mode work like the early part of PendSV itself.
— Chapter III: Priority & Interrupt Masking —
__NVIC_PRIO_BITS
Before setting any priority, both PendSV and SVCall need to sit at the same, lowest priority level in the system. With CMSIS this is one call:
NVIC_SetPriority(PendSV_IRQn, 15); // 0xF, lowest possible
TamgaOS is bare-metal, so the same effect needs to come from writing the priority register address directly — which means knowing how many priority bits the silicon actually implements. That number isn't architecturally fixed; it's vendor-defined, and the K64F header states it explicitly:
#define __NVIC_PRIO_BITS 4 /* Number of priority bits implemented in the NVIC */
4 bits means 16 priority levels (0-15) on this chip, even though the priority register field is 8 bits wide architecturally — the implemented bits sit in the top of that field, and the rest is don't-care. This detail is also why a naive "close all interrupts" approach is wrong: blanket-closing everything isn't logical when only a few interrupts can actually interfere with a task switch in progress. Knowing the priority bit width is what lets us identify and mask only those.
BASEPRI instead of CPSID I
CPSID I disables every maskable interrupt in the system — total silence, including things that have nothing to do with scheduling. BASEPRI is the better tool: it sets a priority floor, masking only interrupts at that priority or lower (numerically higher), while anything more urgent still gets through immediately.
// Cortex-M writes priority values into the TOP bits of the priority byte. // With 4 implemented bits, the value must be shifted left by (8 - 4) = 4. BASEPRI = priority << (8 - 4) = priority << 4
MOV R1, #6 ; R1 = 6 (priority threshold) LSL R1, R1, #4 ; R1 = 6 << 4 = 96 (0x60) MSR BASEPRI, R1 ; mask everything at priority 6 or lower
The shift matters because of where Cortex-M places priority value bits. If you write 6 directly into BASEPRI instead of 6 << 4, the value lands in unimplemented bits and the mask silently does nothing close to what you intended — a classic bare-metal off-by-shift bug.
SVC vs PendSV — two different decisions
SVC
A task voluntarily calling into the OS — e.g. requesting a semaphore. This doesn't necessarily trigger an immediate switch; it's a service call, handled and returned from like any function.
PendSV
The scheduler's own decision that a switch is needed. Deliberately deferred to the lowest priority, so hardware interrupts (UART, PIT) are never delayed by a scheduling decision in progress.
Keeping these as two separate exceptions, rather than overloading one, is what guarantees hardware interrupts stay snappy regardless of what the scheduler is doing.
Pipeline flush — ISB / DSB
After updating PSP and the register file, the new values aren't guaranteed to be visible to instructions already in flight in the pipeline. ISB (Instruction Synchronization Barrier) or DSB flushes the pipeline so the CPU re-fetches with the new state. Skip this, and the processor can keep executing instructions that were fetched under the old task's context — exactly the kind of bug that's nearly impossible to reproduce reliably.
This costs real cycles, which is part of the tradeoff every context switch makes: correctness over raw speed.
— Chapter IV: What's Next —
This post was a lot of fun to write, and I hope you'll enjoy reading it as much as I enjoyed writing it.
Alredy ı am trying to implement code. Coding parts also enjoyable but some scary to broke board :) I hope soon ı will upload to github and codeberg all together !
IMPORTANT REALLY ARMv7-M Architecture Reference Manual (on References part of this page you can access) read so carefully !!!! Every time when ı read ı am noticing somethings new !!!
See you on next post!
References
Everything above traces back to these primary sources. When in doubt, read the spec — not a blog post. Including this one.
ARM architecture
- ARMv7-M Architecture Reference Manual (DDI0403E) — PDF
- ARMv7-M Architecture Reference Manual
- Cortex-M4 Devices Generic User Guide (DUI0553A)
- Cortex-M4 Processor Technical Reference Manual — Table 4-1 system control registers