CodeRigor

FREE LESSON

Freestanding C++: no OS, no heap, small budgets

Write C++ for a microcontroller: what the freestanding library gives you, static storage instead of the heap, -fno-exceptions and -fno-rtti and what they change in the code, constinit, and error handling with std::expected.

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).
Text
  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 throw

This 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:

RAM, decided at link time
Everything firmware needs should be in .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.
RAM, decided at link time
Everything firmware needs should be in .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_back returns nullptr when 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).
C++
#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);
}
std::inplace_vector<int, 3>: the capacity is inside the object
try_push_back(300) finds no room and returns nullptr — no allocation, no exception, and sizeof readings is fixed at compile time.
std::inplace_vector<int, 3>: the capacity is inside the object
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:

C++
struct Guard { Guard(); ~Guard(); };
void step(int);
extern "C" void work(int n) { Guard g; step(n); step(n + 1); }
What exceptions cost: a landing pad per cleanup
The normal path is the same with and without exceptions (13 instructions). With them, each call that might throw gets an edge to a landing pad that destroys g and resumes unwinding — 3 more instructions here, plus unwind tables.
What exceptions cost: a landing pad per cleanup
The normal path is the same with and without exceptions (13 instructions). With them, each call that might throw gets an edge to a landing pad that destroys 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:

C++
#if __cpp_exceptions
    try { construct(); } catch (...) { undo(); throw; }
#else
    construct();
#endif

4. 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:

C++
#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()));
    }
}
std::expected<int, I2cError> — the error lives inline
A union of the value and the error, plus a flag. No heap, no unwinding: 8 bytes on this target.
std::expected<int, I2cError> — the error lives inline
A union of the value and the error, plus a flag. No heap, no unwinding: 8 bytes on this target.

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.

C++
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 expression

6. 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_info per polymorphic class.
  • a heap: malloc/free and 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.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Which C++ features are free on a microcontroller and which cost code size or need runtime support.

Practical C++20 for embedded: what works in a freestanding, no-exceptions, no-heap build.

Stack overflow and other pitfalls of functions on small MCUs: why the stack is small and how it overflows silently.