RTOS Concepts: Tasks, Scheduling, Queues and Priority Inversion
A bare-metal super-loop with state machines and interrupts can run surprisingly complex products. It starts to strain when several activities have very different timing needs: a motor loop every millisecond, a network stack that blocks unpredictably, a display that redraws slowly. A real-time operating system (RTOS) lets you write each activity as an independent task and lets a scheduler decide which one runs, based on priorities you assign.
This article explains tasks and their states, how a preemptive priority scheduler works, how tasks communicate safely, and the classic trap called priority inversion. Examples use FreeRTOS, a widely used open-source RTOS, but the concepts apply to Zephyr, ThreadX, RTX and others.
Tasks and Task States
A task (or thread) is a C function with its own stack and a priority. It normally contains an infinite loop and never returns. The RTOS keeps a control block for each task holding its saved registers, stack pointer, priority and state.
| State | Meaning |
|---|---|
| Running | Currently executing on the CPU (one task per core) |
| Ready | Able to run, waiting for the CPU |
| Blocked | Waiting for an event: a delay to expire, a queue item, a semaphore, with an optional timeout |
| Suspended | Removed from scheduling until explicitly resumed |
The key to RTOS efficiency is the Blocked state. A task waiting for data uses no CPU time at all. If every task is blocked, the scheduler runs the idle task, which is the natural place to enter a low-power sleep mode.
How Preemptive Priority Scheduling Works
Most embedded RTOSes use fixed-priority preemptive scheduling: the highest-priority Ready task always runs. If an interrupt or another task makes a higher-priority task Ready, the scheduler immediately switches to it, which is called preemption. Tasks of equal priority can share the CPU in turns on each tick, called round-robin time slicing.
A context switch saves the registers of the current task on its stack, selects the next task and restores its registers. On ARM Cortex-M, RTOS ports typically use the SysTick timer for the periodic tick and the PendSV exception at the lowest priority to perform the switch, so it never delays an application interrupt.
How do you choose priorities? A well-known rule from rate-monotonic analysis is to give higher priority to tasks with shorter periods. For n independent periodic tasks, Liu and Layland proved that all deadlines are met under this assignment if total CPU utilisation is below a bound:
U = sum(Ci / Ti) <= n x (2^(1/n) - 1) Ci = worst-case execution time of task i Ti = period of task i n = 1 : 1.000 n = 2 : 0.828 n = 3 : 0.780 as n grows the bound approaches ln 2 = 0.693 Example: tasks of 1 ms every 10 ms, 5 ms every 50 ms, 20 ms every 200 ms U = 0.10 + 0.10 + 0.10 = 0.30 <= 0.780 -> schedulable
The test is sufficient, not necessary: a task set above the bound may still work, but needs exact response-time analysis to prove it.
Communicating Between Tasks
Tasks must never share global variables casually, because preemption can occur between any two instructions. RTOS objects handle the synchronisation for you.
| Object | Use for | Note |
|---|---|---|
| Queue | Passing data by copy from producer to consumer | The receiver blocks until data arrives |
| Binary semaphore | Signalling an event, for example from an ISR to a task | No owner, no priority inheritance |
| Counting semaphore | Counting events or available resources | Count goes up on give and down on take |
| Mutex | Protecting a shared resource such as an SPI bus | Has an owner; FreeRTOS mutexes use priority inheritance |
| Event group / task notification | Waiting for one or more flags | Lightweight options in FreeRTOS |
The example below is FreeRTOS-specific and needs a FreeRTOS port for your MCU. A sensor task produces readings every 100 ms and a lower-priority logger task consumes them through a queue.
#include <stdint.h>
#include <stdio.h>
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
typedef struct {
uint32_t timestamp;
int16_t temperature_x10; /* 25.3 C stored as 253 */
} reading_t;
static QueueHandle_t reading_q;
static int16_t read_sensor_x10(void) { return 253; } /* replace with a driver call */
static void sensor_task(void *arg)
{
(void)arg;
TickType_t last_wake = xTaskGetTickCount();
for (;;) {
reading_t r = { xTaskGetTickCount(), read_sensor_x10() };
if (xQueueSend(reading_q, &r, 0) != pdPASS) {
/* queue full: the logger is falling behind, count or report it */
}
vTaskDelayUntil(&last_wake, pdMS_TO_TICKS(100)); /* fixed period */
}
}
static void logger_task(void *arg)
{
(void)arg;
reading_t r;
for (;;) {
if (xQueueReceive(reading_q, &r, portMAX_DELAY) == pdPASS) { /* blocks */
printf("%lu: %d.%d C\n", (unsigned long)r.timestamp,
r.temperature_x10 / 10, r.temperature_x10 % 10);
}
}
}
int main(void)
{
/* hardware and clock initialisation go here */
reading_q = xQueueCreate(8, sizeof(reading_t));
configASSERT(reading_q != NULL);
xTaskCreate(sensor_task, "sensor", 256, NULL, tskIDLE_PRIORITY + 2, NULL);
xTaskCreate(logger_task, "logger", 512, NULL, tskIDLE_PRIORITY + 1, NULL);
vTaskStartScheduler(); /* does not return if the scheduler starts */
for (;;) { }
}
The stack sizes in xTaskCreate are given in words, not bytes, in FreeRTOS, and the logger gets more because printf is stack-hungry. The sensor task uses vTaskDelayUntil so its period stays fixed regardless of how long each pass takes. The example prints positive temperatures only; negative values need sign handling in the format.
Priority Inversion
Priority inversion happens when a high-priority task waits for a resource held by a low-priority task, and a medium-priority task, which does not need the resource, preempts the low-priority task. The high-priority task is now effectively waiting for the medium one, for an unbounded time.
time ------------------------------------------------> High : [wants mutex .... blocked ..............][runs] Medium : [runs ............. runs] Low : [takes mutex] [gives] With priority inheritance: High : [wants mutex][blocked][runs] Low : [takes mutex] [runs at HIGH priority, gives] Medium : [runs]
The best-known real case is the Mars Pathfinder lander in 1997, which suffered repeated system resets on Mars. The cause was a priority inversion around a shared information bus; engineers fixed it by enabling priority inheritance on the relevant mutex and uploading the change.
- Priority inheritance: while a low-priority task holds a mutex that a higher-priority task wants, it temporarily runs at the higher priority. FreeRTOS mutexes do this automatically; binary semaphores do not, so use a mutex to protect resources.
- Priority ceiling: each mutex has a ceiling priority, and any task holding it runs at that ceiling.
- Design out sharing: give a resource to one gatekeeper task and let others send it requests through a queue.
Common Mistakes
- Calling normal API functions from an ISR. FreeRTOS requires the FromISR variants, such as
xQueueSendFromISR, and interrupt priorities set within the range allowed by configMAX_SYSCALL_INTERRUPT_PRIORITY. - Undersized task stacks. Enable stack overflow checking and check
uxTaskGetStackHighWaterMarkduring testing. - A high-priority task that never blocks, which starves every lower-priority task.
- Taking two mutexes in different orders in different tasks, which can deadlock.
Summary
An RTOS runs independent tasks, each with a stack and a priority, and its preemptive scheduler always runs the highest-priority Ready task. Tasks spend most of their time Blocked on delays, queues or semaphores, using no CPU. Pass data through queues, signal with semaphores and protect shared resources with mutexes that support priority inheritance, so a medium-priority task can never hold a high-priority one hostage.