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.
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_arraythe list of constructors that the startup code calls constexpr,constinitcompute 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 staticvariables
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.
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
- Classes, methods,
private, the virtual call are free: the same instructions and bytes as in C (36 and 34 bytes in both languages); the class table itself is 8 bytes of Flash larger than in C. - A global object with a virtual destructor costs 108 bytes of startup code, tables of 20 bytes each
and a dependency on the library. A
constexprconstructor, a protected destructor andconstinitgive zero code and tables of 12 bytes each. - The constructors of global objects are called by the startup code via
.init_array; without this loop the object silently stays all zeros. - A
constexprtable lives in Flash instead of RAM, with no fill code; a lambda is inlined, a function pointer is not. - A template is one copy of the code per parameter; exceptions and RTTI pull in the C++
support library. For firmware:
-fno-exceptions -fno-rtti -fno-threadsafe-statics.
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.