Добавить в избранное

C++ для микроконтроллеров: что можно, что нельзя и как писать для Arduino/STM32

Чтение ≈ 4 мин

C++ на микроконтроллерах — не то же самое, что C++ на десктопе. Разбираем, что работает без исключений и RTTI, как использовать классы и шаблоны в Arduino-скетчах, std:: alternatives для embedded и ограничения памяти.

Почему 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 — память предсказуема.
  • String Arduino осторожно — фрагментирует 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.

Поделиться
Понравился материал?

Комментарии

Добавить комментарий