Встраиваемые системы и робототехника C для встраиваемых систем: память, указатели, volatile, без динамики
0%

C для встраиваемых систем: память, указатели, volatile, без динамики

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)).

Та же тема с другой стороны — раскладка структур: компилятор вставляет невидимые дыры, чтобы каждое поле начиналось с корректного адреса, и на восьми килобайтах ОЗУ эти дыры перестают быть мелочью.

Раскладка структуры в памяти: выравнивание, padding и цена packed

Практический вывод: сортируйте поля по убыванию размера, закрепляйте результат через _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-битное слово. Перевод между этими взглядами — самая рутинная операция в прошивке.

Регистр периферии как 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), подход в целом — в статье про модульное тестирование. На железе остаётся только то, что действительно требует железа, и таких тестов должно быть немного: они медленные и капризные.

Типичные ошибки

  1. Забыть volatile на переменной, разделяемой с обработчиком. Работает с -O0, ломается с -Os, выглядит как «баг компилятора».
  2. Считать, что volatile даёт атомарность. Не даёт: инкремент — три инструкции в любом случае.
  3. Оставить malloc «на первое время». Он не уйдёт сам, а через три недели вернёт NULL в самом неудачном месте.
  4. Не считать стек. Один рекурсивный вызов или локальный буфер на 4 КБ — и вы молча портите .bss.
  5. Описывать регистры битовыми полями структур. Порядок укладки и ширина доступа стандартом не определены.
  6. Приводить uint8_t * к uint32_t * без проверки выравнивания. На M0+ — HardFault, на M4 — тихая потеря производительности.
  7. Смешивать знаковые и беззнаковые типы в сравнениях. if (len - 1 >= 0) для беззнакового len истинно всегда.
  8. Писать 0.5 вместо 0.5f на устройстве с FPU одинарной точности. Одна буква переводит расчёт в программную арифметику.
  9. Вызывать printf внутри обработчика прерывания. Вы измеряете не систему, а систему плюс миллисекунду вывода.
  10. Использовать -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.

Источники

Что дальше

Периферия: GPIO, АЦП, ШИМ, таймеры — управляем железом из кода — теперь, когда регистр перестал быть магией и стал обычной ячейкой памяти с volatile, разберём, что за этими ячейками стоит: как настроить вывод и почему режимов у него шесть, как измерить напряжение и что на практике означает разрядность АЦП, как таймер порождает ШИМ и считает импульсы энкодера, и почему DMA — самый недооценённый способ вернуть ядру такты, которые вы тратили на копирование байт.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов