Real-Time Operating Systems
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.
Three ways to structure embedded software, in increasing order of control:
while(1) polling everything. Simple, but coupled: one slow step delays all the others, and there's no notion of priority.Everything below is the machinery that makes that guarantee possible.
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.
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);
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.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.xTaskCreate| Parameter | Here | What it is |
|---|---|---|
| 1 · task code | vBlinkTask | The 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 depth | 128 | The 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 · argument | NULL | A 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 · priority | 2 | How urgent this task is. Higher number = higher priority. The scheduler always runs the highest-priority ready task. |
| 6 · handle out | NULL | An 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." |
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.
scanfxTaskCreate 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
vBlinkTask by name is passing its address; the kernel calls it like an entry in a dispatch table. c.notes 14void * 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"blink" is a read-only char array, terminator and all. c.notes 07scanf("%d", &x). c.notes 03TaskHandle_t is an opaque pointer to it — you hold it but can't peek inside. c.notes 08 · 16Below: 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
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.
vTaskDelay(n) — counts from right nowThis 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 nowTwo 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.
last actually islast 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:
target = last + n — the next slot on the fixed timetable.target.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
| Lap | last (before call) | target = last+10 | body took | wakes at | last (after call) |
|---|---|---|---|---|---|
| 1 | 0 | 10 | 3ms | 10 | 10 |
| 2 | 10 | 20 | 6ms | 20 | 20 |
| 3 | 20 | 30 | 1ms | 30 | 30 |
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
&last lets the function write its result back into your variable, identical to scanf("%d", &x). c.notes 03last 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 · 12It 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.
xQueueCreate| Parameter | Example | What it is |
|---|---|---|
| 1 · length | 10 | How many items the queue can hold at once before it's full. Not bytes — item slots. |
| 2 · item size | sizeof(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.
xQueueSend / xQueueReceive| Parameter | Example | What it is |
|---|---|---|
| 1 · handle | distQ | Which queue — the same QueueHandle_t returned by xQueueCreate. |
| 2 · item address | &reading | Send: the address to copy from. Receive: the address to copy into. Either way, an address — never the value itself. |
| 3 · ticks to wait | portMAX_DELAY | How 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. |
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
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
&d and &reading are the same address-of pattern as every out-parameter you've seen (scanf, &last, &myHandle). c.notes 05QueueHandle_t is a pointer to an incomplete struct, same as TaskHandle_t. c.notes 16A 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.
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.
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
| Moment | Event | count before | What happens | count after |
|---|---|---|---|---|
| t=0 | Task A wants to send | 3 | take succeeds, A grabs a channel | 2 |
| t=0 | Task B wants to send | 2 | take succeeds, B grabs a channel | 1 |
| t=0 | Task C wants to send | 1 | take succeeds, C grabs a channel | 0 |
| t=0 | Task D wants to send | 0 | take blocks — all 3 channels busy, D just waits | 0 |
| t=2ms | Task A's transfer finishes | 0 | A calls give | 1 |
| t=2ms | (same instant) | 1 | Task D, waiting, is woken immediately and grabs the freed channel | 0 |
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.
| Call | Parameters | What they mean |
|---|---|---|
| xSemaphoreCreateBinary() | none | Starts at 0 ("nothing pending"). No count to configure — it's just one bit. |
| xSemaphoreCreateCounting(3, 3) | max count, initial count | Max = 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 wait | Decrements if >0; blocks (up to wait ticks) if 0. |
| xSemaphoreGive(sem) | handle only | Increments 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
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
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 03xSemaphoreGive(sem) takes just the handle, because unlike xQueueSend/xQueueReceive there's no data to copy through an address. c.notes 05SemaphoreHandle_t, same pointer-to-incomplete-struct pattern as QueueHandle_t and TaskHandle_t. c.notes 16Has an owner. For protecting shared data (a bus, a buffer). Supports priority inheritance.
No owner. For signaling between an ISR/task and another task. Do not use it to guard data.
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.
| Call | Parameters | What they mean |
|---|---|---|
| xSemaphoreCreateMutex() | none | Returns 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 wait | Lock it. Blocks if another task already holds it. This task now "owns" the lock. |
| xSemaphoreGive(bus) | handle only | Unlock 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.
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
Fixed — priority inheritance · H meets its 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
SemaphoreHandle_t for a mutex is the same pointer-to-incomplete-struct as every other RTOS handle you've seen. c.notes 16Task 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.
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.
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.
| Line | What 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
volatile/register concept from earlier. c.notes 12xQueueSendFromISR 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 05FromISR 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.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:
| Term | Meaning |
|---|---|
| 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
| Task | Priority | Own cost (C) | Role |
|---|---|---|---|
| H — balance correction | 3 (highest) | 1ms | the task we're proving a deadline for |
| M — IMU processing | 2 | 2ms | higher than L, but lower than H |
| L — telemetry logging | 1 (lowest) | 3ms, holds the I2C mutex for at most 1ms of that | can 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.
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.
| RTOS | License | Ships in |
|---|---|---|
| FreeRTOS | MIT | The Cortex-M default; countless IoT & consumer MCUs. Stewarded by Amazon. |
| Zephyr | Apache-2.0 | Modern, scalable, big driver stack. Wearables, BLE, Matter. Linux Foundation. |
| VxWorks | proprietary | Hard real-time, safety-certifiable. Mars rovers, avionics, industrial. |
| RTEMS | open (mod. GPL) | Spaceflight & scientific instruments; ESA / NASA probes. |
| QNX | proprietary | POSIX microkernel — drivers in user space. Automotive infotainment / ADAS, medical. |
C recall · fundamentals → embedded
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.
main; printf/scanf move formatted text in and out, matched to a variable's type by a format specifier.int whole numbers · float/double decimals · char one character.%d int · %f float/double · %c char · %zu size_t · %p pointer.\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; }
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.(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
if/else if runs the first matching branch only; loops repeat a block while a condition holds.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); }
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");
sizeof(arr)/sizeof(arr[0]) gives the element count — but only on the real array, never a pointer to it.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]; }
& 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
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).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
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.example · strlen from scratch
int myStrlen(const char *s) { int len = 0; while (s[len] != '\0') len++; // stop at terminator return len; }
. on a value and -> on a pointer.p->x is shorthand for (*p).x.sizeof(struct) can exceed the sum of its members. Order fields big→small to shrink it.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); }
*; to change what a pointer points at, you pass the address of that pointer — **.p=address · *p=value · pp=address of p · *pp=p · **pp=value.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
float byte-by-byte (type punning).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
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.free per allocation, then set the pointer NULL. Order matters: free, then NULL. Always check the return for NULL.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
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 ...
(1<<n) — a value with only bit n set. Core to register manipulation.x |= (1<<n) · Clear x &= ~(1<<n) · Toggle x ^= (1<<n) · Test x & (1<<n).~ 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");
int (*op)(int,int) — the parentheses are mandatory (without them it's a function returning int*).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
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.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)
{ .name="t", .priority=P } set struct fields by name and skip the rest (they zero). Common in SDK config structs.typedef 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.xQueueReceive(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 vTaskDelayUntilThe same loop, run two ways. Watch where each delay period actually starts counting from — that's the entire difference.
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 }