Skip to content

Repository files navigation

ARM Cortex-M Preemptive Scheduler

A bare-metal, tick-based preemptive task scheduler for ARM Cortex-M microcontrollers, built entirely from register-level code — no RTOS, no HAL scheduler, no external dependencies. It implements cooperative task blocking, round-robin preemption, and full context switching using only SysTick, PendSV, and the Cortex-M exception model.

Developed and validated on an STM32F411CEUx (Cortex-M4, 512 KB Flash / 128 KB RAM), where four independent tasks blink LEDs at different rates to visually demonstrate concurrent, preemptively-scheduled execution on a single core.

This project was built as a low-level systems exercise: to understand — and reproduce from scratch — the mechanisms that RTOS kernels (FreeRTOS, ThreadX, Zephyr, etc.) use internally to achieve multitasking on a single-core MCU.


Table of Contents


Overview

Most embedded engineers use an RTOS without ever seeing what happens between "task A is running" and "task B is running." This project strips that away and implements the mechanism directly on the metal:

  • Each task gets its own stack and its own saved CPU register context.
  • The Cortex-M SysTick exception fires at a fixed rate (default 1 kHz) and drives time-based unblocking of delayed tasks.
  • The Cortex-M PendSV exception — the same low-priority, "do the context switch here" exception used by virtually every Cortex-M RTOS — performs the actual register save/restore.
  • The Process Stack Pointer (PSP) is used for all task code, while the Main Stack Pointer (MSP) is reserved for exception/kernel-level code, matching the standard Cortex-M dual-stack model.

The result is a minimal, readable reference implementation of preemptive multitasking — small enough to read end-to-end in one sitting, but built on the exact same primitives production RTOS kernels use.

Key Features

  • True preemption — a running task can be interrupted at any tick boundary, not just at explicit yield points.
  • Round-robin scheduling among READY tasks, with an idle task fallback when everything is blocked.
  • Tick-based delay API (taskDelay) for cooperative, timed blocking (vTaskDelay-style).
  • Full CPU context save/restore (R0–R12, LR, PC, xPSR) across task switches, using the Cortex-M automatic + manual stacking split.
  • Statically allocated task stacks — deterministic memory layout, no heap, no dynamic allocation.
  • Fault isolationHardFault, MemManage, and BusFault exceptions are explicitly enabled and handled.
  • Atomic critical sections via PRIMASK manipulation around shared scheduler state.
  • Zero RTOS / zero HAL dependency — pure CMSIS-level register access, easy to port to any Cortex-M0/M3/M4/M7 part.

How It Works

Task Control Block

Each task is represented by a minimal TCB:

typedef struct {
    uint32_t pspValue;             // Saved Process Stack Pointer for this task
    uint32_t blockCount;           // Tick count at which a blocked task becomes ready
    uint8_t  currentState;         // TASK_READY_STATE | TASK_BLOCKED_STATE
    void     (*taskHandler)(void); // Task entry point
} TCB_t;

userTasks[0] is always the idle task; userTasks[1..MAX_TASKS-1] are application tasks.

Stack Frame Initialization

Before the scheduler ever runs, every task's stack is pre-loaded with a fake exception stack frame, so that the very first context switch into that task looks — from the processor's point of view — exactly like returning from an interrupt:

Offset Register Value
top xPSR 0x01000000 (Thumb bit set)
PC task's entry function
LR 0xFFFFFFFD (EXC_RETURN → Thread mode, use PSP)
R12, R3–R0 0

This is the same trick used by FreeRTOS's pxPortInitialiseStack — it lets PendSV's exception-return mechanism bootstrap a task that has never actually run before.

The SysTick Tick

SysTick is configured for a 1 kHz tick (1 ms resolution) from the HSI 16 MHz clock:

SysTick_Handler:
    updateGlobalTickCount();   // global time base
    unblockTasks();            // wake any task whose delay has expired
    schedule();                // request a context switch (pend PendSV)

Note that SysTick_Handler never performs the switch itself — it only decides that a switch should happen and lets PendSV do the actual work, keeping the time-critical tick handler short and the register-shuffling code at the lowest hardware priority.

Context Switch via PendSV

PendSV_Handler is a naked function — hand-written assembly with no compiler-generated prologue/epilogue — because it must directly manipulate the exception return sequence:

