Need Help or More Information?
Questions about this free C programming course, embedded C training or career guidance? Get in touch.
Bare-Metal Round-Robin Task Scheduler
Build a complete preemptive task scheduler from scratch — Task Control Blocks, SysTick timer, PendSV context switching with STMDB/LDMIA, task_delay blocking, and the PRIMASK critical section. The foundation of every RTOS.
1. Scheduler Architecture Overview
This scheduler runs 5 tasks (1 idle + 4 user tasks) on STM32F407. Each task has its own stack in SRAM and is managed via a Task Control Block (TCB). The SysTick timer fires every 1 ms and triggers PendSV for context switching.
2. Task Control Block (TCB)
/* ── Task Control Block: private info per task ── */
typedef struct {
uint32_t psp_value; /* Saved PSP (stack pointer when task last ran) */
uint32_t block_count; /* Tick count when task should unblock (for delay) */
uint8_t current_state; /* TASK_READY_STATE=0x00 or TASK_BLOCKED_STATE=0xFF */
void (*task_handler)(void); /* Function pointer to task entry point */
} TCB_t;
/* Array of 5 TCBs: [0]=idle, [1]=task1, [2]=task2, [3]=task3, [4]=task4 */
TCB_t user_tasks[MAX_TASKS]; /* MAX_TASKS = 5 */
/* Tracks which task is currently running */
uint8_t current_task = 1; /* Start with task1 */
uint32_t g_tick_count = 0; /* Global millisecond counter */
============================================================
3. Stack Memory Constants
/* ── Memory map constants (from main.h) ── */
#define MAX_TASKS 5
#define SIZE_TASK_STACK 1024U /* 1KB per task stack */
#define SIZE_SCHED_STACK 1024U /* 1KB for scheduler/MSP stack */
#define SRAM_START 0x20000000U
#define SIZE_SRAM ((128) * (1024)) /* 128 KB */
#define SRAM_END (SRAM_START + SIZE_SRAM) /* = 0x20020000 */
#define T1_STACK_START SRAM_END /* 0x20020000 */
#define T2_STACK_START (SRAM_END - 1 * SIZE_TASK_STACK) /* 0x2001FC00 */
#define T3_STACK_START (SRAM_END - 2 * SIZE_TASK_STACK) /* 0x2001F800 */
#define T4_STACK_START (SRAM_END - 3 * SIZE_TASK_STACK) /* 0x2001F400 */
#define IDLE_STACK_START (SRAM_END - 4 * SIZE_TASK_STACK) /* 0x2001F000 */
#define SCHED_STACK_START (SRAM_END - 5 * SIZE_TASK_STACK) /* 0x2001EC00 */
#define TICK_HZ 1000U /* 1 kHz SysTick = 1ms tick */
#define HSI_CLOCK 16000000U
#define SYSTICK_TIM_CLK HSI_CLOCK
#define DUMMY_XPSR 0x01000000U /* T-bit=1, everything else=0 */
#define TASK_READY_STATE 0x00
#define TASK_BLOCKED_STATE 0xFF
============================================================
4. SysTick Timer Registers
| Register | Address | Name | Function |
|---|---|---|---|
| SYST_CSR | 0xE000E010 | Control & Status | bit[0]=ENABLE, bit[1]=TICKINT (exception), bit[2]=CLKSOURCE (1=processor clock) |
| SYST_RVR | 0xE000E014 | Reload Value | 24-bit reload value. Counter counts from this down to 0, then reloads. |
| SYST_CVR | 0xE000E018 | Current Value | Current count. Write any value to clear it and clear COUNTFLAG. |
| SYST_CALIB | 0xE000E01C | Calibration | TENMS field = count for 10ms at processor clock |
Reload Value Calculation
/* SysTick count_value for 1ms tick at 16MHz HSI:
count_value = (SYSTICK_CLK / TICK_HZ) - 1
= (16,000,000 / 1000) - 1
= 15,999
Counter counts: 15999 → 15998 → ... → 0 → [reload 15999] → fires IRQ */
void init_systick_timer(uint32_t tick_hz)
{
uint32_t *pSRVR = (uint32_t*)0xE000E014; /* SysTick Reload Value Register */
uint32_t *pSCSR = (uint32_t*)0xE000E010; /* SysTick Control & Status Register */
uint32_t count = (SYSTICK_TIM_CLK / tick_hz) - 1; /* = 15999 for 1ms */
*pSRVR &= ~(0x00FFFFFF); /* Clear bits[23:0] of reload register */
*pSRVR |= count; /* Load 15999 */
*pSCSR |= (1 << 1); /* TICKINT=1: enable SysTick exception */
*pSCSR |= (1 << 2); /* CLKSOURCE=1: use processor clock (not external) */
*pSCSR |= (1 << 0); /* ENABLE=1: start the counter */
}
============================================================
5. Task Stack Initialization — Fake Exception Frame
Before the scheduler starts, each task’s stack must look as if the task had been interrupted mid-execution. We build a fake exception frame so that when PendSV runs the first time, it can “restore” a task as if it had previously been running.
Task1 initial stack (T1_STACK_START = 0x20020000)
/* ── Build a fake exception + context frame for each task ── */
void init_tasks_stack(void)
{
/* Set initial states and task handler pointers */
user_tasks[0].current_state = TASK_READY_STATE;
user_tasks[0].psp_value = IDLE_STACK_START;
user_tasks[0].task_handler = idle_task;
user_tasks[1].current_state = TASK_READY_STATE;
user_tasks[1].psp_value = T1_STACK_START;
user_tasks[1].task_handler = task1_handler;
/* ... similarly for tasks 2, 3, 4 */
uint32_t *pPSP;
for (int i = 0; i < MAX_TASKS; i++)
{
pPSP = (uint32_t*)user_tasks[i].psp_value;
/* ── Push SF1 (Hardware Exception Frame) ──────────────────
These 8 registers are popped by EXC_RETURN (0xFFFFFFFD):
xPSR → PC → LR → R12 → R3 → R2 → R1 → R0 */
pPSP--; *pPSP = DUMMY_XPSR; /* [0x01000000] xPSR T-bit=1 */
pPSP--; *pPSP = (uint32_t)user_tasks[i].task_handler; /* PC */
pPSP--; *pPSP = 0xFFFFFFFD; /* LR = EXC_RETURN (Thread/PSP) */
pPSP--; *pPSP = 0; /* R12 */
pPSP--; *pPSP = 0; /* R3 */
pPSP--; *pPSP = 0; /* R2 */
pPSP--; *pPSP = 0; /* R1 */
pPSP--; *pPSP = 0; /* R0 */
/* ── Push SF2 (Software Context: R4–R11) ─────────────────
PendSV saves/restores these with STMDB/LDMIA */
for (int j = 0; j < 13; j++) /* R11..R4 + padding? Actually 8 regs */
{
pPSP--;
*pPSP = 0;
}
/* Save the final PSP value into the TCB */
user_tasks[i].psp_value = (uint32_t)pPSP;
}
}
BX LR, LR holds 0xFFFFFFFD (EXC_RETURN). This signals: “return to Thread mode, use PSP to restore the context frame.” The hardware pops SF1 (R0-R3, R12, LR, PC, xPSR) from PSP, effectively starting the task at PC = task_handler_address.
6. PendSV Handler — The Context Switch Engine
PendSV (Pendable Service Call) is the lowest-priority exception and is specifically designed for context switching. It is triggered by writing to ICSR bit[28]. SysTick pends PendSV every 1ms to cause a context switch.
/* ── PendSV_Handler: saves current task context, restores next ── */
__attribute__((naked)) void PendSV_Handler(void)
{
/* ════════════════ SAVE CURRENT TASK CONTEXT ════════════════
The hardware already pushed SF1 (R0-R3,R12,LR,PC,xPSR)
to the current task's PSP when PendSV was taken.
We now save SF2 (R4-R11) manually. */
/* 1. Read current task's PSP (hardware decremented it for SF1) */
__asm volatile("MRS R0, PSP");
/* 2. Push R4-R11 below current PSP (STMDB = Store Multiple Decrement Before)
R0! = R0 is updated: R0 decrements by 32 bytes (8 * 4) */
__asm volatile("STMDB R0!, {R4-R11}");
/* 3. Preserve LR (EXC_RETURN) across function calls */
__asm volatile("PUSH {LR}");
/* 4. Save updated PSP (R0) to TCB — C function call */
__asm volatile("BL save_psp_value");
/* ════════════════ RESTORE NEXT TASK CONTEXT ════════════════ */
/* 5. Select next task to run */
__asm volatile("BL update_next_task");
/* 6. Get next task's saved PSP from its TCB (returns in R0) */
__asm volatile("BL get_psp_value");
/* 7. Restore R4-R11 from next task's stack (LDMIA = Load Multiple Increment After)
R0! increments back to point to the remaining SF1 frame */
__asm volatile("LDMIA R0!, {R4-R11}");
/* 8. Set PSP to updated R0 (now points to SF1 for the next task) */
__asm volatile("MSR PSP, R0");
/* 9. Restore LR (EXC_RETURN = 0xFFFFFFFD) */
__asm volatile("POP {LR}");
/* 10. Return via EXC_RETURN: hardware pops SF1 from PSP,
jumps to next task's PC — context switch complete! */
__asm volatile("BX LR");
}
Context Switch Memory Layout
7. SysTick Handler and PendSV Triggering
/* ── ICSR: Interrupt Control and State Register (0xE000ED04) ──
Bit[28] = PENDSVSET: write 1 to pend PendSV exception */
void schedule(void)
{
uint32_t *pICSR = (uint32_t*)0xE000ED04;
*pICSR |= (1 << 28); /* Set PendSV pending — fires after SysTick exits */
}
void SysTick_Handler(void)
{
update_global_tick_count(); /* g_tick_count++ */
unblock_tasks(); /* check if any delayed task should unblock */
/* Pend PendSV — because PendSV has lower priority than SysTick,
it will fire AFTER SysTick_Handler exits.
This ensures context switch happens at a clean boundary. */
uint32_t *pICSR = (uint32_t*)0xE000ED04;
*pICSR |= (1 << 28);
}
8. task_delay — Blocking with PRIMASK Critical Section
/* ── Critical section macros using PRIMASK ──────────────────────
PRIMASK=1: blocks all interrupts except NMI and HardFault
PRIMASK=0: normal interrupt operation
Used to protect shared data during task state changes */
#define INTERRUPT_DISABLE() do{ \
__asm volatile("MOV R0, #0x1"); \
__asm volatile("MSR PRIMASK, R0"); \
} while(0)
#define INTERRUPT_ENABLE() do{ \
__asm volatile("MOV R0, #0x0"); \
__asm volatile("MSR PRIMASK, R0"); \
} while(0)
/* ── task_delay: block current task for N milliseconds ── */
void task_delay(uint32_t tick_count)
{
/* Disable interrupts — protect g_tick_count and task state */
INTERRUPT_DISABLE();
if (current_task != 0) /* idle task (index 0) cannot block */
{
/* Record when task should unblock */
user_tasks[current_task].block_count =
g_tick_count + tick_count;
/* Mark task as blocked */
user_tasks[current_task].current_state = TASK_BLOCKED_STATE;
/* Trigger PendSV immediately to switch to another task */
schedule();
}
INTERRUPT_ENABLE();
}
/* ── unblock_tasks: called from SysTick every 1ms ── */
void unblock_tasks(void)
{
for (int i = 1; i < MAX_TASKS; i++) /* skip idle task (i=0) */
{
if (user_tasks[i].current_state == TASK_BLOCKED_STATE)
{
if (user_tasks[i].block_count == g_tick_count)
{
user_tasks[i].current_state = TASK_READY_STATE;
}
}
}
}
============================================================
9. Round-Robin Scheduler Algorithm
/* ── update_next_task: select next READY task in round-robin ── */
void update_next_task(void)
{
int state = TASK_BLOCKED_STATE;
/* Scan MAX_TASKS slots starting from current_task+1 */
for (int i = 0; i < MAX_TASKS; i++)
{
current_task++;
current_task %= MAX_TASKS; /* wrap around (0→1→2→3→4→0→...) */
state = user_tasks[current_task].current_state;
/* If ready AND not idle task (index 0), select it */
if ((state == TASK_READY_STATE) && (current_task != 0))
break;
}
/* If no user task is ready, fall back to idle task */
if (state != TASK_READY_STATE)
current_task = 0;
}
/* ── PSP helpers: called from PendSV (assembly) ── */
uint32_t get_psp_value(void)
{
return user_tasks[current_task].psp_value;
}
void save_psp_value(uint32_t new_psp)
{
user_tasks[current_task].psp_value = new_psp;
}
============================================================
10. Startup Sequence
int main(void)
{
/* 1. Enable MemManage, BusFault, UsageFault handlers */
enable_processor_faults();
/* 2. Move MSP to SCHED_STACK_START for scheduler/exception use */
init_scheduler_stack(SCHED_STACK_START);
/* 3. Build fake exception frames for all tasks */
init_tasks_stack();
/* 4. Configure GPIO for LEDs */
led_init_all();
/* 5. Start SysTick at 1kHz */
init_systick_timer(TICK_HZ);
/* 6. Switch SP from MSP to PSP (Task1's initial PSP) */
switch_sp_to_psp();
/* 7. Launch task1 — from here, the scheduler takes over via SysTick+PendSV */
task1_handler();
for(;;);
}
/* ── Move MSP to scheduler stack region ── */
__attribute__((naked)) void init_scheduler_stack(uint32_t top)
{
__asm volatile("MSR MSP, %0" :: "r"(top));
__asm volatile("BX LR");
}
/* ── Switch Thread mode to use PSP ── */
__attribute__((naked)) void switch_sp_to_psp(void)
{
__asm volatile("PUSH {LR}");
__asm volatile("BL get_psp_value"); /* R0 = task1's initial PSP */
__asm volatile("MSR PSP, R0"); /* PSP = task1's stack top */
__asm volatile("POP {LR}");
__asm volatile("MOV R0, #0x02");
__asm volatile("MSR CONTROL, R0"); /* SPSEL=1: Thread uses PSP */
__asm volatile("BX LR");
}
============================================================
11. Interview Questions & Answers
Q1. What is a Task Control Block (TCB) and what information does it store?
Q2. Why is PendSV used for context switching instead of SysTick directly?
Q3. What is the difference between SF1 and SF2 in the context of context switching?
SF2 (Stack Frame 2): Software-saved frame — registers R4-R11 that are callee-saved per AAPCS. The PendSV handler must explicitly STMDB (save) and LDMIA (restore) them because the hardware doesn’t save them.
Q4. Explain STMDB and LDMIA instructions used in PendSV.
LDMIA R0!, {R4-R11}: Load Multiple Increment After. Loads R4 from R0, R5 from R0+4, …, R11 from R0+28. After each load, R0 is incremented. The “!” means R0 advances past the 8 loaded words, leaving R0 pointing to the start of SF1 for the hardware to pop via EXC_RETURN.
Q5. Why does the fake initial stack frame use DUMMY_XPSR = 0x01000000?
Q6. What role does PRIMASK play in task_delay?
current_state and block_count. This prevents a race condition where SysTick fires between reading g_tick_count and writing block_count, potentially causing the task to block with a stale tick value. PRIMASK is cleared after the state update is complete.Q7. What happens to the system when ALL tasks are blocked?
state != TASK_READY_STATE is true, so current_task = 0 (idle task) is selected. The idle task is an infinite loop (while(1);). This ensures the CPU always has something to do. In production, the idle task often calls WFI (Wait For Interrupt) to save power.Q8. Why is the scheduler stack separate from task stacks?
Q9. What is ICSR bit 28 (PENDSVSET) and how is it used here?
Q10. How does the very first context switch work when the scheduler starts?
2) switch_sp_to_psp() loads Task1’s initial PSP and sets CONTROL.SPSEL=1.
3) task1_handler() is called directly (not via PendSV) to start Task1 running.
4) SysTick fires after 1ms → pends PendSV → PendSV runs → saves Task1’s context → picks Task2 → loads Task2’s fake frame → BX 0xFFFFFFFD → hardware pops Task2’s SF1 → PC = task2_handler → Task2 starts running for the first time.
Q11. What is cooperative vs preemptive scheduling and which type is this?
Cooperative scheduling requires tasks to explicitly call a yield function to give up the CPU. It’s simpler but a misbehaving task that never yields can starve all others. Most RTOS implementations (FreeRTOS, Zephyr) use preemptive scheduling, optionally with cooperative yielding.
Q12. How many bytes does each context switch save/restore?
SF2 (software, PendSV): 8 registers (R4-R11) × 4 bytes = 32 bytes
Total: 64 bytes saved per task per context switch.
With 5 tasks switching at 1kHz: 5 × 64 = 320 bytes of stack traffic per second (negligible). The cycle overhead is approximately: 12 cycles (PendSV entry) + ~20 cycles (STMDB/LDMIA) + ~20 cycles (C function calls) + 12 cycles (exit) = ~64 cycles per switch = 4µs at 16MHz.
Q13. What would happen if task_delay is called from the idle task?
if(current_task != 0) before blocking. This prevents the idle task from ever calling task_delay and blocking itself. If the idle task blocked, and all user tasks were also blocked, there would be no runnable task — the scheduler would have nothing to do and the system would deadlock. The idle task must always remain READY.Q14. How would you add priority levels to this round-robin scheduler?
priority field to TCB_t.2) In update_next_task(), instead of simple round-robin, find the highest-priority READY task.
3) For equal-priority tasks, use round-robin among them.
4) Consider a multi-level ready queue: one FIFO per priority level.
5) FreeRTOS implements this: it uses a “ready list” per priority (uxTaskPriority field in TCB), and always picks the highest non-empty priority’s head task. Context switches still happen via PendSV with the same STMDB/LDMIA mechanism.
STM32F407 Deep Dive Series — Example 15: Bare-Metal Round-Robin Task Scheduler
ARM Cortex-M4 TRM · ARMv7-M Architecture Reference Manual · FreeRTOS Internals
Need Help or More Information?
Questions about this free C programming course, embedded C training or career guidance? Get in touch.

2 Comments