Real-Time Operating Systems

The core concepts, one at a time.

A plain-language reference to what an RTOS actually gives you — tasks, a scheduler, and the primitives that let them share a chip safely. Each idea gets its own section, a one-line definition, and the FreeRTOS call that implements it.

00

What an RTOS is

Definition: an operating system whose defining promise is timing, not features — the right code runs within a bounded, predictable deadline.

Three ways to structure embedded software, in increasing order of control:

  • Bare-metal superloop — one while(1) polling everything. Simple, but coupled: one slow step delays all the others, and there's no notion of priority.
  • General-purpose OS (Linux, Windows) — optimizes average throughput and fairness. Excellent average latency, but the worst-case tail is unbounded.
  • RTOS — independent tasks with priorities and a scheduler that guarantees the highest-priority ready task runs within a computable worst case. Kilobytes of footprint, often no MMU.

Everything below is the machinery that makes that guarantee possible.

01

Task

Definition: a task (a.k.a. thread) is an independent unit of execution with its own stack, written as a function that loops forever and never returns.

You hand the kernel a function, a stack size, and a priority number. The kernel gives that task its own stack (its own local variables and call history) and a control block that tracks its state. From then on it's one of possibly many tasks competing for the single CPU.

AnalogyEach task is a worker with their own desk (its private stack). There's one shared machine (the CPU). The scheduler decides whose turn it is at the machine — but each worker's desk and half-finished work is untouched while they wait.

FreeRTOS · a task and its creation

void vBlinkTask(void *pv) {   // the task body
  for (;;) {                  // MUST loop forever — a task never returns
    gpio_toggle(LED);
    vTaskDelay(pdMS_TO_TICKS(500));  // block 500ms → yield the CPU
  }
}

xTaskCreate(vBlinkTask, "blink", 128, NULL, 2, NULL);

The task function itself

  • Returns void and takes one void * argument. This signature is fixed — the kernel needs every task to look the same so it can call any of them the same way.
  • The for(;;) is mandatory. A task must never fall off the end of its function. If it has nothing to do, it blocks (here, vTaskDelay) — it doesn't return. Returning from a task is a bug; to actually end one you call vTaskDelete.

Every argument to xTaskCreate

ParameterHereWhat it is
1 · task codevBlinkTaskThe function the task runs, passed as a function pointer — you give its name, which is its address. The kernel calls it to start the task.
2 · name"blink"A human-readable label for debuggers and trace tools. A plain string literal — the scheduler never uses it.
3 · stack depth128The size of this task's private stack, measured in words, not bytes. On a 32-bit MCU one word = 4 bytes, so 128 → 512 bytes. Every task gets its own stack for its locals and call chain.
4 · argumentNULLA value handed to the task's void *pv parameter. NULL means "no argument." Pass &config here and the task casts pv back to use it — one function body, many configured instances.
5 · priority2How urgent this task is. Higher number = higher priority. The scheduler always runs the highest-priority ready task.
6 · handle outNULLAn out-parameter: the address of a TaskHandle_t to fill in, so you can later suspend/resume/delete this task. NULL = "I won't need a handle."

One function body, many configured instances

The void *pv argument (param 4) exists so you can write the task's logic once and start it several times, each with different data — instead of copy-pasting a near-identical function per instance. Each call to xTaskCreate below launches an independent task with its own stack, but all three share the exact same blinkTask code; only the config passed in differs.

typedef struct { int pin; int delay_ms; } BlinkCfg;  // per-instance config

void blinkTask(void *pv) {
  BlinkCfg *cfg = (BlinkCfg *)pv;     // cast the void* back to the real type
  for (;;) {
    toggle(cfg->pin);
    vTaskDelay(pdMS_TO_TICKS(cfg->delay_ms));
  }
}

// three configs, each a different "personality"
static BlinkCfg red   = { RED,   200 };
static BlinkCfg green = { GREEN, 500 };
static BlinkCfg blue  = { BLUE,  900 };

// SAME function, started three times, each with its own config:
xTaskCreate(blinkTask, "red",   128, &red,   2, NULL);
xTaskCreate(blinkTask, "green", 128, &green, 2, NULL);
xTaskCreate(blinkTask, "blue",  128, &blue,  2, NULL);

The static on each config isn't optional here — it keeps that BlinkCfg alive in .data for the program's whole lifetime. A local variable would go out of scope and become a dangling pointer the moment the surrounding function returned, while the task keeps running and reading through cfg forever.

Getting a handle back — an out-parameter, like scanf

xTaskCreate already uses its return value for success/fail, so it can't also hand back the task's handle that way. Instead you give it an empty box and its address, and it writes the handle into that box for you — exactly how scanf("%d", &x) writes a number into x through &x. Without the handle you have no way to refer to this specific task afterward — no name to suspend, resume, or delete it by.

// the familiar pattern first:
int x;
scanf("%d", &x);              // scanf WRITES into x, via its address

// xTaskCreate's 6th param works the same way:
TaskHandle_t myHandle;             // an empty box for the handle
xTaskCreate(vBlinkTask, "blink", 128, NULL, 2, &myHandle);
//                                              ^^^^^^^^^^ "write the handle in HERE"

// myHandle now NAMES that exact task:
vTaskSuspend(myHandle);       // pause it
vTaskResume(myHandle);        // resume it
vTaskDelete(myHandle);        // destroy it

// passing NULL instead just means "skip it, I'll never need to refer to
// this task again" -- the original xTaskCreate(vBlinkTask, ..., NULL) call.