1. MRS   R0, PSP                 get the outgoing task's stack pointer
2. STMDB R0!, {R4-R11}           manually push the "software" registers
3. BL    savePSPValue            TCB[current].pspValue = R0
4. BL    updateCurrentTask       round-robin -> pick next READY task
5. BL    getPSPValue             R0 = TCB[next].pspValue
6. LDMIA R0!, {R4-R11}           restore the incoming task's registers
7. MSR   PSP, R0                 point PSP at the incoming task's stack
8. BX    LR                      exception return — hardware restores R0-R3, R12, LR, PC, xPSR automatically

The Cortex-M hardware already stacks/unstacks R0–R3, R12, LR, PC and xPSR automatically on exception entry/exit — PendSV_Handler only has to manually handle R4–R11, which the hardware does not touch. This split (hardware frame + software frame) is exactly how the ARM AAPCS and the Cortex-M exception model are designed to cooperate for context switching.

Scheduling Policy

updateCurrentTask() implements simple round-robin scheduling over application tasks:

void updateCurrentTask(void){
	uint32_t flagState = TASK_BLOCKED_STATE;

	for(uint32_t i = 0; i < MAX_TASKS; i++){
		currentTask++;
		currentTask %= MAX_TASKS;

		flagState = userTasks[currentTask].currentState;

		if((flagState == TASK_READY_STATE) && (currentTask != 0))
			break;
	}

	if(flagState == TASK_BLOCKED_STATE)
		currentTask = 0;
}

This is intentionally simple — no priorities, no time-slicing beyond one tick — to keep the core mechanism (context switching) the focus of the project rather than the scheduling algorithm itself.

Task State Machine

        ┌─────────────┐            taskDelay()           ┌──────────────┐
        │             │ ───────────────────────────────► │              │
        │    READY    │                                  │    BLOCKED   │
        │             │ ◄─────────────────────────────── │              │
        └─────────────┘  blockCount ==  globalTickCount  └──────────────┘
               │         
               │  round-robin pick
               │
               ▼
        ┌─────────────┐
        │   RUNNING   │  (implicit: currentTask == this task)
        └─────────────┘

A BLOCKED task is moved back to READY in unblockTasks(), called from SysTick_Handler, the instant globalTickCount reaches its stored blockCount.

Memory Layout

All stacks are statically carved out of the top of SRAM, each 1 KB, descending from SRAM_END (0x20020000 on the STM32F411, which has 128 KB of RAM):

0x20020000  ┌────────────────────┐  ← SRAM_END
            │   Task 1 Stack     │  1 KB
0x20019C00  ├────────────────────┤
            │   Task 2 Stack     │  1 KB
0x20019800  ├────────────────────┤
            │   Task 3 Stack     │  1 KB
0x20019400  ├────────────────────┤
            │   Task 4 Stack     │  1 KB
0x20019000  ├────────────────────┤
            │   Idle Task Stack  │  1 KB
0x20018C00  ├────────────────────┤
            │  Scheduler (MSP)   │  1 KB
0x20018800  └────────────────────┘
            │  ... rest of SRAM  │
0x20000000  └────────────────────┘  ← SRAM_START
  • The scheduler stack (MSP) is used for Reset_Handler, main() initialization, and all exception handling (SysTick, PendSV, faults).
  • Every task stack (PSP) is private to its task and is switched atomically inside PendSV_Handler.

Project Structure

ARM-Cortex-M-Preemptive-Scheduler/
├── Inc/
│   ├── main.h        			  # Scheduler config: stack layout, tick rate, register addresses
│   └── led.h          			  # GPIO/LED pin definitions
├── Src/
│   ├── main.c          		  # Scheduler core: TCBs, SysTick/PendSV handlers, tasks
│   ├── led.c            		  # Minimal GPIOB LED driver (bare register access)
│   ├── syscalls.c       		  # Newlib syscall stubs (for semihosting/printf)
│   └── sysmem.c        		  # Newlib heap stubs (_sbrk)
├── Startup/
│   └── startup_stm32f411ceux.s   # Reset handler & vector table
├── STM32F411CEUX_FLASH.ld        # Linker script (Flash execution)
├── STM32F411CEUX_RAM.ld          # Linker script (RAM execution)
└── .project / .cproject          # STM32CubeIDE project files

Demo Application

Five tasks run concurrently on the same core: one idle task and four LED-blinking tasks on GPIOB, each with a different period, so the preemption is visible with nothing more than the naked eye:

