Procedural Programming with C · Professional · Week 54

Real-Time Operating Systems

A superloop runs everything in one sequence, so the slowest task sets the response time of every other. An RTOS gives each job its own stack and lets a scheduler decide — which solves that problem and reintroduces every concurrency hazard from week 41, on a machine with no memory protection.

By the end of this week you can
  • Say what a superloop guarantees and where it fails.
  • Create tasks and reason about preemptive priority scheduling.
  • Communicate between tasks with queues and semaphores rather than shared globals.
  • Recognize priority inversion and explain how inheritance fixes it.
  • Choose between a superloop, an RTOS, and an event loop with a reason.

1The superloop and its limit

int main(void)
{
    init();
    for (;;) {
        read_sensors();       /*  2 ms */
        update_control();     /*  1 ms */
        log_to_flash();       /* 50 ms — the problem */
        handle_ui();          /*  1 ms */
    }
}

Everything runs in one sequence at one priority. That makes it trivially easy to reason about — no races, no stack per task, no scheduler — and it is the right design for most small devices.

The limit is visible above. read_sensors runs once every 54 ms, not every 2, because the flash write is in front of it. The response time of every task is the sum of all the others, and one slow function degrades the entire system.

Two ways out without an RTOS. Break the long operation into a state machine that yields each iteration — week 47's technique, on a microcontroller. Or move it into an interrupt, which only works when the work is short.

When neither is enough, a scheduler is the answer.

2Tasks

An RTOS gives each task its own stack and switches between them. A preemptive scheduler interrupts a running task the instant a higher-priority one becomes ready.

void sensor_task(void *arg)
{
    for (;;) {                                  /* a task never returns */
        read_sensors();
        vTaskDelay(pdMS_TO_TICKS(10));          /* yields the processor */
    }
}

xTaskCreate(sensor_task, "sensor", 256, NULL, 3, NULL);
/*                        name    stack  arg  priority  handle */
vTaskStartScheduler();                          /* never returns */

The examples here use FreeRTOS because it is the most widely deployed and runs under QEMU; Zephyr, ThreadX and RT-Thread differ in spelling, not in concept.

Task stateMeans
RunningExecuting now — one per core
ReadyCould run; a higher priority is running
BlockedWaiting for a queue, a semaphore, or a delay
SuspendedExplicitly stopped

The distinction that matters: a blocked task costs nothing. It is not scheduled and consumes no cycles, which is what lets the processor sleep. A task that polls in a loop instead of blocking defeats the entire design.

Every task needs its own stack, sized correctly. Ten tasks with 512-byte stacks consume 5 KB before any of them does anything — a large fraction of a small part's RAM. Too small and the task overflows into its neighbour, silently, because there is no MMU. Size them with week 52's painting technique; FreeRTOS provides uxTaskGetStackHighWaterMark for exactly this.

3Scheduling

PolicyBehavior
Preemptive priorityThe highest-priority ready task runs. The default, and what "real-time" means.
Round robinEqual-priority tasks share time slices.
CooperativeA task runs until it yields. Predictable; one bad task hangs everything.

"Real-time" means predictable, not fast. A system that responds within 10 ms every single time is real-time; one that usually responds in 1 ms and occasionally takes 50 is not.

Assign priorities by deadline, not by importance. A task that must respond in 1 ms outranks one that must respond in 100 ms, even if the second is doing something more valuable — that is rate-monotonic scheduling, and it is provably optimal for fixed priorities.

Starvation is the failure mode: a high-priority task that never blocks prevents every lower one from running at all. Every task must block on something — a delay, a queue, a semaphore.

4Communication

Sharing a global between tasks is week 41's data race with no thread sanitizer available. Use the primitives instead.

Queues

QueueHandle_t q = xQueueCreate(10, sizeof(Reading));

xQueueSend(q, &reading, portMAX_DELAY);        /* blocks if full  */
xQueueReceive(q, &reading, portMAX_DELAY);     /* blocks if empty */

xQueueSendFromISR(q, &reading, &woken);        /* the ISR variant */

A queue copies the data, so ownership never crosses — the sender may reuse its buffer immediately. It also provides the blocking that lets the receiver sleep. This is week 41's producer-consumer, with the mutex and condition variable inside the primitive.

The FromISR suffix is not optional: the ordinary functions may block, and blocking inside an interrupt handler deadlocks the system.

Semaphores and mutexes

PrimitiveFor
Binary semaphoreSignalling: an ISR tells a task something happened
Counting semaphoreManaging N identical resources
MutexProtecting shared data — and it has priority inheritance

A mutex is not merely a binary semaphore with a different name. It records its owner, which enables the inheritance mechanism in the next section, and it must be released by the task that took it.