↳ this is all C you already know

  • Function pointer — passing vBlinkTask by name is passing its address; the kernel calls it like an entry in a dispatch table. c.notes 14
  • void * generic pointer — the argument (param 4) and the task's pv are type-agnostic addresses you cast back to the real type before use. c.notes 05
  • String literal"blink" is a read-only char array, terminator and all. c.notes 07
  • The stack region — each task's stack is a slice of the same stack area you learned in memory layout; that's where its locals live. c.notes 15
  • Out-parameter via address — the handle (param 6) is written back through a pointer, exactly like scanf("%d", &x). c.notes 03
  • Struct + opaque handle — internally the kernel builds a Task Control Block (a struct holding the task's stack pointer, priority, and state), and TaskHandle_t is an opaque pointer to it — you hold it but can't peek inside. c.notes 08 · 16
02

Task states

Definition: at any instant a task is in exactly one state. Transitions are driven by the scheduler and by events.
  • Running — currently executing on the CPU. Only one task per core is ever running.
  • Ready — able to run, but a higher-priority task holds the CPU. Waiting its turn.
  • Blocked — waiting for an event (a delay to expire, a queue item, a semaphore). Uses zero CPU while blocked.
  • Suspended — explicitly parked; the scheduler ignores it until resumed.
READY wants CPU RUNNING owns the CPU BLOCKED waiting on event dispatch preempt block event arrives → ready
03

Scheduler

Definition: the kernel component that decides which ready task runs — re-evaluated on every tick and every event.
  • Fixed-priority preemptive is the common policy: always run the highest-priority task that is ready. No exceptions, no aging.
  • The tick is a periodic timer interrupt (often 1 kHz). It expires delays, moves blocked tasks to ready, and may trigger a switch.
  • Equal priority → optional time-sliced round-robin between them.

Below: the logger (low) runs only as filler. The control task (medium) wakes on a period and preempts it. The sensor task (high) is normally blocked, and the instant it's ready it takes the CPU from everyone.

Fixed-priority preemptive — higher priority always wins the CPU

sensor p3
blocked
run
blocked
control p2
ready
run
logger p1
run
ready (preempted)
run
running ready blocked
04

Preemption & context switch

Definition: preemption is a higher-priority task interrupting a lower one mid-execution; a context switch is saving one task's CPU registers & stack pointer and restoring another's.
  • Because the full register set and stack pointer are saved, a preempted task later resumes exactly where it stopped — it never knows it was paused.
  • A switch costs real time (a few microseconds on an MCU). It isn't free, so thrashing between tasks has a cost you budget for.
  • Preemption is what makes the timing guarantee real: a time-critical task doesn't wait politely for a lower one to finish.
05

Timing & delays

Definition: a task yields the CPU by blocking on a delay — never by spinning. Which delay primitive you use decides whether your loop's period stays exact or slowly drifts.

Blocking vs. spinning, first

A task that has nothing to do right now has two options. It can spin — sit in a tight loop repeatedly checking a condition, which keeps it RUNNING and burning 100% CPU on nothing, starving every lower-priority task the whole time. Or it can block — tell the kernel "wake me later," which drops it out of the ready list entirely at 0% CPU until that time (or event) arrives. Every delay call below is the second kind.

Real-world scenarioPicture a quadruped robot's gait controller — the loop that reads each leg's joint-angle sensors and pushes new servo targets to keep all four legs moving in a coordinated trot, every 5ms, 200 times a second. The gait math assumes each update lands at a fixed, known time interval — if one cycle takes 5ms and the next takes 9ms, that leg's joint arrives at its target late relative to the other three, the trot falls out of sync, and the robot stumbles. This is precisely the kind of timing bug an FYP quadruped hits: the leg motion looks fine in isolation but the whole gait desyncs under load. Getting the delay primitive right is the difference between a stable trot and a robot that trips over its own feet.

vTaskDelay(n) — counts from right now

This is the simple one: "block for n ticks, starting from this exact instant." The catch is that "this exact instant" is after your task's body already ran — so whatever time the body took gets added on top of n, every single lap. If the body's duration varies lap to lap (it almost always does — sensor noise, branch conditions, cache effects), the total period varies too. That's the drift.

void vGaitTask(void *pv) {
  for (;;) {
    read_joint_angles_update_servos();  // takes ~3-6ms, NOT fixed
    vTaskDelay(pdMS_TO_TICKS(5));       // +5ms from NOW, whenever "now" is
  }
}
// real period = body time + 5ms -- different every lap, legs drift out of sync

xTaskCreate(vGaitTask, "gait", 256, NULL, 3, NULL);

vTaskDelayUntil(&last, n) — counts from the schedule, not from now

Two parameters. n is the easy one — it's just the fixed period you want between wake-ups, in ticks (5ms in the gait loop, converted with pdMS_TO_TICKS(5)). It never changes call to call; it's the constant "every 5ms" part of the sentence. last is the harder one, and the one that actually does the work — it's not a duration at all, it's a single point in time.

Think of a school's bell schedule. Period 2 starts at 9:45 — always, on the dot — regardless of whether period 1's teacher dismissed students exactly on time or ran two minutes over. The bell doesn't say "45 minutes after whenever period 1 actually ends"; it rings against a fixed master timetable that was set at the start of the day. In that analogy, the class length is n (a constant, "45 minutes," set once) and 9:45 itself is last — the specific clock time the previous bell was scheduled for. vTaskDelayUntil is that timetable.

What last actually is

last is a variable that holds one thing: the tick count of the most recent scheduled wake-up — not "how long ago the task last ran," not a duration at all, just a single point on the clock. Every call to vTaskDelayUntil does three things, in order:

  1. Compute the next target: target = last + n — the next slot on the fixed timetable.
  2. Block the task until the clock actually reaches target.
  3. Write target back into last, through the pointer you passed — so next lap's calculation starts from this lap's target, not from whatever time it happened to actually wake up at.

This is exactly why it takes &last and not last: the function needs to update your variable in place so the next call sees the new target — the same out-parameter pattern as scanf and the task-handle example above. do_work() genuinely has nothing to do with computing the next wake time at all — it only has to finish before that time arrives, or the deadline is missed.

void vGaitTask(void *pv) {
  TickType_t last = xTaskGetTickCount();   // seed with the CURRENT time, ONCE,
                                            // before the loop even starts
  for (;;) {
    read_joint_angles_update_servos();          // takes ~3-6ms, NOT fixed -- doesn't matter
    vTaskDelayUntil(&last, pdMS_TO_TICKS(5));  // last: READ old target, WRITE new target
  }
}
// real period = exactly 5ms, every lap, regardless of body time

xTaskCreate(vGaitTask, "gait", 256, NULL, 3, NULL);

Notice last is declared inside the task, but outside the for(;;) loop — so it's set once, when the task starts, using the real clock via xTaskGetTickCount(). After that, vTaskDelayUntil owns it entirely on every subsequent lap; you never touch it again yourself.

tracing three laps by hand

Laplast (before call)target = last+10body tookwakes atlast (after call)
10103ms1010
210206ms2020
320301ms3030

The body took a different amount of time every lap (3, then 6, then 1ms) — and it made zero difference to when the task woke up. It woke at 10, 20, 30: a clean, unbroken 10-tick cadence, because each target was computed from the previous target, never from "now."

See this animated, with randomized body times, on the delay.drift page →

↳ this is all C you already know

  • Out-parameter via address&last lets the function write its result back into your variable, identical to scanf("%d", &x). c.notes 03
  • Ordinary local, extraordinary lifetimelast is a plain (non-static) local variable. It doesn't need static because a task function is only ever "called" once by the kernel and then loops forever inside that single call — so a local declared before for(;;) already persists across every lap, the same way a local in any function persists across the statements below it. c.notes 06 · 12
06

Queue

Definition: a thread-safe FIFO that passes copies of data between tasks, or from an interrupt to a task — never pointers to shared originals.
Real-world scenarioOn the quadruped, an ultrasonic distance sensor is polled by one task every 20ms. A separate navigation task decides whether to keep walking, slow down, or turn — but it doesn't poll the sensor itself; it just wants to know whenever a new reading shows up. A queue is the mailbox between them: the sensor task drops each new distance value in, the navigation task blocks with nothing to do until a value arrives, then wakes up, reads it, and reacts. Neither task needs to know or care about the other's schedule.

Why not just a shared global variable?

It looks tempting: float distance; at file scope, sensor task writes it, navigation task reads it. The problem is a torn read — a float is 4 bytes, and there's no guarantee the write happens as one atomic step. If the navigation task reads distance at the exact instant the sensor task is halfway through writing a new value (2 bytes of the old number, 2 bytes of the new one), it reads garbage that was never a real distance at all — and on some architectures this is even more likely with structs than single floats. A queue sidesteps this entirely: the kernel copies the whole item in and the whole item out under its own internal lock, so the reader either sees the complete old value or the complete new value — never a mix.

Every argument, xQueueCreate

ParameterExampleWhat it is
1 · length10How many items the queue can hold at once before it's full. Not bytes — item slots.
2 · item sizesizeof(float)The size of one item, in bytes. The kernel uses length × item size to allocate the queue's internal buffer — a fixed block it manages like a ring buffer.

A queue can hold whole structs, not just a single number — pass sizeof(SensorReading) instead, and every send/receive copies the entire struct in one shot.

Every argument, xQueueSend / xQueueReceive

ParameterExampleWhat it is
1 · handledistQWhich queue — the same QueueHandle_t returned by xQueueCreate.
2 · item address&readingSend: the address to copy from. Receive: the address to copy into. Either way, an address — never the value itself.
3 · ticks to waitportMAX_DELAYHow long to block if the queue is full (send) or empty (receive) before giving up. portMAX_DELAY = wait forever; 0 = don't wait at all, fail immediately.

Blocking is the rendezvous

If the navigation task calls xQueueReceive while the queue is empty, it doesn't spin or poll — it blocks, using zero CPU, until the sensor task actually sends something. The instant a value arrives, the kernel wakes it up. This is the same BLOCKED state from section 02 — a queue is simply one of the things a task can block on.

Producer / consumer, out of step — consumer blocks until data exists

sensor task
read+send
idle
read+send
nav task
blocked (empty)
react
blocked (empty)
react

FreeRTOS · the full producer/consumer, wrapped in real tasks

QueueHandle_t distQ;   // shared handle, visible to both tasks

void vSensorTask(void *pv) {
  for (;;) {
    float d = read_ultrasonic_cm();
    xQueueSend(distQ, &d, 0);      // don't block sensor loop -- drop if full
    vTaskDelay(pdMS_TO_TICKS(20));
  }
}

void vNavTask(void *pv) {
  float reading;
  for (;;) {
    xQueueReceive(distQ, &reading, portMAX_DELAY);  // block until data exists
    react_to_distance(reading);
  }
}

int main(void) {
  distQ = xQueueCreate(10, sizeof(float));
  xTaskCreate(vSensorTask, "sensor", 256, NULL, 2, NULL);
  xTaskCreate(vNavTask,    "nav",    256, NULL, 3, NULL);
  vTaskStartScheduler();
}

↳ this is all C you already know

  • Address, not value&d and &reading are the same address-of pattern as every out-parameter you've seen (scanf, &last, &myHandle). c.notes 05
  • Copy semantics — a queue item is copied in and out the same way a struct is copied when passed by value rather than by pointer; that's exactly why torn reads can't happen — there's no shared original left exposed mid-write. c.notes 08
  • Fixed-size ring buffer — internally, a queue is just an array with a read index and a write index, wrapping around — the same contiguous, fixed-size block you already know from Arrays, managed for you. c.notes 04
  • Opaque handleQueueHandle_t is a pointer to an incomplete struct, same as TaskHandle_t. c.notes 16
07

Semaphore

Definition: a kernel counter used for signaling and resource-counting — it carries no data, just "go" tokens.
Real-world scenarioEach of the quadruped's feet has a contact sensor that fires an interrupt the instant it touches the ground. The gait-control task doesn't care about a data value from that sensor — it only needs to know the touchdown event happened, so it can advance to the next phase of the step cycle. That's a pure "it happened" signal with zero payload — a job a queue could technically do, but a semaphore is built for exactly this and nothing more.

Binary semaphore — pure signaling

A binary semaphore holds one bit of state: 0 ("nothing pending") or 1 ("an event is waiting to be consumed"). An ISR (or another task) calls give to raise it to 1; a task calls take, which blocks while it's 0 and, the instant it becomes 1, wakes the task and drops it back to 0. There's no ownership concept here — unlike a mutex, anyone can give, anyone can take.

Why not just a volatile bool footDown flag instead? Mostly the same reasoning as queues vs. a shared global — but there's a sharper failure mode here: a bare flag can't distinguish "no event" from "event already consumed," and gives you no safe blocking primitive at all — your task would have to spin-check it, burning CPU. The semaphore gives you the blocking (0% CPU while waiting) for free, plus race-safety on the give/take pair itself.

Counting semaphore — N identical resources

Say an MCU has only 3 DMA channels available for peripheral transfers, shared across several independent tasks (e.g. one streaming to a display, one logging to flash, one pushing samples out over UART). Any of them may want to start a transfer at nearly the same instant, but only 3 DMA channels physically exist. A counting semaphore initialized to 3 models this directly: each take decrements the count (borrow a channel), each give increments it (release it once the transfer completes), and a take at count 0 blocks until someone gives one back.

The bare mechanic, without any embedded specificsA parking garage has exactly 3 spots. count = how many spots are empty. A car arriving is a take: if count > 0, it parks and count drops by 1; if count == 0, the car just waits at the gate — blocked, not circling around burning gas. A car leaving is a give: count goes up by 1, and if a car was waiting at the gate, it's let in immediately. That's the whole mechanic — no DMA, no tasks, just a number that moves up and down with a queue of waiters when it hits zero.

tracing it with 4 tasks, only 3 channels

MomentEventcount beforeWhat happenscount after
t=0Task A wants to send3take succeeds, A grabs a channel2
t=0Task B wants to send2take succeeds, B grabs a channel1
t=0Task C wants to send1take succeeds, C grabs a channel0
t=0Task D wants to send0take blocks — all 3 channels busy, D just waits0
t=2msTask A's transfer finishes0A calls give1
t=2ms(same instant)1Task D, waiting, is woken immediately and grabs the freed channel0

One thing worth being explicit about: the semaphore's count doesn't know or care which of the 3 physical channels is free — it only enforces the number "no more than 3 in use at once." In real code, something separate (often a small array of 3 channel IDs, or the DMA driver itself) hands out the actual channel number once a take succeeds. The semaphore's job is narrower than it sounds: it's purely the admission gate — "are you allowed through right now, and if not, wait here" — not a mechanism that identifies or reserves a specific resource for you.

Every argument

CallParametersWhat they mean
xSemaphoreCreateBinary()noneStarts at 0 ("nothing pending"). No count to configure — it's just one bit.
xSemaphoreCreateCounting(3, 3)max count, initial countMax = the number of identical resources that exist. Initial = how many are free right now (usually the same as max, at startup).
xSemaphoreTake(sem, wait)handle, ticks to waitDecrements if >0; blocks (up to wait ticks) if 0.
xSemaphoreGive(sem)handle onlyIncrements by 1. No data pointer here — unlike a queue, there is nothing to copy.

Notice xSemaphoreGive takes no second parameter — this is the concrete difference from a queue: a queue's xQueueSend needs an address because it copies a payload; a semaphore carries no payload, so there's nothing to point at.

ISR signals, task blocks then wakes

foot ISR
give
gait task
blocked (take)
advance step

FreeRTOS · ISR signals a binary semaphore, task blocks on it

SemaphoreHandle_t footSem;

void EXTI2_IRQHandler(void) {          // foot-contact interrupt
  BaseType_t woken = pdFALSE;
  xSemaphoreGiveFromISR(footSem, &woken);   // ISR-safe variant
  portYIELD_FROM_ISR(woken);
}

void vGaitEventTask(void *pv) {
  footSem = xSemaphoreCreateBinary();
  for (;;) {
    xSemaphoreTake(footSem, portMAX_DELAY);   // block until a foot touches down
    advance_gait_phase();
  }
}

↳ this is all C you already know

  • It's just a guarded integer — a semaphore's count is conceptually an int with its increment/decrement made atomic and blocking-aware by the kernel; the same idea as a variable you'd never trust to count++ directly if two tasks touched it. c.notes 03
  • No out-parameter neededxSemaphoreGive(sem) takes just the handle, because unlike xQueueSend/xQueueReceive there's no data to copy through an address. c.notes 05
  • Opaque handleSemaphoreHandle_t, same pointer-to-incomplete-struct pattern as QueueHandle_t and TaskHandle_t. c.notes 16
08

Mutex & priority inversion

Definition: a mutex is a mutual-exclusion lock with ownership — only one task at a time may hold it to touch a shared resource, and only the holder can release it.
Real-world scenario — your actual hardwareThis is the true story of your own quadruped: all 12 servos sit on one PCA9685 board, on one shared I2C bus. Each leg's control task periodically needs to write new position commands to that same chip over that same single wire. I2C is inherently serial — only one transaction can be on the bus at any instant. If two leg tasks' I2C writes ever interleaved (task A starts sending "register X, value Y" for the front-left leg, gets preempted mid-transaction, task B starts its own write for the back-right leg on the same bus), the bytes could interleave into one corrupted, meaningless transaction — wrong register, wrong value, maybe garbage sent to a completely different servo. A mutex is exactly the tool for "only one task may touch this bus at a time."

MUTEX

Has an owner. For protecting shared data (a bus, a buffer). Supports priority inheritance.

BINARY SEMAPHORE

No owner. For signaling between an ISR/task and another task. Do not use it to guard data.

Ownership — the one new rule a mutex adds

A binary semaphore has no concept of "whose" it is — any task (or ISR) can `give` it, any task can `take` it, regardless of who did what before. A mutex adds exactly one extra invariant: only the task that successfully `take`s it is allowed to `give` it back. If a different task calls `give` on a mutex it never took, that's an error condition the kernel can detect and reject — this is precisely why a mutex, unlike a binary semaphore, is never given from an ISR (an ISR can't "own" anything in the task sense) and why FreeRTOS doesn't even provide a `xSemaphoreGiveFromISR`-style call for mutexes.

