Embedded HandbookApps
All sections › XVII. Systems programming in C
STM32F407, STM32F405 · level advanced · 10 min read
Open in the interactive handbook

146а. C++ on a microcontroller: what is free and what is expensive

In section 146, objects, function tables and inheritance are built by hand in C. C++ does the same thing for the programmer. For firmware there is one question: what you pay for it — in bytes of Flash and RAM, in hidden startup code, in dependencies on libraries.

The answer is checked by building: the same code in C++ (cmp.cpp) and in C (cmp_c.c), arm-none-eabi-g++ -Os for Cortex-M4, without the standard library — only the language itself. The static constructors were run in QEMU (netduinoplus2). The files are in _verify/cpp146a/, checked by _verify/cpp146a_check.py.

C++ versus C: bytes for each experiment in this section — equal, more expensive, cheaper

Terms used in the documentation

Term Meaning
zero-overhead principle you do not pay for what you do not use
vtable, vptr the virtual function table and the pointer to it inside the object
static initialization / dynamic initialization initialization by data / by code at startup
.init_array the list of constructors that the startup code calls
constexpr, constinit compute at compile time / require initialization by data
exception tables (.ARM.exidx) stack unwinding tables for exceptions
RTTI (typeinfo) type information at run time
thread-safe statics (__cxa_guard_*) thread-safe initialization of local static variables

146а.1 Free: classes, methods, the virtual call

What C++ C Difference
a class with two methods versus a struct and functions 36 bytes 36 bytes the instructions are identical, one for one
a loop over a virtual area() versus a table of pointers 34 bytes 34 bytes none
object: pointer to the table + fields 12 bytes 12 bytes none

A class method is a function with an implicit first argument this; private is a check done by the compiler, there is nothing of it in the code. A virtual call is the same as v->ops->area(v) in section 146: read the pointer to the table, read the address from the table, jump.


146а.2 Expensive without notice: a global object with a virtual destructor

The same hierarchy, but with the familiar virtual ~Shape() = default; and ordinary constructors:

What appeared Size
initialization code for the global objects (_GLOBAL__sub_I…) 108 bytes
the table of each class 20 bytes instead of 4 (C)
"deleting" destructors 2 × 14 bytes
external symbols operator delete, __aeabi_atexit, __dso_handle

The objects g_rect(3, 4) and g_square(5) moved from ready-made data (.data) to .bss: they are filled in by code at startup, and the same code registers their destructors through __aeabi_atexit — in case the program exits, which never happens in firmware. operator delete is needed by the "deleting" destructor, even though the program has no new. Without the standard library this does not link at all.

The cure is to tell the compiler that none of this is needed:

struct Shape {
    virtual int32_t area() const = 0;

protected:
    ~Shape() = default;                         // удалять через указатель на базу нельзя — и не нужно
};
constinit Rect g_rect(3, 4);
constinit Square g_square(5);

The constructors are constexpr, the destructor is protected and not virtual, the objects are constinit (C++20 — a compile error if initialization by data is not possible). Result: there is no initialization code, the objects are in .data, the tables are 12 bytes each, and there are no external symbols at all.

A C++ table starts with two service words — the offset to the start of the object and a pointer to typeinfo (with -fno-rtti it is zero, but it still takes space). So a single virtual function means a 12-byte table versus 4 in C: an extra 8 bytes of Flash per class, not per object.

Two columns: with a virtual destructor and ordinary constructors — 108 bytes of initialization code, a class table of 20 bytes, deleting destructors 2 of 14 bytes each and three external symbols, objects in bss; with constexpr constructors, a protected destructor and constinit — no initialization code, tables of 12 bytes each, no external symbols at all, objects in data


146а.3 The startup code calls the constructors of global objects

If a constructor cannot be executed at compile time (it reads a volatile variable or a register, or calls a function), the compiler puts its address into the .init_array section, and it must be called by the startup code — before main. An experiment in QEMU with an object Config g_cfg whose constructor sets ready = 1 and magic:

Startup code g_cfg.ready g_cfg.magic
with a loop over .init_array 1 0xC0FFEE07
"as for C" (section 4в) — without the loop 0 0x00000000

Without the loop the object stays all zeros — without a single warning. The linker script needs a section with start and end labels, and the startup code needs three lines:

    for (void (**f)(void) = __init_array_start; f < __init_array_end; f++)
        (*f)();                                  /* конструкторы глобальных объектов */

Ready-made startup files from vendor packages do this by calling __libc_init_array. The order of constructors across files is not defined: an object from one file must not use an object from another file in its constructor.

A local static is initialized on the first call of the function, not through .init_array: in the experiment local_static() returned 1 twice, and the call counter was 1. But by default the compiler does this "thread-safely" — it calls __cxa_guard_acquire and __cxa_guard_release from the library. For firmware without threads, use the -fno-threadsafe-statics flag, and the calls are gone. If a function with such a static is called from several RTOS tasks or from an interrupt, the flag is safe only when the first call is guaranteed to happen before the scheduler starts and before interrupts are enabled. Otherwise it is better to make the object global with constinit.


146а.4 Better than in C: constexpr and lambdas

A table computed by the compiler. CRC-8 (polynomial 0x07) — 256 bytes:

C++ constexpr C, table filled at startup
where the table lives Flash (.rodata) RAM (.bss), 256 bytes
fill code none 48 bytes + a call to crc8_init() before first use
calculation function 32 bytes 32 bytes

