# RTOS vs Bare Metal: When a Microcontroller Needs a Scheduler

> A superloop is the right architecture more often than embedded tutorials admit — until the day it isn't, and you need preemption, priorities, and blocking. What FreeRTOS actually buys you on an ESP32, and what it costs.

URL: https://harshith.in/blog/rtos-vs-bare-metal/
Author: Harshith Sunku (Staff Engineer) — https://harshith.in/about/
Tags: embedded, systems, concurrency

Building [an ESP32 Wake-on-LAN gateway](https://harshith.in/projects/esp32-mqtt-wol/), I hit the decision
every embedded project reaches: superloop or RTOS? The tutorials treat
this as a maturity ladder — bare metal for beginners, RTOS for "real"
firmware — which is wrong and leads people to bolt a scheduler onto
problems that never needed one. The honest framing is about *what
kind of timing you're promising*, and it's worth getting right because
the wrong choice is either needless complexity or a rewrite.

## The Superloop Is Not a Toy

Bare-metal firmware is one big loop plus interrupt handlers:

```c
for (;;) {
    read_sensors();
    update_control();
    service_comms();
    update_display();
}
```

Its virtues are real and underrated: you can reason about the *entire*
program's timing by reading one function top to bottom, there's no
scheduler overhead, no per-task stacks eating your 300 KB of RAM, and
no concurrency bugs because there's no concurrency — just a sequence.
For a huge class of devices (a thermostat, a sensor beacon, my WOL
gateway's core logic) this is not a compromise, it's the correct,
debuggable, shippable architecture. The interrupts handle the truly
time-critical edges; the loop handles everything else in a rhythm you
can measure with one GPIO toggle and a scope.

The superloop breaks on one specific problem: **a long task starving a
time-sensitive one.** If `update_display()` takes 40 ms and
`update_control()` must run every 5 ms, the loop can't honor both —
one task's worst-case duration becomes every other task's worst-case
latency. You can fight this with state machines (chop the long task
into resumable pieces) and it works until the state machines collapse
under their own weight. That collapse is the actual signal to reach
for an RTOS — not project size, not prestige.

## What an RTOS Actually Provides

FreeRTOS (which ESP-IDF ships and runs by default on the ESP32's two
cores) gives you **preemptive priority scheduling**: independent tasks
with priorities, and the scheduler *interrupts* a lower-priority task
the instant a higher-priority one becomes ready.

```mermaid
flowchart TD
    T[SysTick / event] --> S{Higher-priority<br/>task ready?}
    S -- yes --> P[Preempt: save context,<br/>switch to it]
    S -- no --> C[Resume current task]
    P --> R[High task runs, then blocks]
    R --> C
```

The transformative capability is **blocking**: a task calls
`vTaskDelay()` or waits on a queue/semaphore and the scheduler runs
*other* tasks meanwhile — no more chopping logic into state machines,
because "wait here" no longer means "freeze everything." Each task
gets linear, readable code; the control task's 5 ms deadline is met
because it preempts the display task mid-work. Tasks communicate
through queues (the idiomatic, race-free channel) and synchronize with
semaphores/mutexes. On the ESP32 specifically, this is also how you
use both cores and coexist with the Wi-Fi/BT stack, which itself runs
as high-priority RTOS tasks you must not starve.

The costs are equally concrete and are where beginners get hurt:
**per-task stacks** (each task needs its own, sized for worst-case
nesting + ISR overhead — undersize it and you get a stack overflow
that corrupts memory silently; FreeRTOS's high-water-mark API exists
to tune this, and you must use it), context-switch overhead, and —
the classic real-time trap — **priority inversion**: a high-priority
task blocked on a mutex held by a low-priority task that a
medium-priority task keeps preempting. The high task waits on the low
task indefinitely; this is the bug that famously nearly killed the
1997 Mars Pathfinder mission. FreeRTOS mutexes offer priority
inheritance to bound it — use mutexes (not binary semaphores) for
resource protection precisely for this reason.

And note what "real-time" does and doesn't mean: an RTOS gives
*bounded, predictable* latency, not *fast*. Hard real-time requires
you actually analyze worst-case execution times and prove deadlines
are met; the scheduler is a tool for meeting deadlines, not a promise
that they are met. Plenty of "RTOS" projects have worse worst-case
timing than a tight superloop because nobody did that analysis.

## Takeaways

- The superloop is the correct architecture whenever you can meet all
  timing by reading one loop — no scheduler tax, no concurrency bugs.
- It fails on the specific problem of a long task starving a
  deadline-bound one; drowning in state machines is the signal to
  switch, not project size.
- An RTOS buys preemptive priority scheduling and *blocking* — linear
  task code, met deadlines via preemption, multi-core use.
- It charges per-task stacks (size them with high-water marks or
  corrupt memory), context switches, and priority-inversion risk
  (use priority-inheriting mutexes).
- Real-time = bounded and predictable, not fast; without worst-case
  analysis you have a scheduler, not a guarantee.