Every argument

CallParametersWhat they mean
xSemaphoreCreateMutex()noneReturns a SemaphoreHandle_t, unlocked, with priority inheritance built in — that's the one thing a plain binary semaphore never gets.
xSemaphoreTake(bus, wait)handle, ticks to waitLock it. Blocks if another task already holds it. This task now "owns" the lock.
xSemaphoreGive(bus)handle onlyUnlock it — but only valid if called by the same task that locked it.

FreeRTOS · four leg tasks serializing access to one I2C bus

SemaphoreHandle_t i2cBus;   // created once, in main(), before the scheduler starts

void vLegTask(void *pv) {
  LegConfig *cfg = (LegConfig *)pv;
  for (;;) {
    compute_next_joint_targets(cfg);

    xSemaphoreTake(i2cBus, portMAX_DELAY);   // lock the bus -- may block
    pca9685_write_channels(cfg->channels, cfg->targets);
    xSemaphoreGive(i2cBus);                 // unlock -- SAME task that locked it

    vTaskDelay(pdMS_TO_TICKS(5));
  }
}

Notice the critical section — everything between Take and Give — is kept as short as possible: just the actual I2C write, nothing else. compute_next_joint_targets runs before locking, so the bus isn't held hostage during unrelated computation.

Priority inversion — the Mars Pathfinder bug

