RTOS · 21 pages
- RTOS tutorial — 10 lessonstutorial
- Lesson 1 — Why an RTOS
- Lesson 2 — Hello FreeRTOS
- Lesson 3 — Tasks
- Lesson 4 — Semaphores
- Lesson 5 — Mutex & priority inh.
- Lesson 6 — Queues
- Lesson 7 — Events & notifications
- Lesson 8 — Streams & timers
- Lesson 9 — Memory management
- Lesson 10 — Tickless idle + debug
- RTOS landscape
- Scheduling theory
- Priority inversion & inheritance
- IPC & synchronization primitives
- Tickless idle
- FreeRTOS
- Zephyr
- ThreadX (Azure RTOS)
- RT-Thread
- Safety-certified RTOS
FreeRTOS
The pragmatic RTOS — task creation, scheduling, the Cortex-M port internals, queues, semaphores, software timers, and tickless idle.
FreeRTOS ships on more Cortex-M designs than any other RTOS — by orders of magnitude. It's MIT-licensed, ~6 KB minimum, has a port for every Cortex-M variant in existence, and its API has been essentially stable since 2010. You learn it once.
The basic structure
#include "FreeRTOS.h"
#include "task.h"
void blink_task(void *arg) {
(void)arg;
while (1) {
toggle_led();
vTaskDelay(pdMS_TO_TICKS(500));
}
}
int main(void) {
SystemClock_Config();
gpio_init();
xTaskCreate(blink_task, "blink", 128, NULL, 1, NULL);
vTaskStartScheduler();
while (1) {} // should never reach here
}Three things to notice:
- Tasks take a
void *arg — typically you cast a struct pointer to it, or passNULL. - Stack size is in words, not bytes.
128here means 128 × 4 = 512 bytes of stack. - Priority is unsigned.
0is lowest.configMAX_PRIORITIES - 1is highest.
After vTaskStartScheduler(), FreeRTOS is in charge. Your main becomes irrelevant.
The Cortex-M port internals
The FreeRTOS Cortex-M port uses three exceptions:
- SysTick at lowest priority. Drives the tick counter; if a task should preempt, it pends PendSV.
- PendSV at lowest priority. Does the actual context switch (saves r4–r11 + LR onto the current task's stack, picks the next task, loads its registers).
- SVC at high priority. Only used to start the first task (escape from
vTaskStartSchedulerinto task context).
Lowest priority for the schedulers because they shouldn't preempt user ISRs. Your high-priority hardware ISR runs to completion; then PendSV runs if a higher-priority task became ready.
configMAX_SYSCALL_INTERRUPT_PRIORITY
A FreeRTOS configuration constant — the priority threshold for ISRs that can call FreeRTOS APIs. ISRs with priority higher than this (i.e. lower numerical value) cannot call FreeRTOS — they're invisible to the kernel. Useful for ultra-low-latency hardware ISRs that can't tolerate scheduler interference.
// Typical STM32 with 4 priority bits implemented
#define configKERNEL_INTERRUPT_PRIORITY (15 << 4) // PendSV/SysTick at lowest
#define configMAX_SYSCALL_INTERRUPT_PRIORITY (5 << 4) // anything priority < 5 is invisibleThis is the config knob you'll get wrong on first use. Document it for your team.
Queues — the main IPC primitive
QueueHandle_t q = xQueueCreate(10, sizeof(struct event));
// Producer
struct event ev = { .type = EV_BUTTON };
xQueueSend(q, &ev, portMAX_DELAY);
// Consumer
struct event ev;
xQueueReceive(q, &ev, portMAX_DELAY);Queues are copy-by-value. The 10 × sizeof(struct event) of storage is allocated inside xQueueCreate. The sender's ev is memcpy'd into the queue; the receiver's ev is memcpy'd out.
This is the recommended pattern. Don't pass pointers between tasks unless you have a clear lifetime story — copies sidestep most synchronisation bugs.
Notifications — the lightweight signal
Each task has a 32-bit "notification value". You can set it from anywhere, and a task can wait on it.
// In producer
xTaskNotify(consumer_task, 0x01, eSetBits);
// In consumer
uint32_t flags;
xTaskNotifyWait(0, 0xFFFFFFFF, &flags, portMAX_DELAY);
if (flags & 0x01) { /* handle */ }Faster than a queue (no copy, no separate data structure) and uses less RAM (the notification value is part of the TCB). Great for signalling "something happened" without payload.
Software timers
TimerHandle_t t = xTimerCreate("blink", pdMS_TO_TICKS(500),
pdTRUE, // auto-reload
NULL, // ID
timer_cb);
xTimerStart(t, 0);
void timer_cb(TimerHandle_t t) {
toggle_led();
}The callback runs in the timer daemon task, not in an ISR. Don't do long work in it.
A common confusion: software timers are not hardware-accurate. They fire on tick boundaries, with jitter up to one tick. For sub-millisecond timing, use a hardware timer and an ISR.
Tickless idle
When all tasks are blocked, the idle task runs. If you enable tickless idle (configUSE_TICKLESS_IDLE = 1), the idle task computes the time until the next scheduled wake-up, programs SysTick (or a low-power timer) to fire then, and issues WFI — the chip sleeps until the wake-up.
// Define in your config — the maximum expected idle period
#define configEXPECTED_IDLE_TIME_BEFORE_SLEEP 20 // ticksIf your WFI takes the chip into a deep sleep mode that loses peripheral state, you can override portSUPPRESS_TICKS_AND_SLEEP() to do whatever preparation/recovery is needed (saving peripheral state, switching clock sources, etc.).
This is the standard low-power pattern. Without tickless idle, the SysTick fires every millisecond regardless of whether anything is happening; with it, your chip stays asleep until something needs to happen.
Stack overflow detection
#define configCHECK_FOR_STACK_OVERFLOW 2
void vApplicationStackOverflowHook(TaskHandle_t t, char *name) {
// Halt or log — overflow was detected
__BKPT(0);
}Method 1 checks the SP against the task's stack bounds on context switch. Method 2 additionally fills the stack with a known pattern (0xA5A5A5A5) and checks the top words on each switch. Method 2 catches more overflows for a small CPU cost.
In production, definitely enable method 2 during development. Whether you keep it on in release is a project decision.
Static vs dynamic allocation
By default, FreeRTOS uses its own heap (pvPortMalloc). For projects that ban malloc, every API has a *Static variant that takes pre-allocated storage:
StaticTask_t blink_tcb;
StackType_t blink_stack[128];
xTaskCreateStatic(blink_task, "blink", 128, NULL, 1, blink_stack, &blink_tcb);Set configSUPPORT_DYNAMIC_ALLOCATION = 0 and configSUPPORT_STATIC_ALLOCATION = 1 to enforce static-only at compile time.
The five heap implementations
FreeRTOS ships five pvPortMalloc implementations:
- heap_1 — no free. Allocate-only. Used when you allocate all your kernel objects at boot and never free anything.
- heap_2 — best-fit, no coalescence. Predictable but fragments.
- heap_3 — wraps the C library's
malloc/free. Use this if you want a single heap with newlib. - heap_4 — first-fit with coalescence. The default for most projects. Reasonable fragmentation behaviour.
- heap_5 — heap_4 across multiple non-contiguous memory regions. Use when you want to expose two SRAM banks as one heap.
See also
- The RTOS landscape — comparison with Zephyr, ThreadX.
- NVIC — the priority story underneath the scheduler.
- SysTick — the tick source.