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.

StateMeaning
RunningCurrently executing on the CPU (one task per core)
ReadyAble to run, waiting for the CPU
BlockedWaiting for an event: a delay to expire, a queue item, a semaphore, with an optional timeout
SuspendedRemoved 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.

ObjectUse forNote
QueuePassing data by copy from producer to consumerThe receiver blocks until data arrives
Binary semaphoreSignalling an event, for example from an ISR to a taskNo owner, no priority inheritance
Counting semaphoreCounting events or available resourcesCount goes up on give and down on take
MutexProtecting a shared resource such as an SPI busHas an owner; FreeRTOS mutexes use priority inheritance
Event group / task notificationWaiting for one or more flagsLightweight 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.

RTOS-specific: FreeRTOS producer and consumer tasks connected by 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 uxTaskGetStackHighWaterMark during 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.