July 1997: Pathfinder kept resetting on Mars. A high-priority task (H) needed a mutex held by a low-priority task (L). A medium task (M) — needing no lock — preempted L and ran long, so L never released the mutex, so H stayed blocked below M. The watchdog saw H miss its deadline and rebooted the craft.

Translate that directly onto your bus: suppose your high-priority balance-correction task (H) needs the I2C bus, currently locked by a low-priority telemetry-logging task (L). A medium-priority task (M) — say, reading the ultrasonic sensor, no lock needed — preempts L and runs long. L can't finish and release the bus. H sits blocked, indirectly stuck behind M, even though H outranks M. That's priority inversion: a low-priority task, through a lock, is effectively capping how long a high-priority task can be delayed by a completely unrelated medium-priority one.

Broken — no inheritance · H misses its deadline

H · p3
blocked on mutex
run
M · p2
runs long
L · p1
holds mutex
can't run
release

Fixed — priority inheritance · H meets its deadline

H · p3
blocked
run
M · p2
ready
run
L · p1
holds mutex
boosted→p3
running ready / boosted blocked deadline

Priority inheritance is the fix: while H waits on the mutex, the kernel temporarily boosts the holder L to H's priority, so M can't preempt it. L finishes its critical section fast and drops back down. In FreeRTOS this is automatic for a mutex (and absent from a plain semaphore — which is exactly why you don't guard data with one).

FreeRTOS · mutex with inheritance

SemaphoreHandle_t m = xSemaphoreCreateMutex();  /* inheritance ON */

xSemaphoreTake(m, portMAX_DELAY);
touch_shared_bus();     /* keep the critical section short */
xSemaphoreGive(m);

↳ this is all C you already know — plus one genuinely new rule

  • Torn writes, again — an interleaved I2C transaction from two tasks is the exact same "half-old, half-new" corruption idea as the torn-read race condition from the Queue section, just happening on the wire instead of in RAM. c.notes — see section 06 above
  • Opaque handleSemaphoreHandle_t for a mutex is the same pointer-to-incomplete-struct as every other RTOS handle you've seen. c.notes 16
  • What's genuinely new here: plain C has no built-in concept of "only the task that locked this may unlock it" — that ownership rule, and the automatic priority-boosting that comes with it, is entirely a service the RTOS kernel provides on top of C, not something expressible with C's own syntax alone.
09

Interrupts (ISRs)

Definition: an interrupt service routine runs above every task, often with interrupts disabled — so it must be short and non-blocking.
Real-world scenarioYour quadruped's IMU (the chip giving orientation/balance data) has a "data ready" pin wired to the MCU. The instant fresh gyro/accelerometer data is available inside the IMU, it pulls that pin — triggering a hardware interrupt immediately, regardless of what the CPU happens to be doing at that exact instant, even in the middle of a lower-priority task's code. That's the whole point of an interrupt: it doesn't wait its turn in any queue or scheduler decision — it preempts everything, instantly, the moment the hardware event happens.

Why "above every task" isn't an exaggeration

Task priorities (1, 2, 3...) only rank tasks against each other. An ISR isn't a task at all, and it sits in a separate tier entirely — when an interrupt fires, it runs immediately, ahead of literally every task in the system, including your highest-priority one. This is exactly why an ISR must be kept short: while it's running, nothing else — not even your most time-critical task — gets a chance to run. A slow ISR doesn't just delay low-priority background work; it can delay everything.

Why an ISR can't block

Every blocking call you've seen so far (xQueueReceive, xSemaphoreTake with a real timeout) works by putting the calling task to sleep and letting the scheduler run something else. But an ISR isn't a task — it has no task control block, no priority in the scheduler's list, nothing for the kernel to "put to sleep." There's no meaningful answer to "which task should run instead" from inside an ISR, because the whole concept of blocking is task-shaped, and an ISR simply isn't one. That's the real reason xQueueReceive/xSemaphoreTake are illegal inside an ISR, and why every ISR-safe call ends in FromISR and never takes a timeout parameter at all — it's not a stylistic choice, it's structurally impossible for an ISR to wait for anything.

Deferred interrupt processing

The fix is the pattern you've now seen a few times: the ISR does the bare minimum — grab the raw data, hand it off — and a normal task, back in the regular scheduler's world, does the actual work of interpreting it. The ISR defers the real processing to a task that can safely take its time, block, and be pre-empted normally.

Every piece of the handoff, explained

LineWhat it does
BaseType_t woken = pdFALSE;Starts false — "so far, nothing we've done requires an immediate switch."
float sample = IMU_DATA_REG;Read the raw value straight out of a memory-mapped hardware register — the fastest possible operation, no bus transaction, no blocking.
xQueueSendFromISR(imuQ, &sample, &woken)The ISR-safe give/send. Same address-of pattern as always for the data (&sample) — plus a second out-parameter, &woken, which this call writes into: did this send just unblock a task waiting on the queue?
portYIELD_FROM_ISR(woken)If woken came back true, force the context switch to happen right now, as this ISR exits — don't wait for the next scheduler tick to notice.

Without that last line, the unblocked task would still eventually run — but only whenever the scheduler next happened to check, which could be a full tick period later. For a balance-control task reacting to fresh IMU data, that delay could be the difference between correcting a stumble and not.

FreeRTOS · ISR hands off to a task

void IMU_DATA_READY_IRQHandler(void) {
  BaseType_t woken = pdFALSE;
  float sample = read_imu_register();       /* quick HW read, no blocking */

  xQueueSendFromISR(imuQ, &sample, &woken);  /* FromISR variant */
  portYIELD_FROM_ISR(woken);                    /* switch NOW if needed */
}

void vBalanceTask(void *pv) {
  float orientation;
  for (;;) {
    xQueueReceive(imuQ, &orientation, portMAX_DELAY);  /* safe here -- this IS a task */
    correct_balance(orientation);
  }
}

↳ this is all C you already know

  • Memory-mapped register read — reading a hardware register is just dereferencing a pointer to a fixed address, exactly the volatile/register concept from earlier. c.notes 12
  • Two out-parameters in one callxQueueSendFromISR takes both &sample (data going in) and &woken (a result coming back) — nothing new syntactically, just two instances of the same address-of pattern in one function call. c.notes 05
  • Function naming as a contract — every ISR-safe call ending in FromISR is a naming convention, not a language feature — but it's the kind of self-documenting API design worth recognizing and imitating in your own embedded code.
10

Determinism

Definition: the whole point of an RTOS — a computable worst-case response time, not just a fast average one.
Real-world scenarioBefore trusting the quadruped's balance-correction task on real hardware, you'd want to actually prove — on paper, before ever running it — that it can never take longer than, say, 5ms to respond to a stumble, no matter what else the robot happens to be doing at that instant. Not "it was fast in my tests." Proven, for the absolute worst case. That's what this section gives you the tools to do.

The formula

Because scheduling is fixed-priority and preemptive (section 03) and blocking time is bounded by priority inheritance (section 08), the worst-case response time (WCRT) of a task is just three numbers added together:

TermMeaning
C (own cost)How long this task's own body takes to run, worst case.
I (interference)How much a higher-priority task can preempt and delay this one — bounded because you know every higher-priority task's own worst-case execution time.
B (blocking)The longest a lower-priority task can hold a mutex this task needs — bounded by priority inheritance to just one critical section's length, never more (that's the entire point of section 08).