5Priority inversion

The classic real-time failure, and the one that famously reset the Mars Pathfinder rover repeatedly in 1997.

Low  priority: takes mutex M, starts working
Mid  priority: becomes ready, preempts Low  ← Low never finishes
High priority: becomes ready, needs M, blocks ← waits for Low
                                               which waits for Mid

The high-priority task is blocked behind a medium-priority task it does not interact with, for an unbounded time. The priorities have effectively inverted.

Priority inheritance fixes it: while a high-priority task waits on a mutex, the owner temporarily inherits that priority, so it cannot be preempted by the medium task and finishes promptly. FreeRTOS mutexes do this; binary semaphores do not — which is the practical reason to use a mutex for mutual exclusion and a semaphore only for signalling.

The remaining rules are week 41's, unchanged: consistent lock ordering to prevent deadlock, and critical sections as short as possible.

6Worked example: the same device, two ways

A device that samples a sensor every 10 ms, updates a control loop, writes a slow log, and responds to input. First as a superloop, then with a scheduler — the same work, measured.

Superloop

/* superloop.c */
#include <stdint.h>
#include "board.h"          /* uart_puts, put_int, tick_count from week 51 */

#define LOG_COST_TICKS 50   /* the slow operation */

static uint32_t sensor_runs, control_runs, log_runs, ui_runs;
static uint32_t sensor_worst_gap, last_sensor_tick;

static void busy_wait(uint32_t ticks)
{
    uint32_t start = tick_count;
    while (tick_count - start < ticks) { }
}

static void read_sensors(void)
{
    uint32_t now = tick_count;
    uint32_t gap = now - last_sensor_tick;
    if (gap > sensor_worst_gap) sensor_worst_gap = gap;
    last_sensor_tick = now;
    sensor_runs++;
    busy_wait(2);
}

static void update_control(void) { control_runs++; busy_wait(1); }
static void write_log(void)      { log_runs++;     busy_wait(LOG_COST_TICKS); }
static void handle_ui(void)      { ui_runs++;      busy_wait(1); }

int main(void)
{
    board_init();
    uart_puts("\n=== superloop ===\n");

    uint32_t started = tick_count;
    while (tick_count - started < 2000) {     /* 2 seconds */
        read_sensors();
        update_control();
        write_log();
        handle_ui();
    }

    uart_puts("in 2000 ticks:\n");
    uart_puts("  sensor runs      : "); put_int((int32_t)sensor_runs);
    uart_puts("\n  control runs     : "); put_int((int32_t)control_runs);
    uart_puts("\n  log runs         : "); put_int((int32_t)log_runs);
    uart_puts("\n  WORST sensor gap : "); put_int((int32_t)sensor_worst_gap);
    uart_puts(" ticks  (wanted 10)\n");
    uart_puts("\n  the log's 50 ticks are in front of every sensor read\n");

    for (;;) { }
}
in 2000 ticks:
  sensor runs      : 37
  control runs     : 37
  log runs         : 37
  WORST sensor gap : 54 ticks  (wanted 10)

Thirty-seven samples where two hundred were wanted, and a worst-case gap of 54 ticks — the sum of every other task. No amount of tuning fixes this; the structure causes it.

With FreeRTOS

/* rtos.c */
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "semphr.h"
#include "board.h"

#define PRIO_SENSOR  4      /* 10 ms deadline: highest */
#define PRIO_CONTROL 3
#define PRIO_UI      2
#define PRIO_LOG     1      /* no deadline: lowest     */

typedef struct { uint32_t tick; int32_t value; } Reading;

static QueueHandle_t     readings;      /* sensor  → control */
static QueueHandle_t     log_queue;     /* control → log     */
static SemaphoreHandle_t stats_mutex;   /* a MUTEX: inheritance */

static uint32_t sensor_runs, control_runs, log_runs;
static uint32_t sensor_worst_gap, last_sensor_tick;

static void busy_wait(uint32_t ticks)
{
    TickType_t start = xTaskGetTickCount();
    while (xTaskGetTickCount() - start < ticks) { }   /* real work */
}

/* --- highest priority: a hard 10 ms period --- */
static void sensor_task(void *arg)
{
    (void)arg;
    TickType_t last_wake = xTaskGetTickCount();

    for (;;) {
        uint32_t now = (uint32_t)xTaskGetTickCount();
        uint32_t gap = now - last_sensor_tick;

        if (xSemaphoreTake(stats_mutex, pdMS_TO_TICKS(5)) == pdTRUE) {
            if (last_sensor_tick != 0 && gap > sensor_worst_gap) {
                sensor_worst_gap = gap;
            }
            sensor_runs++;
            xSemaphoreGive(stats_mutex);
        }
        last_sensor_tick = now;

        Reading r = { .tick = now, .value = (int32_t)(now % 100) };
        xQueueSend(readings, &r, 0);          /* never block the sensor */

        busy_wait(2);
        vTaskDelayUntil(&last_wake, pdMS_TO_TICKS(10));   /* exact period */
    }
}

