Real-Time Operating Systems
"Real-time" means deterministic, not fast. A system that responds in a guaranteed 10 milliseconds every single time is real-time; one that averages 1 millisecond but occasionally takes 200 is not. For a motor controller, an airbag, or an audio pipeline, the guaranteed bound is the requirement and the average is nearly irrelevant.
An RTOS provides that guarantee by giving you tasks with priorities and a scheduler that always runs the highest-priority ready task, preempting anything lower the instant it becomes ready. You gain the ability to write each activity as its own straightforward sequential loop instead of a hand-interleaved state machine — and you take on the classic concurrency problems: shared data, deadlock, priority inversion, and a separate stack to size for every task.
Most firmware starts as a superloop and should. The question is knowing when it stops working.
TL;DR
- Real-time = deterministic bounds, not high throughput.
- An RTOS schedules tasks by priority, preemptively — the highest-priority ready task always runs.
- A superloop is fine until activities have genuinely different timing requirements.
- Queues are the preferred way to move data between tasks; shared globals with mutexes are a fallback.
- Mutex ≠ semaphore. Mutexes have ownership and priority inheritance; semaphores are signals and counters.
- Priority inversion is the classic failure — a mutex with priority inheritance is the fix.
- Every task needs its own stack, sized from measurement, not guesswork.
- ISRs must use the
FromISRAPI variants and must not block.
Quick Example
The same device, written both ways:
The change that matters is in telemetry_task: it blocks on the network for 200 ms and the sensor still runs at exactly 100 Hz, because the scheduler preempts it. In the superloop, that blocking call breaks everything.
Core Concepts
Tasks and states
Exactly one task runs at a time on a single core. The scheduler picks the highest-priority ready task; a blocked task consumes no CPU at all, which is the key to both efficiency and power saving.
Preemptive priority scheduling
Same-priority tasks are time-sliced round-robin if the configuration enables it. The design rule is simple: priority reflects deadline urgency, not importance. A task with a 1 ms deadline outranks one with a 1 s deadline regardless of which matters more to the product.
Keep the number of priority levels small. Systems with fifteen distinct priorities are almost always systems nobody can reason about.
Blocking delays vs. busy waiting
vTaskDelay waits 10 ms after the work finishes, so the period drifts by however long the work took. vTaskDelayUntil targets absolute times and stays locked to the intended rate — the right choice for any periodic task.
Queues
Queues are the preferred inter-task communication mechanism because they combine data transfer with synchronization and copy by value, avoiding shared-memory hazards entirely.
Queues copy the data, so the sender can reuse its buffer immediately. For large payloads, send a pointer to a pool-allocated buffer instead, with a clear ownership rule about who frees it.
Mutexes vs. semaphores
They look similar and are used for completely different things.
Using a semaphore where a mutex belongs loses priority inheritance, which is exactly how the priority inversion problem appears.
Priority inversion
Priority inheritance is the fix: while a high-priority task waits on a mutex, the mutex's owner is temporarily raised to that priority, so it can't be preempted by anything in between. FreeRTOS mutexes (not semaphores) implement this, which is the practical reason to use the right primitive.
Deadlock is the other hazard: two tasks each holding one mutex and waiting for the other's. The reliable prevention is a global lock ordering — always acquire mutexes in the same sequence — plus timeouts on every take.
Stack sizing
Every task has its own stack, and overflow silently corrupts adjacent memory on an MCU with no MMU.
Note the unit: FreeRTOS usStackDepth is in words, so 512 is 2 KB on a 32-bit MCU. Getting this wrong by a factor of four is a common early bug.
Size it by measurement rather than guessing:
Enable configCHECK_FOR_STACK_OVERFLOW in development and implement the hook. Then log high-water marks in a debug build, size each stack at the peak plus a safety margin, and remember that a deep call chain or a large local array — char buf[1024] — can blow a stack that was fine yesterday.
Choosing an RTOS
FreeRTOS is the default: small, well documented, supported by every vendor, and enough for most projects. Zephyr is closer to a small operating system — device tree configuration, a driver model, a networking stack, Bluetooth, and a build system — which is more setup and much less integration work on a connected product. Bare metal remains correct for genuinely simple devices, and choosing it deliberately is not a lesser choice.
Best Practices
Start with a superloop; adopt an RTOS when you need one
A superloop with a timer tick and non-blocking state machines handles a surprising amount. Move to an RTOS when activities have conflicting timing requirements, when a blocking library forces the issue, or when the manual interleaving becomes the hardest part of the code.
Assign priority by deadline, not importance
The task with the tightest deadline gets the highest priority. This is what rate-monotonic scheduling formalizes, and it's counterintuitive to product thinking — the safety-critical task might correctly sit below a fast sensor task.
Prefer queues to shared memory
A queue is a copy plus a synchronization point. Shared globals with a mutex require every access site to be correct forever. Where you must share, keep the critical sections tiny and never call blocking functions inside one.
Use FromISR variants and yield correctly
Calling a blocking API from an ISR corrupts the scheduler. Omitting the yield means the woken high-priority task waits until the next tick — which quietly destroys your latency guarantee.
Never block while holding a mutex
Holding a lock across a network call, a long I/O operation, or a vTaskDelay blocks every other user of that resource for the duration. Take the lock, do the minimum, release.
Measure stack usage, don't estimate it
Enable overflow checking in development, read high-water marks, and set sizes from data. Fill the stacks with a known pattern at startup so you can inspect unused depth even without RTOS support.
Give every blocking call a timeout
portMAX_DELAY is appropriate for a consumer task whose only job is to wait for work. Everywhere else, a finite timeout with an error path converts a silent hang into a diagnosable failure.
Keep the idle task useful
The idle hook is where you put the MCU into a low-power mode (tickless idle in FreeRTOS). On a battery device this is often the largest single power saving available, and it's essentially free.
Common Mistakes
Busy-waiting inside a task
Using a semaphore for mutual exclusion
Blocking API calls from an ISR
Misreading the stack size unit
Priority inversion from a plain lock
Too many priority levels
FAQ
Do I need an RTOS?
Only if you have concurrent activities with genuinely different timing requirements, or a blocking library you can't restructure around. A device that samples a sensor and blinks an LED does not. A device servicing a radio, running a control loop, and driving a UI does. If your superloop is full of manual state machines and timing counters, that's the signal.
What overhead does it add?
RAM: the kernel plus a stack per task, typically 5–15 KB total for a small FreeRTOS system. Flash: 6–12 KB. CPU: a context switch is on the order of microseconds, and the tick interrupt runs at your configured rate (commonly 1 kHz). On a part with 20 KB of RAM this is significant; on one with 256 KB it's noise.
FreeRTOS or Zephyr?
FreeRTOS for most MCU projects — smaller, simpler, universally supported, and quick to learn. Zephyr for connected products where its built-in networking, Bluetooth, driver model, and device tree save more work than its complexity costs. Zephyr is closer to a small operating system and the learning curve reflects that; it also handles the "port to a different MCU" problem far better.
How do I debug an RTOS application?
Use a trace tool — Tracealyzer, SEGGER SystemView, or Percepio — which shows task switches, blocking, and interrupt timing on a timeline. Concurrency bugs are essentially invisible to a debugger, because stopping at a breakpoint destroys the timing that caused them. Also enable stack overflow checking, malloc-failed hooks, and assertions in development builds.
What's a tickless idle?
Instead of waking on every tick just to increment a counter, the kernel computes how long until the next task must run and sleeps for exactly that long, correcting the tick count on wake. On battery-powered devices this is frequently the largest single power win available, and it's a configuration option rather than a code change.
Can I use an RTOS on a multi-core MCU?
Yes, with a symmetric multiprocessing configuration (FreeRTOS SMP, Zephyr SMP) or by running an independent instance per core with explicit inter-core communication. The asymmetric approach — one core for real-time control, one for connectivity — is common on parts like the RP2040 and ESP32, and it's often simpler to reason about than true SMP.
Related Topics
- Microcontrollers — Interrupts and timers underneath the scheduler
- Embedded C —
volatile, atomicity, and ISR-safe patterns - Embedded Rust — RTIC and async as alternatives to task-based RTOS
- ESP32 — Ships with FreeRTOS as its default runtime
- Concurrency Patterns — Locks, queues, and deadlock in general
- MQTT — Typically driven from a dedicated network task
- IoT Security — Task isolation and memory protection