WCRT = C + I + B. If that sum fits inside the task's deadline, you've mathematically proven it holds — every time, not just in your test runs. This technique is called rate-monotonic analysis.

worked numbers — the balance task's WCRT

TaskPriorityOwn cost (C)Role
H — balance correction3 (highest)1msthe task we're proving a deadline for
M — IMU processing22mshigher than L, but lower than H
L — telemetry logging1 (lowest)3ms, holds the I2C mutex for at most 1ms of thatcan briefly block H

H is the highest-priority task, so nothing can preempt it — I = 0. The only thing that can delay it is L holding a mutex H also needs, and priority inheritance caps that at L's critical section length — B = 1ms. H's own body — C = 1ms.

WCRT = C + I + B = 1 + 0 + 1 = 2ms

If H's actual deadline is 5ms, 2ms < 5ms — proven, not hoped for. This is exactly why section 08's fix mattered: without priority inheritance, B wouldn't be bounded by "one critical section" at all — it could be however long M happened to run, unbounded, and this whole calculation would fall apart.

Why a general-purpose OS gives you no such number

A GPOS scheduler optimizes for fairness and throughput, not analyzability — it can reschedule based on dynamic priority adjustments, background daemons, page faults, disk I/O, network stacks, none of which have a knowable worst case. There's no formula like the one above you could even attempt to write for Linux's default scheduler; "the terms in the sum are themselves unbounded" is the whole reason a GPOS can't make this promise, no matter how fast it usually is.