/* --- blocks on the queue: costs nothing while waiting --- */
static void control_task(void *arg)
{
    (void)arg;
    Reading r;
    for (;;) {
        if (xQueueReceive(readings, &r, portMAX_DELAY) == pdTRUE) {
            if (xSemaphoreTake(stats_mutex, portMAX_DELAY) == pdTRUE) {
                control_runs++;
                xSemaphoreGive(stats_mutex);
            }
            busy_wait(1);
            xQueueSend(log_queue, &r, 0);     /* drop if the log is behind */
        }
    }
}

/* --- lowest priority: preempted constantly, and that is correct --- */
static void log_task(void *arg)
{
    (void)arg;
    Reading r;
    for (;;) {
        if (xQueueReceive(log_queue, &r, portMAX_DELAY) == pdTRUE) {
            if (xSemaphoreTake(stats_mutex, portMAX_DELAY) == pdTRUE) {
                log_runs++;
                xSemaphoreGive(stats_mutex);
            }
            busy_wait(50);                    /* the slow operation */
        }
    }
}

static void report_task(void *arg)
{
    (void)arg;
    vTaskDelay(pdMS_TO_TICKS(2000));

    uart_puts("\n=== FreeRTOS, same work ===\n");
    uart_puts("in 2000 ticks:\n");
    uart_puts("  sensor runs      : "); put_int((int32_t)sensor_runs);
    uart_puts("\n  control runs     : "); put_int((int32_t)control_runs);
    uart_puts("\n  log runs         : "); put_int((int32_t)log_runs);
    uart_puts("\n  WORST sensor gap : "); put_int((int32_t)sensor_worst_gap);
    uart_puts(" ticks  (wanted 10)\n");

    uart_puts("\nstack high-water marks (words unused):\n");
    uart_puts("  sensor : ");
    put_int((int32_t)uxTaskGetStackHighWaterMark(NULL));
    uart_puts("\n");
    uart_puts("  free heap: ");
    put_int((int32_t)xPortGetFreeHeapSize());
    uart_puts(" bytes\n");

    vTaskSuspend(NULL);
}

int main(void)
{
    board_init();

    readings    = xQueueCreate(8, sizeof(Reading));
    log_queue   = xQueueCreate(8, sizeof(Reading));
    stats_mutex = xSemaphoreCreateMutex();      /* MUTEX, not a semaphore */

    xTaskCreate(sensor_task,  "sensor",  256, NULL, PRIO_SENSOR,  NULL);
    xTaskCreate(control_task, "control", 256, NULL, PRIO_CONTROL, NULL);
    xTaskCreate(log_task,     "log",     256, NULL, PRIO_LOG,     NULL);
    xTaskCreate(report_task,  "report",  256, NULL, PRIO_UI,      NULL);

    vTaskStartScheduler();                       /* never returns */
    for (;;) { }
}
=== FreeRTOS, same work ===
in 2000 ticks:
  sensor runs      : 199
  control runs     : 199
  log runs         : 38
  WORST sensor gap : 10 ticks  (wanted 10)

What the numbers say

SuperloopRTOS
Sensor samples37199
Worst sensor gap54 ticks10 ticks
Log writes3738
RAM overhead0~4 KB of stacks and kernel
Flash overhead0~6 KB

The sensor now meets its deadline every time, and the log does the same amount of work — it simply does it in the gaps. That is the whole value proposition, and it costs about ten kilobytes.

Build and run under QEMU

git clone --depth 1 https://github.com/FreeRTOS/FreeRTOS-Kernel
arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -ffreestanding -O2 -g \
    -I. -IFreeRTOS-Kernel/include \
    -IFreeRTOS-Kernel/portable/GCC/ARM_CM3 \
    -T firmware.ld -o rtos.elf \
    startup.c rtos.c board.c \
    FreeRTOS-Kernel/tasks.c FreeRTOS-Kernel/queue.c \
    FreeRTOS-Kernel/list.c  FreeRTOS-Kernel/timers.c \
    FreeRTOS-Kernel/portable/GCC/ARM_CM3/port.c \
    FreeRTOS-Kernel/portable/MemMang/heap_4.c

qemu-system-arm -M lm3s6965evb -nographic -kernel rtos.elf

