Embedded HandbookПриложения
Все разделы › XVII. Системное программирование на C
STM32F407, STM32F405 · уровень продвинутый · 10 мин чтения
Открыть в интерактивном справочнике

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.

C++ против C: байты по каждому опыту раздела — поровну, дороже, дешевле

Как это называется в документации

Оригинал Перевод
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 на класс, а не на объект.

Две колонки: с virtual деструктором и обычными конструкторами — 108 байт кода инициализации, таблица класса 20 байт, удаляющие деструкторы 2 по 14 и три внешних символа, объекты в bss; с constexpr-конструкторами, защищённым деструктором и constinit — кода инициализации нет, таблицы по 12 байт, внешних символов ни одного, объекты в data


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 Итоги

Полные исходники

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: модуль и символьное устройство.

Открыть в интерактивном справочнике