11

RTOS in the wild

Definition: the same concepts, packaged at different points on the size / certification / license spectrum.
RTOSLicenseShips in
FreeRTOSMITThe Cortex-M default; countless IoT & consumer MCUs. Stewarded by Amazon.
ZephyrApache-2.0Modern, scalable, big driver stack. Wearables, BLE, Matter. Linux Foundation.
VxWorksproprietaryHard real-time, safety-certifiable. Mars rovers, avionics, industrial.
RTEMSopen (mod. GPL)Spaceflight & scientific instruments; ESA / NASA probes.
QNXproprietaryPOSIX microkernel — drivers in user space. Automotive infotainment / ADAS, medical.

C recall · fundamentals → embedded

The C notes, worked through.

Every topic covered so far, laid out the same way as the RTOS reference — a one-line definition, the facts that carry weight, the traps that cost you, and a small worked example. Pointers, memory, and bits go deepest, because that's what embedded interviews probe.

00

Program structure & I/O

Definition: every C program starts at main; printf/scanf move formatted text in and out, matched to a variable's type by a format specifier.
  • Types: int whole numbers · float/double decimals · char one character.
  • Specifiers: %d int · %f float/double · %c char · %zu size_t · %p pointer.
  • End output with \n. A wrong specifier reads the wrong bytes off the stack — undefined, not just ugly.