A FreeRTOSConfig.h is required; the kernel repository ships an example for this exact machine.

Cause priority inversion

Replace the mutex with a binary semaphore and add a medium-priority task that never blocks:

stats_mutex = xSemaphoreCreateBinary();          /* NO inheritance */
xSemaphoreGive(stats_mutex);

static void greedy_task(void *arg)
{
    (void)arg;
    for (;;) { busy_wait(30); vTaskDelay(1); }    /* mid priority */
}
xTaskCreate(greedy_task, "greedy", 256, NULL, PRIO_CONTROL, NULL);

The sensor task now blocks waiting for the low-priority log task to release the semaphore, while the medium-priority greedy task prevents the log task from running. The worst sensor gap jumps from 10 ticks to 40 or more.

Change one line back — xSemaphoreCreateMutex() — and the gap returns to 10. Priority inheritance raises the log task's priority while the sensor waits, so it finishes and releases immediately. One function call is the difference between a system that meets its deadline and one that does not, which is why the distinction between a mutex and a semaphore is worth knowing precisely.

Measure the stacks

uxTaskGetStackHighWaterMark(handle)   /* words still unused, ever */

Run the worst-case workload and check each task. A value near zero means the stack is about to overflow into its neighbour with no fault; a large value means RAM is being wasted. This is week 52's painting, provided by the kernel.

Enable configCHECK_FOR_STACK_OVERFLOW 2 in the configuration and provide vApplicationStackOverflowHook — the kernel then checks the pattern on every switch and calls you rather than corrupting memory silently.

7Choosing

SuperloopRTOSEvent loop
Everything is shortMixed deadlines, some long workMany I/O sources, one core
No concurrency bugs possibleEvery week-41 hazard returnsNo locking; state machines
Zero overhead~10 KB and a stack per taskSmall
Trivial to reason aboutNeeds priority analysisNeeds explicit state
Most small devicesMotor control, protocol stacksNetwork servers — week 47

Start with a superloop. Most embedded products ship with one, and it is the only design with no concurrency bugs available to it. Move to an RTOS when you can name a deadline the superloop demonstrably misses — as this week's measurement did — and not before.

8Common mistakes

MistakeWhat happensFix
A binary semaphore for mutual exclusionNo priority inheritance; inversionUse a mutex.
A high-priority task that never blocksEverything below it starvesEvery task blocks on something.
Stack too smallSilent corruption of a neighbourHigh-water marks plus the overflow hook.
Blocking calls inside an ISRDeadlockThe FromISR variants.
Sharing a global instead of using a queueData race, no sanitizer availableQueues copy; use them.
Taking two mutexes in different ordersDeadlock — week 41A consistent lock order.
vTaskDelay for a periodic taskPeriod drifts by the execution timevTaskDelayUntil.
Priorities assigned by importanceDeadlines missedBy deadline: shortest gets highest.
Reaching for an RTOS by default10 KB and a class of bugs, for nothingMeasure the superloop first.

9Check yourself

What exactly does a superloop get wrong?

Response time. Every task's period is the sum of all the others' execution times, so one slow operation degrades everything — the measurement above showed a 10 ms sensor deadline stretching to 54 ms because a 50 ms log write sat in front of it. Nothing about the individual functions is wrong; the structure imposes the coupling.

Why is a mutex not just a binary semaphore?

Because a mutex records its owner, which allows priority inheritance: while a high-priority task waits, the owner temporarily runs at that priority and cannot be preempted by unrelated medium-priority work. A binary semaphore has no owner and no inheritance, so it permits unbounded priority inversion — the Mars Pathfinder failure.

What does "real-time" actually mean?

Predictable, not fast. A system that always responds within 10 ms is real-time; one that usually responds in 1 ms and occasionally takes 50 is not, because the guarantee is what matters. That is why worst-case analysis, not average throughput, is the measure of a real-time design.

Why must every task block on something?

Because a preemptive priority scheduler runs the highest-priority ready task, so a high-priority task that never blocks never yields and every lower-priority task starves. Blocking on a delay, a queue, or a semaphore is what lets the scheduler run anything else — and it is also what lets the processor sleep.

When should you not use an RTOS?

When a superloop meets every deadline. The kernel costs roughly ten kilobytes plus a stack per task, and it reintroduces races, deadlock, priority inversion, and stack overflow on a machine with no memory protection and no thread sanitizer. Adopt it when you can point at a measured deadline that the simpler design misses.

10Where this leads

Week 55 is about finding out what went wrong on a device you cannot attach a debugger to: on-chip debugging, tracing on a constrained target, hardware abstraction layers that let firmware be tested on a host, and the simulator-in-the-loop setup this whole block has been using.