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.
- Overview
- Key Features
- How It Works
- Memory Layout
- Project Structure
- Demo Application
- Hardware Requirements
- Software Requirements
- Getting Started
- Configuration
- Design Notes & Limitations
- Roadmap
- Author
- License
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
SysTickexception fires at a fixed rate (default 1 kHz) and drives time-based unblocking of delayed tasks. - The Cortex-M
PendSVexception — 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.
- True preemption — a running task can be interrupted at any tick boundary, not just at explicit yield points.
- Round-robin scheduling among
READYtasks, 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 isolation —
HardFault,MemManage, andBusFaultexceptions are explicitly enabled and handled. - Atomic critical sections via
PRIMASKmanipulation 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.
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.
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.
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.
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.
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.
┌─────────────┐ 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.
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 forReset_Handler,main()initialization, and all exception handling (SysTick,PendSV, faults). - Every task stack (
PSP) is private to its task and is switched atomically insidePendSV_Handler.
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
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.
- 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.cminusled.c) has no STM32-specific dependency beyond theSysTick/PendSV/SHCRSregister 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.
- STM32CubeIDE (project is pre-configured as an STM32CubeIDE / Eclipse CDT project), or
- A standalone
arm-none-eabi-gcctoolchain +make/openocdif you prefer building outside the IDE. openocdor ST-Link Utility / STM32CubeProgrammer for flashing.
- Clone the repository:
git clone https://github.com/ShayanSaed/ARM-Cortex-M-Preemptive-Scheduler.git
- Open STM32CubeIDE →
File → Import → Existing Projects into Workspace→ select the cloned folder. - Build the project (
Project → Build All). - Connect your board via ST-Link and click Debug or Run to flash it.
- Watch the four LEDs blink at independent, overlapping rates — clear visual proof of preemptive scheduling on a single core.
# 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"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.
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
WFIsleep in the idle loop. - Single translation unit for the kernel —
main.cintentionally keeps scheduler + demo tasks together for readability; a production fork would split these.
- 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)
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.
This project is licensed under the MIT License — feel free to use these exercises as a learning reference.