Почему C++ на микроконтроллере — это не страшно
Распространённый миф: «C++ слишком тяжёл для AVR/STM32, там только чистый C». На деле C++ работает отлично — если понимать, что дорого, а что бесплатно. Arduino IDE собирает скетчи именно C++-компилятором (avr-g++), и классы, шаблоны, constexpr используются постоянно — просто без исключений, RTTI и кучи.
Знаменитый пример — сам фреймворк Arduino: Serial, Wire, String, digitalWrite — это C++. Библиотека Adafruit, TFT, датчики — всё классы.
Что можно: бесплатные возможности
Классы — инкапсуляция без цены
class Led {
uint8_t pin_;
public:
explicit Led(uint8_t pin) : pin_(pin) { pinMode(pin_, OUTPUT); }
void on() { digitalWrite(pin_, HIGH); }
void off() { digitalWrite(pin_, LOW); }
void toggle() { digitalWrite(pin_, !digitalRead(pin_)); }
};
Led led(13);
led.on(); // компилируется в те же инструкции, что и C-функция
Класс без виртуальных функций не занимает памяти сверх полей. Led led — это просто pin_ и три функции, которые компилятор инлайнит в digitalWrite напрямую.
Шаблоны — обобщения без дублирования
template<typename T>
T clamp(T v, T lo, T hi) {
return v < lo ? lo : (v > hi ? hi : v);
}
clamp(5, 0, 10); // int
clamp(3.14, 0.0, 1.0); // double — та же функция
constexpr — вычисления при компиляции, ноль байт в рантайме:
constexpr int tableSize = 256;
constexpr uint16_t toFixed(float f) { return f * 256; }
constexpr uint16_t half = toFixed(0.5); // вычислено компилятором
RAII — освобождение без исключений
Даже без исключений деструкторы работают — guard-классы для пинов, CS SPI, отключения прерываний:
class IrqGuard {
public:
IrqGuard() { noInterrupts(); }
~IrqGuard() { interrupts(); }
};
void criticalSection() {
IrqGuard g;
// код с выключенными прерываниями
// при выходе — включатся сами
}
Что нельзя или слишком дорого
Исключения
По умолчанию на AVR -fno-exceptions — и правильно: раскрутка стека требует таблиц и кучу. На STM32/ESP32 исключения можно включить, но это +десятки КБ флеша и непредсказуемое время.
RTTI и виртуальные функции — с оговорками
dynamic_cast и typeid требуют RTTI (таблицы типов) — обычно выключено (-fno-rtti). Виртуальные функции сами по себе работают — vtable занимает несколько байт на класс и один указатель на объект. Но косвенный вызов мешает инлайну и может ударить по предсказуемости времени на жёстком real-time.
Динамическая память (new/malloc)
Куча на AVR — ~2 КБ, фрагментация убьёт длительную работу. Запрещено в серьёзных embedded-проектах: new, delete, String (который под капотом malloc). Используют статические буферы, пулы памяти, etl::vector из Embedded Template Library.
std::string, iostream и прочие тяжёлые
std::string тянет аллокатор и исключения — на AVR нет. iostream — сотни КБ. Вместо них:
char buf[N]+snprintf/strncpy;etl::string<64>— статическая строка из ETL;Print-интерфейс Arduino вместо потоков.
Embedded-библиотеки вместо STL
ETL (Embedded Template Library) — STL-подобные контейнеры без кучи:
#include <etl/vector.h>
#include <etl/string.h>
etl::vector<int, 10> v; // максимум 10 — статический буфер
etl::string<64> s = "data"; // строка до 64 символов
v.push_back(5);
s += "_suffix";
Фиксированная вместимость в типе — память известна при компиляции, без new.
Практический пример: датчик как класс
class DhtSensor {
uint8_t pin_;
float temp_ = 0, hum_ = 0;
uint32_t lastRead_ = 0;
public:
explicit DhtSensor(uint8_t pin) : pin_(pin) {}
void begin() { /* инициализация DHT */ }
void update() {
uint32_t now = millis();
if (now - lastRead_ < 2000) return; // DHT нельзя опрашивать чаще
lastRead_ = now;
// чтение
}
float temperature() const { return temp_; }
float humidity() const { return hum_; }
};
DhtSensor dht(2);
void loop() {
dht.update();
Serial.println(dht.temperature());
}
Инкапсуляция: update сам следит за ограничением опроса, lastRead_ — деталь реализации. Из loop — просто вызов.
Чек-лист для embedded C++
- Классы без виртуальных функций — бесплатно.
- Шаблоны и
constexpr— компилируйте максимум при сборке. static/глобальные объекты вместоnew— память предсказуема.StringArduino осторожно — фрагментирует heap; лучшеchar[]илиetl::string.- Исключения и RTTI выключены по умолчанию — не пытайтесь их включать.
- RAII для ресурсов — работает и без исключений.
- Флаги
-Wall -Wextraи анализатор — они спасут там, где нет дебаггера.
Итог
C++ на микроконтроллере — это 80 % языка: классы, шаблоны, constexpr, RAII — всё работает и бесплатно. Вычёркиваем: исключения, RTTI, динамическую память, тяжёлую STL. Держите etl/static буферы, помните про каждый байт — и C++ станет мощнее чистого C без потери предсказуемости.
Пример: драйвер периферии через класс
class Uart {
volatile uint32_t* const regs_;
public:
explicit Uart(uint32_t* base) : regs_(reinterpret_cast<volatile uint32_t*>(base)) {}
void write(uint8_t b) {
while (regs_[STATUS] & TX_FULL) {}
regs_[DATA] = b;
}
uint8_t read() { return regs_[DATA] & 0xFF; }
};
// constexpr-объект на фиксированном адресе
inline Uart uart0{reinterpret_cast<uint32_t*>(0x40010000)};
Класс инкапсулирует протокол работы с регистрами — вызов uart0.write(b) разворачивается в те же обращения к памяти, что и голый C-код, но безопаснее и читаемее.
std::array и встраивание без new
// фиксированный размер — компилятор знает всё при компиляции
std::array<Sensor, 8> sensors;
std::array<uint8_t, 256> rxBuffer;
// ring buffer на статической памяти
template<typename T, size_t N>
class RingBuffer {
T buf_[N];
size_t head_ = 0, tail_ = 0;
public:
bool push(const T& v) {
size_t next = (head_ + 1) % N;
if (next == tail_) return false; // полон
buf_[head_] = v; head_ = next;
return true;
}
};
Без единой аллокации — вся память известна при компиляции.
С++20 в embedded: чего уже можно
std::span— «окно» на массив любого вида без копирования.constexprпочти для всего — таблицы CRC, синусов, коэффициентов считаются компилятором.concept— проверка интерфейсов драйверов при компиляции.std::array,std::tuple,std::optional— без кучи, но с безопасностью.
Чего избегать в микроконтроллерах
new/delete/malloc— фрагментация и неопределённое время.std::string,std::vectorбез резерва — растущие аллокации.throw/try/catch— код раздувается;std::optional/expectedвместо.- RTTI (
dynamic_cast,typeid) — лишние байты в ROM. std::function— может аллоцировать; шаблоны/указатели на функции лучше.
Итоговое правило embedded
Берите из C++ то, что делает код чище без цены в рантайме: классы, шаблоны, constexpr, RAII, enum class, std::array. И отказывайтесь от того, что тратит память и время непредсказуемо: куча, исключения, RTTI. Результат — безопасный читаемый код, который компилируется в такой же машинный код, как и C.