example

#include <stdio.h>

int main(void) {
  int   age  = 25;
  double pi  = 3.14159;
  char  grade = 'A';
  printf("%d  %.2f  %c\n", age, pi, grade);   // 25  3.14  A
  return 0;
}
01

Operators & type conversion

Definition: arithmetic on two ints stays an int — the result is truncated unless you cast one side to a floating type first.
  • 7 / 2 == 3 (integer division drops the remainder). % is the remainder — integers only.
  • Force a real quotient by promoting one operand: (double)a / b.

example

int a = 7, b = 2;
printf("%d\n", a / b);            // 3   truncated
printf("%d\n", a % b);            // 1   remainder
printf("%.2f\n", (double)a / b);  // 3.50  cast first
02

Control flow

Definition: if/else if runs the first matching branch only; loops repeat a block while a condition holds.
  • Once an earlier branch matches, later ones are skipped — no need to re-check the lower bound of a range.
  • for when the count is known; while when it isn't. Forgetting to advance the loop variable is the classic infinite loop.

example · one branch per number

for (int i = 1; i <= 15; i++) {
  if      (i % 15 == 0) printf("FizzBuzz\n");
  else if (i % 3  == 0) printf("Fizz\n");
  else if (i % 5  == 0) printf("Buzz\n");
  else                 printf("%d\n", i);
}
03

Functions

Definition: parameters are passed by value — the function gets a copy, so it cannot change the caller's variable unless you pass a pointer to it.
  • Keep computation separate from side effects: return a value and let the caller decide what to do with it.
  • Passing an address is how you "return" more than one thing, or mutate the original.

example · compute vs. print, kept separate

int isPrime(int n) {
  for (int i = 2; i < n; i++)
    if (n % i == 0) return 0;   // found a divisor
  return 1;
}
// caller decides to print:
if (isPrime(13)) printf("prime\n");
04

Arrays

Definition: a fixed-size, contiguous, zero-indexed block of one type — with no bounds checking. Writing past the end is undefined behavior.
  • sizeof(arr)/sizeof(arr[0]) gives the element count — but only on the real array, never a pointer to it.
  • You can't reassign a whole array after declaration. Seed a min/max scan from arr[0], not a guessed constant.

example · min / max / sum in one pass

int a[5] = {3, 43, 34, 2, 8};
int n = sizeof(a) / sizeof(a[0]);
int sum = 0, min = a[0], max = a[0];
for (int i = 0; i < n; i++) {
  sum += a[i];
  if (a[i] > max) max = a[i];
  if (a[i] < min) min = a[i];
}
05

Pointers

Definition: a pointer holds a memory address. & takes an address; * means "pointer to" in a declaration and "the value at" in an expression.
  • p is the address, *p is the value there. Modifying *p changes the caller's original.
  • const int *p can't change the value; int *const p can't be repointed.
  • void* holds any address but must be cast before dereferencing.

example · swap through pointers

void swap(int *a, int *b) {
  int t = *a;   // value at a
  *a = *b;
  *b = t;
}
int x = 1, y = 2;
swap(&x, &y);      // pass addresses → x==2, y==1
06

Pointer arithmetic & array decay

Definition: p+1 advances by sizeof(*p) bytes, not one; and an array passed to a function decays to a pointer, losing its size.
  • arr[i] is literally *(arr+i).
  • Inside a function, int a[10], int a[], and int *a are the same type — so sizeof gives the pointer size (8), not the array. Always pass the length.

example · length must travel with the pointer

int sum(int *a, int len) {   // len is not optional
  int s = 0;
  for (int i = 0; i < len; i++) s += a[i];
  return s;
}
int nums[10] = { /* ... */ };
sum(nums, sizeof(nums)/sizeof(nums[0]));  // size known HERE only
07

Strings

Definition: a C string is a char array ending in a null terminator \0. Every string function walks memory until it hits that byte.
  • char s[] = "hi" is your own mutable copy; char *s = "hi" points at a read-only literal — writing to it is undefined.
  • Forget the terminator on a hand-built buffer and functions read off the end. strlen/strcmp are just loops.

example · strlen from scratch

int myStrlen(const char *s) {
  int len = 0;
  while (s[len] != '\0') len++;   // stop at terminator
  return len;
}
08

Structs

Definition: a struct groups fields of different types under one name; access with . on a value and -> on a pointer.
  • p->x is shorthand for (*p).x.
  • Padding: the compiler aligns fields, so sizeof(struct) can exceed the sum of its members. Order fields big→small to shrink it.
  • Pass big structs by pointer — cheaper, and lets the function mutate them.

example

typedef struct {
  int id;
  float reading;
  char status;
} Sensor;                         // sizeof == 12, not 9 (padding)

void print(Sensor *s) {
  printf("%d %.1f %c\n", s->id, s->reading, s->status);
}
09

Pointer to pointer

Definition: to change a value you pass one *; to change what a pointer points at, you pass the address of that pointer — **.
  • Ladder: p=address · *p=value · pp=address of p · *pp=p · **pp=value.
  • Ask yourself: am I changing the data, or changing which thing the pointer refers to?

example · redirect the caller's pointer

void pointAt(int **pp, int *target) {
  *pp = target;         // changes the caller's pointer itself
}
int a = 1, b = 2, *p = &a;
pointAt(&p, &b);         // now *p == 2
10

Unions & bitfields

Definition: a union's members all share the same memory (size = the largest member); a bitfield pins a struct member to an exact number of bits.
  • Unions let you view the same bytes as two types — e.g. inspect a float byte-by-byte (type punning).
  • Bitfields model hardware registers. Use unsigned — a 1-bit signed field reads back 0/−1, not 0/1. You can't take sizeof or & of one.