Task LED Pin On/Off Period
idleTask runs only when all other tasks are blocked
task1_handler 🟢 Green PB0 1000 ms
task2_handler 🟠 Orange PB1 500 ms
task3_handler 🔴 Red PB2 250 ms
task4_handler 🔵 Blue PB10 125 ms

Each task simply toggles its LED and calls taskDelay(ms), which blocks the task and immediately triggers a reschedule — a textbook demonstration of tick-driven, time-sliced multitasking.

Hardware Requirements

  • An STM32F411CEUx-based board (e.g. a "Black Pill" style development board), running on the internal HSI 16 MHz clock.
  • 4 LEDs (or onboard equivalents) wired to GPIOB: PB0, PB1, PB2, PB10.
  • An ST-Link (or compatible SWD probe) for flashing and debugging.

The scheduler core itself (main.c minus led.c) has no STM32-specific dependency beyond the SysTick/PendSV/SHCRS register addresses, which are identical across all Cortex-M3/M4/M7 parts — porting to another Cortex-M MCU mainly means replacing the LED driver and the linker script.

Software Requirements

  • STM32CubeIDE (project is pre-configured as an STM32CubeIDE / Eclipse CDT project), or
  • A standalone arm-none-eabi-gcc toolchain + make/openocd if you prefer building outside the IDE.
  • openocd or ST-Link Utility / STM32CubeProgrammer for flashing.

Getting Started

Option A — STM32CubeIDE (recommended)

  1. Clone the repository:
    git clone https://github.com/ShayanSaed/ARM-Cortex-M-Preemptive-Scheduler.git
  2. Open STM32CubeIDEFile → Import → Existing Projects into Workspace → select the cloned folder.
  3. Build the project (Project → Build All).
  4. Connect your board via ST-Link and click Debug or Run to flash it.
  5. Watch the four LEDs blink at independent, overlapping rates — clear visual proof of preemptive scheduling on a single core.

Option B — Command line (arm-none-eabi-gcc + OpenOCD)

# Build
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -O0 -g \
  -TSTM32F411CEUX_FLASH.ld \
  Src/*.c Startup/startup_stm32f411ceux.s \
  -Iinc -o scheduler.elf

# Flash (adjust interface/target config for your probe)
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg \
  -c "program scheduler.elf verify reset exit"

Configuration

Everything about the scheduler's shape is a compile-time constant in Inc/main.h:

Macro Meaning Default
MAX_TASKS Idle task + application tasks 5
TASK_STACK_SIZE Bytes reserved per task stack 1024
SCHEDULER_STACK_SIZE Bytes reserved for the MSP/kernel stack 1024
TICK_HZ SysTick frequency (scheduler resolution) 1000 (1 ms)
SYSTICK_TIM_CLK Clock source feeding SysTick HSI_CLK (16 MHz)

To add a new task: increment MAX_TASKS, add its stack-start macro, register its handler in initTasks(), and write the handler function.

Design Notes & Limitations

This project prioritizes clarity of the core mechanism over feature completeness. Known limitations, by design:

  • No priority scheduling — all application tasks are equal-priority, round-robin only.
  • Fixed, compile-time task set — no dynamic task creation/deletion.
  • No inter-task synchronization primitives — no mutexes, semaphores, or queues (yet).
  • No stack-overflow detection — stacks are fixed-size and unchecked; a runaway task can corrupt an adjacent task's stack.
  • Busy-wait idle task — no low-power WFI sleep in the idle loop.
  • Single translation unit for the kernelmain.c intentionally keeps scheduler + demo tasks together for readability; a production fork would split these.

Roadmap

  • Priority-based (or priority + round-robin hybrid) scheduling
  • Binary/counting semaphores and mutexes with priority inheritance
  • WFI-based low-power idle task
  • Stack usage watermarking / overflow guard (canary values)
  • Port to an additional Cortex-M target (e.g. STM32F1 / nRF52) to validate portability
  • Unit tests for scheduler logic on a host build (mocked registers)

Author

Shayan Saed GitHub: @ShayanSaed

This repository documents a from-scratch implementation of ARM Cortex-M exception handling and preemptive scheduling, built as a hands-on exploration of embedded systems and RTOS internals. Feedback, issues, and suggestions are welcome.


License

This project is licensed under the MIT License — feel free to use these exercises as a learning reference.

About

A bare-metal preemptive task scheduler for ARM Cortex-M microcontrollers, built from scratch using SysTick and PendSV for context switching — no RTOS dependency. Developed and tested on the STM32F411CEUX.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Contributors

Languages