Встраиваемый C++: что работает не так, как на десктопе
На микроконтроллере с 64 КБ RAM исключения, RTTI и куча превращаются из удобства в источник проблем. Разбираем, что отключать, чем заменять и почему статическое размещение объектов надёжнее new.

Прошивка на STM32F103 (Cortex-M3, 20 КБ SRAM, 64 КБ флеша) неожиданно перестаёт помещаться после того, как вы подключили <string> и один throw. Линкер добавил обвязку раскрутки стека, таблицы RTTI и аллокатор — и десяток килобайт флеша ушёл на код, который вы напрямую не писали. На десктопе это шум, на контроллере — треть доступной памяти. Разбираем, какие механизмы C++ дорого стоят во встраиваемой среде, чем их заменить и как писать так, чтобы поведение оставалось предсказуемым под ограничением в килобайты.
Речь про freestanding-окружение: bare-metal или RTOS на Cortex-M, AVR, RISC-V без полноценной ОС. Стандарт языка — C++17 как разумный минимум для GCC arm-none-eabi 10+ и подобных тулчейнов. Всё, что ниже, касается кода без операционной системы под ним, а не Linux на embedded-плате вроде Raspberry Pi — там правила ближе к десктопным.
Что стоит дорого и почему
Три механизма языка тянут за собой рантайм, который вы не контролируете напрямую.
- Исключения. Даже один
throwв проекте заставляет линкер включить механизм раскрутки стека (__cxa_throw, personality-функции, таблицы.ARM.extabи.ARM.exidx). Это единицы килобайт флеша и непредсказуемое время обработки — недопустимое в жёстком реальном времени. - RTTI.
dynamic_castиtypeidтребуют таблиц типов для каждого полиморфного класса. На системе с сотней классов это заметный расход флеша. - Динамическая память.
new,std::string,std::vectorбез своего аллокатора работают через кучу. Фрагментация на устройстве, которое не перезагружается неделями, приводит к отказу выделения при формально свободной памяти. И malloc в прерывании — прямой путь к состоянию гонки, если аллокатор не потокобезопасен.
Правило встраиваемого C++: платите только за то, что осознанно выбрали. Всё, что тулчейн добавляет за вашей спиной, — потенциальный перерасход памяти и потеря детерминизма.
Отключаем исключения и RTTI
В GCC и Clang для arm-none-eabi это делается флагами компиляции:
-fno-exceptions -fno-rtti -fno-unwind-tables -fno-asynchronous-unwind-tables
После этого throw и dynamic_cast вызовут ошибку компиляции — что и требуется. Стандартная библиотека, собранная с исключениями, при отказе выделения памяти обычно вызывает std::terminate вместо throw; для freestanding это чаще всего означает, что вы не используете такие контейнеры вовсе либо подменяете обработчик.
Чем заменять стандартные приёмы
Отказ от кучи не означает отказ от абстракций. Меняется место размещения объектов и способ обработки ошибок.
Статические контейнеры вместо динамических
Вместо std::vector, растущего в куче, — контейнер фиксированной ёмкости на стеке или в статической памяти. В C++17 стандартного аналога нет; используют std::array для фиксированного размера или сторонние решения. Библиотека ETL (Embedded Template Library) даёт etl::vector<T, N> с ёмкостью, заданной на этапе компиляции, — вся память резервируется заранее, куча не задействуется.
#include <array>
#include <cstdint>
// Буфер приёма фиксированного размера, размещён статически.
// Никаких аллокаций в рантайме — размер известен компилятору.
static std::array<uint8_t, 256> rx_buffer{};
static std::size_t rx_len = 0;
// Добавить байт, вернуть false при переполнении вместо исключения.
bool rx_push(uint8_t byte) {
if (rx_len >= rx_buffer.size()) {
return false; // переполнение — сигнализируем кодом, не throw
}
rx_buffer[rx_len++] = byte;
return true;
}
Коды возврата и std::optional вместо исключений
Там, где на десктопе бросили бы исключение, во встраиваемом коде возвращают статус. std::optional<T> из <optional> доступен в C++17 и не требует кучи — он хранит значение внутри себя:
#include <optional>
#include <cstdint>
// Чтение регистра датчика: пусто, если устройство не ответило.
std::optional<uint16_t> read_sensor(uint8_t reg) {
if (!i2c_start(0x48)) {
return std::nullopt; // нет ACK — возвращаем пустоту
}
return i2c_read16(reg);
}
void poll() {
if (auto value = read_sensor(0x00)) {
process(*value);
}
// иначе тихо пропускаем цикл опроса
}
Для случаев, где нужен код причины ошибки, а не только факт, подойдёт собственный тип-результат или std::expected — но он появился только в C++23, в arm-none-eabi GCC ниже 13 его нет. Проверяйте версию тулчейна, прежде чем на него закладываться.
constexpr вместо инициализации в рантайме
Таблицы CRC, коэффициенты фильтров, карты пинов — всё это вычисляется на этапе компиляции и ложится во флеш, а не в SRAM. constexpr-функция, вызванная в constexpr-контексте, не оставляет кода в прошивке:
#include <array>
#include <cstdint>
// Таблица CRC-8 считается компилятором, лежит во флеше как константа.
constexpr std::array<uint8_t, 256> make_crc8_table() {
std::array<uint8_t, 256> table{};
for (int i = 0; i < 256; ++i) {
uint8_t crc = static_cast<uint8_t>(i);
for (int bit = 0; bit < 8; ++bit) {
crc = (crc & 0x80) ? static_cast<uint8_t>((crc << 1) ^ 0x07)
: static_cast<uint8_t>(crc << 1);
}
table[i] = crc;
}
return table;
}
constexpr auto crc8_table = make_crc8_table();
Где приёмы ломаются
Отказ от кучи и исключений не бесплатен — переносит проблемы в другое место.
- Статические объекты со сложными конструкторами. Глобальный объект с нетривиальным конструктором инициализируется до
mainчерез таблицу.init_array. Если конструктор трогает периферию, которая ещё не настроена, получите зависание до первой строки вашего кода. Порядок инициализации глобалов между разными единицами трансляции не определён — классическая проблема «static initialization order fiasco». - Виртуальные функции. Они не требуют RTTI и работают при
-fno-rtti, но каждый полиморфный класс тянет vtable во флеше и косвенный вызов через указатель. На горячем пути это лишняя загрузка из памяти. Отключать полиморфизм целиком не нужно, но в прерывании на 100 кГц косвенный вызов стоит замерить. - Шаблоны и раздувание кода. Инстанцирование одного шаблона под пять разных типов даёт пять копий машинного кода.
etl::vector<uint8_t, 32>иetl::vector<uint8_t, 64>— два разных типа, две копии. Следите за размером через карту линкера. - Стек в прерываниях. Глубокая рекурсия или большие локальные
std::arrayв обработчике прерывания переполняют стек молча — без MPU вы узнаете об этом по искажённым данным, а не по аккуратному сбою.
Что мерить
Все решения проверяются размером и таймингом, а не ощущениями.
| Что смотреть | Чем | На что реагировать |
|---|---|---|
| Размер секций .text/.data/.bss | arm-none-eabi-size firmware.elf | .bss близко к объёму SRAM — риск переполнения стеком в рантайме |
| Кто съел флеш | Карта линкера (-Wl,-Map=out.map) | Секции .ARM.exidx, __cxa_* — значит, исключения не отключились |
| Глубина стека | Заливка стека паттерном 0xAA, замер «водяного знака» | Использовано больше 70–80% — уменьшайте локальные буферы |
| Время в ISR | Тоггл GPIO на входе/выходе + осциллограф | Косвенные вызовы и деление удлиняют обработчик |
Что делать дальше
Соберите проект с -fno-exceptions -fno-rtti и посмотрите карту линкера: если из неё исчезли секции раскрутки, вы уже сэкономили килобайты. Дальше — залейте стек известным паттерном и снимите реальную глубину под нагрузкой, а не по прикидке. Для замены стандартной библиотеки посмотрите ETL: она собрана под freestanding и не тянет кучу. И заведите привычку сверять size при каждом изменении — во встраиваемом C++ регрессия по памяти видна только на графике, а не в компиляторе.
Поиск источников: Реальные статьи и книги со ссылками, библиография по ГОСТ
Спросить за 15 ₽Источники: GCC: Options Controlling C++ Dialect (-fno-exceptions, -fno-rtti), Embedded Template Library — документация
Частые вопросы
У меня другой контроллер — AVR или RISC-V, а не Cortex-M. Приёмы подойдут?
Можно ли вообще использовать std::vector и new на микроконтроллере?
Я отключил исключения, но линкер всё равно тянет секции раскрутки. Почему?
std::expected удобнее optional для кодов ошибок. Он точно есть в моём компиляторе?
Грозит ли отключение RTTI поломкой при работе с виртуальными функциями?
Насколько constexpr-таблицы экономят память по сравнению с расчётом в рантайне?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.