On a microcontroller there is no operating system: no processes, no threads (unless an RTOS provides them), no filesystem, and often no heap you want to use. There are kilobytes of flash for code and RAM for data, and the device may have to run for years without a reboot. C++ is a very good fit — if you know which parts cost what.
1. Hosted and freestanding
The standard defines two kinds of implementation:
- Hosted: the full library — containers, strings, streams, threads, filesystem.
- Freestanding: what can work with no OS. It used to be little more
than
<cstdint>,<limits>,<type_traits>and<new>. C++20–26 added a lot:<bit>,<utility>,<tuple>,<ranges>,<atomic>, and in C++26<array>,<optional>,<variant>,<expected>,<string_view>and much of<algorithm>— minus the members that throw (array::at,optional::value,expected::value).
freestanding (no OS needed) hosted only
───────────────────────────── ─────────────────────────────
<bit> <utility> <tuple> <type_traits> <vector> <string> <map>
<array>* <optional>* <variant>* <iostream> <fstream> <format>
<expected>* <string_view>* <ranges> <thread> <mutex> <filesystem>
<atomic> <new> <limits> <cstdint>
* minus the members that throwThis board's toolchain is GCC with newlib, which is a hosted-ish C
library: malloc, printf and fopen all work (through semihosting to the
host), so everything compiles. That's the trap — the code links and runs,
and the image is 60 KB bigger than it needed to be, with a general-purpose
heap inside.
2. Memory without a heap
new on bare metal is not magic: it calls the global operator new
(bl _Znwj), which calls newlib's malloc, which carves blocks out of RAM
beyond the end of .bss. It works — but allocation time varies,
fragmentation can make a long-running device fail after a week, and "out of
memory" has nowhere to go. Firmware decides its memory at compile time:
.data or .bss, sized by the compiler. A heap, if any, grows up into the gap the stack grows down into — and nobody checks when they meet.std::array<T, N>and C arrays for fixed tables;std::inplace_vector<T, N>(C++26): a vector whose capacity is part of the object —try_push_backreturnsnullptrwhen full;- placement new into static, aligned storage for objects whose lifetime
you control, ended with
std::destroy_at; - pools of fixed-size slots for objects that come and go (the build task).
#include <cstddef>
#include <cstdio>
#include <inplace_vector>
#include <memory>
#include <new>
struct Sensor {
int addr;
explicit Sensor(int a) : addr(a) { std::printf("Sensor(%#x)\n", addr); }
~Sensor() { std::printf("~Sensor(%#x)\n", addr); }
};
int main() {
// A fixed-capacity list: no heap, refuses the 4th element.
std::inplace_vector<int, 3> readings;
for (int i = 0; i < 4; ++i)
if (!readings.try_push_back(i * 100)) std::printf("full, dropped %d\n", i * 100);
// An object in static storage: placement new in, destroy_at out.
alignas(Sensor) static std::byte storage[sizeof(Sensor)];
Sensor* s = ::new (static_cast<void*>(storage)) Sensor(0x48);
std::printf("sizeof readings = %zu, sensor at %p\n", sizeof readings, static_cast<void*>(s));
std::destroy_at(s);
}try_push_back(300) finds no room and returns nullptr — no allocation, no exception, and sizeof readings is fixed at compile time.Two details matter with raw storage: alignment (alignas(T) — a
std::byte array is 1-byte aligned) and lifetime (construct before
use, destroy exactly once, never delete what wasn't newed).
3. Exceptions and RTTI
Most firmware is built with -fno-exceptions -fno-rtti. It's worth knowing
exactly what that buys:
struct Guard { Guard(); ~Guard(); };
void step(int);
extern "C" void work(int n) { Guard g; step(n); step(n + 1); }g and resumes unwinding — 3 more instructions here, plus unwind tables.The cost of exceptions isn't the throw — it's a landing pad in every
function that holds an object with a destructor across a call that might
throw, plus unwind tables (.ARM.exidx/.ARM.extab) and the unwinder
itself. Marking functions that can't fail noexcept removes their
landing pads even with exceptions on.
-fno-exceptions changes your code: throw/try stop compiling. It
does not change the prebuilt library: std::array::at still calls
std::__throw_out_of_range_fmt, and an exception with no handler means
std::terminate. -fno-rtti removes the type_info tables, and with them
dynamic_cast (which calls __dynamic_cast) and typeid: use your own
type tags or std::variant instead.
To write code that works both ways (tests usually run with exceptions, firmware without), test the feature macro:
#if __cpp_exceptions
try { construct(); } catch (...) { undo(); throw; }
#else
construct();
#endif4. Errors without exceptions
std::expected<T, E> returns a value or an error, stored inline — no
heap, no unwinding, and the caller can't forget to look:
#include <cstdio>
#include <expected>
enum class I2cError { nack, timeout };
std::expected<int, I2cError> read_temp(bool present) {
if (!present) return std::unexpected(I2cError::nack);
return 2150; // centi-degrees
}
int main() {
for (bool present : {true, false}) {
auto t = read_temp(present).transform([](int c) { return c / 100; });
if (t) std::printf("%d C\n", *t);
else std::printf("error %d\n", static_cast<int>(t.error()));
}
}5. Static initialization
A global initialized by a function call runs at startup, from a
compiler-generated function called by crt0 before main — in an order
between translation units that you don't control (the "static
initialization order fiasco"). constexpr and constinit make
initialization happen at compile time: the value is simply in .data.
int read_config();
int baud = read_config(); // dynamic: code runs before main
constinit int div = 25'000'000 / 115'200; // constant: just data, still mutable
constinit int bad = read_config(); // error: not a constant expression6. Where the flash goes
Rules of thumb on a Cortex-M:
std::cout: iostreams, locales and static initialization — tens of KB.printf("%f"): floating-point formatting (newlib-nano drops it unless you ask).- exceptions: landing pads, unwind tables, the unwinder.
- RTTI: a
type_infoper polymorphic class. - a heap:
malloc/freeand their bookkeeping.
Free: templates you don't instantiate, constexpr computation (the result
is data), std::array, inline functions that disappear. Measure: the
compiler explorer's instruction counts, and arm-none-eabi-size on the
image.