Building an ESP32 Wake-on-LAN gateway, 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:
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.
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 --> CThe 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.