Need Help or More Information?
Questions about this free C programming course, embedded C training or career guidance? Get in touch.
Free Embedded C Programming Course — Module 8
ARM Cortex-M Task Stack Init & Dummy Stack Frames
How to pre-populate SF1 and SF2 on a task’s private stack so the scheduler can first-launch it correctly
Introduction
Core Concept
Why Every New Task Needs a Dummy Stack Frame
The ARM Cortex-M context-switch mechanism is symmetric: PendSV saves register state onto the current task’s stack before switching, and restores it from the next task’s stack after switching. This works perfectly for a task that has already run at least once — its previous state is already there. But a brand-new task has never run, so its private stack is empty and the scheduler would load garbage into the CPU.
The solution is simple: before launching the scheduler, pre-fill each task’s stack with a fake (dummy) context that looks exactly like a real saved state. When PendSV pops that frame during the very first context switch, the processor transitions cleanly to Thread mode and begins executing the task’s entry function.
💡
Key insight This technique is used by every real RTOS (FreeRTOS, Zephyr, ThreadX) — they all pre-build a “fake” stack frame for every task during task creation.
1
Allocate private stack per task
Each task gets its own 1 KB region in SRAM. The stack grows downward, so it starts at the top of its region.
2
Pre-populate SF1 (hardware frame) with fake values
Push xPSR, PC (task entry), LR (EXC_RETURN), R12, R3–R0. Only PC and xPSR need real values; the rest can be 0.
3
Pre-populate SF2 (software frame) with zeros
Push R4–R11 (8 registers). All zeros is safe — the task will overwrite them immediately.
4
Save resulting PSP into the task’s TCB
Store the final stack pointer value so the scheduler can load it into PSP when the task is first scheduled.
5
Launch the scheduler
Only now is it safe to start SysTick and trigger the first PendSV. Each task’s stack looks like it was previously suspended.
Diagram
Complete Dummy Stack Frame Layout
A fresh task stack occupies exactly 64 bytes — 8 registers in SF2 (32 B) plus 8 registers in SF1 (32 B). The PSP is set to point at the bottom of the complete frame (lowest address).
Auto-pushed by hardware on exception entry. On return, hardware pops these and jumps to PC.
Manually pushed/popped by PendSV ISR using
STMDB/LDMIA. Bit 24 = Thumb mode. If zero the processor faults on exception return.
“Return to Thread mode, use PSP.” Placed in the LR slot of SF1.
Reference
Critical Values in the Dummy Frame
Most registers in the fake frame can be initialised to zero without consequence — the task will set them when it runs. However three slots carry processor-defined semantics and must be set correctly:
| Register | Frame | Required Value | Reason |
|---|---|---|---|
xPSR | SF1 (top) | 0x01000000 | Bit 24 = Thumb mode bit. ARM Cortex-M only supports Thumb; if this bit is 0 when BXNS resolves, a UsageFault INVSTATE fires. |
PC (Return Addr) | SF1 | Address of task_handler() with LSB forced to 1 | The LSB=1 marks a Thumb-mode function. Most compilers do this automatically; if you copy a raw uint32_t address, OR it with 1u explicitly. |
LR (EXC_RETURN) | SF1 | 0xFFFFFFFD | On EXC_RETURN, the processor reads this as a magic code: “Thread mode, PSP, no FPU frame.” This restores the task context rather than returning to a normal caller. |
R4–R11 | SF2 | 0x00000000 | Safe placeholder. The task overwrites these during its first execution. |
R0–R3, R12 | SF1 | 0x00000000 | Could pass arguments via R0–R3 to the task entry function if desired (for parameterised task handlers). |
Code
Implementing init_task_stack()
Below is a complete, annotated implementation. The function walks the stack downward (decrement-before-store) using a uint32_t* pointer, then saves the final PSP value into the task control block.
C
/*
* init_task_stack.c
* Pre-populates dummy SF1 + SF2 on each task's private stack
* so the scheduler can first-launch it as if it was suspended.
*/
#include "scheduler.h"
/* Stack region constants (128 KB SRAM, top = 0x2001FFFF+1) */
#define SRAM_START 0x20000000U
#define SRAM_SIZE (128U * 1024U) /* 128 KB */
#define SRAM_END (SRAM_START + SRAM_SIZE) /* 0x20020000 */
#define STACK_SIZE 1024U /* 1 KB per task */
/* Base address of each task's stack (grows downward from top) */
#define T1_STACK_START SRAM_END
#define T2_STACK_START (T1_STACK_START - STACK_SIZE)
#define T3_STACK_START (T2_STACK_START - STACK_SIZE)
#define T4_STACK_START (T3_STACK_START - STACK_SIZE)
#define SCHED_STACK_START (T4_STACK_START - STACK_SIZE)
/* EXC_RETURN: "Thread mode, PSP, no FPU" */
#define EXC_RETURN_THREAD_PSP 0xFFFFFFFDU
/* xPSR: Thumb bit must be set */
#define DUMMY_xPSR 0x01000000U
/* Forward declarations of the four task functions */
void task1_handler(void);
void task2_handler(void);
void task3_handler(void);
void task4_handler(void);
/* Task control block array (defined in scheduler.c) */
extern tcb_t g_tcb[MAX_TASKS];
/*
* init_stack_for_task()
* Writes a 16-word (64-byte) dummy frame to the task's private stack
* and stores the resulting PSP in its TCB.
*
* Stack layout (each cell = 4 bytes, address decrements downward):
*
* High address +------------------+
* (stack_top-1) | xPSR | 0x01000000
* | PC | &task_handlerX
* | LR | 0xFFFFFFFD (EXC_RETURN)
* | R12 | 0
* | R3 | 0
* | R2 | 0
* | R1 | 0
* | R0 | 0 <- SF1 base
* | R11 | 0
* | R10 | 0
* | R9 | 0
* | R8 | 0
* | R7 | 0
* | R6 | 0
* | R5 | 0
* Low address | R4 | 0 <- PSP points here
* +------------------+
*/
static void init_stack_for_task(uint32_t stack_top, void (*entry)(void), tcb_t *tcb)
{
/* Cast stack_top to a uint32_t pointer.
* We decrement BEFORE writing (ARM full-descending stack). */
uint32_t *sp = (uint32_t *)stack_top;
/* ─── SF1: hardware frame (pushed last → highest address) ─── */
*(--sp) = DUMMY_xPSR; /* xPSR */
*(--sp) = (uint32_t)entry | 0x1U; /* PC (Thumb LSB) */
*(--sp) = EXC_RETURN_THREAD_PSP; /* LR */
*(--sp) = 0x0000000CU; /* R12 — use 0xC as debug marker */
*(--sp) = 0x00000003U; /* R3 */
*(--sp) = 0x00000002U; /* R2 */
*(--sp) = 0x00000001U; /* R1 */
*(--sp) = 0x00000000U; /* R0 */
/* ─── SF2: software frame (pushed first → lowest address) ─── */
*(--sp) = 0x0000000BU; /* R11 — use register number as marker */
*(--sp) = 0x0000000AU; /* R10 */
*(--sp) = 0x00000009U; /* R9 */
*(--sp) = 0x00000008U; /* R8 */
*(--sp) = 0x00000007U; /* R7 */
*(--sp) = 0x00000006U; /* R6 */
*(--sp) = 0x00000005U; /* R5 */
*(--sp) = 0x00000004U; /* R4 */
/* Save the PSP into the TCB */
tcb->psp_value = (uint32_t)sp;
}
/*
* init_tasks_stack()
* Call this once during startup, before init_systick() or sched_launch().
*/
void init_tasks_stack(void)
{
init_stack_for_task(T1_STACK_START, task1_handler, &g_tcb[0]);
init_stack_for_task(T2_STACK_START, task2_handler, &g_tcb[1]);
init_stack_for_task(T3_STACK_START, task3_handler, &g_tcb[2]);
init_stack_for_task(T4_STACK_START, task4_handler, &g_tcb[3]);
}
C
/*
* scheduler.h
* Minimal RTOS scheduler definitions
*/
#ifndef SCHEDULER_H
#define SCHEDULER_H
#include <stdint.h>
#define MAX_TASKS 4
/*
* Task Control Block (TCB)
* The only state we need for a simple round-robin scheduler
* is the saved PSP value per task.
*/
typedef struct {
uint32_t psp_value; /* Saved stack pointer (PSP) for this task */
} tcb_t;
/* Defined in scheduler.c */
extern tcb_t g_tcb[MAX_TASKS];
extern uint8_t g_current_task;
/* API */
void init_tasks_stack(void);
void init_systick(uint32_t tick_hz);
void init_scheduler_stack(uint32_t sched_stack_start);
void sched_launch(void);
void save_psp(uint32_t psp);
uint32_t get_psp(void);
void update_next_task(void);
#endif /* SCHEDULER_H */
C
/*
* main.c
* Four-task round-robin scheduler startup sequence
*/
#include "scheduler.h"
/* ── Task implementations (LED toggle with busy-wait) ── */
void task1_handler(void)
{
while (1) {
toggle_led(LED_GREEN); /* 1 s period */
delay_ms(500);
}
}
void task2_handler(void)
{
while (1) {
toggle_led(LED_ORANGE); /* 500 ms period */
delay_ms(250);
}
}
void task3_handler(void)
{
while (1) {
toggle_led(LED_RED); /* 250 ms period */
delay_ms(125);
}
}
void task4_handler(void)
{
while (1) {
toggle_led(LED_BLUE); /* 125 ms period */
delay_ms(62);
}
}
/* ── Startup ── */
int main(void)
{
/* 1. Set up the MSP for the scheduler itself */
init_scheduler_stack(SCHED_STACK_START);
/* 2. Initialise hardware */
led_init_all();
/* 3. Build fake stack frames for all tasks — MUST be before launch */
init_tasks_stack();
/* 4. Configure SysTick for 1 ms tick (assuming 16 MHz clock) */
init_systick(1000); /* 1000 ticks/s = 1 ms per tick */
/* 5. Hand control to the scheduler (never returns) */
sched_launch();
/* Should never reach here */
for (;;);
}
Code
/* PendSV_Handler.s — GNU AS (ARM Cortex-M Thumb2)
*
* Called by SysTick_Handler (sets PENDSVSET bit).
* Performs context switch between tasks using PSP.
*/
.syntax unified
.cpu cortex-m4
.thumb
.global PendSV_Handler
.type PendSV_Handler, %function
PendSV_Handler:
/* ── SAVE current task context ── */
MRS R0, PSP /* R0 = current task PSP */
STMDB R0!, {R4-R11} /* Push SF2 (R4-R11) onto task stack */
BL save_psp /* save_psp(R0) → stores in TCB */
/* ── SELECT next task ── */
BL update_next_task /* Advance g_current_task index */
/* ── RESTORE next task context ── */
BL get_psp /* R0 = next task's PSP from TCB */
LDMIA R0!, {R4-R11} /* Pop SF2 (R4-R11) from next stack */
MSR PSP, R0 /* Update PSP to above SF1 */
MOV LR, #0xFFFFFFFD /* EXC_RETURN: Thread/PSP/no FPU */
BX LR /* Return; hardware pops SF1 → runs task*/
.size PendSV_Handler, . - PendSV_Handler
Memory Layout
SRAM Stack Region Map
Five 1 KB stacks are carved from the top of SRAM. The scheduler itself uses the topmost region (it runs in Handler mode with MSP). Tasks T1–T4 occupy the four regions below it.
Handler mode (SysTick, PendSV)
125 ms LED toggle
250 ms LED toggle
500 ms LED toggle
1 s LED toggle
⚠️
Stack overflow caution Each task has exactly 1 KB. A 64-byte dummy frame leaves 960 bytes of usable space. Deep function calls or large local arrays can overflow into the next task’s memory silently. Add a stack canary or MPU guard region in production code.
Practical Demo
Four-Task LED Toggle Demo
A classic first scheduler demo: four tasks each toggle a different LED at different rates. Because the tasks share CPU time via round-robin scheduling (1 ms slice), all four LEDs appear to blink independently — yet only one task runs at a time.
C
/*
* tasks.c — four LED tasks for STM32F4xx Discovery board
* LED pins: PD12 Green, PD13 Orange, PD14 Red, PD15 Blue
*/
#include "stm32f4xx.h"
#include "scheduler.h"
#define LED_GREEN (1u << 12)
#define LED_ORANGE (1u << 13)
#define LED_RED (1u << 14)
#define LED_BLUE (1u << 15)
static void delay_ms(uint32_t ms)
{
/* Simple busy-wait calibrated for 16 MHz AHB clock.
* Replace with a proper timer in production. */
volatile uint32_t count = ms * 2000U;
while (count--);
}
static void toggle_led(uint32_t pin)
{
GPIOD->ODR ^= pin; /* XOR-toggle the pin */
}
/* ── Task handlers ── */
void task1_handler(void)
{
for (;;) {
toggle_led(LED_GREEN);
delay_ms(500);
}
}
void task2_handler(void)
{
for (;;) {
toggle_led(LED_ORANGE);
delay_ms(250);
}
}
void task3_handler(void)
{
for (;;) {
toggle_led(LED_RED);
delay_ms(125);
}
}
void task4_handler(void)
{
for (;;) {
toggle_led(LED_BLUE);
delay_ms(62);
}
}
/* ── LED GPIO init ── */
void led_init_all(void)
{
/* Enable clock for GPIOD */
RCC->AHB1ENR |= RCC_AHB1ENR_GPIODEN;
/* PD12–PD15: Output, push-pull, no pull */
GPIOD->MODER &= ~(0xFFu << 24); /* clear bits 24-31 */
GPIOD->MODER |= (0x55u << 24); /* 01=output for each pin */
GPIOD->OTYPER &= ~(0xFu << 12); /* push-pull */
GPIOD->OSPEEDR|= (0xFFu << 24); /* high speed */
}
C
/*
* delay.c — SysTick-based accurate delay
* Requires SysTick to be running at 1 ms interval.
*/
#include "stm32f4xx.h"
#include <stdint.h>
static volatile uint32_t g_tick_count = 0;
/* Called by SysTick_Handler (along with context-switch logic) */
void delay_tick_increment(void)
{
g_tick_count++;
}
/* Blocking delay — task is still running (busy-wait) */
void delay_ms(uint32_t ms)
{
uint32_t start = g_tick_count;
while ((g_tick_count - start) < ms) {
/* yield CPU voluntarily in a real RTOS — here we busy-wait */
__NOP();
}
}
Code
/* ── Semihosting / ITM trace output (optional debug) ── */
[ 0 ms] Scheduler started. Current task: T1
[ 1 ms] Context switch: T1 → T2
[ 2 ms] Context switch: T2 → T3
[ 3 ms] Context switch: T3 → T4
[ 4 ms] Context switch: T4 → T1
...
[ 62 ms] T4: BLUE LED toggles (62 ms elapsed)
[125 ms] T3: RED LED toggles
[125 ms] T4: BLUE LED toggles
[250 ms] T2: ORANGE LED toggles
[250 ms] T3: RED LED toggles
[250 ms] T4: BLUE LED toggles
[500 ms] T1: GREEN LED toggles
[500 ms] T2: ORANGE LED toggles
[500 ms] T3: RED LED toggles
[500 ms] T4: BLUE LED toggles
[1000ms] T1: GREEN LED toggles ← back to start of 1 s cycle
/* Board observation:
* - Green LED blinks at 1 Hz (slowest)
* - Orange LED blinks at 2 Hz
* - Red LED blinks at 4 Hz
* - Blue LED blinks at 8 Hz (fastest)
* All four LEDs appear independent — cooperative multitasking illusion!
*/
Debugging
Common Mistakes & How to Fix Them
🔴
UsageFault: INVSTATE — xPSR Thumb bit missing
Symptom: Hard fault on first EXC_RETURN. Cause: dummy xPSR initialised to 0 instead of 0x01000000. Fix: always set bit 24.
🔴
UsageFault: INVPC — invalid EXC_RETURN
Symptom: Fault on first task launch. Cause: LR slot in SF1 set to 0 or a real function address. Fix: set it to 0xFFFFFFFD exactly.
⚠️
Task runs wrong function — PC LSB forgotten
Symptom: task_handler never called; random code executed. Cause: PC written as (uint32_t)handler without ORing 1. Fix: always | 0x1U when writing a function address to the PC slot of an exception frame.
⚠️
init_tasks_stack() called after sched_launch()
Symptom: random crashes because some tasks have uninitialised stacks. Fix: always call init_tasks_stack() before init_systick() and sched_launch().
💡
Debugging tip: use non-zero register markers
Instead of zero-filling R4–R11, fill them with their register numbers (R4=0x4, R5=0x5, …) as shown in the code above. This makes it easy to spot un-modified registers in a debugger — if you see R7 = 0x7, you know T4’s dummy SF2 was never overwritten.
Summary
Key Facts at a Glance
Startup sequence — correct order matters
- Step 1:
init_scheduler_stack()— switch MSP to scheduler’s 1 KB region - Step 2: Initialise GPIO / peripherals
- Step 3:
init_tasks_stack()— build dummy frames for all tasks - Step 4:
init_systick(1000)— start 1 ms tick (triggers SysTick IRQ) - Step 5:
sched_launch()— switch to Task 1 and never return
Quiz
Test Your Understanding
1. What value must be stored in the xPSR slot of a dummy SF1 frame on ARM Cortex-M?
- a) 0x00000000 (all zeros)
- b) 0x01000000 (Thumb bit set)
- c) 0xFFFFFFFD (EXC_RETURN)
- d) 0xDEADBEEF (any non-zero)
Check Answer
2. Which registers are saved/restored by PendSV (SF2), rather than automatically by hardware (SF1)?
- a) R0–R3, R12, LR, PC, xPSR
- b) R4–R11
- c) R0–R7 (lower half only)
- d) All 16 registers at once
Check Answer
3. Why is it necessary to OR the task entry address with 0x1 when writing it to the PC slot?
- a) To tell the linker it is a privileged function
- b) To mark the address as Thumb mode — ARM Cortex-M only supports Thumb instructions
- c) To align the address to a 2-byte boundary
- d) It is not needed; the hardware sets bit 0 automatically
Check Answer
4. What does the EXC_RETURN value 0xFFFFFFFD tell the processor?
- a) Return to Handler mode using MSP
- b) Return to Thread mode using MSP
- c) Return to Thread mode using PSP, no FPU state
- d) Reset the processor
Check Answer
5. In the four-task LED demo, how many times does the 1 ms SysTick fire in one full 1-second cycle?
- a) 4
- b) 250
- c) 1000
- d) 4000
Check Answer
6. What is the total size (in bytes) of the dummy stack frame placed on a brand-new task’s stack?
- a) 32 bytes (SF1 only)
- b) 40 bytes
- c) 64 bytes (SF1 + SF2)
- d) 128 bytes (with FPU registers)
Check Answer
FAQ
Frequently Asked Questions
Can I pass arguments to a task through R0–R3 in the dummy frame? ▾
Yes. The AAPCS (ARM Procedure Call Standard) uses R0–R3 for the first four integer arguments. If your task entry function has a signature like void my_task(void *arg), you can store the argument pointer in the R0 slot of the dummy SF1. When the task first runs, R0 will hold that pointer just as if it were passed by a normal function call.
What happens if I forget to set the Thumb bit in xPSR? ▾
A UsageFault with INVSTATE (bit 1 of UFSR = 1) fires immediately on the first EXC_RETURN. The processor tried to execute an instruction in a mode it doesn’t support. On Cortex-M, there is no ARM-state — only Thumb. Always initialise xPSR to 0x01000000.
Why does the stack pointer decrement before the write (full-descending)? ▾
The ARM Architecture Reference Manual defines the Cortex-M stack as Full Descending (FD): SP points to the last used slot, and a push first decrements SP, then writes. This matches how STMDB (“Store Multiple Decrement Before”) works and how the hardware itself pushes SF1 during an exception. Your uint32_t *sp; *(--sp) = value; pattern mirrors exactly that behavior.
Why does Task 1 always run first after sched_launch()? ▾
sched_launch() manually loads T1’s PSP from the TCB and pops SF2 via LDMIA, then sets LR = EXC_RETURN and executes BX LR. The processor performs an EXC_RETURN sequence which pops SF1 and starts execution at PC = T1’s entry. It is deterministic: whatever task has index 0 in the TCB array runs first. Change g_current_task = 0 to a different index if you want a different first task.
The busy-wait delay will starve other tasks if a task never yields — how do real RTOSes solve this? ▾
Real RTOSes implement a blocking delay: the task calls vTaskDelay() (FreeRTOS) or k_sleep() (Zephyr), which moves it to a “sleeping” list and immediately triggers a context switch to another ready task. The tick interrupt wakes it when the timeout expires. Our simple demo uses a busy-wait for clarity, but in production you always want cooperative blocking to avoid wasting CPU cycles.
Does the FP (floating-point) extension change the stack frame size? ▾
Yes. If the FPU is enabled and a task uses floating-point instructions, the hardware also saves S0–S15 and FPSCR on exception entry (adding 72 bytes to SF1). EXC_RETURN bit 4 distinguishes a standard frame (0xFFFFFFFD) from an extended FPU frame (0xFFFFFFED). For a Cortex-M4F scheduler you must either disable the FPU, always save the full FP context, or use lazy FP stacking (FPCCR.LSPEN = 1).
How do I detect a stack overflow in practice? ▾
Three common techniques: (1) Stack canary — fill the bottom of the stack with a magic value and check it on every context switch; (2) MPU guard — configure the MPU to generate a MemManage fault if any access touches the guard region below the stack; (3) Watermark — paint the entire stack with a pattern (0xDEADBEEF) at init and periodically count untouched words to compute max stack depth.
EmbeddedPathashala — Embedded Systems Programming on ARM Cortex-M3/M4
Need Help or More Information?
Questions about this free C programming course, embedded C training or career guidance? Get in touch.

2 Comments