Bare-Metal Round-Robin Task Scheduler-Embedded C Course Online

Need Help or More Information?

Questions about this free C programming course, embedded C training or career guidance? Get in touch.

Example 15 — STM32F407 Discovery

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.

SRAM layout with per-task stacks
SRAM Layout (128 KB from 0x20000000 to 0x20020000):
0x20020000
← SRAM_END = T1_STACK_START (top)
T1 stack (1KB)
0x2001FC00
← T2_STACK_START
T2 stack (1KB)
0x2001F800
← T3_STACK_START
T3 stack (1KB)
0x2001F400
← T4_STACK_START
T4 stack (1KB)
0x2001F000
← IDLE_STACK_START
IDLE stack (1KB)
0x2001EC00
← SCHED_STACK_START (MSP = scheduler/exception stack)
Scheduler stack (1KB)
0x2001E800
and below: global variables, heap, etc.
============================================================

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

RegisterAddressNameFunction
SYST_CSR0xE000E010Control & Statusbit[0]=ENABLE, bit[1]=TICKINT (exception), bit[2]=CLKSOURCE (1=processor clock)
SYST_RVR0xE000E014Reload Value24-bit reload value. Counter counts from this down to 0, then reloads.
SYST_CVR0xE000E018Current ValueCurrent count. Write any value to clear it and clear COUNTFLAG.
SYST_CALIB0xE000E01CCalibrationTENMS 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)

0x20020000-4xPSR0x01000000 (T=1)
0x20020000-8PCtask1_handler addr
0x20020000-CLR0xFFFFFFFD
0x20020000-10R120x00000000
0x20020000-14R30x00000000
0x20020000-18R20x00000000
0x20020000-1CR10x00000000
0x20020000-20R00x00000000
0x20020000-24R110x00000000
……R10-R4 = 0
PSP →R40x00000000 ← psp_value stored here
/* ── 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;
    }
}
Why LR = 0xFFFFFFFD? When PendSV restores a task and executes 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

Task stack before and after a context switch
Task stack BEFORE switch (task1 interrupted by SysTick → PendSV):
old_PSP:
xPSRPC(task1)LRR12R3R2R1R0
← SF1 (hardware pushed)
new_PSP:
R11R10R9R8R7R6R5R4
← SF2 (PendSV pushes)
↑ TCB.psp_value saved here
Task stack AFTER switch (task2 restored from previous PendSV):
Saved PSP (from TCB):
R11R10…R4xPSRPC(task2)LRR12R3R2R1R0
PendSV LDMIA restores R4-R11 → new PSP points to SF1
BX 0xFFFFFFFD → hardware pops SF1 → PC = task2’s saved PC → task2 resumes!
============================================================

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);
}
Why PendSV must have the lowest priority: If PendSV had higher priority than SysTick, it could interrupt the tick handler mid-execution. By making PendSV the lowest priority exception, it always runs after all other exception handlers complete. This prevents half-updated scheduler state from causing incorrect task switches.
============================================================

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?
A TCB (Task Control Block) is a data structure that holds all the private, per-task information the scheduler needs. At minimum: (1) PSP value — the saved stack pointer when the task last yielded, (2) task state — READY, BLOCKED, SUSPENDED, (3) priority level, (4) block count — tick when the task should unblock, (5) task handler — function pointer to the task code. More complex RTOSes add: task name, stack size, event flags, message queues, statistics counters.
Q2. Why is PendSV used for context switching instead of SysTick directly?
SysTick has a higher priority than PendSV. If we did context switching inside SysTick, we’d be saving/restoring task registers while still inside the SysTick exception — messy and potentially dangerous. By pending PendSV from SysTick, the context switch happens after SysTick exits, at a clean exception entry/exit boundary with no other exceptions pending. PendSV’s lowest priority also ensures it runs after all other ISRs complete (never interrupts an ISR).
Q3. What is the difference between SF1 and SF2 in the context of context switching?
SF1 (Stack Frame 1): Hardware-saved frame — the 8 registers (R0-R3, R12, LR, PC, xPSR) pushed automatically by the processor on any exception entry. Restored automatically on EXC_RETURN.
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.
STMDB R0!, {R4-R11}: Store Multiple Decrement Before. Before storing each register, R0 is decremented by 4. The “!” means R0 is updated (writeback). Result: R4..R11 are stored at R0-4..R0-32, and R0 points to R4’s location (lowest address = top of the additional context).
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?
The T-bit (bit 24) of xPSR must be 1 for Cortex-M4 to execute in Thumb mode. Setting DUMMY_XPSR = 0x01000000 sets only bit 24 (T=1) with all other bits 0. If T=0 when the first task starts, the processor would trigger a UsageFault (INVSTATE) immediately. The LR=0xFFFFFFFD and DUMMY_XPSR together ensure the first context restore starts the task cleanly in Thread/Thumb mode with PSP.
Q6. What role does PRIMASK play in task_delay?
PRIMASK is a 1-bit register that, when set to 1, masks all configurable interrupts (prevents SysTick and PendSV from firing). In task_delay, PRIMASK is set before modifying 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?
The update_next_task() function scans all task slots. If no user task (index 1-4) is READY, the loop completes without breaking, and 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?
The scheduler stack (mapped to MSP) is used for exception handlers — SysTick, PendSV, fault handlers. Keeping it separate from task PSP stacks provides isolation: a stack overflow in a task won’t corrupt the scheduler’s execution context. In a production RTOS with MPU, the scheduler stack is in a protected memory region. MSP remains valid even if all PSP task stacks are corrupted.
Q9. What is ICSR bit 28 (PENDSVSET) and how is it used here?
The ICSR (Interrupt Control and State Register) at 0xE000ED04 contains PENDSVSET at bit 28. Writing 1 to this bit causes PendSV to become pending. The processor will execute PendSV_Handler as soon as all higher-priority exceptions finish. In this scheduler, both SysTick_Handler and task_delay() write to PENDSVSET to trigger a context switch. The complementary bit[27] = PENDSVCLR can clear the pending state.
Q10. How does the very first context switch work when the scheduler starts?
1) init_tasks_stack() builds fake frames (SF1+SF2) for all tasks on their respective PSP stacks.
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?
This is preemptive scheduling — tasks are interrupted at regular intervals (every 1ms via SysTick) and the scheduler selects the next task regardless of what the current task is doing. Tasks don’t need to voluntarily yield.
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?
SF1 (hardware): 8 registers × 4 bytes = 32 bytes
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?
The code checks 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?
1) Add a 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

Leave a Reply

Your email address will not be published. Required fields are marked *