Введение: Почему 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()->