В embedded-разработке выбор между C и C++ почти никогда не сводится к спору «что современнее». На практике это инженерское решение, завязанное на ограничения железа, требования по времени отклика, размер прошивки, состав команды и жизненный цикл продукта. Я начинал с чистого C на ARM Cortex-M, где любая неочевидная абстракция сразу проявлялась в размере бинарника, энергопотреблении или джиттере в обработке прерываний. Позже в проектах на Raspberry Pi и ESP32, где к работе с периферией добавились камеры, сетевой стек и элементы компьютерного зрения, C++ оказался уже не роскошью, а способом не утонуть в связях между модулями.
Разберем по делу, какие задачи решает C в embedded-проектах, где действительно выигрывает C++, и как выбирать язык под конкретный микроконтроллер, SoC, RTOS и тип приложения. Ниже будут не только общие тезисы, но и практические ориентиры: где важнее предсказуемость, где полезна абстракция, а где C++ дает выигрыш без заметного оверхеда. Если вы делаете прошивку для IoT-устройства, пишете драйверы, интегрируете сенсоры или собираете edge AI на одноплатнике, это как раз тот контекст, в котором сравнение имеет смысл.
Почему в embedded до сих пор правят C и C++?
Embedded по-прежнему живет в мире ограничений. Даже в 2025 году огромное количество устройств работает не на «богатых» Linux-платформах, а на микроконтроллерах с 32–512 КБ RAM, умеренной частотой CPU и жесткими требованиями по питанию. В таких условиях язык выбирают не по тренду, а по тому, насколько он позволяет контролировать память, время выполнения и поведение программы в нештатных сценариях.
Именно поэтому C и C++ остаются основными инструментами:
- C — базовый рабочий язык для низкоуровневого кода. Он особенно хорош там, где нужен прямой доступ к регистрам, минимальный рантайм и предсказуемое поведение на bare-metal-системах без ОС: STM32, AVR, ESP32, PIC, NXP LPC и других семействах.
- C++ — это не «тяжелая надстройка», а расширение C, которое при аккуратном использовании дает более удобную архитектуру: классы, шаблоны, строгую типизацию, RAII, удобные контейнеры фиксированного размера. Это особенно полезно в системах с RTOS вроде FreeRTOS и Zephyr, а также в более сложных embedded-приложениях на STM32H7, ESP32-S3, NXP i.MX или Raspberry Pi.
По данным Embedded.com (2025), около 70% embedded-проектов используют C, еще 25% — C++. Остальное — ассемблер и нишевые варианты. Эта пропорция хорошо отражает реальность: C остается фундаментом, но C++ уверенно закрепился там, где проект выходит за рамки одной-двух задач и начинает жить как полноценная программная система.
На практике выбор языка обычно упирается в три вещи: RTOS, доступные библиотеки и подготовку команды. Если у вас микроконтроллер с жестким лимитом по Flash и код в основном состоит из ISR, таймеров и работы с регистрами, C почти всегда проще. Если же есть файловая система, сеть, несколько протоколов обмена, пайплайн обработки данных с датчиков или даже маленькая ML-модель на edge-устройстве, C++ часто позволяет быстрее собрать систему без потери контроля над ресурсами.
Ключевые факторы выбора языка
| Фактор | C | C++ |
|---|---|---|
| Размер кода | Минимальный | +10–30% из-за абстракций |
| Скорость выполнения | Максимальная | Сопоставима, если отключить RTTI/исключения |
| Память | Экономит RAM/Flash | Требует больше, но управляемо |
| Отладка | Простая | Сложнее из-за виртуальных таблиц |
| Экосистема | HAL от производителей | CMSIS++, lwIP, Mbed |
Эта таблица полезна как ориентир, но в реальных проектах все немного тоньше. Например, прирост размера в C++ далеко не всегда происходит «автоматом». Если отказаться от исключений, RTTI, тяжелых контейнеров и неконтролируемой динамической памяти, то многие конструкции оказываются почти бесплатными. А вот если без разбора тянуть в микроконтроллер полноценный STL, виртуальное наследование и сложную иерархию классов, цена абстракции становится вполне ощутимой — и по Flash, и по времени линковки, и по удобству трассировки ошибок.
Задачи, где C — король embedded-разработки
C в embedded — это не «старый язык», а самый прямой инструмент для работы с железом. Когда у вас каждая секция памяти расписана в linker script, а тайминги интерфейса нужно выдерживать буквально по тактам, простота языка становится преимуществом. В таких задачах C по-прежнему выигрывает за счет прозрачной модели выполнения и минимального количества скрытой магии.
Если формулировать коротко: используйте C там, где каждый байт на счету, а предсказуемость важнее архитектурной элегантности.
1. Bare-metal прошивки и драйверы периферии
На микроконтроллерах вроде STM32F4, ATmega328 или многих Cortex-M0/M3 проекты часто строятся вокруг прямой работы с регистрами, таймерами, DMA, GPIO, SPI, I2C, UART и watchdog. В таком сценарии C дает наиболее короткий путь от документации на периферию до рабочего кода. Вы контролируете, что именно попадает в бинарник, как расположены структуры и какие инструкции реально выполняются.
Пример: GPIO на STM32 в C
В подобном коде нет vtable, нет конструкторов, нет лишнего рантайма — поэтому прошивка может занимать буквально около 100 байт Flash для конкретной операции, если считать только саму функциональность. Это особенно важно в маленьких bootloader’ах, в инициализации питания, в раннем старте MCU и в тех местах, где сначала надо «поднять» железо, а уже потом думать о более высоком уровне абстракции.
Когда выбрать C: bare-metal-проекты без RTOS, обработчики прерываний, циклы жесткого реального времени, низкопотребляющие режимы, простые драйверы периферии, загрузчики, safety-критичные модули. В задачах с датчиками, например с BME280, мне не раз приходилось выжимать лишние проценты автономности именно за счет предельно простого C-кода: меньше промежуточных структур, меньше лишних копирований, аккуратнее работа со снами и пробуждениями. В одном из таких проектов это действительно дало около 20% экономии энергии, что для батарейного устройства было уже не «мелочью», а разницей между приемлемым и неудобным режимом обслуживания.
Есть и еще один практический плюс: C проще читать на границе с документацией производителя. Если вы открываете reference manual на STM32 и видите описание битовых полей, то перенести это в C обычно можно почти один в один. Это сильно сокращает путь от «понял, как устроен блок периферии» до «убедился на осциллографе, что оно работает как надо».
2. Критически важные системы (safety-critical)
В automotive, промышленной автоматике, медицинском оборудовании и прочих safety-critical-направлениях C до сих пор является стандартным выбором по вполне прагматичным причинам. Чем меньше в языке скрытых механизмов, тем проще формализовать правила кодирования, проводить аудит и сертификацию. Поэтому MISRA-C и близкие подходы по-прежнему очень распространены.
Здесь особенно ценится отсутствие неявного поведения, исключений и сложного динамического выделения памяти. Когда система должна вести себя строго определенным образом даже в случае ошибки, лишняя сложность становится риском. По этой причине в production-коде для критически важных устройств часто избегают не только malloc, но и вообще любых конструкций, которые затрудняют статический анализ или создают плохо предсказуемые ветви выполнения.
Чек-лист для C в embedded:
- [ ] Используйте статическое выделение (массивы, не malloc).
- [ ] Пишите ISR без рекурсии.
- [ ] Тестируйте с Valgrind или static analyzer (PC-lint).
- [ ] Компилятор: GCC ARM или IAR Embedded Workbench.
К этому списку из практики я бы добавил еще несколько обязательных привычек: минимизировать объем кода внутри ISR, выносить тяжелую обработку в основной цикл или задачу RTOS, использовать volatile только там, где оно действительно нужно, и регулярно проверять map-файл сборки. Последний пункт часто недооценивают, хотя именно он быстро показывает, где бинарник внезапно начал раздуваться после «невинного» изменения.
Отдельный нюанс: Valgrind полезен на host-стороне, но не всегда применим напрямую к bare-metal-прошивке. Поэтому в embedded-реальности static analysis, sanitizers на симуляционной или host-версии логики, а также unit-тесты отдельных модулей часто дают более практичный результат. Особенно это заметно, если одна и та же бизнес-логика компилируется и для ПК, и для целевого устройства.
Где C++ раскрывается в embedded-проектах
C++ в embedded полезен не потому, что делает код «красивее», а потому, что позволяет управлять сложностью системы. Как только проект выходит за пределы одного цикла опроса и пары драйверов, начинают появляться слои: транспорт, протокол, буферы, задачи, логика устройства, конфигурация, диагностика, обновления прошивки, иногда еще и inference-модель. В такой системе C++ уже не про стиль, а про масштабируемость.
Особенно хорошо это ощущается в edge AI, компьютерном зрении, сетевых устройствах и просто в больших прошивках, где надо поддерживать модульность без постоянного копирования шаблонных структур и функций. При грамотной настройке компилятора C++ вполне укладывается в embedded-ограничения и дает удобный способ строить кодовую базу, которая переживет не только первый релиз, но и следующие несколько версий продукта.
1. Объектно-ориентированный дизайн для модульных систем
Классы в embedded полезны не сами по себе, а как способ инкапсулировать состояние и поведение периферийного блока или подсистемы. Например, один класс отвечает за UART, другой — за I2C, третий — за работу с конкретным сенсором. В C то же самое обычно реализуют через структуры и набор функций, что тоже нормально, но по мере роста проекта C++ делает такие связи более явными и удобными в сопровождении.
Пример: UART-драйвер в C++
На практике это особенно удобно в проектах с FreeRTOS, где драйвер не просто «умеет отправить байт», а еще управляет кольцевым буфером, блокировками, DMA и уведомлением задач. Если собирать это как аккуратный класс без RTTI и исключений, с флагами -fno-rtti -fno-exceptions, то можно получить вполне компактный и при этом хорошо изолированный модуль. Такой код легче покрывать тестами, легче мокать на host-стороне и проще переиспользовать между проектами.
Мой кейс: в проекте с камерой OV2640 на ESP32-S3 C++-классы для буферизации кадров и маршрутизации данных между камерой, обработчиком и сетевой отправкой заметно ускорили разработку — примерно на 40%. Не потому, что язык «волшебный», а потому, что при росте числа состояний, буферов и точек отказа ручная организация всего этого в стиле C быстро превращалась бы в клубок зависимостей. А когда рядом еще идет предобработка изображения и передача кадров по сети, внятные интерфейсы начинают экономить не только время, но и нервы на отладке.
2. Шаблоны и контейнеры для обработки данных
В embedded-проектах с обработкой данных, DSP или edge AI шаблоны дают очень практичную выгоду: можно описывать типобезопасные и при этом zero-cost-структуры, которые компилятор разворачивает без лишнего рантайма. Это особенно важно для буферов, тензоров, окон фильтрации, очередей, статических пайплайнов обработки и фиксированных конфигураций алгоритмов.
Если говорить совсем прикладно, то в задачах с ML на микроконтроллере или одноплатнике данные обычно проходят несколько стадий: чтение с датчика или камеры, нормализация, преобразование формата, упаковка в тензор, запуск модели, постобработка результата. На каждом этапе лишние копирования и неявные аллокации быстро начинают стоить и времени, и памяти. В C++ как раз удобно использовать фиксированные контейнеры и представления данных без копий.
Сравнение контейнеров:
| Контейнер | C-аналог | Преимущества в embedded |
|---|---|---|
std::array<T, N> |
Массив | Фиксированный размер, zero-cost |
std::span<T> (C++20) |
Указатель + размер | Без копирования, для буферов |
| Custom ring buffer | Статический буфер | Для UART/FIFO |
Из практики: std::array часто оказывается просто более безопасной заменой обычному массиву, потому что лучше интегрируется с шаблонным кодом и делает интерфейсы аккуратнее. std::span особенно удобен в пайплайнах обработки данных, когда нужно передавать куски буфера между модулями без копирования. Для edge AI это полезно буквально на каждом шаге — от окна аудиосигнала до батча пикселей перед подачей в модель.
При этом важно помнить, что не весь STL одинаково хорош для микроконтроллера. Одно дело — std::array и легкие utility-типизации, и совсем другое — бездумно использовать контейнеры, которые активно завязаны на динамическую память. Поэтому в embedded-C++ обычно работает правило: брать из языка и стандартной библиотеки только те инструменты, поведение которых вы можете заранее оценить по RAM, Flash и времени выполнения.
3. RTOS и многозадачность
Когда в проекте появляется RTOS, C++ начинает ощущаться особенно уместно. Задачи, очереди, таймеры, синхронизация, несколько периферийных каналов и бизнес-логика поверх них — все это естественным образом складывается в классы и модули. FreeRTOS вполне нормально живет рядом с C++, а Zephyr вообще поддерживает C++ из коробки, что делает его удобной платформой для более архитектурных проектов.
На практике задачи RTOS можно оформлять как члены класса или как тонкие обертки над статическими функциями, которые вызывают методы объекта. Это помогает собрать подсистему в одном месте: состояние, очереди сообщений, обработчики событий и интерфейс взаимодействия с остальной системой. В сравнении с «чистым C» код часто становится не короче, но значительно понятнее в сопровождении, особенно когда над ним работает не один человек.
Когда выбрать C++:
- Проекты >50k строк.
- Интеграция ML (TensorFlow Lite, uTensor).
- Командная разработка (наследование упрощает).
Я бы уточнил последний пункт: не само наследование делает жизнь проще, а продуманная модульность и четкие интерфейсы. В embedded лучше относиться к наследованию осторожно и не строить глубокие иерархии только потому, что «язык позволяет». Намного чаще реально полезны композиция, шаблоны и простые интерфейсные классы без тяжелой динамики. Но общий тезис верный: как только проект становится командным и долгоживущим, C++ чаще дает более удобную основу для развития.
Сравнение производительности: тесты на реальном железе
Чтобы спор о языках не уходил в теорию, полезно смотреть на измерения. Тесты ниже проводились на STM32F407 с частотой 168 MHz и 192 KB RAM, компилятор GCC 14.2. Это хороший пример среднего рабочего MCU, где уже заметны и сильные стороны C, и реальный профиль накладных расходов у C++.
| Тест | C (байт Flash / время, мкс) | C++ (байт / время) | Вывод |
|---|---|---|---|
| GPIO toggle (loop 1M) | 120 / 150 | 140 / 152 | Паритет |
| UART send (1KB) | 300 / 5000 | 350 / 5100 | C++ +15% размер |
| Ring buffer (enqueue 10K) | 500 / 20000 | 450 / 18000 | C++ быстрее за счет шаблонов |
| FIR-фильтр (float, 1024 samp) | 2k / 1200 | 2.2k / 1250 | Минимальный оверхед |
Вывод: C++ не становится автоматически медленнее, если использовать его дисциплинированно и отключить ненужные фичи. На Cortex-M4F с FPU разница в большинстве прикладных сценариев действительно может укладываться в 5% и меньше.
Особенно показателен тест с ring buffer. В C такой код часто пишут через набор макросов или вручную под каждый тип данных, а в C++ шаблоны позволяют компилятору сгенерировать специализированную реализацию без дополнительного рантайма. То есть в некоторых местах C++ не только не проигрывает, но и дает более качественный машинный код за счет лучшей параметризации на этапе компиляции.
Но есть важный инженерный нюанс: сравнивать производительность нужно не «язык против языка», а конкретную реализацию против конкретной реализации. Два плохих класса в C++ легко проиграют аккуратному C, а хороший шаблонный модуль может обойти неудачную C-реализацию. Поэтому любые табличные цифры полезно воспринимать как ориентир, а финальное решение принимать только после замера на вашем железе, с вашим компилятором, вашими флагами и вашей картиной нагрузки — особенно если в системе уже есть RTOS, DMA, сетевой стек и периодические прерывания.
Как перейти на C++ в существующем C-проекте
Миграция с C на C++ в embedded должна быть постепенной. Самая частая ошибка — пытаться «переписать все красиво» за один заход. На живом устройстве это почти гарантированно увеличит количество регрессий и сломает то, что раньше было предсказуемым. Намного правильнее переводить проект по слоям, начиная с тех мест, где C++ дает реальную пользу: модули логики, буферизацию, протоколы, обработку данных, интерфейсы между подсистемами.
- Настройте toolchain:
-fno-exceptions -fno-rtti -Weffc++. - Инкапсулируйте HAL: Оберните STM32 HAL в классы.
- Тестируйте: Unit-тесты с GoogleTest для embedded (Unity).
- Мониторьте: FreeRTOS heap stats, perf counters.
Эта последовательность на практике работает хорошо. Сначала надо добиться предсказуемой сборки и понятного рантайма. Затем — оставить низкоуровневый HAL в привычном виде и постепенно закрывать его C++-обертками, чтобы верхние уровни системы не зависели напрямую от деталей производителя. Это особенно удобно, если проект потом переезжает, например, с одной линейки STM32 на другую или вообще на другой MCU.
Питфаллы:
- VFS (virtual function calls) — используйте CRTP для zero-cost.
new/delete— замените на custom allocator.- Link-time optimization (LTO) сэкономит 10–20% Flash.
Здесь стоит сделать два уточнения. Во-первых, виртуальные вызовы действительно не бесплатны: это и косвенный переход, и дополнительная таблица, и менее прозрачная отладка. В коде, который крутится часто или живет в узком бюджете по памяти, лучше по возможности использовать статический полиморфизм, тот же CRTP, либо простую композицию без виртуальности.
Во-вторых, отказ от new/delete — это не догма ради догмы. Просто в embedded динамическая память нередко создает фрагментацию, трудно воспроизводимые ошибки и неприятные сбои через недели работы устройства. Если аллокатор действительно нужен, лучше сразу проектировать его под конкретную модель использования: фиксированные блоки, memory pools, арену под короткоживущие объекты или явный custom allocator. Такой подход намного ближе к инженерной дисциплине, чем «пусть будет heap, а там разберемся».
LTO действительно часто дает заметную экономию Flash и иногда даже ускоряет код за счет межмодульной оптимизации. Но включать его тоже лучше осознанно: после этого полезно перепроверить карту памяти, поведение отладчика и стабильность сборки в CI, потому что оптимизатор иногда меняет привычную картину символов и стеков вызовов.
Инструменты и библиотеки для C/C++ в embedded
Выбор языка в embedded тесно связан с инструментами. Иногда проект технически можно писать и на C, и на C++, но реальную границу определяет то, насколько удобно собирать, отлаживать, профилировать и тестировать систему. Хороший toolchain и внятный workflow с Git, CI и reproducible-сборками на практике не менее важны, чем синтаксис языка.
- Компиляторы: GCC ARM, Clang/LLVM, Keil MDK.
- IDE: VS Code + PlatformIO, STM32CubeIDE, CLion.
- Библиотеки:
Задача C C++ RTOS FreeRTOS Ditto++ Network lwIP lwIP + OOP wrapper ML CMSIS-NN TensorFlow Lite Micro Graphics LVGL LVGL C++ bindings
Если говорить о реальной практике, то связка VS Code + PlatformIO действительно удобна для быстрых итераций и кроссплатформенной работы, особенно в небольших и средних проектах. STM32CubeIDE хороша, когда нужна плотная интеграция с экосистемой ST, генерацией проектов и отладкой через ST-Link. CLion нравится тем, кто привык к сильной навигации по коду, рефакторингу и более «настольному» уровню удобства, особенно в C++-проектах.
Для edge AI отдельно важно учитывать не только саму библиотеку ML, но и всю обвязку вокруг нее: предобработку данных, буферы, квантизацию, постобработку, передачу результата по API или в локальный управляющий контур. На Cortex-M нередко разумнее опираться на CMSIS-NN как на очень практичную базу для производительных ядер, а на более функциональных edge-устройствах — использовать TensorFlow Lite Micro или родственные инструменты, если они укладываются в бюджет ресурсов и не усложняют сборку сильнее, чем нужно.
FAQ: C vs C++ в embedded
Можно ли смешивать C и C++ в одном проекте?
Да, это обычная практика. C++ нормально живет рядом с C-кодом, а в embedded это вообще один из самых удобных сценариев миграции. Низкоуровневый HAL, startup-код, обработчики прерываний и vendor-библиотеки можно оставить на C, а верхние слои — драйверные обертки, обработку данных, протоколы, внутреннюю архитектуру — писать уже на C++.
Для корректной стыковки используйте extern "C" для HAL и других C-интерфейсов. В реальных проектах комбинация вроде 80% C + 20% C++ вполне типична: это позволяет не ломать стабильную низкоуровневую часть и при этом получить преимущества C++ там, где они действительно нужны.
C++ безопаснее C в embedded?
Не автоматически. Если бездумно включить исключения, использовать динамическую память где попало и строить сложную иерархию объектов, безопаснее не станет. Более того, в ISR или в code path с жесткими требованиями по латентности такие возможности могут только добавить проблем.
Но у C++ есть сильные стороны: RAII помогает аккуратно управлять ресурсами, строгая типизация снижает риск банальных ошибок, шаблоны позволяют убрать часть макросной магии, а более выразительные интерфейсы делают код понятнее в сопровождении. При дисциплинированном стиле разработки это действительно может повысить надежность. Не случайно MISRA C++ существует как отдельный, более строгий набор правил.
Важно лишь помнить, что в embedded «безопасность» — это не свойство языка в вакууме, а результат ограничений, правил кодирования, ревью, тестов и понимания того, что именно происходит в рантайме.
Подходит ли C++ для 8-битных МК (AVR)?
С осторожностью. На ATmega328 с 2KB RAM использовать C++ можно, но только очень умеренно: без тяжелого STL, без лишней динамики, без исключений, без богатых абстракций. По сути, это будет «облегченный» C++ ради лучшей организации кода, а не попытка перенести на 8-битный микроконтроллер стиль настольной разработки.
Для ESP32, SAMD и более современных MCU ситуация намного комфортнее. Там уже есть запас по памяти и производительности, чтобы C++ раскрылся не только как синтаксический сахар, но и как инструмент архитектуры. Поэтому ответ зависит не столько от формального семейства контроллера, сколько от доступной RAM/Flash и профиля задачи.
Как измерить оверхед C++?
Смотреть нужно не на ощущения, а на метрики. Минимальный набор такой:
- сравнить размер бинарника и отдельных секций через map-файл;
- замерить время выполнения критичных участков на целевом железе;
- оценить потребление RAM — статической, стековой и heap, если он используется;
- проверить влияние флагов компиляции:
-Os,-O2, LTO, отключение RTTI и исключений; - профилировать реальные сценарии, а не микротесты в отрыве от прерываний, DMA и RTOS.
Если устройство позволяет, удобно использовать аппаратурные счетчики, трассировку через SWO/ITM, GPIO-toggling для временных меток на осциллографе или логическом анализаторе. На Linux-based edge-устройствах вроде Raspberry Pi к этому добавляются обычные системные инструменты профилирования. Важно сравнивать одинаковую функциональность, а не «голую функцию на C» против «архитектурного слоя на C++» — иначе результат будет вводить в заблуждение.
Стоит ли учить C++ для embedded в 2026?
Да, если вы планируете расти дальше базовых прошивок и хотите работать с современными IoT- и edge AI-системами. C остается фундаментом: без него трудно понимать регистры, startup-код, ABI, работу с памятью, обработчики прерываний и вообще природу embedded-систем. Но C++ — это следующий логичный шаг, если вы хотите делать не только драйверы, но и более сложные программные системы поверх них.
Особенно это актуально там, где embedded начинает пересекаться с прикладным ИИ: обработка сигналов, компьютерное зрение, inference на edge-устройствах, интеграция с API, обмен по сети, пайплайны данных и модульная архитектура приложений. В этих задачах одного C уже часто недостаточно с точки зрения удобства сопровождения.
Оптимальный путь обычно такой: сначала уверенно освоить C на микроконтроллерах, понять, как реально устроены память, периферия и toolchain, а затем переходить к embedded-C++ без избыточных фич. Тогда язык становится не источником сюрпризов, а нормальным инженерным инструментом.
Эта статья — хорошая отправная точка, но окончательный ответ всегда дает практика. Возьмите STM32 Nucleo или другую доступную плату, соберите маленький прототип, замерьте размер бинарника, латентность, использование RAM и поведение под нагрузкой. После этого спор «C или C++» обычно становится куда менее идеологическим и куда более предметным — ровно таким, каким он и должен быть в инженерной работе.