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
SysTick Setup & Context Switch Stack Frames
Calculate the SysTick reload value for a precise 1 ms tick, then trace the full T1-to-T2 context switch through both hardware-created (SF1) and software-created (SF2) stack frames.
1 — Quick Recap: The Two Stack Frames
A context switch creates exactly two stack frames on the outgoing task’s stack and destroys two frames on the incoming task’s stack. Understanding which frame is created by hardware and which by software is the key to writing a correct PendSV handler.
▶
Stack Frame 1 (SF1)
Hardware Exception Frame
Created automatically by the CPU on every exception entry. Contains: xPSR, PC, LR, r12, r3, r2, r1, r0 — 8 registers, 32 bytes. Destroyed automatically by the CPU on EXC_RETURN.
🖊
Stack Frame 2 (SF2)
Software Context Frame
Created manually by the PendSV handler in assembly. Contains: r11, r10, r9, r8, r7, r6, r5, r4 — 8 registers, 32 bytes. Destroyed manually by the PendSV handler on restore.
💾
PSP in TCB
Saved Stack Pointer
After both frames are pushed, the updated PSP is saved into the task’s TCB entry. On restore, the TCB value is loaded back into PSP so the CPU knows where to unstack from.
SF2 (manual, purple) sits on top of SF1 (hardware, yellow). PSP in TCB points to the r4 slot at the very top.
2 — SysTick Reload Value Calculation (16 MHz Clock)
The SysTick timer is a simple 24-bit down-counter. It starts at a reload value, counts down to zero, fires an interrupt, reloads, and repeats. To get a 1 ms interrupt on a 16 MHz processor you need to find the correct reload value.
The Mathematics
16,000,000
Hz (16 MHz)
÷
1,000
Hz (1 kHz = 1 ms period)
=
16,000
counts per tick
Step-by-Step Time Resolution
1
1 ÷ 16,000,000 = 0.0625 μs
One SysTick clock cycle at 16 MHz takes 62.5 nanoseconds (0.0625 microseconds)
2
0.0625 μs × 16 = 1 μs
16 counts produce a 1 microsecond delay
3
0.0625 μs × 16,000 = 1 ms
16,000 counts produce a 1 millisecond delay — this is the reload value
General formula:
Reload Value = (Processor Clock Hz ÷ Desired Tick Hz) − 1
The −1 accounts for the fact that SysTick fires when the counter reaches zero, not when it wraps. With a reload of 15,999 the counter counts from 15,999 → 0 (16,000 steps total), giving exactly 1 ms.
Reload Values for Common Clocks
| MCU / Clock Speed | Desired Period | Reload Value | Formula |
|---|---|---|---|
| STM32F0 @ 16 MHz | 1 ms | 15,999 | (16,000,000 / 1,000) − 1 |
| STM32F4 @ 84 MHz | 1 ms | 83,999 | (84,000,000 / 1,000) − 1 |
| STM32F4 @ 168 MHz | 1 ms | 167,999 | (168,000,000 / 1,000) − 1 |
| STM32F7 @ 216 MHz | 1 ms | 215,999 | (216,000,000 / 1,000) − 1 |
| Any clock | Any period | (Clock / TickHz) − 1 | CMSIS: SysTick_Config(SystemCoreClock / TickHz) |
Maximum reload value: SysTick is a 24-bit counter, so the maximum reload value is 0xFFFFFF = 16,777,215. On a 168 MHz processor, the lowest possible tick frequency is 168,000,000 / 16,777,215 ≈ 10 Hz (100 ms minimum). For longer periods you must use a general-purpose timer instead.
3 — Configuring SysTick in C
CMSIS (recommended)
C
#include "stm32f4xx.h" /* or core_cm4.h for bare CMSIS */
#define TICK_HZ 1000U /* desired tick frequency: 1 kHz = 1 ms period */
void systick_init(void)
{
/*
* SysTick_Config() does three things:
* 1. Writes the reload register: SYST_RVR = (SystemCoreClock / TICK_HZ) - 1
* 2. Sets the current value register to zero: SYST_CVR = 0
* 3. Enables SysTick with processor clock source + interrupt enabled
*
* SystemCoreClock is a CMSIS global set by SystemInit() to the actual
* core clock after PLL configuration (e.g. 16,000,000 or 168,000,000).
*/
uint32_t reload = SystemCoreClock / TICK_HZ - 1;
if (reload > 0xFFFFFF) {
/* Reload value exceeds 24-bit counter — tick period too long */
while (1); /* trap: use a GPTimer instead */
}
SysTick_Config(SystemCoreClock / TICK_HZ);
/* Priority: SysTick highest, PendSV lowest — critical for correct operation */
NVIC_SetPriority(SysTick_IRQn, 0);
NVIC_SetPriority(PendSV_IRQn, 0xFF);
}
Manual registers
C
#include <stdint.h>
/* SysTick register base address (same on all Cortex-M) */
#define SYST_CSR (*(volatile uint32_t *)0xE000E010U) /* Control & Status */
#define SYST_RVR (*(volatile uint32_t *)0xE000E014U) /* Reload Value Reg */
#define SYST_CVR (*(volatile uint32_t *)0xE000E018U) /* Current Value Reg */
/* CSR bits */
#define SYST_ENABLE (1U << 0) /* enable counter */
#define SYST_TICKINT (1U << 1) /* enable exception */
#define SYST_CLKSOURCE (1U << 2) /* 1 = processor clock */
#define PROCESSOR_CLOCK_HZ 16000000U
#define TICK_HZ 1000U
void systick_init_manual(void)
{
/* 1. Disable SysTick while configuring */
SYST_CSR = 0;
/* 2. Set reload value: counts from this value down to 0, then fires */
SYST_RVR = (PROCESSOR_CLOCK_HZ / TICK_HZ) - 1;
/* = (16,000,000 / 1,000) - 1 = 15,999 */
/* 3. Clear current value to avoid immediate fire on enable */
SYST_CVR = 0;
/* 4. Enable counter | enable interrupt | use processor clock */
SYST_CSR = SYST_ENABLE | SYST_TICKINT | SYST_CLKSOURCE;
}
Key registers: SYST_RVR is the reload (the value loaded when counter hits 0). SYST_CVR is the live counter (write any value to clear it to 0). SYST_CSR bit 2 selects the clock source: 1 = processor clock, 0 = external reference clock.
SysTick_Handler
C
#include "stm32f4xx.h"
#include "scheduler.h"
/*
* SysTick_Handler fires every 1 ms.
*
* IMPORTANT: This handler must NOT perform any context switch itself.
* Doing so risks a UsageFault (INVPC) if a peripheral IRQ fires during
* a task's time slot (see previous lesson for full explanation).
*
* The only job here is:
* 1. Advance the scheduler's task index
* 2. Set PENDSVSET to defer the actual switch to PendSV
*/
void SysTick_Handler(void)
{
/* Move the scheduler index to the next task (round-robin) */
update_next_task();
/* Request context switch via PendSV — fires after all ISRs complete */
SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;
}
Keep SysTick_Handler short. Every microsecond spent in SysTick_Handler is time stolen from all tasks. The handler shown above runs in under 10 clock cycles. Never call printf, read sensors, or perform any blocking operation inside it.
4 — Phase 1: Hardware Creates SF1 on T1’s Stack
When SysTick fires while T1 is running in Thread mode, the CPU automatically creates Stack Frame 1 (SF1) on T1’s PSP stack before transferring control to SysTick_Handler. No code you write causes this — it is entirely automatic.
A
T1 running in Thread mode — PSP is active
T1 is executing its task code. The PSP register points to the top of T1’s current stack (local variables, call frames). The CPU is in Thread mode, unprivileged, using PSP.
B
SysTick fires — CPU begins exception entry
The SysTick counter reaches zero. The CPU detects the pending exception and begins the exception entry sequence before executing the next instruction in T1.
C
CPU pushes SF1 onto T1’s PSP stack — automatically
The processor pushes 8 registers in a fixed order: r0, r1, r2, r3, r12, LR, PC, xPSR. The PSP decrements by 32 bytes. This is the hardware-created Stack Frame 1.
D
CPU switches to Handler mode — MSP now active
LR is loaded with EXC_RETURN (0xFFFFFFFD). The CPU enters Handler mode, switches to the MSP, and jumps to SysTick_Handler. T1’s PSP still points to the top of SF1.
SF1 is pushed by hardware. PSP decrements by 32 bytes. SysTick_Handler runs on MSP (not PSP).
5 — Phase 2: Software Saves SF2 on T1’s Stack (PendSV)
SysTick_Handler updates the scheduler index and pends PendSV, then exits. PendSV fires next (at lowest priority, after all other ISRs). Inside PendSV, the programmer’s code creates Stack Frame 2 (SF2) by pushing r4–r11 manually, then saves T1’s updated PSP into the TCB.
Code
@ PendSV_Handler — Phase 2: save T1's context (SF2)
@ At entry: CPU is in Handler mode using MSP
@ T1's PSP still points to the top of SF1 (r0 slot)
mrs r0, psp @ r0 = T1's current PSP (top of SF1)
isb
@ Push r4–r11 BELOW SF1, building SF2 on T1's stack
@ STMDB: Decrement Before each store — stack grows downward
@ After this: r0 points to r4 (top of SF2 = top of complete frame)
stmdb r0!, {r4-r11}
@ Save the updated PSP (pointing to top of SF2) into T1's TCB
@ This is the value we will need to RESTORE T1 in the future
push {lr} @ preserve EXC_RETURN before BL overwrites LR
bl save_current_psp @ save_current_psp(r0) — stores r0 in TCB[current]
bl update_next_task @ advance round-robin index to T2
pop {lr} @ restore EXC_RETURN
STMDB pushes r4–r11 below SF1. The new PSP (pointing to r4) is stored in TCB[T1]. T1’s context is now completely saved.
6 — Phase 3: Software Restores SF2 from T2’s Stack
Now PendSV loads T2’s saved PSP from the TCB and pops the 8 callee-saved registers from T2’s stack. After this, r4–r11 hold T2’s values and the PSP is updated to point to the top of T2’s SF1 (the hardware frame), ready for the CPU to unstack automatically.
Code
@ PendSV_Handler — Phase 3: restore T2's context (SF2)
push {lr}
bl get_next_psp @ r0 = TCB[T2].psp — points to T2's r4 (top of SF2)
pop {lr}
@ Pop r4–r11 from T2's stack (SF2)
@ LDMIA: Increment After each load — stack shrinks upward
@ r4 is loaded first (lowest address), r11 last
@ After this: r0 points to T2's SF1 (r0 slot of T2's hardware frame)
ldmia r0!, {r4-r11}
@ Write the updated PSP — now pointing to T2's SF1
@ When PendSV returns with BX LR, the CPU will unstack SF1 from here
msr psp, r0
isb
@ EXC_RETURN in LR = 0xFFFFFFFD → Thread mode, PSP, no FP
@ CPU will automatically pop SF1 (r0–r3, r12, LR, PC, xPSR) from PSP
bx lr
LDMIA pops SF2 (r4–r11) and advances PSP to point at T2’s SF1. The hardware will unstack SF1 automatically on exception return.
7 — Phase 4: Hardware Unstacks SF1 — T2 Runs
The final step requires no code at all. When PendSV executes BX LR with LR = 0xFFFFFFFD, the CPU detects the EXC_RETURN pattern and automatically pops T2’s SF1 from the PSP stack, restoring all eight hardware-saved registers. T2 resumes exactly where it was interrupted.
BX LR
0xFFFFFFFD
Use PSP for unstack
r12,LR,PC,xPSR
from T2’s PSP
Thread mode, PSP active
Hardware unstacking: CPU pops SF1 from T2’s PSP, advances PSP by 32 bytes, jumps to T2’s saved PC. T2 runs as if never interrupted.
The complete T1→T2 switch is done in four phases:
HW pushes SF1 on T1 → SW pushes SF2 on T1, saves PSP in TCB → SW pops SF2 from T2, loads PSP from TCB → HW pops SF1 from T2, resumes T2.
8 — Complete Scheduler Code Walk-Through
main.c
C
#include <stdint.h>
#include <stdio.h>
#include "scheduler.h"
void task1(void) { while(1) { printf("[T1] running\n"); for(volatile int i=0;i<50000;i++); } }
void task2(void) { while(1) { printf("[T2] running\n"); for(volatile int i=0;i<50000;i++); } }
void task3(void) { while(1) { printf("[T3] running\n"); for(volatile int i=0;i<50000;i++); } }
void task4(void) { while(1) { printf("[T4] running\n"); for(volatile int i=0;i<50000;i++); } }
int main(void)
{
/* Step 1: Initialise scheduler stacks and TCBs */
sched_init(task1, task2, task3, task4);
/* Step 2: Start 1 ms SysTick, set PendSV to lowest priority */
systick_init();
/* Step 3: Switch Thread mode to use PSP, launch task1 */
sched_launch(); /* never returns */
return 0;
}
scheduler.c
C
#include "scheduler.h"
#include "stm32f4xx.h"
#include <stdint.h>
#define TASK_COUNT 4
#define STACK_SIZE 1024U /* bytes per task stack */
#define SRAM_END (0x20000000U + (128U*1024U))
/* Stack tops for each task (stacks grow downward from these addresses) */
static const uint32_t stk_top[TASK_COUNT] = {
SRAM_END,
SRAM_END - STACK_SIZE,
SRAM_END - 2*STACK_SIZE,
SRAM_END - 3*STACK_SIZE
};
static uint32_t tcb_psp[TASK_COUNT]; /* saved PSP for each task */
static uint8_t current = 0;
/* Build fake SF1 + SF2 on each task's stack so PendSV can first-restore */
static void build_stack(uint8_t idx, void(*fn)(void))
{
uint32_t *sp = (uint32_t *)stk_top[idx];
/* SF1 — hardware exception frame (fake values, PC points to task fn) */
*--sp = 0x01000000; /* xPSR — Thumb bit set */
*--sp = (uint32_t)fn; /* PC — task entry */
*--sp = 0xFFFFFFFD; /* LR — EXC_RETURN */
*--sp = 0; /* r12 */
*--sp = 0; /* r3 */
*--sp = 0; /* r2 */
*--sp = 0; /* r1 */
*--sp = 0; /* r0 */
/* SF2 — manually saved callee registers (zeros for new task) */
*--sp = 0; /* r11 */
*--sp = 0; /* r10 */
*--sp = 0; /* r9 */
*--sp = 0; /* r8 */
*--sp = 0; /* r7 */
*--sp = 0; /* r6 */
*--sp = 0; /* r5 */
*--sp = 0; /* r4 */
tcb_psp[idx] = (uint32_t)sp; /* PSP = top of SF2 = top of r4 slot */
}
void sched_init(void(*t1)(void),void(*t2)(void),void(*t3)(void),void(*t4)(void))
{
void(*fns[TASK_COUNT])(void) = {t1, t2, t3, t4};
for (int i = 0; i < TASK_COUNT; i++) build_stack(i, fns[i]);
}
void systick_init(void)
{
SysTick_Config(SystemCoreClock / 1000U); /* 1 ms tick */
NVIC_SetPriority(SysTick_IRQn, 0x00);
NVIC_SetPriority(PendSV_IRQn, 0xFF);
}
void update_next_task(void) { current = (current + 1) % TASK_COUNT; }
uint32_t get_next_psp(void) { return tcb_psp[current]; }
void save_current_psp(uint32_t psp) {
uint8_t prev = (current + TASK_COUNT - 1) % TASK_COUNT;
tcb_psp[prev] = psp;
}
void SysTick_Handler(void)
{
update_next_task();
SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;
}
void sched_launch(void)
{
__asm volatile(
"ldr r0, =tcb_psp \n"
"ldr r0, [r0] \n" /* PSP of task 0 */
"msr psp, r0 \n"
"mov r0, #0x02 \n" /* CONTROL: SPSEL=1 */
"msr control, r0 \n"
"isb \n"
"ldm sp!, {r4-r11} \n" /* pop SF2 of task 0 */
"pop {r0-r3,r12,lr} \n" /* pop partial SF1 */
"pop {pc} \n" /* jump to task entry */
: : : "memory"
);
}
pendsv_handler.s
C
.syntax unified
.thumb
.extern save_current_psp @ void(uint32_t)
.extern get_next_psp @ uint32_t(void)
.extern update_next_task @ void(void)
.global PendSV_Handler
.type PendSV_Handler, %function
PendSV_Handler:
@ ─── SAVE T1 (outgoing) ─────────────────────────────────────
@ SF1 already on T1's PSP stack (hardware did it on SysTick entry)
@ We only push SF2 (r4-r11)
mrs r0, psp @ r0 = T1's PSP (top of SF1)
isb
stmdb r0!, {r4-r11} @ push SF2: r4-r11 below SF1
@ r0 now = top of complete saved frame
push {lr} @ save EXC_RETURN
bl save_current_psp @ TCB[T1].psp = r0 ← T1 fully saved
pop {lr}
@ ─── RESTORE T2 (incoming) ──────────────────────────────────
@ Load T2's PSP from TCB, pop SF2 (r4-r11), set PSP to T2's SF1 top
push {lr}
bl get_next_psp @ r0 = TCB[T2].psp (top of T2's SF2)
pop {lr}
ldmia r0!, {r4-r11} @ pop SF2: r4-r11 restored to T2's values
@ r0 now = top of T2's SF1
msr psp, r0 @ PSP = top of T2's SF1
isb
@ BX LR with EXC_RETURN = 0xFFFFFFFD:
@ CPU automatically pops SF1 from T2's PSP:
@ r0, r1, r2, r3, r12, LR, PC, xPSR ← hardware unstack
@ T2 resumes at its saved PC in Thread mode
bx lr
Output
Expected UART Output
// Tasks interleave every 1 ms — each gets ~1 ms slice
[T1] running
[T2] running
[T3] running
[T4] running
[T1] running
[T2] running
[T3] running
[T4] running
// Cycle repeats every 4 ms (4 × 1 ms)
If all 4 tasks print in round-robin order your scheduler is working. If only T1 prints, PendSV is not firing — check that PENDSVSET is set in SysTick_Handler and PendSV priority is lower than SysTick (0xFF vs 0x00).
🧠 Quick Knowledge Check
1. What is the SysTick reload value for a 1 ms tick on a 16 MHz processor?
- a) 16,000,000
- b) 15,999
- c) 1,000
- d) 16,000
Check Answer
2. What registers are in Stack Frame 1 (SF1)?
- a) r4, r5, r6, r7, r8, r9, r10, r11
- b) r0, r1, r2, r3, r12, LR, PC, xPSR
- c) All 16 registers
- d) PC, xPSR, and SP only
Check Answer
3. Who creates Stack Frame 2 (SF2) and when?
- a) The CPU hardware, on exception entry
- b) The SysTick_Handler C function
- c) The PendSV handler assembly code, using STMDB to push r4–r11
- d) The linker script at compile time
Check Answer
4. After saving T1 and before restoring T2, what value is stored in TCB[T1]?
- a) The original PSP before any exception (top of T1’s local variables)
- b) The PSP after SF1 was pushed (top of r0 in SF1)
- c) The PSP after SF2 was pushed (top of r4 in SF2)
- d) The address of T1’s task function
Check Answer
5. How does the CPU know to unstack SF1 from T2’s PSP (not from MSP) when PendSV returns?
- a) PendSV writes a flag to the NVIC before returning
- b) The EXC_RETURN value in LR (0xFFFFFFFD) encodes bit 2 = 1 which means “use PSP”
- c) The CPU always unstacks from PSP on PendSV return
- d) The MSR PSP instruction tells the CPU to use PSP
Check Answer
6. Why must SYST_CVR be cleared to zero before enabling SysTick?
- a) To prevent the counter from wrapping around immediately and firing an interrupt before the first expected tick
- b) To reset the processor clock
- c) The CVR register controls the interrupt priority
- d) It is not necessary; the hardware clears it on startup
Check Answer
Alt: “SysTick reload value calculation 16MHz ARM Cortex-M 1ms tick embedded C”
Alt: “ARM Cortex-M context switch SF1 SF2 stack frames PendSV handler embedded C”
Alt: “ARM Cortex-M four phase context switch T1 to T2 SysTick PendSV bare metal”
Frequently Asked Questions
Does SysTick_Config() subtract 1 from the reload value automatically?
Yes. The CMSIS SysTick_Config(ticks) function writes ticks - 1 to the SYST_RVR register. So when you call SysTick_Config(SystemCoreClock / 1000) it writes 15,999 to SYST_RVR for a 16 MHz clock.
If you configure the registers manually, you must subtract 1 yourself: SYST_RVR = (CLOCK / TICK_HZ) - 1;. Forgetting the -1 means your timer fires one count late every tick, accumulating a small but measurable timing error.
Can I use a different timer instead of SysTick for the RTOS tick?
Yes, but SysTick is strongly preferred because it is part of the Cortex-M core standard — it exists on every Cortex-M device without any device-specific configuration. FreeRTOS, Zephyr, and most commercial RTOS products use SysTick as the default tick source.
You might choose a GPTimer instead if: (1) you need a tick period longer than the SysTick maximum (about 100 ms at 168 MHz), (2) you need the scheduler to work with the processor in low-power tickless idle mode, or (3) your SysTick is already used for another purpose (unusual but possible in complex systems).
What is the exact order in which SF1 registers are pushed to the stack?
The Cortex-M architecture reference manual specifies this exact order (from highest to lowest stack address, i.e., r0 is at the lowest address):
Highest address (first pushed): xPSR → PC → LR → r12 → r3 → r2 → r1 → r0 (lowest address, PSP points here)
This means when you read [PSP + 0] you get r0, [PSP + 4] gives r1, and [PSP + 28] gives PC (the resume address). This is why the SVC handler trick of reading the SVC number from ((uint8_t *)frame[6])[-2] works — frame[6] is PC, and the SVC instruction is 2 bytes before it.
After sched_launch(), how does the very first task start without a real exception frame?
The fake stack frames built by build_stack() solve this. When sched_launch() sets PSP to tcb_psp[0] (which points to the r4 slot of task 0’s fake SF2), it then manually pops SF2 and SF1 using regular POP instructions.
The last instruction is POP {pc}, which loads the PC with the task’s entry function address — jumping directly into the task. From this point on, SysTick fires every 1 ms and creates a real SF1, and PendSV creates a real SF2. The fake frames are only needed for the very first launch.
What is the total RAM overhead of this 4-task scheduler?
Task stacks: 4 × 1 KB = 4 KB
Scheduler (MSP) stack: 1 KB
TCB array (PSP values): 4 × 4 bytes = 16 bytes
Code (scheduler + PendSV): ≈ 100–200 bytes in flash
Total stack RAM: 5 KB
On an STM32F407 with 192 KB of SRAM, this is about 2.6% overhead — negligible. Each additional task costs 1 KB of stack plus 4 bytes of TCB. You could run 180+ tasks before hitting the RAM limit (though 4–20 is typical for practical designs).
Why is it important that SysTick has a higher priority than PendSV?
If PendSV had the same or higher priority than SysTick, PendSV could preempt SysTick before it finishes. At that point, the scheduler’s task index might not yet be updated, leading to the wrong task being switched in.
More critically, if a peripheral IRQ (at some intermediate priority) fires during a task’s time slot, and PendSV has a higher priority than that IRQ, PendSV would preempt the ISR and attempt to return to Thread mode while the ISR is still on the exception stack — causing a UsageFault (INVPC). Setting PendSV to the absolute lowest priority (0xFF) prevents all these problems: PendSV only runs when there is nothing else pending.
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