Embedded RTOS: Multi-Tasking on a Microcontroller
Why an RTOS?
Bare-metal super-loops work — until they don't. The moment you have a UART that needs servicing every 1 ms, a button that must respond instantly, a sensor sampled at 100 Hz and a Wi-Fi stack, you need true concurrency. That is what an RTOS gives you.
RTOS = Real-Time Operating System. Not "fast" — predictable. A task with a deadline always runs by its deadline.
Core concepts
- Task — an independent thread of execution with its own stack and priority.
- Scheduler — picks which task runs next, based on priority and state.
- Tick — periodic timer (typically 1 ms) that drives time-keeping.
- Queue — thread-safe FIFO for passing data between tasks.
- Semaphore — signalling primitive (binary or counting).
- Mutex — exclusive lock with priority inheritance.
- Software timer — schedule a callback to run later.
The scheduler in 30 seconds
FreeRTOS, Zephyr, ThreadX and friends use preemptive priority-based scheduling:
- Every task is in one of: Running, Ready, Blocked, Suspended.
- The highest-priority Ready task always runs.
- If two tasks share a priority, they round-robin on each tick.
- A task blocks when waiting for a queue, semaphore, mutex or delay.
Tasks in FreeRTOS
A FreeRTOS task is just an infinite-loop function with its own stack:
#include "FreeRTOS.h"
#include "task.h"
static void blink_task(void *arg) {
(void)arg;
for (;;) {
gpio_toggle(LED_PIN);
vTaskDelay(pdMS_TO_TICKS(500));
}
}
static void sensor_task(void *arg) {
(void)arg;
for (;;) {
int16_t t = read_temperature();
publish(t);
vTaskDelay(pdMS_TO_TICKS(100));
}
}
int main(void) {
board_init();
xTaskCreate(blink_task, "blink", 256, NULL, 1, NULL);
xTaskCreate(sensor_task, "sensor", 512, NULL, 3, NULL);
vTaskStartScheduler(); // never returns
for (;;) { }
}
Synchronization — queues, semaphores, mutexes
Queue
QueueHandle_t q = xQueueCreate(8, sizeof(int16_t));
/* producer */
int16_t reading = read_adc();
xQueueSend(q, &reading, portMAX_DELAY);
/* consumer */
int16_t v;
if (xQueueReceive(q, &v, pdMS_TO_TICKS(100))) {
process(v);
}
Mutex
SemaphoreHandle_t spi_lock = xSemaphoreCreateMutex();
void log_to_flash(const char *line) {
if (xSemaphoreTake(spi_lock, pdMS_TO_TICKS(50))) {
spi_write(line);
xSemaphoreGive(spi_lock);
}
}
ISRs & the RTOS — the golden rule
Inside an interrupt service routine, you may only call the ...FromISR() versions of RTOS APIs:
void EXTI0_IRQHandler(void) {
BaseType_t higher_prio_woken = pdFALSE;
xSemaphoreGiveFromISR(button_sem, &higher_prio_woken);
portYIELD_FROM_ISR(higher_prio_woken);
EXTI->PR = (1U << 0); // clear flag
}
Never block inside an ISR. No vTaskDelay, no xQueueSend, no printf. Signal a task and let it do the work.
Pitfalls & tips
- Stack sizing — start with
configCHECK_FOR_STACK_OVERFLOW = 2, then tune with high-water-mark APIs. - Priority inversion — always use a mutex (with priority inheritance), not a binary semaphore, for shared resources.
- Tickless idle — enable it to slash power consumption when idle.
- Task storms — don't spawn one task per feature; consolidate where you can.
- Determinism > throughput — measure worst-case, not average.
An RTOS does not make your firmware "fancier" — it makes it predictable. That is what shipping products is built on.