Embedded C++: Как писать код для микроконтроллеров и не сойти с ума

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: Почему C++ в embedded — это не мазохизм, а суперсила

Когда слышишь «embedded разработка», перед глазами обычно встает суровый инженер с паяльником, который пишет на чистом C и молится на даташит. Или, наоборот, — энтузиаст, который заливает digitalWrite() на Arduino и радуется, что мигает светодиодом. Но между этими двумя мирами есть золотая середина — embedded разработка на C++.

Да-да, тот самый C++, который пугает шаблонами и виртуальными функциями. На самом деле, современные микроконтроллеры (ARM Cortex-M, RISC-V) вполне способны переваривать C++ код. Более того, разумное использование ООП, инкапсуляции и даже шаблонов может сделать ваш embedded код чище, безопаснее и быстрее.

В этой статье мы разберем, как писать эффективный C++ код для микроконтроллеров, обойдем грабли с виртуальными таблицами и научимся работать с железом на уровне регистров, но в стиле C++. Погнали?

1. База: Чем embedded C++ отличается от обычного

Главное правило embedded разработки: ресурсы ограничены. У вас нет гигабайт RAM и гигагерцового процессора. У вас 32 КБ флеша и 8 КБ ОЗУ. Каждый байт на счету. Поэтому C++ в embedded — это не про «напишу класс с 10 уровнями наследования», а про контроль над абстракциями.

Что можно использовать без страха:

  • Классы и структуры — для группировки данных и функций. Это бесплатно (по памяти), если нет виртуальных функций.
  • Пространства имен — чтобы не плодить префиксы вроде hal_gpio_init().
  • constexpr и шаблоны — вычисления на этапе компиляции, экономия тактов.
  • Ссылки и передача по константной ссылке — безопаснее указателей.

Что лучше не трогать без острой нужды:

  • Динамическая память (new/delete) — куча фрагментируется, аллокатор может подвести. В bare-metal проектах — табу.
  • Исключения (try/catch) — требуют поддержки рантайма, занимают много места.
  • Виртуальные функции — каждая виртуальная функция создает таблицу (vtable) в RAM/Flash, а вызов идет через двойную косвенность. Но иногда они оправданы (драйверы).
  • RTTI (typeid) — вообще не нужно.

Суть проста: компилятор C++ генерирует такой же эффективный код, как и C, если вы не используете «тяжелые» фичи. Проверяйте дизассемблер — и будет вам счастье.

2. Практика: Работа с регистрами в стиле C++

В классическом embedded C мы пишем что-то вроде:

#define GPIOA_BASE 0x40020000
#define GPIOA_MODER (*(volatile uint32_t*)(GPIOA_BASE + 0x00))
GPIOA_MODER |= (1 << 10); // Устанавливаем бит 10 (выход)

Это работает, но выглядит архаично. В C++ мы можем сделать удобную обертку без оверхеда.

Пример: Класс для работы с пином

#include <cstdint>

class GpioPin {
public:
    // Запрещаем копирование
    GpioPin(const GpioPin&) = delete;
    GpioPin& operator=(const GpioPin&) = delete;

    // Перечисление для режимов
    enum class Mode : uint32_t {
        Input = 0b00,
        Output = 0b01,
        AltFunc = 0b10,
        Analog = 0b11
    };

    // Конструктор: принимает адрес порта и номер пина (0-15)
    GpioPin(volatile uint32_t* port_base, uint8_t pin)
        : port_(port_base), pin_(pin) {
        // Можно сразу настроить пин
    }

    // Установить режим работы
    void setMode(Mode mode) {
        volatile uint32_t* moder = port_ + 0x00; // Смещение регистра MODER
        *moder &= ~(0b11 << (pin_ * 2));
        *moder |= (static_cast<uint32_t>(mode) << (pin_ * 2));
    }

    // Установить высокий уровень
    void high() {
        volatile uint32_t* bsrr = port_ + 0x18; // BSRR
        *bsrr = (1 << pin_); // Установка бита
    }

    // Установить низкий уровень
    void low() {
        volatile uint32_t* bsrr = port_ + 0x18;
        *bsrr = (1 << (pin_ + 16)); // Сброс бита
    }

    // Прочитать значение
    bool read() const {
        volatile uint32_t* idr = port_ + 0x10; // IDR
        return (*idr >> pin_) & 1;
    }

private:
    volatile uint32_t* port_; // Указатель на базу порта (volatile!)
    uint8_t pin_;             // Номер пина (экономия памяти)
};

// Использование:
// GpioPin led(reinterpret_cast<volatile uint32_t*>(0x40020000), 5);
// led.setMode(GpioPin::Mode::Output);
// led.high();

Что мы получили? Абстракцию, которая компилируется в ровно те же инструкции, что и макросы. Никакого оверхеда по памяти — все методы инлайнятся. При этом код стал читаемым, безопасным (не перепутаешь регистр) и переиспользуемым.

3. Шаблоны: компилятор — ваш лучший друг

Шаблоны в embedded — это мощный инструмент для статического полиморфизма. Вместо того чтобы передавать адрес порта в рантайме, мы можем «зашить» его на этапе компиляции. Это даст компилятору возможность оптимизировать код до идеала.

Пример: Шаблонный класс для SPI

template <uint32_t SpiBaseAddr>
class SpiMaster {
public:
    // Структура для отображения регистров (как в CMSIS)
    struct SpiRegs {
        volatile uint32_t CR1;   // Control register 1
        volatile uint32_t CR2;   // Control register 2
        volatile uint32_t SR;    // Status register
        volatile uint32_t DR;    // Data register
    };

    void init() {
        regs()->CR1 |= (1 << 6); // Включаем SPI
    }

    void send(uint8_t byte) {
        while (!(regs()->SR & (1 << 1))); // Ждем TX empty
        regs()->DR = byte;
        while (!(regs()->SR & (1 << 0))); // Ждем RX not empty
        (void)regs()->                

Блоги

Рекомендаций по книгам