PendSV Exception & Context Switching on ARM Cortex-M3/M4
Understand how PendSV works, why it is the cornerstone of every bare-metal RTOS, and how to build a round-robin task scheduler from scratch in C.
1 — SVC Handler Exercise: Four Arithmetic Operations
In the previous lesson you learned how to write and invoke an SVC (SuperVisor Call) handler. This exercise puts that knowledge into practice: the thread-mode application passes two operands through the exception stack frame, uses the SVC number to select an operation, and reads the result back.
Why use SVC for arithmetic? In a real RTOS, a task running in unprivileged Thread mode cannot directly access certain hardware resources. SVC is the clean, hardware-enforced gate through which unprivileged code requests a privileged service. This exercise mimics that pattern exactly.
SVC Service Numbers
| SVC Number | Operation | Expression | Level |
|---|---|---|---|
36 | Addition | result = op1 + op2 | Basic |
37 | Subtraction | result = op1 - op2 | Basic |
38 | Multiplication | result = op1 * op2 | Intermediate |
39 | Division | result = op1 / op2 | Guard needed |
How Operands Are Passed via the Stack Frame
When the CPU takes any exception it automatically pushes eight registers onto the active stack. We exploit those saved registers — especially r0 and r1 — to pass operands to the SVC handler without any global variables.
The SVC number is encoded in the lower byte of the SVC instruction itself, found 2 bytes before the saved PC.
Complete Implementation
main.c (caller)
main.c — Thread-mode caller
#include <stdint.h>
#include <stdio.h>
#define SVC_ADD 36
#define SVC_SUB 37
#define SVC_MUL 38
#define SVC_DIV 39
/*
* Passes op1 into r0 and op2 into r1, then fires SVC #num.
* The handler writes the result into the saved r0 slot in the
* stack frame, so the CPU restores it into r0 on return.
*/
static inline int32_t svc_call(uint8_t num, int32_t op1, int32_t op2)
{
int32_t result;
__asm volatile(
"mov r0, %1 \n"
"mov r1, %2 \n"
"svc %3 \n"
"mov %0, r0 \n"
: "=r"(result)
: "r"(op1), "r"(op2), "I"(num)
: "r0", "r1", "cc"
);
return result;
}
int main(void)
{
int32_t a = 20, b = 4;
printf("Add : %d + %d = %d\n", a, b, svc_call(SVC_ADD, a, b));
printf("Sub : %d - %d = %d\n", a, b, svc_call(SVC_SUB, a, b));
printf("Mul : %d * %d = %d\n", a, b, svc_call(SVC_MUL, a, b));
printf("Div : %d / %d = %d\n", a, b, svc_call(SVC_DIV, a, b));
while(1);
}SVC Handler
svc_handler.c — Privileged handler
#include <stdint.h>
/*
* svc_frame[] maps directly to the exception stack frame:
* [0]=r0 [1]=r1 [2]=r2 [3]=r3
* [4]=r12 [5]=LR [6]=PC [7]=xPSR
*
* The SVC number is the low byte of the 2-byte Thumb SVC instruction
* located immediately before the saved PC.
*/
void SVC_Handler_Main(uint32_t *svc_frame)
{
uint8_t svc_num = ((uint8_t *)svc_frame[6])[-2];
int32_t op1 = (int32_t)svc_frame[0];
int32_t op2 = (int32_t)svc_frame[1];
int32_t result = 0;
switch (svc_num) {
case 36: result = op1 + op2; break;
case 37: result = op1 - op2; break;
case 38: result = op1 * op2; break;
case 39: result = (op2 != 0) ? op1 / op2 : 0; break;
default: break;
}
/* Write result into saved r0 — caller receives it on return */
svc_frame[0] = (uint32_t)result;
}
/* Naked trampoline: determine which stack was active, pass it to C */
__attribute__((naked)) void SVC_Handler(void)
{
__asm volatile(
"tst lr, #4 \n" /* EXC_RETURN bit[2]: 0=MSP, 1=PSP */
"ite eq \n"
"mrseq r0, msp \n"
"mrsne r0, psp \n"
"b SVC_Handler_Main \n"
);
}Key trick: EXC_RETURN in LR tells us which stack (MSP or PSP) was active when the SVC fired. We always read the correct frame regardless of privilege level.
Expected Output
Expected Serial/UART Output
// UART console — 115200 baud
Add : 20 + 4 = 24
Sub : 20 – 4 = 16
Mul : 20 * 4 = 80
Div : 20 / 4 = 5
Test tip: Set b = 0 and verify the division guard returns 0 instead of triggering a HardFault from divide-by-zero.
2 — What Is the PendSV Exception?
PendSV (Pendable Service Call) is exception type 14 on every ARM Cortex-M processor. Unlike hardware interrupts which are triggered by peripherals, PendSV is triggered entirely by software — by setting a single bit in the Interrupt Control and State Register (ICSR).
Software writes bit 28 (PENDSVSET) of SCB->ICSR. The exception is held pending until the CPU is ready to take it.
Always configured to the lowest possible priority (0xFF / 255) so it runs only after every other pending exception has been serviced.
Deferred context switching. The scheduler pends PendSV; the actual register save/restore happens inside the PendSV handler, safely after all ISRs finish.
Bottom-half interrupt processing — split time-critical ISR work from the slower “bottom half” that runs at low priority in PendSV.
Exception Priority Landscape
How to Trigger PendSV in C
Pend the PendSV exception from software
#include "stm32f4xx.h" /* or CMSIS core_cm4.h */
void scheduler_init(void)
{
/* Set PendSV to the absolute lowest priority at startup */
NVIC_SetPriority(PendSV_IRQn, 0xFF);
}
/* Call this from inside any ISR to request a context switch */
void request_context_switch(void)
{
/* Set PENDSVSET bit[28] in ICSR.
PendSV will fire after the current exception returns. */
SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;
}
3 — How Context Switching Works
Context switching is the act of saving the CPU register state of the running task and restoring the register state of the next task so that each task appears to have its own private processor. The Cortex-M hardware handles part of this automatically; the PendSV handler does the rest.
What Must Be Saved and Restored?
xPSR, PC, LR, r12, r3, r2, r1, r0
Pushed onto the task’s Process Stack (PSP) automatically before the PendSV handler runs.
r4, r5, r6, r7, r8, r9, r10, r11
These callee-saved registers are NOT pushed by hardware. The PendSV assembly stub must push and pop them explicitly.
Normal Operation: SysTick + PendSV Timeline
Step-by-Step Sequence (with an IRQ in the middle)
- Task A runs in Thread mode (PSP active) The CPU executes Task A’s code using the Process Stack Pointer.
- SysTick fires — hardware auto-saves 8 registers The CPU pushes xPSR, PC, LR, r12, r3, r2, r1, r0 onto Task A’s PSP and jumps to SysTick_Handler.
- Scheduler selects the next task, pends PendSV SysTick_Handler updates the TCB pointer then writes
SCB->ICSR |= PENDSVSET. It does NOT touch any task registers. - SysTick returns — any pending IRQ runs first Because PendSV has the lowest priority, any peripheral interrupt that was waiting runs to completion before PendSV is taken.
- PendSV handler runs — performs the actual switch The handler reads the PSP, pushes r4-r11 of Task A, saves the updated PSP into TCB-A, loads PSP from TCB-B, then pops r4-r11 of Task B.
- PendSV returns with EXC_RETURN = 0xFFFFFFFD Hardware pops r0-r3, r12, LR, PC, xPSR from Task B’s stack. Task B resumes exactly where it was interrupted last time.
4 — Why Switching Inside SysTick Causes a UsageFault
A tempting shortcut is to perform the entire context switch directly inside the SysTick handler. This approach breaks badly whenever any peripheral interrupt fires during a task time slot.
Danger: If SysTick attempts to return to Thread mode while a peripheral ISR is still present on the exception stack, the Cortex-M raises a UsageFault (INVPC) — an invalid attempt to return to Thread mode from a nested exception context.
PendSV solves this: the switch is deferred until the processor is genuinely back at the base exception level with an empty exception stack. PendSV then exits cleanly to Thread mode.
| Strategy | Interrupt-safe? | Complexity | Recommended? |
|---|---|---|---|
| Switch inside SysTick handler | No — UsageFault risk | Low | No |
| Switch in PendSV at lowest priority | Yes — always safe | Medium | Yes |
5 — Offloading Interrupt Processing with PendSV
Long-running ISR work starves other interrupts and reduces system responsiveness. The solution — used in Linux, FreeRTOS, and every industrial RTOS — is to split the ISR into two halves:
Time-Critical Work
Runs inside the ISR at high priority. Acknowledges the hardware, reads raw data into a buffer, then pends PendSV and exits immediately.
Time-Consuming Work
Runs inside PendSV at the lowest priority. Processes the buffered data, runs CRC checks, parses packets, calls application-level logic.
Top-half / bottom-half UART processing pattern
#include <stdint.h>
#include "ring_buffer.h"
static ring_buf_t uart_rx_buf;
/* TOP HALF — runs at UART ISR priority, must finish in microseconds */
void USART2_IRQHandler(void)
{
uint8_t byte = USART2->DR & 0xFF;
ring_buf_put(&uart_rx_buf, byte);
/* Schedule bottom half — PendSV fires after all ISRs complete */
SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;
}
/* BOTTOM HALF — runs in PendSV at lowest priority, can take its time */
void PendSV_Handler(void)
{
uint8_t byte;
while (ring_buf_get(&uart_rx_buf, &byte)) {
protocol_process_byte(byte);
}
}
6 — Implementing a Round-Robin Scheduler
A round-robin scheduler gives every task an equal time slice and cycles through them in a fixed circular order. No task can monopolise the CPU; each one gets its turn regardless of what it is doing. It is the simplest preemptive scheduling algorithm and is an ideal first RTOS to build from scratch.
Plan: SysTick fires every 1 ms, advances the current-task pointer, and pends PendSV. The PendSV assembly handler saves r4-r11 of the outgoing task, switches the PSP, and restores r4-r11 of the incoming task. Hardware handles the remaining eight registers on exception exit.
Task Control Block (TCB) — Data Structure
scheduler.h
#ifndef SCHEDULER_H
#define SCHEDULER_H
#include <stdint.h>
#define MAX_TASKS 4
#define STACK_SIZE 512 /* 512 words = 2 KB per task */
typedef enum { TASK_READY = 0, TASK_RUNNING, TASK_BLOCKED } task_state_t;
typedef struct {
uint32_t *psp; /* saved process stack pointer */
task_state_t state;
uint32_t id;
uint32_t stack[STACK_SIZE]; /* private stack storage */
} tcb_t;
void scheduler_init(void);
void task_create(uint32_t id, void (*entry)(void));
void scheduler_start(void);
void task_exit_hook(void);
#endifscheduler.c
scheduler.c — init, task creation, SysTick handler
#include "scheduler.h"
#include "stm32f4xx.h"
static tcb_t tcb[MAX_TASKS];
static uint8_t task_count = 0;
static uint8_t current = 0;
/* Exported symbols — PendSV assembly reads these */
tcb_t *current_tcb = NULL;
tcb_t *next_tcb = NULL;
/*
* Build a fake exception stack frame so PendSV can "restore" a brand-new task
* the same way it restores any previously interrupted task.
*
* Stack layout after init (grows downward, top of stack is highest address):
* ... [r11][r10][r9][r8][r7][r6][r5][r4] ← manually-saved by PendSV
* [r0] [r1] [r2][r3][r12][LR][PC][xPSR] ← auto-saved by hardware
*/
static void init_task_stack(tcb_t *t, void (*entry)(void))
{
uint32_t *sp = &t->stack[STACK_SIZE]; /* top of stack */
/* Hardware exception frame */
*--sp = 0x01000000; /* xPSR — Thumb bit set, required! */
*--sp = (uint32_t)entry; /* PC — task entry function */
*--sp = (uint32_t)task_exit_hook;/* LR — if task ever returns */
*--sp = 0x00000000; /* r12 */
*--sp = 0x00000003; /* r3 */
*--sp = 0x00000002; /* r2 */
*--sp = 0x00000001; /* r1 */
*--sp = 0x00000000; /* r0 */
/* Manually-saved frame (PendSV pushes/pops r4-r11) */
*--sp = 0x00000011; /* r11 */
*--sp = 0x00000010; /* r10 */
*--sp = 0x00000009; /* r9 */
*--sp = 0x00000008; /* r8 */
*--sp = 0x00000007; /* r7 */
*--sp = 0x00000006; /* r6 */
*--sp = 0x00000005; /* r5 */
*--sp = 0x00000004; /* r4 */
t->psp = sp;
}
void task_create(uint32_t id, void (*entry)(void))
{
tcb_t *t = &tcb[task_count++];
t->id = id;
t->state = TASK_READY;
init_task_stack(t, entry);
}
void scheduler_init(void)
{
NVIC_SetPriority(PendSV_IRQn, 0xFF); /* PendSV: lowest */
NVIC_SetPriority(SysTick_IRQn, 0x00); /* SysTick: highest configurable */
SysTick_Config(SystemCoreClock / 1000); /* 1 ms time slice */
}
/* SysTick: only decides which task is next, never touches registers */
void SysTick_Handler(void)
{
current_tcb = &tcb[current];
current = (current + 1) % task_count;
next_tcb = &tcb[current];
SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;
}
void task_exit_hook(void)
{
/* Tasks should never return. Spin here if one does. */
while(1);
}
/* Launch the scheduler — loads PSP of task 0 and switches to Thread mode */
void scheduler_start(void)
{
current_tcb = &tcb[0];
__asm volatile(
"msr psp, %0 \n" /* set PSP to task 0's initial SP */
"mov r0, #0x03 \n" /* CONTROL: SPSEL=1(PSP), nPRIV=1 */
"msr control, r0 \n"
"isb \n"
"ldm %0, {r4-r11} \n" /* pop manually-saved regs */
"add %0, %0, #32 \n" /* advance past r4-r11 (8x4 bytes) */
"msr psp, %0 \n"
"pop {r0-r3,r12,lr} \n"
"pop {pc} \n" /* jump to task entry */
: : "r"(current_tcb->psp) : "memory"
);
}pendsv_handler.s
ARM Assembly
.syntax unified
.thumb
.extern current_tcb @ pointer-to-pointer: running task TCB
.extern next_tcb @ pointer-to-pointer: next task TCB
.global PendSV_Handler
.type PendSV_Handler, %function
PendSV_Handler:
@ ── 1. Save outgoing task context ─────────────────────────────
mrs r0, psp @ r0 = PSP of outgoing task
isb
@ Push callee-saved registers onto the task stack
stmdb r0!, {r4-r11} @ store r4-r11, decrement r0
@ Persist updated PSP into the TCB
ldr r2, =current_tcb
ldr r1, [r2] @ r1 = current_tcb pointer
str r0, [r1, #0] @ current_tcb->psp = updated PSP
@ ── 2. Restore incoming task context ──────────────────────────
ldr r3, =next_tcb
ldr r1, [r3] @ r1 = next_tcb pointer
ldr r0, [r1, #0] @ r0 = next_tcb->psp
@ Pop callee-saved registers from the new task's stack
ldmia r0!, {r4-r11} @ load r4-r11, increment r0
@ Restore PSP to point at the hardware-saved frame
msr psp, r0
isb
@ Update current_tcb to reflect the new running task
str r1, [r2, #0] @ current_tcb = next_tcb
@ ── 3. Return to Thread mode using PSP ─────────────────────────
@ EXC_RETURN = 0xFFFFFFFD:
@ bits[3:0] = 1101
@ bit[4]=1 → no FP extension
@ bit[3]=1 → return to Thread mode
@ bit[2]=1 → use PSP
@ bit[1]=0 → reserved
@ bit[0]=1 → must be 1
ldr lr, =0xFFFFFFFD
bx lrEXC_RETURN = 0xFFFFFFFD is the magic value that tells the CPU to exit exception mode, return to unprivileged Thread mode, and use the PSP for the stack. Any other value will corrupt the CPU state or trigger a fault.
tasks.c + main.c
tasks.c + main.c — four concurrent tasks
#include <stdio.h>
#include <stdint.h>
#include "scheduler.h"
static void delay(volatile uint32_t n) { while(n--); }
void task1(void) /* LED blink */
{
while(1) {
printf("[Task 1] LED toggle\n");
delay(100000);
}
}
void task2(void) /* counter */
{
uint32_t count = 0;
while(1) {
printf("[Task 2] count = %lu\n", ++count);
delay(200000);
}
}
void task3(void) /* sensor read */
{
while(1) {
printf("[Task 3] sensor read\n");
delay(150000);
}
}
void task4(void) /* heartbeat */
{
while(1) {
printf("[Task 4] heartbeat\n");
delay(80000);
}
}
int main(void)
{
scheduler_init();
task_create(1, task1);
task_create(2, task2);
task_create(3, task3);
task_create(4, task4);
scheduler_start(); /* never returns */
return 0;
}// Serial output — tasks interleave every 1 ms
[Task 1] LED toggle
[Task 2] count = 1
[Task 3] sensor read
[Task 4] heartbeat
[Task 1] LED toggle
[Task 2] count = 2
…
Build & Flash
Compile and flash to STM32F411 Nucleo board
# Prerequisites: sudo apt install gcc-arm-none-eabi openocd
arm-none-eabi-gcc \
-mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard \
-O2 -g -Wall \
-T STM32F411CEUX_FLASH.ld \
main.c scheduler.c tasks.c pendsv_handler.s \
startup_stm32f411xe.s \
-o scheduler_demo.elf
# Create binary for flashing
arm-none-eabi-objcopy -O binary scheduler_demo.elf scheduler_demo.bin
# Flash via OpenOCD (STLink)
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg \
-c "program scheduler_demo.elf verify reset exit"
# Monitor UART output
minicom -b 115200 -D /dev/ttyACM0Verify it works: All four tasks should print their messages in a cyclic pattern. If only one task prints, check that PendSV_Handler is correctly linked and that PendSV priority is set lower than SysTick.
🧠 Quick Knowledge Check
1. What register must you write to trigger PendSV from software?
- NVIC_ISER — Interrupt Set Enable Register
- SCB->ICSR — bit 28 (PENDSVSET)
- SysTick->CTRL
- SCB->SHPR3
Check Answer
Correct answer: B — SCB->ICSR — bit 28 (PENDSVSET)
SCB->ICSR bit 28 (PENDSVSET) is the software trigger for PendSV.
2. Why is PendSV configured with the lowest priority in an RTOS?
- To make context switches faster
- So it only runs after all other pending exceptions complete, making the return to Thread mode always safe
- Because it cannot be preempted once started
- To reduce interrupt latency for peripherals
Check Answer
Correct answer: B — So it only runs after all other pending exceptions complete, making the return to Thread mode always safe
Lowest priority ensures PendSV fires only when the exception stack is empty, preventing the INVPC UsageFault.
3. Which registers does Cortex-M hardware save automatically on exception entry?
- r4, r5, r6, r7, r8, r9, r10, r11
- r0, r1, r2, r3, r12, LR, PC, xPSR
- All 16 general-purpose registers
- Only PC and xPSR
Check Answer
Correct answer: B — r0, r1, r2, r3, r12, LR, PC, xPSR
The hardware exception frame is r0-r3, r12, LR, PC, xPSR. The PendSV handler manually saves r4-r11.
4. What EXC_RETURN value returns the CPU to Thread mode using the PSP?
- 0xFFFFFFF1 — Handler mode, MSP
- 0xFFFFFFF9 — Thread mode, MSP
- 0xFFFFFFFD — Thread mode, PSP
- 0x00000000 — reset value
Check Answer
Correct answer: C — 0xFFFFFFFD — Thread mode, PSP
0xFFFFFFFD = Thread mode + PSP, no FP extension. This is the standard EXC_RETURN for task return in an RTOS.
5. Why do we set xPSR = 0x01000000 in the fake task stack frame?
- To enable all interrupts in the new task
- To set the Thumb bit (T bit = bit 24), which must always be 1 on Cortex-M
- To indicate that the task has the highest priority
- To reset the carry and overflow flags
Check Answer
Correct answer: B — To set the Thumb bit (T bit = bit 24), which must always be 1 on Cortex-M
Bit 24 (T bit) of xPSR must always be 1 on Cortex-M. A T=0 state triggers a UsageFault (INVSTATE) on the next instruction.
Alt text: “ARM Cortex-M exception priority diagram for embedded C programming course”
Alt text: “Round-robin scheduler context switch timeline ARM Cortex-M4 bare-metal C”
Alt text: “ARM Cortex-M SVC exception stack frame layout embedded C programming”

1 Comment