C для встраиваемых систем: память, указатели, volatile, без динамики
Почему C и почему это не тот C, который вы знаете
Программист «больших» машин приходит сюда с готовым мнением: C — старый язык без безопасности, зачем он в 2026 году. Ответ прагматичный: на микроконтроллере C — единственный слой, у которого стоимость каждой конструкции видна глазами. Присваивание превращается в одну инструкцию, вызов функции — в известную последовательность, структура — в известную раскладку байт. Ни один язык с рантаймом, сборщиком мусора или неявными аллокациями такой прозрачности не даёт, а без неё нельзя гарантировать ни время, ни память. Плюс бытовое: компилятор C есть для любой архитектуры, вендорские заголовки и драйверы написаны на нём, а ABI на C — общий язык между вашим кодом, чужим стеком радио и загрузчиком десятилетней давности.
Но это другой C. В терминах стандарта вы переезжаете из hosted-реализации в freestanding: гарантированы только заголовки вроде <stdint.h>, <stddef.h>, <limits.h>, <stdbool.h> — то есть типы и константы, а не функции. Ни printf, ни malloc, ни файлов, ни main, который кто-то вызывает: точка входа задаётся таблицей векторов. Компилятору об этом сообщают флагом -ffreestanding. Дальше держите в голове три ограничения, из которых выводится каждое правило статьи — подробно они разобраны в карте трека.
| Ограничение | Что запрещает в C | Что требует взамен |
|---|---|---|
| Память конечна и не защищена | malloc, рекурсию, большие локальные буферы |
статику, пулы, известный сверху расход стека |
| Энергия невосполнима | ожидание в пустом цикле, лишние такты | сон как основное состояние, DMA, короткий активный код |
| Время — часть контракта | конструкции с неограниченным временем работы | детерминированные O(1)-операции, короткие обработчики |
Где живут переменные
На сервере вопрос «где лежит эта переменная» задают редко. Здесь он первый при написании каждой строки: от ответа зависит, влезет ли прошивка и не испортит ли она себя через месяц работы. Класс хранения в C напрямую отображается в секцию образа.
/* .rodata — во Flash: место в прошивке занимает, в ОЗУ ни байта */
static const uint16_t sine_lut[256] = { 0, 804, 1608, /* ... ещё 253 значения ... */ };
/* .data — и во Flash, и в ОЗУ: начальное значение лежит в образе и копируется
в ОЗУ стартовым кодом при каждом включении, то есть стоит двойную цену */
static uint32_t sample_period_us = 1000;
/* .bss — только в ОЗУ: во Flash ничего, обнуляется до вызова main.
Все крупные буферы должны попадать сюда */
static uint16_t adc_buffer[512];
/* Тоже .bss, но видна снаружи: глобальная изменяемая переменная без static
почти всегда ошибка проектирования — её сможет испортить любой модуль */
uint32_t error_counter;
void process(void)
{
uint8_t scratch[64]; /* стек: живёт до выхода из функции */
static uint32_t calls; /* .bss: переживает вызов, но невидима снаружи */
calls++; (void)scratch;
}
Проверять результат надо отчётом инструментов, а не догадками: размер секций — такая же обязательная метрика сборки, как зелёные тесты.
$ arm-none-eabi-size -A firmware.elf
.isr_vector 392 # Flash .data 108 # Flash + ОЗУ
.text 24608 # Flash .bss 9312 # только ОЗУ
.rodata 1284 # Flash # стек компоновщику не виден
# Итого Flash = 26 392 байта, ОЗУ = 9 420 байт — и это ещё БЕЗ стека,
# который компоновщик просто не видит.
Последняя строка — источник половины загадочных отказов. Компоновщик знает про статические данные и ничего не знает про глубину стека: он честно соберёт прошивку, где .bss заканчивается ровно там, где стек начнёт работать. Как выглядит адресное пространство целиком, разобрано в статье про железо для программиста. Отсюда рабочее правило принятия решений, в котором malloc не появляется ни в одной ветке:
Типы: int — это не 32 бита
Стандарт гарантирует про int только то, что он не уже 16 бит. На Arduino Uno это ровно 16, на STM32 и ESP32 — 32, на Raspberry Pi — 32, но указатель там уже 64-битный.
| Платформа | int |
указатель | Невыровненный доступ | Плавающая точка |
|---|---|---|---|---|
| ATmega328P (Arduino Uno) | 16 бит | 16 бит, гарвардская архитектура | разрешён | только программная |
| Cortex-M0+ (STM32G0, RP2040) | 32 бита | 32 бита | запрещён, даёт HardFault | только программная |
| Cortex-M4F / M7 (STM32F4, H7) | 32 бита | 32 бита | для LDR/STR да, для LDM/STM нет |
FPU одинарной точности, double программный |
| Xtensa LX6 (ESP32) | 32 бита | 32 бита | ограниченно, к флеш-кэшу только выровненный | FPU одинарной точности на одном ядре |
| ARM64 Linux (Raspberry Pi) | 32 бита | 64 бита | разрешён | полноценный FPU и NEON |
Вывод: фиксированная ширина в объявлениях обязательна — uint8_t, int16_t, uint32_t из <stdint.h> единственный способ написать код, одинаково работающий на всех строках таблицы. Есть ещё uint_fast8_t — «не меньше 8 бит, но выберите то, что быстрее»: на 32-битном ядре он развернётся в 32 бита и сэкономит инструкции преобразования.
uint8_t a = 200, b = 100;
if (a + b > 255) { /* СРАБОТАЕТ: оба повышены до int, 300 действительно больше 255 */ }
uint8_t sum = a + b; /* но сюда молча запишется 44 — усечение без предупреждения */
size_t len = 0; /* а здесь len - 1 равно SIZE_MAX: цикл почти вечный */
for (size_t i = 0; i <= len - 1; i++) { }
Отдельно — неопределённое поведение при переполнении знаковых типов: беззнаковые честно оборачиваются по модулю, знаковые дают UB, и оптимизатор вправе выкинуть проверку if (x + 1 < x) как заведомо ложную. Отсюда практика: счётчики, маски и всё битовое — беззнаковые. Про представление чисел стоит прочитать статью двоичная система и биты. Минимальный набор, ловящий всё перечисленное на сборке: -Wall -Wextra -Wconversion -Wsign-conversion -Wdouble-promotion.
Указатель — это физический адрес
На сервере за указателем стоит MMU и обещание, что промах приведёт к сигналу. Здесь число в указателе попадает прямо на шину адреса, и следствий три. Разыменование NULL не падает: адрес 0 на Cortex-M — обычно алиас начала Flash, чтение вернёт осмысленное число, а ошибка проявится через минуту в другом месте. Запись по битому указателю портит чужие данные или регистр периферии — вместо «сегфолта» вы получаете поехавший двигатель. И третье, полезное: регистры периферии становятся обычными переменными.
/* Порядок и размер полей повторяют карту регистров из даташита, поэтому доступ
по имени превращается в одну инструкцию с константным смещением */
typedef struct {
volatile uint32_t MODER; /* 0x00 режим выводов */
volatile uint32_t OTYPER, OSPEEDR, PUPDR; /* 0x04, 0x08, 0x0C настройки вывода */
volatile const uint32_t IDR; /* 0x10 входы — только чтение */
volatile uint32_t ODR; /* 0x14 выходы */
volatile uint32_t BSRR; /* 0x18 атомарные установка и сброс */
} GPIO_TypeDef; /* дальше — только доступ по имени поля */
#define GPIOA ((GPIO_TypeDef *)0x40020000UL) /* адрес блока из карты памяти */
/* Вставит кто-то поле в середину — сборка упадёт здесь, а не двигатель на стенде */
_Static_assert(offsetof(GPIO_TypeDef, BSRR) == 0x18, "раскладка регистров разъехалась");
volatile const у IDR не противоречие: const запрещает писать вам, volatile запрещает компилятору считать, что значение не меняется само. Про выравнивание: приведение uint8_t * к uint32_t * законно, только если адрес кратен четырём. На Cortex-M0+ нарушение — мгновенный HardFault, на Cortex-M4 всё «работает», но одна инструкция превращается в несколько. Правильный способ вынуть 32-битное поле из принятого пакета — не приведение указателя, а memcpy: компилятор развернёт его в тот же оптимальный код, но без нарушения правил строгого алиасинга: вместо uint32_t ts = *(uint32_t *)(&packet[5]) пишите memcpy(&ts, &packet[5], sizeof(ts)).
Та же тема с другой стороны — раскладка структур: компилятор вставляет невидимые дыры, чтобы каждое поле начиналось с корректного адреса, и на восьми килобайтах ОЗУ эти дыры перестают быть мелочью.
Практический вывод: сортируйте поля по убыванию размера, закрепляйте результат через _Static_assert(sizeof(struct S) == 8, "...") и применяйте packed только там, где структура буквально уходит в эфир или на шину. И никогда не берите адрес поля упакованной структуры — полученный указатель окажется невыровненным, а компилятор об этом уже не узнает.
volatile: что он обещает и чего не обещает
Формулировка стандарта короткая: обращения к volatile-объекту являются наблюдаемым поведением абстрактной машины, поэтому компилятор обязан выполнить их ровно столько раз и в том порядке, как написано в исходнике — ни выбросить, ни склеить два чтения в одно, ни переставить относительно другого volatile-обращения. Это нужно ровно в трёх случаях: регистр периферии, переменная, разделяемая с обработчиком прерывания, и буфер, который заполняет DMA.
static uint8_t dma_done; /* volatile забыт */
void DMA1_Stream0_IRQHandler(void) { dma_done = 1; }
void wait_dma(void)
{
while (dma_done == 0) { } /* с -O0 работает, с -Os зависает навсегда */
dma_done = 0;
}
Компилятор рассуждает безупречно: внутри цикла dma_done никто не меняет, значит, читать её каждый раз незачем. С -Os он выполнит ldrb один раз до цикла и дальше будет сравнивать регистр — получится вечный цикл. Это не баг оптимизатора: прерывания не описаны в модели языка, компилятор о них ничего не знает. Из-за этого же -O0 — плохой инструмент отладки на микроконтроллере: он маскирует настоящие ошибки и меняет тайминги, рабочий режим — -Og -g3. Что именно делает оптимизатор, разбирается в треке про компиляторы. Объявления при этом читают справа налево, и разница смысловая: volatile uint32_t *p — указатель на volatile-данные, именно так объявляют регистр; uint32_t *volatile p — volatile-указатель на обычные данные, почти всегда не то, что вы хотели; volatile uint32_t *const p — и адрес фиксирован, и содержимое считается изменчивым.
Теперь важное — чего volatile НЕ делает; каждый пункт стоил кому-то недели отладки:
- Не даёт атомарности.
counter++— по-прежнему три инструкции, между ними влезает прерывание. - Не упорядочивает ни обычные переменные, ни железо. Не-volatile обращения можно переставить через volatile-доступ — нужен барьер компилятора
__asm volatile ("" ::: "memory"); а на Cortex-M7 запись задерживается в буфере записи, и между настройкой периферии и зависящим от неё действием ставят__DSB()или__DMB(). - Не решает проблему кэша. Если у ядра есть кэш данных (Cortex-M7, внешняя память ESP32), DMA пишет мимо него: нужны
SCB_CleanDCache_by_Addrперед передачей иSCB_InvalidateDCache_by_Addrпосле приёма. - Не защищает от разрыва. Чтение
uint64_tна 32-битном ядре — две инструкции, прерывание между ними даёт полуобновлённое значение.
Полезное чтение: работа Eide и Regehr «Volatiles are Miscompiled, and What to Do about It» (https://users.cs.utah.edu/~regehr/papers/emsoft08-preprint.pdf) показывает, что даже промышленные компиляторы исторически ошибались в этой семантике, а документ ядра Linux «Why the volatile type class should not be used» (https://www.kernel.org/doc/html/latest/process/volatile-considered-harmful.html) объясняет, почему в системе с настоящими примитивами синхронизации volatile избыточен, а на голом железе — необходим.
Гонки: почему одного volatile мало
Возьмём самый частый код в прошивке — счётчик, который увеличивает обработчик прерывания и читает основной цикл.
Окно между чтением и записью — это и есть гонка, и volatile тут не помог бы: он вообще не про это. Рабочих решений три. Первое, самое дешёвое, — единственный писатель: если в переменную пишет только обработчик, а основной цикл её только читает, гонки нет вовсе, при условии что доступ помещается в одно выровненное слово. Второе — критическая секция минимальной длины.
static inline uint32_t irq_save(void)
{
uint32_t primask;
__asm volatile ("mrs %0, primask" : "=r"(primask));
__asm volatile ("cpsid i" ::: "memory"); /* запрет прерываний + барьер компилятора */
return primask; /* сохраняем ПРЕЖНЕЕ состояние, не «было включено» */
}
static inline void irq_restore(uint32_t primask)
{
__asm volatile ("" ::: "memory");
if ((primask & 1u) == 0u) { __asm volatile ("cpsie i"); }
}
static volatile uint32_t pulses;
void EXTI0_IRQHandler(void) { pulses++; } /* писатель один, защита здесь не нужна */
uint32_t take_pulses(void) /* а «забрать и обнулить» защиты уже требует */
{
const uint32_t st = irq_save();
const uint32_t v = pulses; pulses = 0;
irq_restore(st);
return v;
}
Восстановление прежнего состояния вместо слепого «разрешить» обязательно — иначе вложенный вызов включит прерывания раньше времени. Цена секции три-четыре такта, но за это время растёт латентность всех прерываний, поэтому внутрь помещают считанные инструкции. На Cortex-M3 и выше есть более тонкий BASEPRI, глушащий только приоритеты ниже заданного; подробности — в статье про прерывания и реальное время. Третье решение — атомарные операции C11: atomic_fetch_add(&counter, 1) выглядит красивее, но помните, во что он разворачивается — на Cortex-M3 и выше в пару LDREX/STREX с повтором при неудаче, а на Cortex-M0 без этих инструкций в вызов библиотечной функции, которая внутри просто запрещает прерывания. Абстракция одна, стоимость на разных ядрах отличается в разы.
Структура, которая обходится вообще без блокировок, — кольцевой буфер с одним производителем и одним потребителем:
#define RB_CAP 64u /* обязательно степень двойки: маска вместо деления */
typedef struct {
uint8_t buf[RB_CAP];
volatile uint32_t head; /* пишет ТОЛЬКО производитель, читают оба */
volatile uint32_t tail; /* пишет ТОЛЬКО потребитель, читают оба */
} ring_t;
bool rb_push(ring_t *rb, uint8_t byte) /* вызывается только из ISR, O(1) */
{
const uint32_t h = rb->head, next = (h + 1u) & (RB_CAP - 1u);
if (next == rb->tail) { return false; } /* полон: байт теряем осознанно */
rb->buf[h] = byte;
__asm volatile ("" ::: "memory"); /* данные записаны ДО публикации индекса */
rb->head = next;
return true;
}
/* rb_pop симметричен: читает tail, сравнивает с head, забирает байт,
ставит барьер и только потом двигает tail — вызывается из основного цикла */
Сложность: O(1) по времени на операцию, O(RB_CAP) по памяти, ноль динамических аллокаций. Корректность держится на трёх условиях: ёмкость — степень двойки, у каждого индекса ровно один писатель, 32-битный выровненный доступ на Cortex-M выполняется одной инструкцией. На AVR с 8-битной шиной последнее условие нарушается — там индексы читают под запретом прерываний.
Биты и регистры
Даташит описывает регистр как набор полей, C видит одно 32-битное слово. Перевод между этими взглядами — самая рутинная операция в прошивке.
#define BIT(n) (1UL << (n))
#define FIELD_MSK(pos, w) ((((1UL << (w)) - 1UL)) << (pos))
#define FIELD_SET(reg, pos, w, val) ((reg) = ((reg) & ~FIELD_MSK(pos, w)) \
| (((uint32_t)(val) << (pos)) & FIELD_MSK(pos, w)))
FIELD_SET(GPIOA->MODER, 10u, 2u, 1u); /* PA5 в режим выхода: MODER5 = биты 11:10 */
GPIOA->BSRR = BIT(5); /* поднять вывод 5 — одна инструкция, без чтения */
GPIOA->BSRR = BIT(5 + 16); /* опустить вывод 5 */
Два правила. Маскировать нужно с обеих сторон — и очищать поле перед записью, и обрезать значение по маске: забытая очистка даёт залипшие биты от прежней настройки, забытое обрезание — порчу соседних полей. И не описывайте регистры битовыми полями структур: стандарт не фиксирует ни порядок укладки (от старших битов или от младших), ни ширину контейнера, ни размер доступа, которым компилятор их прочитает, — а многие периферийные блоки не переживают байтового обращения к 32-битному регистру. Маски скучнее, но переносимы. Для ARM всё это уже написано в CMSIS (https://arm-software.github.io/CMSIS_5/Core/html/index.html): пишите TIM2->CR1 |= TIM_CR1_CEN, а не магические числа — через год вы сами не вспомните, что значило 0x00000001.
Жизнь без динамики
Запрет malloc — не аскеза, а арифметика. Фрагментация: устройство работает год без перезапуска, и даже когда свободной памяти суммарно хватает, она разбита на куски — запрос на 200 байт однажды вернёт NULL в непредсказуемый момент, невоспроизводимый на столе. Неограниченное время: аллокатор ходит по списку свободных блоков, длительность зависит от истории, а обработчик обязан уложиться в 50 микросекунд. Некому обработать отказ: на сервере NULL означает перезапуск процесса, здесь — остановку станка. Куча растёт навстречу стеку: в раскладке newlib куча идёт вверх от конца .bss, стек — вниз от вершины ОЗУ, встретятся молча. Отраслевые стандарты фиксируют это прямо: правило JPL для полётного ПО и MISRA C (https://misra.org.uk/) запрещают динамическую память после инициализации.
Замен четыре. Просто статически: если вы можете назвать максимум («не больше 8 датчиков»), объявите массив на 8 элементов — память, «сэкономленная» динамикой, всё равно должна быть в наличии в худшем случае. Арена: выделение сдвигом указателя, освобождение разом для всей фазы работы, обе операции O(1). Кольцевой буфер, написанный выше: он не выделяет память, он ею владеет. И пул блоков фиксированного размера — для случая, когда объекты появляются и исчезают, но все одинаковы: пакеты, сообщения, записи журнала.
#define POOL_BLOCKS 16u
#define POOL_BLOCK_SZ 64u
typedef struct block { struct block *next; } block_t;
_Static_assert(POOL_BLOCK_SZ >= sizeof(block_t), "блок меньше служебного указателя");
static uint8_t pool_mem[POOL_BLOCKS][POOL_BLOCK_SZ] __attribute__((aligned(8)));
static block_t *free_list;
static uint32_t in_use, high_water; /* high_water — метрика для отчёта, а не отладка */
void pool_init(void) /* список свободных блоков живёт в самих блоках */
{
free_list = NULL;
for (uint32_t i = 0; i < POOL_BLOCKS; i++) {
block_t *b = (block_t *)(void *)pool_mem[i];
b->next = free_list; free_list = b;
}
in_use = high_water = 0;
}
void *pool_alloc(void) /* O(1) в худшем случае: снимаем голову списка, поиска нет */
{
const uint32_t st = irq_save();
block_t *b = free_list;
if (b != NULL) {
free_list = b->next;
if (++in_use > high_water) { high_water = in_use; }
}
irq_restore(st);
return b; /* NULL — штатный ответ «пул исчерпан», а не катастрофа */
}
void pool_free(void *p) /* O(1); фрагментации нет по построению — блоки одинаковы */
{
if (p == NULL) { return; }
const uint32_t st = irq_save();
((block_t *)p)->next = free_list; free_list = (block_t *)p; in_use--;
irq_restore(st);
}
Сложность: O(1) и на захвате, и на возврате; память ровно POOL_BLOCKS * POOL_BLOCK_SZ байт и известна компоновщику. Поле high_water стоит четыре байта и отвечает на вопрос «а не мало ли блоков» данными с работающего устройства, а не догадкой. Жизненный цикл блока удобно держать в голове как автомат — и обратите внимание на состояние fail: исчерпание пула это нормальное спроектированное состояние со счётчиком и понятной реакцией, а не «такого не случится».
Стек компоновщик не считает, поэтому считать приходится вам. Три приёма: флаг -Wstack-usage=512, ругающийся на слишком жирные кадры; заливка стека шаблоном на старте с последующим измерением отметки максимума; настройка MPU так, чтобы нижняя страница стека давала отказ вместо тихой порчи данных. Проверка отметки максимума — это цикл, идущий от границы _sstack вверх, пока встречается шаблон 0xDEADBEEF: O(N) по размеру стека, поэтому его вызывают редко, раз в минуту из диагностической задачи. Для контраста полезно посмотреть, как та же задача решается в «большом» мире — в статье про управление памятью в ОС.
Плавающая точка: почему её обычно нет
На Cortex-M0 и M0+ блока плавающей точки не существует: каждое float-умножение — вызов библиотечной функции на десятки тактов, деление — на сотню с лишним. На Cortex-M4F есть FPU одинарной точности, но double там всё равно программный, и одна константа без суффикса f (0.5 вместо 0.5f) молча уводит весь расчёт в программную арифметику — ровно это ловит -Wdouble-promotion. Рабочая альтернатива — фиксированная точка: целое число с подразумеваемым множителем.
typedef int16_t q15_t; /* Q15: знаковое 16-битное, 15 бит после запятой; 0x4000 это 0.5 */
static inline q15_t q15_mul(q15_t a, q15_t b)
{ /* Промежуточный результат ОБЯЗАН быть 32-битным: произведение двух 16-битных
чисел не помещается в 16 бит, сдвиг возвращает масштаб */
return (q15_t)(((int32_t)a * (int32_t)b) >> 15);
}
/* Экспоненциальное сглаживание целиком в целых числах: на Cortex-M0 это
несколько тактов против сотни у программного float */
#define EMA(y, x, alpha) ((q15_t)((y) + q15_mul((alpha), (q15_t)((x) - (y)))))
Помнить нужно три вещи: диапазон и точность вы выбираете сами и за переполнение отвечаете сами; при умножении масштабы складываются, поэтому сдвиг обязателен; для 32-битных величин берут Q16.16. Готовые фильтры и преобразования в форматах q15 и q31 есть в CMSIS-DSP (https://arm-software.github.io/CMSIS-DSP/latest/) — писать своё БПФ не нужно. Теоретическая база — в статье про представление данных.
C++ на микроконтроллере: что берём, что выбрасываем
На Cortex-M это давно норма — при условии, что вы точно знаете, какое подмножество используете. Берём то, что не стоит ничего в рантайме: constexpr (вычисления переезжают на этап компиляции), шаблоны (специализация под конкретный вывод порта разворачивается в одну инструкцию), enum class (нельзя случайно сложить миллиметры с градусами), ссылки, static_assert, RAII для парных операций. Выбрасываем: исключения (-fno-exceptions — таблицы раскрутки стека стоят килобайты Flash и делают время непредсказуемым), RTTI (-fno-rtti), new/delete, потоки ввода-вывода, std::string, std::function, std::vector — всё, что незаметно ходит в кучу; плюс -fno-threadsafe-statics, иначе локальные статические объекты потянут блокировки инициализации.
// Вывод порта как тип: адрес и номер известны на этапе компиляции,
// поэтому LedGreen::set() превращается ровно в одну инструкцию STR.
template <std::uint32_t Base, std::uint32_t PinN>
struct Pin {
static volatile std::uint32_t &bsrr() {
return *reinterpret_cast<volatile std::uint32_t *>(Base + 0x18u); }
static void set() { bsrr() = 1u << PinN; }
static void clear() { bsrr() = 1u << (PinN + 16); }
};
using LedGreen = Pin<0x40020000u, 5u>; // PA5
// RAII для критической секции: конструктор зовёт irq_save, деструктор
// irq_restore — восстановление невозможно забыть даже на раннем return
class IrqLock { public: IrqLock() : s_(irq_save()) {} ~IrqLock() { irq_restore(s_); }
IrqLock(const IrqLock &) = delete; private: std::uint32_t s_; };
Виртуальные функции разрешены, но у них есть цена: указатель на таблицу в каждом объекте плюс косвенный вызов. Там, где полиморфизм нужен, а динамика — нет, применяют CRTP или простой указатель на структуру функций. Готовый набор контейнеров без кучи даёт Embedded Template Library (https://www.etlcpp.com/) — интерфейсы как у STL, но ёмкость фиксированная.
Python: у него здесь есть место, но не в прошивке
Python на микроконтроллере существует в двух видах, и путать их дорого. MicroPython и CircuitPython — полноценные интерпретаторы, работающие прямо на ESP32 или RP2040: датчик читается пятью строками, итерация занимает секунды вместо минут сборки и прошивки. Цена — десятки килобайт Flash под интерпретатор, обязательная куча со сборщиком мусора и, как следствие, недетерминированные паузы. Для проверки гипотезы «а видит ли этот датчик вообще что-нибудь» идеально; для управления двигателем с шагом 50 микросекунд — нет. Python как инструмент вокруг прошивки незаменим: разбор карты памяти, генерация заголовков из SVD-описаний, автоматизация стендов через pyserial и pyocd, обработка захваченных осциллограмм.
#!/usr/bin/env python3
"""Топ потребителей ОЗУ: разбор вывода arm-none-eabi-nm --print-size --size-sort.
Сложность O(n log n) по числу символов из-за сортировки, память O(n)."""
import sys
RAM_TYPES = {"b", "B", "d", "D"} # b/B — секция .bss, d/D — .data; они и занимают ОЗУ
rows = [(int(p[1]), p[2], p[3]) for p in (ln.split() for ln in sys.stdin)
if len(p) == 4 and p[2] in RAM_TYPES] # строки без размера отсеиваются
rows.sort(reverse=True)
total = sum(size for size, _, _ in rows)
print(f"всего в ОЗУ: {total} байт, символов: {len(rows)}")
for size, sym_type, name in rows[:15]:
print(f"{size:7d} байт {100.0 * size / total:5.1f}% [{sym_type}] {name}")
Третья территория Python — робототехника поверх Linux: на Raspberry Pi узлы ROS 2 пишут на rclpy, и это оправданно — там нет сроков в микросекундах, зато есть зрение, планирование и обмен сообщениями. Разделение обязанностей получается естественным: микроконтроллер держит контур управления, одноплатник думает. Подробно — в статье про робототехнику; на портале тема продолжена в разделе НИР — определение и классификация роботизированных систем и теоретическое обоснование микросервисной архитектуры для распределённых роботизированных систем показывают, как из отдельных контроллеров собирается система целиком.
Одинаковый C, четыре разных мира
Arduino (AVR ATmega328P). Восемь бит, 2 КБ ОЗУ, 32 КБ Flash, int шириной 16 бит и гарвардская архитектура: константы во Flash не адресуются обычным указателем, для них есть PROGMEM и pgm_read_*. Отличная площадка, чтобы почувствовать цену каждого байта, и плохая для всего, что требует скорости.
STM32 (Cortex-M). Референсная платформа для управления: предсказуемая латентность прерываний, богатые таймеры, отладка по SWD из коробки, CMSIS и подробнейшие справочные руководства. Внутри семейства разница ощутима: на M0+ нет ни невыровненного доступа, ни FPU; на M4F есть FPU одинарной точности и DSP-инструкции; на M7 появляется кэш данных, а с ним — обязанность думать о когерентности с DMA.
ESP32. Берут ради связи: Wi-Fi и BLE на борту, 520 КБ ОЗУ, FreeRTOS сразу в SDK. Главный C-нюанс неочевиден: код по умолчанию исполняется из внешней флеш-памяти через кэш, и если обработчик сработает в момент, когда кэш отключён (например, идёт запись во флеш), система упадёт. Поэтому критичный код и данные размечают явно:
static DRAM_ATTR volatile uint32_t edge_count; /* данные — во внутреннюю память */
static void IRAM_ATTR gpio_isr_handler(void *arg) /* и код обработчика тоже */
{
(void)arg; edge_count++;
}
Raspberry Pi. Это уже не микроконтроллер, а компьютер с Linux: C здесь становится обычным C — есть malloc, виртуальная память и сегфолт. Взамен исчезает детерминизм: планировщик общего назначения может отложить ваш поток на десятки миллисекунд, и оптимизация кода это не лечит (частично помогают ядро с PREEMPT_RT и изоляция ядер процессора). Шпаргалка по выбору: нужно детерминированное управление — STM32; нужна связь без внешних модулей — ESP32; нужны дешевизна и нестандартная периферия — RP2040 с блоками PIO; нужно учиться и быстро пробовать — любая плата с Arduino-средой; нужны зрение, ROS и файлы — Raspberry Pi. Если результат «правильный, но на 5 мс позже» неприемлем — берите микроконтроллер; если нужны файловая система, сеть и вычисления — берите Linux; в серьёзном изделии обычно есть и то, и другое, связанные по UART, SPI или CAN.
Отладка без принтов
Самая дорогая привычка, привезённая с сервера, — отлаживать логами. printf вреден сразу с трёх сторон. Он огромный: полноценная реализация с поддержкой %f добавляет 8–12 КБ Flash — на устройстве с 32 КБ это треть прошивки. Он медленный: строка на UART со скоростью 115200 бод — около миллисекунды на 12 символов, а обработчик обязан уложиться в 50 микросекунд. Он меняет то, что вы измеряете: классический сценарий — баг воспроизводится, вы добавляете вывод, баг исчезает; не потому, что починен, а потому, что сдвинулись тайминги. Такое явление принтами не ловится по определению.
Ножка-маркер и осциллограф. Две записи в BSRR стоят по такту и дают точную картину: длительность обработчика, джиттер, пропущенные срабатывания. Счётчик тактов ядра нужен, когда требуется не картинка, а число: DWT даёт точность в один такт без внешнего оборудования.
/* Маркер: GPIOB->BSRR = BIT(0) первой строкой обработчика и BIT(0 + 16)
последней — на осциллографе видны и длительность, и джиттер, и пропуски */
#define DEMCR (*(volatile uint32_t *)0xE000EDFCUL)
#define DWT_CTRL (*(volatile uint32_t *)0xE0001000UL)
#define DWT_CYCCNT (*(volatile uint32_t *)0xE0001004UL)
void cycles_init(void) /* TRCENA включает блок трассировки, CYCCNTENA — счётчик */
{
DEMCR |= (1UL << 24); DWT_CYCCNT = 0; DWT_CTRL |= 1UL;
}
/* Точное число тактов; беззнаковая разность корректна и при переполнении
счётчика — на 168 МГц оно наступает раз в 25 секунд */
uint32_t measure_cycles(void (*fn)(void))
{
const uint32_t t0 = DWT_CYCCNT;
fn();
return DWT_CYCCNT - t0;
}
Обработчик отказа, который что-то рассказывает. По умолчанию HardFault_Handler — пустой бесконечный цикл, и вы узнаёте только то, что «всё зависло». Пятнадцать строк превращают его в источник адреса виновной инструкции: naked-обёртка достаёт указатель на стековый кадр, а дальше читаются регистры состояния.
/* Вызывается из HardFault_Handler, помеченного __attribute__((naked)): тот
пятью инструкциями кладёт в r0 указатель на стековый кадр — MSP или PSP,
в зависимости от бита 2 регистра LR — и прыгает сюда */
void hardfault_report(const uint32_t *frame)
{
const uint32_t pc = frame[6]; /* где именно упало */
const uint32_t cfsr = *(volatile uint32_t *)0xE000ED28UL; /* причина отказа */
const uint32_t bfar = *(volatile uint32_t *)0xE000ED38UL; /* адрес, куда лезли */
(void)pc; (void)cfsr; (void)bfar; /* сохранить в секцию, не обнуляемую при
сбросе, и перезагрузиться: после перезапуска можно сообщить причину по сети */
for (;;) { }
}
Отдельно стоит знать про точку останова по данным: отладчик умеет останавливать ядро не на инструкции, а при записи в конкретный адрес. Это единственный вменяемый ответ на вопрос «кто портит эту переменную», и он находит виновника за минуты вместо дней. Инструменты: OpenOCD (https://openocd.org/), probe-rs (https://probe.rs/), PulseView из проекта sigrok (https://sigrok.org/wiki/PulseView), SEGGER RTT для вывода без остановки ядра (https://www.segger.com/products/debug-probes/j-link/technology/about-real-time-transfer/). Полный разбор — в статье про отладку и производство, общая дисциплина измерений — в треке производительность.
Как это выглядит в проде
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard \
-std=c11 -Og -g3 -ffreestanding -fno-common -ffunction-sections -fdata-sections \
-Wall -Wextra -Wconversion -Wsign-conversion -Wshadow -Wundef \
-Wdouble-promotion -Wstack-usage=512 -fstack-usage -c src/main.c -o build/main.o
arm-none-eabi-gcc build/*.o -o build/firmware.elf -T linker/stm32f411.ld -nostartfiles \
-Wl,--gc-sections -Wl,-Map=build/firmware.map -Wl,--print-memory-usage
Ключевые пары: -ffunction-sections -fdata-sections вместе с -Wl,--gc-sections выбрасывают из образа всё, на что никто не ссылается; -fno-common превращает случайное дублирование глобальной переменной в ошибку компоновки, а не в тихое объединение; --print-memory-usage печатает процент занятости Flash и ОЗУ прямо в лог сборки — этот процент стоит проверять в CI пороговым значением. Большая часть тестов при этом должна идти на хосте, и приём, который это позволяет, простой: логика не обращается к регистрам напрямую, она получает указатель на блок регистров — на устройстве туда приходит настоящий адрес, в тесте обычная структура в памяти.
void timer_set_prescaler(TIM_TypeDef *tim, uint16_t psc) { tim->PSC = psc; }
void test_prescaler(void) /* обычный gcc на хосте, ASan и UBSan, миллисекунды на прогон */
{
TIM_TypeDef fake = {0}; /* регистр подменён обычной структурой в памяти */
timer_set_prescaler(&fake, 7999);
assert(fake.PSC == 7999);
}
Так проверяется всё, что не зависит от физики: разбор протокольных кадров, конечные автоматы, фильтры, расчёт CRC, пограничные значения фиксированной точки. Инструментарий — Unity и Ceedling (http://www.throwtheswitch.org/unity), подход в целом — в статье про модульное тестирование. На железе остаётся только то, что действительно требует железа, и таких тестов должно быть немного: они медленные и капризные.
Типичные ошибки
- Забыть
volatileна переменной, разделяемой с обработчиком. Работает с-O0, ломается с-Os, выглядит как «баг компилятора». - Считать, что
volatileдаёт атомарность. Не даёт: инкремент — три инструкции в любом случае. - Оставить
malloc«на первое время». Он не уйдёт сам, а через три недели вернётNULLв самом неудачном месте. - Не считать стек. Один рекурсивный вызов или локальный буфер на 4 КБ — и вы молча портите
.bss. - Описывать регистры битовыми полями структур. Порядок укладки и ширина доступа стандартом не определены.
- Приводить
uint8_t *кuint32_t *без проверки выравнивания. На M0+ — HardFault, на M4 — тихая потеря производительности. - Смешивать знаковые и беззнаковые типы в сравнениях.
if (len - 1 >= 0)для беззнаковогоlenистинно всегда. - Писать
0.5вместо0.5fна устройстве с FPU одинарной точности. Одна буква переводит расчёт в программную арифметику. - Вызывать
printfвнутри обработчика прерывания. Вы измеряете не систему, а систему плюс миллисекунду вывода. - Использовать
-O0как «безопасный» режим. Он маскирует ошибки синхронизации и меняет тайминги; режим отладки —-Og -g3.
Мини-итог
C на микроконтроллере — тот же язык и совершенно другая инженерия. Память распределяется статически, потому что компоновщик обязан заранее сказать, влезет ли всё. Указатель — физический адрес без страховки, поэтому выравнивание и раскладка структур становятся вашей ответственностью. volatile обязателен для регистров и переменных, разделяемых с обработчиками, но не даёт ни атомарности, ни барьеров — путать это дорого. Динамическая память заменяется статикой, пулами, аренами и кольцевыми буферами, все с честным O(1) и известным потолком. Плавающая точка уступает фиксированной там, где нет FPU. C++ полезен ровно тем подмножеством, которое ничего не стоит в рантайме, а Python живёт вокруг прошивки и поверх Linux, но не внутри контура управления.
Главное практическое правило: вы не узнаете правду про свой код из логов. Ножка-маркер на осциллографе, счётчик тактов DWT, точка останова по данным и логический анализатор на шине рассказывают то, что printf физически рассказать не способен, — и рассказывают, не меняя измеряемую систему. Что сделать на этой неделе: соберите любой свой рабочий файл на C с -Wconversion -Wsign-conversion -Wdouble-promotion и прочитайте предупреждения — почти наверняка найдётся минимум одно скрытое усечение; затем сравните arm-none-eabi-size до и после включения --gc-sections.
Источники
- ISO/IEC 9899:2011, рабочий проект N1570 —
volatile, классы хранения, целочисленные повышения: https://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf - Barr Group. Embedded C Coding Standard — https://barrgroup.com/embedded-systems/books/embedded-c-coding-standard
- Arm. CMSIS-Core — https://arm-software.github.io/CMSIS_5/Core/html/index.html и CMSIS-DSP — https://arm-software.github.io/CMSIS-DSP/latest/
- Eide, Regehr. Volatiles are Miscompiled, and What to Do about It, EMSOFT 2008 — https://users.cs.utah.edu/~regehr/papers/emsoft08-preprint.pdf
- Linux kernel. Why the volatile type class should not be used — https://www.kernel.org/doc/html/latest/process/volatile-considered-harmful.html
- Elecia White. Making Embedded Systems, O’Reilly — https://www.oreilly.com/library/view/making-embedded-systems/9781449308889/
- MicroPython — https://docs.micropython.org/ , OpenOCD — https://openocd.org/ , probe-rs — https://probe.rs/ , PulseView — https://sigrok.org/wiki/PulseView , Embedded Template Library — https://www.etlcpp.com/
Что дальше
Периферия: GPIO, АЦП, ШИМ, таймеры — управляем железом из кода — теперь, когда регистр перестал быть магией и стал обычной ячейкой памяти с volatile, разберём, что за этими ячейками стоит: как настроить вывод и почему режимов у него шесть, как измерить напряжение и что на практике означает разрядность АЦП, как таймер порождает ШИМ и считает импульсы энкодера, и почему DMA — самый недооценённый способ вернуть ядру такты, которые вы тратили на копирование байт.