A check right in the code: static_assert(CRC8.t[1] == 0x07 && CRC8.t[0x80] == 0x89) — a wrong table will not build.

In C you can also compute the table in advance with a script and declare it static const — it will also go into Flash, but you will have to regenerate it on every change of the polynomial. constexpr does the same inside the compiler, and the table cannot drift away from the algorithm.

A lambda passed to a template — versus a function pointer:

C++: template + lambda C: function + pointer
code 30 bytes 56 bytes (three functions)
call for each element none — inlined blx r8 — an indirect branch

The compiler sees the type of the lambda and inlines its body into the loop. It inlines a function pointer only if it sees both the receiving function and the specific address in the same file — at -Os and across file boundaries almost never; in the experiment the call stayed indirect.


146а.5 Templates: one copy of the code per parameter

A ring buffer Ring<N> for three sizes (16, 64, 100) versus a single C buffer with the size stored in a field:

C++ Ring<16>, Ring<64>, Ring<100> C, one code for all
code 172 bytes 136 bytes
data 24 + 72 + 108 = 204 bytes 3 × 16 + 180 = 228 bytes

A template is not one function but one instance for each N. In return, in each of them the size is a constant: the modulo by 16 and 64 becomes a mask, and no pointer to the buffer is needed. Three sizes are still tolerable; a template instantiated for twenty types will noticeably bloat the firmware. The common code that does not depend on N is moved into a non-template base.


146а.6 Exceptions and RTTI

The same cmp.cpp, built with -fexceptions -frtti instead of -fno-exceptions -fno-rtti:

What was added Size
.ARM.exidx — an unwind entry for each function 12 × 8 = 96 bytes
typeinfo for Shape, Rect, Square 32 bytes
type names as strings (4Rect, 5Shape, 6Square) 21 bytes
external symbols __aeabi_unwind_cpp_pr0, the __cxxabiv1::__class_type_info tables

The tables themselves are small. What is expensive is what they pull in: the table parser and the stack unwinding code from the C++ support library — this toolchain does not have it at all, so the firmware simply will not link. And this while the code contains not a single throw. That is why the standard flags for firmware are -fno-exceptions -fno-rtti -fno-threadsafe-statics, and errors are reported with a return code or a "value or error" type.

The standard library. What lives in headers and does not allocate memory is almost free: std::array, std::optional, <algorithm>, <numeric>, <type_traits>, <cstdint>, and since C++20 std::span. A sum of std::array<int, 8> through std::accumulate is 22 bytes of code versus 20 for a loop in C, a search with an std::optional result is 30 bytes versus 28, and not a single external symbol. What prints or takes the heap is expensive: containers such as std::vector, std::string, std::function, and above all <iostream>. The toolchain from the PlatformIO package (GCC 7.2.1, section 115) does have the C++ library, and a "hello" with it, with nano.specs, weighs this much:

"Hello" through Code RAM
printf 4351 bytes 152 bytes
std::vector<int> and printf 4887 bytes 156 bytes and the heap
std::cout 153321 bytes 6468 bytes

<iostream> is 150 KB, 35 times more than printf and more than all the Flash of the F103C8. std::vector added only 536 bytes of code, but that means malloc and a heap with all its troubles (section 25).


146а.7 Pitfalls

A global object is "empty" before main and after it. The startup code does not walk .init_array.

Mysterious __aeabi_atexit, __dso_handle, operator delete at link time. A global object with a non-trivial destructor, or a virtual destructor. Use a protected non-virtual destructor and constinit.

__cxa_guard_acquire not found. A local static with a constructor; use -fno-threadsafe-statics.

__aeabi_unwind_cpp_pr0 not found. The code was built with exceptions — at least one file without -fno-exceptions.

The firmware grew by kilobytes from a single template. One instance per type; move the common part into a non-template base.

The C startup code suddenly calls memcpy and memset. -Os turns the loops that copy .data and zero .bss into library calls — and with -nostdlib those do not exist. Use the -fno-tree-loop-distribute-patterns flag for the startup file.

The order of constructors from different files. It is not defined; make dependent global objects through a function with a local static.


146а.8 Summary

Full sources

cmp2.cpp — the variant of part 2: constexpr constructors and constinit, objects without initialization code.

// Раздел 146а: вариант части 2 из cmp.cpp — глобальные объекты без кода инициализации.
#include <stdint.h>

// 2б. То же, но конструкторы constexpr, деструктор не виртуальный (защищённый): объекты — готовые данные
struct Shape {
    virtual int32_t area() const = 0;

protected:
    ~Shape() = default;                         // удалять через указатель на базу нельзя — и не нужно
};
struct Rect : Shape {
    int32_t w, h;
    constexpr Rect(int32_t a, int32_t b) : w(a), h(b) {}
    int32_t area() const override { return w * h; }
};
struct Square : Shape {
    int32_t s;
    constexpr explicit Square(int32_t a) : s(a) {}
    int32_t area() const override { return s * s; }
};

extern "C" int32_t virt_demo(const Shape *const *v, int n)
{
    int32_t t = 0;
    for (int i = 0; i < n; i++)
        t += v[i]->area();
    return t;
}

constinit Rect g_rect(3, 4);
constinit Square g_square(5);
extern "C" const Shape *const g_shapes[2] = {&g_rect, &g_square};

Next: section 147 — the Linux kernel: a module and a character device.

Open in the interactive handbook