Bare-metal or RTOS on a small MCU
The firmware for your product has grown past the point where one loop and a few interrupts feel comfortable. Someone on the team wants FreeRTOS or Zephyr. Someone else says an RTOS is bloat on a part with 32 KB of RAM and that a state machine will do. Both are right about something, and the decision gets made by whoever argues loudest rather than by looking at what the product actually has to do.
This note is the version of that argument we would have with ourselves before starting a new STM32, nRF52 or ESP32 design.
What a superloop is good at, and where it breaks
A bare-metal design is a while(1) loop that polls flags, plus interrupt handlers that set them. There is no scheduler. The only thing that can pre-empt the main loop is an interrupt, and the only shared state is whatever the interrupts touch. That is a small system to reason about. You can read the whole thing in an afternoon, you can see every path from an event to a response, and you can measure the worst-case loop time with a scope on a GPIO pin.
It breaks in a predictable way. The loop is cooperative, so one long operation delays everything behind it. A flash erase, a blocking I2C transaction with a stuck slave, or a long CRC over a firmware image all stall the loop, and now the UI is unresponsive or a sensor sample is missed. The usual fix is to break the long job into a state machine that does a slice per iteration. That works, and a well-built product can run for its whole life this way. But every long job you slice adds one more state machine, and the interactions between them live in your head rather than in the code.
A rule that has held up for us: if you can list every timing requirement of the product and each one is met by the worst-case loop time with margin, stay bare-metal. If you find yourself writing a scheduler, even a tiny one with a table of function pointers and periods, you have started writing an RTOS without the parts of an RTOS that make it debuggable.
What an RTOS actually buys, and what it actually costs
The thing an RTOS gives you is not “multitasking”. It is pre-emption with priorities. A high-priority task that services a radio or a motor control loop runs when its event fires, regardless of what the low-priority logging task was in the middle of. You stop slicing long jobs into state machines. Each task is written as straight-line code that blocks on a queue or a semaphore, and the kernel handles the interleaving.
The costs are concrete.
Flash. The FreeRTOS kernel is small. The project’s own FAQ puts the kernel at roughly 5 to 10 KB of ROM with a typical configuration, and notes that surprising growth in a tiny test program usually comes from C library string and floating-point code, not the kernel (FreeRTOS memory usage FAQ). Zephyr is larger because it carries a device model and a driver layer, and its footprint depends heavily on what you enable through Kconfig.
RAM, and this is the real one. Every task has its own stack. On a bare-metal design there is one stack and the linker tells you how big it is. With an RTOS you now have one stack per task plus the kernel’s own data, and you size each stack by guessing, then measuring. Undersize one and you get a corruption that shows up as a crash in unrelated code. Oversize all of them and a 32 KB part is full before the application is written. Any printf-style formatting, TLS, or a filesystem call inside a task adds hundreds of bytes to that task’s stack in ways that are hard to see from the source.
Debugging. A bare-metal crash has one stack to unwind. An RTOS crash has several, and the one that faulted may not be the one that caused the fault. The kernels have tooling for this. FreeRTOS offers two stack-overflow check methods and a per-task high-water-mark query, and its own documentation is honest that neither method is guaranteed to catch every overflow (FreeRTOS stack overflow checking). Zephyr’s user mode goes further on parts with a memory protection unit: thread stacks are laid out so that exceeding them raises a hardware fault, and user threads are treated as untrusted with permission tracking on kernel objects and drivers (Zephyr user mode overview). That is a real safety and security feature, but it costs configuration effort and it only applies to threads you have deliberately placed in user mode.
Shared state. The moment you have two tasks, every variable both of them touch needs a mutex, a queue, or an atomic. Bare-metal code has the same problem between the loop and its interrupt handlers, but the surface is smaller and the tools are simpler.
The decision is sometimes already made for you
Before you debate at all, check what the silicon vendor’s SDK assumes.
Espressif’s ESP-IDF is built on a modified FreeRTOS with symmetric multiprocessing support. On the ESP32 you are writing FreeRTOS tasks whether you call them that or not, and on single-core targets the same kernel runs with one core (ESP-IDF FreeRTOS SMP guide). There is no meaningful bare-metal option for a Wi-Fi or Bluetooth product on that family, because the radio stack itself runs as tasks.
Nordic’s nRF Connect SDK integrates Zephyr and adds its own libraries, drivers and the MCUboot bootloader on top (nRF Connect SDK and Zephyr). Nordic’s older bare-metal SDK still exists for the nRF52 line, but new BLE work on nRF54 and current SoftDevice controllers is steered toward Zephyr. Choosing bare-metal there means choosing to be off the vendor’s main path for updates and examples.
STMicroelectronics is the opposite case. STM32 parts run fine with nothing but the HAL or LL drivers, and the same tooling can generate a FreeRTOS project through the CMSIS-RTOS layer. Microchip’s SAM and PIC families and TI’s MSP430 are similar: bare-metal is a first-class, supported path.
So the honest first question is not “RTOS or not” but “which vendor ecosystem, and what does it assume”. A BLE product on Nordic is a Zephyr product. A Wi-Fi product on Espressif is a FreeRTOS product. A sensor node on an STM32L0 or an MSP430 is a genuine choice.
The checks a careful engineer runs before committing
When the choice is real, we work through this list rather than arguing from taste.
| Question | Points toward bare-metal | Points toward an RTOS |
|---|---|---|
| How many independent timing requirements? | One or two, all fast | Several, at different rates |
| Any operation that must block for a long time? | No, or it can be sliced | Yes, and slicing it is ugly |
| Who maintains this in three years? | The same one engineer | Someone new, needs structure |
| Vendor SDK assumes a kernel? | No | Yes, use theirs |
| Need isolation between trusted and untrusted code? | No | Yes, use an MPU-aware kernel |
Then, whichever way it goes, the same verification applies. Measure worst-case timing on the bench with a GPIO toggle and a scope, not by inspection — the same bench discipline that finds a sleep-current problem an RTOS’s idle task can otherwise hide. If it is an RTOS, turn on the kernel’s stack checking during development, read the high-water marks after a long soak test, and only then set final stack sizes with margin. If it is bare-metal, instrument the loop time the same way and log the maximum. Either way, the number you want is the one measured on the real board with the real radio and the real flash, because those are the parts that stall unexpectedly.
One more check that people skip: think about the firmware update path now. A bootloader that has to validate an image and swap slots is a long blocking operation. On an RTOS it is a task. On a bare-metal design it usually runs before the main loop starts, which is fine, as long as you have decided that on purpose.
We take on bare-metal and RTOS work across STM32, Nordic and Espressif parts, and the useful thing to send us is the list of things the product must do at the same time, with the timing each one needs.
Deciding between bare-metal and an RTOS for your next board? Send us the requirements and we will write the firmware either way.
Tools for this topic
- ESP32 browser flasherFlash a .bin over USB from the browser. It reads the chip and the file first and refuses firmware built for a different chip, revision or flash size.
- STM32 browser flasherFlash a .dfu, .hex or .bin over USB DFU from the browser. It reads the chip’s flash map and identity first and refuses firmware whose bytes or vector table do not belong on it. Never touches option bytes.
- Browser serial terminalConnect a USB board and read or send serial text and hex from the browser. Baud rate and format, DTR/RTS reset profiles for ESP32 and Arduino, timestamps and a saved log. Nothing leaves your computer.
Related notes
- Provisioning serial numbers on the lineWhere per-unit serials, keys and debug locks go on STM32, nRF52 and ESP32, what is irreversible, and how to plan the programming step.
- Firmware updates with recovery and rollbackPlan firmware updates around recovery: image validation, rollback, application confirmation, compatible data, and failure tests on shipped hardware.
- BLE provisioning for a device with no screenHow to get Wi-Fi credentials onto a screenless BLE device: what pairing really protects, per-device secrets, phone quirks, and failure paths to design.