example · same 4 bytes, two views

union { float f; unsigned char b[4]; } u;
u.f = 23.5f;
for (int i = 0; i < 4; i++)
  printf("%02X ", u.b[i]);   // raw bytes of the float
11

Dynamic memory

Definition: malloc/calloc/realloc/free manage the heap — memory you request and must release by hand.
  • malloc = uninitialized · calloc = zeroed · realloc may move the block, so assign it to a temp and null-check before overwriting.
  • One free per allocation, then set the pointer NULL. Order matters: free, then NULL. Always check the return for NULL.
  • The bug family: leaks, double-free, use-after-free (dangling pointer).

example · grow safely

int *p = malloc(n * sizeof(int));
if (!p) return 1;

int *tmp = realloc(p, (n + 5) * sizeof(int));
if (!tmp) { free(p); return 1; }   // original still valid
p = tmp;

free(p);
p = NULL;                            // free, THEN null
12

Storage classes & volatile

Definition: storage classes control a variable's lifetime and visibility; volatile tells the compiler a value can change outside this code's control — never optimize the read away.
  • static in a function keeps its value across calls; at file scope it limits visibility to that file.
  • volatile is essential for memory-mapped registers and variables shared with an ISR.
  • const volatile = a read-only hardware status register: hardware changes it, your code only reads.

example · a counter that survives calls

int nextId(void) {
  static int id = 0;   // initialized once, kept across calls
  return ++id;
}
// nextId() → 1, 2, 3 ...
13

Bitwise operators

Definition: operate on individual bits using a mask (1<<n) — a value with only bit n set. Core to register manipulation.
  • Set x |= (1<<n) · Clear x &= ~(1<<n) · Toggle x ^= (1<<n) · Test x & (1<<n).
  • Bit 0 is the rightmost (LSB); ~ flips every bit. Use uint8_t to model an 8-bit register.

example · the four operations

unsigned char reg = 0;
reg |=  (1 << 3);      // set   bit 3  → 0000 1000
reg &= ~(1 << 3);      // clear bit 3  → 0000 0000
reg ^=  (1 << 5);      // toggle bit 5 → 0010 0000
if (reg & (1 << 5)) puts("bit 5 set");
14

Function pointers

Definition: a variable holding the address of a function, so you can call it indirectly, store it in a table, or pass it as a callback.
  • Declaration: int (*op)(int,int) — the parentheses are mandatory (without them it's a function returning int*).
  • An array of them is a dispatch table — and passing one as a callback is exactly how xTaskCreate receives an RTOS task.

example · a dispatch table

int add(int a,int b){return a+b;}
int sub(int a,int b){return a-b;}

int (*ops[2])(int,int) = { add, sub };
printf("%d\n", ops[0](10, 5));   // 15
15

Memory layout & macros

Definition: a running program is split into fixed regions; the preprocessor does blind text substitution before compilation — with no idea of types or precedence.
  • Regions: stack (locals) · heap (malloc) · .data (initialized globals/statics) · .bss (zeroed) · .text (code, read-only).
  • An int x = 5; outside any function lives in .data; an uninitialized int y; lives in .bss. A static local lives in the same place — .data/.bss, not the stack — which is why it survives across calls.
  • Because macros are pure text, wrap the body and every parameter in parentheses, or precedence bites you.

example · why the parens matter

#define SQ_BAD(x)  x * x
#define SQ(x)     ((x) * (x))

SQ_BAD(2 + 3)   // → 2 + 3*2 + 3 == 11   (wrong)
SQ(2 + 3)       // → ((2+3)*(2+3)) == 25 (right)
16

The bridge to RTOS

Definition: three everyday C idioms you'll meet the moment you open a FreeRTOS or vendor SDK header — all built from things above.
  • Designated initializers{ .name="t", .priority=P } set struct fields by name and skip the rest (they zero). Common in SDK config structs.
  • Opaque handletypedef struct QueueDefinition *QueueHandle_t; is a pointer to an incomplete type: you hold it and pass it, but can't peek inside. That's how libraries hide their internals.
  • Out-parametersxQueueReceive(q, &v, ...) passes &v so the call writes its result back into your variable, exactly like scanf.

example · all three at once

const osThreadAttr_t attr = {   // designated initializers
  .name = "blink",
  .priority = osPriorityNormal,
  .stack_size = 128 * 4,
};
QueueHandle_t q = xQueueCreate(10, sizeof(int));  // opaque handle
int v;
xQueueReceive(q, &v, portMAX_DELAY);       // out-parameter

Worked visually

vTaskDelay vs vTaskDelayUntil

The same loop, run two ways. Watch where each delay period actually starts counting from — that's the entire difference.

task body running (do_work) — length varies each lap blocked in the delay call
vTaskDelay(10)counts from the call site — drifts
vTaskDelayUntil(&last, 10)counts from the last target wake time — fixed
same 4 laps, same body durations, in both rows above

Reading the diagram

  • In the top row, each lap's teal "wait" block starts right after that lap's amber body block ends — so a long body pushes the whole rest of the timeline later. The chips below the track show each lap's actual total period, and every number is different.
  • In the bottom row, the dashed tick marks sit at the fixed targets 0, 10, 20, 30 no matter how long the amber body took — a long body just eats into that lap's own wait time. The chips below all read the same: 10ms.

code for both rows

// TOP — drifts
for (;;) {
  do_work();                       // variable duration
  vTaskDelay(pdMS_TO_TICKS(10));   // counts 10 ticks from NOW
}

// BOTTOM — fixed period
TickType_t last = xTaskGetTickCount();
for (;;) {
  do_work();                                    // variable duration
  vTaskDelayUntil(&last, pdMS_TO_TICKS(10));   // counts from LAST TARGET
}