146а. C++ на микроконтроллере: что бесплатно, а что дорого
В разделе 146 объекты, таблицы функций и наследование построены вручную на C. C++ делает то же самое за программиста. Вопрос для прошивки один: чем за это платят — байтами Flash, ОЗУ, скрытым кодом при старте, зависимостью от библиотек.
Ответ проверен сборкой: один и тот же код на C++ (cmp.cpp) и на C (cmp_c.c),
arm-none-eabi-g++ -Os для Cortex-M4, без стандартной библиотеки — только сам
язык. Статические конструкторы прогнаны в QEMU (netduinoplus2). Файлы —
_verify/cpp146a/, сверяет _verify/cpp146a_check.py.
Как это называется в документации
Оригинал Перевод zero-overhead principle за то, чем не пользуешься, не платишь vtable, vptr таблица виртуальных функций и указатель на неё в объекте static initialization / dynamic initialization инициализация данными / кодом при старте .init_arrayсписок конструкторов, которые вызывает стартовый код constexpr,constinitвычислить при компиляции / потребовать инициализации данными exception tables ( .ARM.exidx)таблицы раскрутки стека для исключений RTTI ( typeinfo)сведения о типах во время работы thread-safe statics ( __cxa_guard_*)потокобезопасная инициализация локальных static
146а.1 Бесплатно: классы, методы, виртуальный вызов
| Что | C++ | C | Разница |
|---|---|---|---|
| класс с двумя методами против структуры и функций | 36 байт | 36 байт | команды совпадают одна в одну |
цикл по виртуальному area() против таблицы указателей |
34 байта | 34 байта | нет |
| объект: указатель на таблицу + поля | 12 байт | 12 байт | нет |
Метод класса — это функция с неявным первым аргументом this; private — проверка
компилятора, в коде её нет. Виртуальный вызов — то же, что v->ops->area(v)
в разделе 146: прочитать указатель на таблицу, из таблицы — адрес, перейти.
146а.2 Дорого незаметно: глобальный объект с виртуальным деструктором
Та же иерархия, но с привычным virtual ~Shape() = default; и обычными конструкторами:
| Что появилось | Размер |
|---|---|
код инициализации глобальных объектов (_GLOBAL__sub_I…) |
108 байт |
| таблица каждого класса | 20 байт вместо 4 (C) |
| «удаляющие» деструкторы | 2 × 14 байт |
| внешние символы | operator delete, __aeabi_atexit, __dso_handle |
Объекты g_rect(3, 4) и g_square(5) переехали из готовых данных (.data)
в .bss: их заполняет код при старте, а деструкторы он же регистрирует
через __aeabi_atexit — на случай выхода из программы, которого у прошивки
не бывает. operator delete нужен «удаляющему» деструктору, хотя new в программе
нет. Без стандартной библиотеки такое не скомпонуется вовсе.
Лечение — сказать компилятору, что всё это не нужно:
struct Shape {
virtual int32_t area() const = 0;
protected:
~Shape() = default; // удалять через указатель на базу нельзя — и не нужно
};
constinit Rect g_rect(3, 4);
constinit Square g_square(5);
Конструкторы constexpr, деструктор защищённый и не виртуальный, объекты
constinit (C++20 — ошибка компиляции, если инициализировать данными не получится).
Результат: кода инициализации нет, объекты — в .data, таблицы — по 12 байт,
внешних символов — ни одного.
Таблица C++ начинается с двух служебных слов — смещения до начала объекта
и указателя на typeinfo (при -fno-rtti он нулевой, но место занимает).
Поэтому одна виртуальная функция — это таблица в 12 байт против 4 у C: лишние
8 байт Flash на класс, а не на объект.
146а.3 Конструкторы глобальных объектов вызывает стартовый код
Если конструктор нельзя выполнить при компиляции (он читает volatile-переменную,
регистр, вызывает функцию), компилятор кладёт его адрес в секцию .init_array, а
вызвать его должен стартовый код — до main. Опыт в QEMU с объектом
Config g_cfg, чей конструктор ставит ready = 1 и magic:
| Стартовый код | g_cfg.ready |
g_cfg.magic |
|---|---|---|
с циклом по .init_array |
1 | 0xC0FFEE07 |
| «как для C» (раздел 4в) — без цикла | 0 | 0x00000000 |
Без цикла объект остаётся нулями — без единого предупреждения. В скрипт компоновщика нужна секция с метками начала и конца, в стартовый код — три строки:
for (void (**f)(void) = __init_array_start; f < __init_array_end; f++)
(*f)(); /* конструкторы глобальных объектов */
Готовые стартовые файлы из пакетов производителя делают это вызовом
__libc_init_array. Порядок конструкторов между файлами не определён: объект
из одного файла не должен в своём конструкторе пользоваться объектом из другого.
Локальный static инициализируется при первом вызове функции, не через
.init_array: в опыте local_static() дважды вернул 1, счётчик вызовов — 1.
Но по умолчанию компилятор делает это «потокобезопасно» — вызывает
__cxa_guard_acquire и __cxa_guard_release из библиотеки. Для прошивки без
потоков — ключ -fno-threadsafe-statics, и вызовов нет. Если функцию с таким
static зовут из нескольких задач ОСРВ или из прерывания, ключ безопасен,
только когда первый вызов гарантированно случается до запуска планировщика
и разрешения прерываний. Иначе объект лучше сделать глобальным с constinit.
146а.4 Лучше, чем в C: constexpr и лямбды
Таблица, посчитанная компилятором. CRC-8 (многочлен 0x07) — 256 байт:
C++ constexpr |
C, таблица при старте | |
|---|---|---|
| где лежит таблица | Flash (.rodata) |
ОЗУ (.bss), 256 байт |
| код заполнения | нет | 48 байт + вызов crc8_init() до первого использования |
| функция расчёта | 32 байта | 32 байта |
Проверка прямо в коде: static_assert(CRC8.t[1] == 0x07 && CRC8.t[0x80] == 0x89) —
неверная таблица не соберётся.
В C таблицу можно и заранее посчитать скриптом и объявить static const —
она тоже ляжет во Flash, но её придётся перегенерировать при любой правке
многочлена. constexpr делает то же в самом компиляторе, и таблица не может
разойтись с алгоритмом.
Лямбда в шаблон — против указателя на функцию:
| C++: шаблон + лямбда | C: функция + указатель | |
|---|---|---|
| код | 30 байт | 56 байт (три функции) |
| вызов на каждый элемент | нет — встроено | blx r8 — косвенный переход |
Компилятор видит тип лямбды и встраивает её тело в цикл. Указатель на функцию
он встраивает, только если в одном файле видит и функцию-приёмник, и конкретный
адрес, — на -Os и через границу файлов почти никогда; в опыте вызов остался
косвенным.
146а.5 Шаблоны: по копии кода на каждый параметр
Кольцевой буфер Ring<N> для трёх размеров (16, 64, 100) против одного буфера
C с размером в поле:
C++ Ring<16>, Ring<64>, Ring<100> |
C, один код на все | |
|---|---|---|
| код | 172 байта | 136 байт |
| данные | 24 + 72 + 108 = 204 байта | 3 × 16 + 180 = 228 байт |
Шаблон — это не одна функция, а по экземпляру на каждый N. Зато в каждом
размер — константа: деление по модулю 16 и 64 становится маской, указатель на
буфер не нужен. Три размера — ещё терпимо; шаблон, развёрнутый для двадцати типов,
заметно раздует прошивку. Общий код, не зависящий от N, выносят в нешаблонную
базу.
146а.6 Исключения и RTTI
Тот же cmp.cpp, собранный с -fexceptions -frtti вместо -fno-exceptions -fno-rtti:
| Что добавилось | Размер |
|---|---|
.ARM.exidx — запись раскрутки на каждую функцию |
12 × 8 = 96 байт |
typeinfo для Shape, Rect, Square |
32 байта |
имена типов строками (4Rect, 5Shape, 6Square) |
21 байт |
| внешние символы | __aeabi_unwind_cpp_pr0, таблицы __cxxabiv1::__class_type_info |
Сами по себе таблицы невелики. Дорого то, что они тянут: разборщик таблиц и код
раскрутки стека из библиотеки поддержки C++ — её в этом наборе инструментов нет
вовсе, прошивка просто не скомпонуется. И это при том, что throw в коде
нет ни одного. Поэтому для прошивки стандартные ключи —
-fno-exceptions -fno-rtti -fno-threadsafe-statics, а ошибки — кодом возврата
или типом «значение или ошибка».
Стандартная библиотека. Почти бесплатно то, что живёт в заголовках и не
выделяет память: std::array, std::optional, <algorithm>, <numeric>,
<type_traits>, <cstdint>, с C++20 — std::span. Сумма std::array<int, 8>
через std::accumulate — 22 байта кода против 20 у цикла на C, поиск с
ответом std::optional — 30 байт против 28, и ни одного внешнего символа.
Дорого то, что печатает или берёт кучу: контейнеры вроде std::vector,
std::string, std::function и особенно <iostream>. В наборе из пакета
PlatformIO (GCC 7.2.1, раздел 115) библиотека C++ есть, и «привет» с ней, с
nano.specs, весит так:
| «Привет» через | Код | ОЗУ |
|---|---|---|
printf |
4351 байт | 152 байта |
std::vector<int> и printf |
4887 байт | 156 байт и куча |
std::cout |
153 321 байт | 6468 байт |
<iostream> — это 150 КБ, в 35 раз больше printf и больше всей Flash у
F103C8. std::vector добавил всего 536 байт кода, но это malloc и куча
со всеми её бедами (раздел 25).
146а.7 Грабли
Глобальный объект «пустой» до main и после. Стартовый код не проходит
.init_array.
Непонятные __aeabi_atexit, __dso_handle, operator delete при компоновке.
Глобальный объект с нетривиальным деструктором или виртуальный деструктор.
Защищённый невиртуальный деструктор и constinit.
__cxa_guard_acquire не найден. Локальный static с конструктором;
-fno-threadsafe-statics.
__aeabi_unwind_cpp_pr0 не найден. Код собран с исключениями — хотя бы один
файл без -fno-exceptions.
Прошивка выросла на килобайты от одного шаблона. Экземпляр на каждый тип; общую часть — в нешаблонную базу.
Стартовый код на C вдруг зовёт memcpy и memset. -Os превращает циклы
копирования .data и обнуления .bss в вызовы библиотеки — при -nostdlib их
нет. Ключ -fno-tree-loop-distribute-patterns для стартового файла.
Порядок конструкторов из разных файлов. Не определён; зависимые глобальные
объекты — через функцию с локальным static.
146а.8 Итоги
- Классы, методы,
private, виртуальный вызов — бесплатно: те же команды и байты, что в C (36 и 34 байта в обоих языках); сама таблица класса — на 8 байт Flash больше, чем у C. - Глобальный объект с виртуальным деструктором — 108 байт кода старта, таблицы по 20 байт
и зависимость от библиотеки.
constexpr-конструктор, защищённый деструктор,constinit— ноль кода, таблицы по 12 байт. - Конструкторы глобальных объектов вызывает стартовый код по
.init_array; без этого цикла объект молча остаётся нулями. constexpr-таблица — во Flash вместо ОЗУ, без кода заполнения; лямбда встраивается, указатель на функцию — нет.- Шаблон — копия кода на параметр; исключения и RTTI тянут библиотеку поддержки
C++. Для прошивки:
-fno-exceptions -fno-rtti -fno-threadsafe-statics.
Полные исходники
cmp2.cpp — вариант части 2: constexpr-конструкторы и constinit, объекты без кода инициализации.
// Раздел 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};
Дальше: раздел 147 — ядро Linux: модуль и символьное устройство.