Embedded HandbookПриложения
Все разделы › I. Основы
STM32F103, STM32F407 · уровень базовый · 14 мин чтения
Открыть в интерактивном справочнике

3. Язык C для встраиваемых систем

Язык здесь тот же, что и на большом компьютере, но привычки нужны другие. Регистр по фиксированному адресу меняется сам по себе, int не всегда того размера, что вы думаете, а char на ARM ведёт себя не так, как на x86. Этот раздел — про то, что в микроконтроллерном C отличается на практике.


3.1 Типы: почему stdint.h, а не int

Главное правило встраиваемого C: не пишите int, long, short там, где важен размер. Подключайте <stdint.h> и берите типы с явной разрядностью.

Вместо Пишите Разрядность
unsigned char uint8_t 8 бит без знака
char (как число) int8_t 8 бит со знаком
unsigned short uint16_t 16 бит без знака
unsigned int, unsigned long uint32_t 32 бита без знака
— uint64_t 64 бита без знака

Причина не в педантичности. Размеры базовых типов зависят от платформы, и код, работающий на компьютере, на микроконтроллере может повести себя иначе.

Что реально на ARM Cortex-M:

Тип Размер Комментарий
char 1 беззнаковый по умолчанию
short 2
int 4
long 4 а на 64-битном Linux — 8
long long 8
void * 4
float 4 без FPU считается программно
double 8 на M4 FPU не помогает — он одинарной точности

Размеры базовых типов на Cortex-M клетками по байту: char 1, short 2, int 4, long 4, long long 8, указатель 4, float 4, double 8; выделены три ловушки — char без знака, long в 4 байта против 8 на 64-битном Linux и double, который на M4 считается программно

⚠️ char на ARM беззнаковый. На x86 он знаковый. Код вида

c char c = get_byte(); if (c < 0) { /* ... */ }

на компьютере иногда срабатывает, а на STM32 условие никогда не выполнится: (char)-1 там равно 255. Если нужен знак — пишите int8_t явно. Поведение можно изменить ключом -fsigned-char, но полагаться на него не стоит: сломается при смене компилятора.

⚠️ double на Cortex-M4 не ускоряется. FPU у M4 одинарной точности. Написали double x = 1.5; или константу без суффикса вроде x * 0.1 — и вычисление ушло в программную библиотеку, медленнее в десятки раз. Компилятор сам выручает только константы, которые во float записываются точно (1.5, 3.0); 0.1 так не записывается и остаётся double на любом уровне оптимизации. Надёжнее всего суффикс: 1.5f, 0.1f; ключ GCC и функции вроде sin — в разделе 69.7. Полноценная двойная точность в железе появляется только у Cortex-M7 с FPU двойной точности.

Отдельно про размеры полей структур: для дробных величин во встраиваемых задачах часто выгоднее вообще уйти от плавающей точки к арифметике с фиксированной точкой — хранить, скажем, температуру в сотых долях градуса как int16_t. На камне без FPU это на порядок быстрее и точнее по памяти.


3.2 Битовые операции

Вся работа с регистрами — это установка, сброс и проверка отдельных бит.

Задача Как Пояснение
установить бит n reg \|= (1u << n); «или» с маской
сбросить бит n reg &= ~(1u << n); «и» с инверсией маски
инвертировать бит n reg ^= (1u << n); «исключающее или»
проверить бит n if (reg & (1u << n)) результат — маска, не единица
прочитать бит n как 0 или 1 (reg >> n) & 1u сдвинуть бит на место нулевого и отрезать остальные
записать поле reg = (reg & ~MASK) \| VALUE; сначала очистить, потом положить

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

CMSIS оформляет то же самое макросами SET_BIT, CLEAR_BIT, READ_BIT, MODIFY_REG — они разбираются в разделе 5.

⚠️ Пишите 1u, а не 1. Литерал 1 имеет тип int — знаковый. Сдвиг 1 << 31 попадает в знаковый бит, и это неопределённое поведение: формально компилятор вправе сделать что угодно. Правильно 1u << 31.

Проверка бита возвращает не «истину», а маску. Это та же мина, что разобрана в разделе 8 на примере кнопки:

if (READ_BIT(GPIOA->IDR, GPIO_IDR_IDR5) == 1)   /* НИКОГДА не сработает */
if (READ_BIT(GPIOA->IDR, GPIO_IDR_IDR5) != 0)   /* правильно */

GPIO_IDR_IDR5 равен 0x0020, а не 1. Сравнение с единицей случайно работает только для нулевого бита.

Если нужно само значение бита — ровно 0 или 1, — его сдвигают на место нулевого и отрезают остальные: (GPIOA->IDR >> 5) & 1u. Такое выражение уже можно сравнивать с единицей, складывать и класть в массив. Проверка по маске и чтение бита — две разные операции: первая отвечает «есть или нет», вторая даёт число.


3.3 Указатели и структуры регистров

Периферия отображена в память, поэтому регистр — это просто адрес. Самый прямой способ до него добраться:

#define GPIOC_ODR  (*(volatile uint32_t *)0x4001100C)

GPIOC_ODR |= (1u << 13);

Разбор записи изнутри наружу: 0x4001100C — число; (volatile uint32_t *) — считать его адресом 32-битной ячейки, которая может меняться сама; внешняя * — обратиться к содержимому.

Работать так со всеми регистрами неудобно, поэтому периферию описывают структурой, а базовый адрес задают один раз:

typedef struct {
    volatile uint32_t CRL;    /* +0x00 */
    volatile uint32_t CRH;    /* +0x04 */
    volatile uint32_t IDR;    /* +0x08 */
    volatile uint32_t ODR;    /* +0x0C */
    volatile uint32_t BSRR;   /* +0x10 */
    volatile uint32_t BRR;    /* +0x14 */
    volatile uint32_t LCKR;   /* +0x18 */
} GPIO_TypeDef;

#define GPIOC ((GPIO_TypeDef *) 0x40011000)

Дальше GPIOC->ODR компилятор превращает в обращение по адресу 0x40011000 + 0x0C. Порядок полей обязан совпадать с картой регистров из reference manual — отсюда и поля RESERVED в заголовках: они затыкают пропуски в карте, чтобы смещения не уехали.

Работает это только потому, что поля из uint32_t укладываются подряд, без выравнивающих дыр: sizeof(GPIO_TypeDef) ровно 28 байт для семи регистров.

⚠️ Смешанная структура получает дыры. Вот это уже не карта регистров:

c typedef struct { uint8_t a; uint32_t b; } Mixed; /* sizeof == 8, не 5 */

Между a и b компилятор вставит три байта, чтобы выровнять b на границу слова. Никогда не описывайте регистры структурой из разнотипных полей и не полагайтесь на sizeof при разборе бинарных протоколов — там нужен __packed или ручная сборка из байтов.


3.4 volatile: почему исчезает ваш код

Как это называется в документации

Оригинал Перевод
memory-mapped register регистр, отображённый в память
read-modify-write чтение-изменение-запись
atomic access атомарный доступ
word / half-word / byte access доступ словом / полусловом / байтом
aligned / unaligned access выровненный / невыровненный доступ
bit mask битовая маска
to set / clear a bit установить / сбросить бит
reserved bits зарезервированные биты

Самое важное ключевое слово встраиваемого C. Обещает оно одно: каждое чтение и каждая запись из текста программы действительно произойдут — по этому адресу, столько раз и в том же порядке относительно других volatile. Держать значение в регистре, выбрасывать «лишнюю» запись или склеивать две записи в одну компилятору нельзя. Отсюда и польза: значение может измениться помимо этого кода — обработчиком прерывания, другой задачей или самим железом, — а запись в регистр периферии сама по себе действие, и пропустить её нельзя. Остальную программу volatile не замедляет и оптимизацию не выключает; чего он не обещает — неделимости и порядка относительно обычных переменных, — разобрано в разделе 124.2.

Что бывает без него, лучше показать выводом компилятора, чем объяснением. Две одинаковые функции ожидания флага, отличие в одном слове:

static uint8_t          flag_plain    = 0;
static volatile uint8_t flag_volatile = 0;

void wait_without_volatile(void) { while (flag_plain    == 0) { } }
void wait_with_volatile   (void) { while (flag_volatile == 0) { } }

void EXTI0_IRQHandler(void) { flag_plain = 1; flag_volatile = 1; }

Компилируем armclang с оптимизацией -O2 под Cortex-M3 и смотрим ассемблер.

Без volatile:

wait_without_volatile:
        bx      lr

Всё. Одна инструкция — возврат. Компилятор рассудил так: без volatile флаг можно прочитать один раз — внутри цикла его никто не меняет, а вызова обработчика компилятор не видит, хоть тот и лежит в том же файле. Если там 0, цикл вечный и ничего не делает, а стандарт C разрешает считать такой цикл конечным — остаётся только выход. Функция ожидания просто исчезла. Так поступает armclang. GCC (он стоит в CubeIDE) на том же коде делает иначе: читает флаг один раз и, если там 0, уходит в вечный переход сам на себя — программа зависает. Оба исхода — ошибка, и лечится она одинаково.

С volatile:

wait_with_volatile:
        movw    r0, :lower16:flag_volatile
        movt    r0, :upper16:flag_volatile
        b       .LBB1_3
.LBB1_1:
        ldrb    r1, [r0]
        cmp     r1, #0
        it      ne
        bxne    lr
.LBB1_2:
        ldrb    r1, [r0]
        cbnz    r1, .LBB1_5
.LBB1_3:
        ldrb    r1, [r0]          @ честное чтение из памяти
        cbnz    r1, .LBB1_5       @ флаг не 0 — на выход
        ldrb    r1, [r0]
        cmp     r1, #0
        beq     .LBB1_1           @ назад, пока флаг 0
.LBB1_5:
        bx      lr

Цикл на месте, ldrb перечитывает переменную из памяти каждый раз. Компилятор лишь развернул его вчетверо: за один проход четыре чтения флага, и после каждого — проверка.

Исчезнуть можно по-разному. Выше флаг был статической переменной, и у armclang цикл пропал целиком. Если то же ожидание написано через указатель на регистр, вывод компилятора другой, а результат не лучше:

#define SR (*(uint32_t *)0x40011000)          /* без volatile */

void wait_plain(void) { while ((SR & (1u << 7)) == 0) { } }

Без volatile компилятор вправе обращаться с регистром как с обычной переменной — выбросить чтение, перенести его или склеить с соседним. Здесь он читает регистр один раз, до цикла, а сам цикл сворачивает в переход сам на себя. Программа зависает навсегда, даже если железо давно выставило флаг. На плате это выглядит как «прошивка не дошла до следующей строки», и в отладчике видно бесконечный b по одному и тому же адресу. С volatile в том же месте появляется чтение на каждом витке — и ожидание работает.

Вторая ловушка из той же семьи — задержка пустым циклом. for (uint32_t i = 0; i < 1000; i++) {} на -O2 превращается в четыре байта: одно bx lr. Цикла нет, задержки нет, а в исходнике она как будто есть. Со счётчиком volatile цикл остаётся — тридцать два байта, чтение и запись счётчика на каждом витке.

Четыре функции в выводе компилятора: без volatile ожидание флага сворачивается в переход сам на себя, а пустая задержка исчезает целиком

⚠️ Задержка пустым циклом плоха и сама по себе: её время зависит от оптимизации, частоты ядра и даже от того, попал ли код в кэш. volatile здесь лечит только исчезновение, но не произвольность времени — для задержек есть таймеры (раздел 13) и SysTick (раздел 7).

Где volatile обязателен:

Чего volatile НЕ делает:

⚠️ volatile не даёт атомарности. Он запрещает кэшировать значение в регистре, но flag++ останется тремя операциями (чтение, инкремент, запись), между которыми может вклиниться прерывание. Для атомарности нужны критические секции, регистры с раздельной установкой и сбросом вроде BSRR либо специальные инструкции LDREX/STREX. Подробнее — в разделе 30.

⚠️ volatile не гарантирует порядок относительно обычных переменных и не мешает процессору переупорядочивать доступ к памяти. На Cortex-M это редко заметно, но при работе с DMA буфер после передачи иногда требует барьера (__DMB()), иначе ядро может увидеть устаревшие данные.


3.5 Целочисленное продвижение

Ловушка, которая ломает арифметику на маленьких типах.

В любом выражении uint8_t и uint16_t продвигаются до int. Поэтому:

uint8_t a = 200, b = 100;

if (a + b > 255)        /* ИСТИНА: a + b считается как int и равно 300 */
uint8_t c = a + b;      /* а тут 300 обрежется до 44 */

Первое поведение часто оказывается неожиданным, второе — тоже, и они противоречат друг другу в пределах трёх строк.

Сравнение знакового с беззнаковым — ещё хуже:

if (-1 > 1u)            /* ИСТИНА! */

-1 приводится к unsigned и становится 0xFFFFFFFF. Отсюда практическое правило: не смешивайте знаковые и беззнаковые в одном сравнении, а счётчики циклов, идущие вниз, объявляйте знаковыми:

for (uint8_t i = 10; i >= 0; i--)   /* ВЕЧНЫЙ ЦИКЛ: i всегда >= 0 */
for (int8_t  i = 10; i >= 0; i--)   /* правильно */

Такие места ловятся ключами -Wall -Wextra -Wsign-compare — включайте их с первого дня проекта, потом будет поздно разгребать.


3.6 const, static, inline

const для больших таблиц — не про неизменяемость, а про память: константный массив размещается во Flash, а не копируется в RAM при старте. На камне с 20 КБ ОЗУ это решает.

const uint8_t font_table[256] = { /* ... */ };   /* во Flash */
uint8_t       buffer[256];                       /* в RAM */

Во Flash попадает только константа со статическим временем жизни — объявленная вне функции или со static. Таблицу внутри функции пишут так: static const uint8_t lut[256] = { … };. Без static это обычная локальная переменная, и компилятор вправе копировать её на стек при каждом вызове. GCC 12 так и сделал на всех уровнях оптимизации — 256 байт стека и memcpy на каждый вызов; armclang с -O1 и выше обошёлся без копии, пока адрес таблицы не уходит в другую функцию.

static на уровне файла ограничивает видимость этим файлом — меньше шансов на конфликт имён и больше свободы у оптимизатора. static внутри функции делает переменную живущей всё время работы программы, но видимой только в функции.

inline просит подставить тело функции вместо вызова: быстрее, но код растёт. Для коротких обёрток над регистрами уместно, для всего подряд — нет. Обратите внимание: inline — это просьба, а не приказ; принудительная подстановка делается расширением компилятора (__forceinline в Keil, __attribute__((always_inline)) в GCC).


3.7 Стиль именования

Единого стандарта нет, но полезно знать распространённые, чтобы читать чужие библиотеки. В FreeRTOS принята венгерская нотация — тип зашит в имя.

Префикс Тип
c char
s short
l long
x BaseType_t, структуры, дескрипторы
u добавляется для беззнаковых: uc, us, ul
p добавляется для указателей: pc, pv
v функция ничего не возвращает: vTaskDelay

Отсюда ulCounter читается как unsigned long, а pcMessage — как указатель на char. Имена функций дополнительно несут имя модуля: xQueueSend — из очередей, возвращает BaseType_t.

Библиотеки ST придерживаются другого стиля: модуль, подчёркивание, действие — HAL_GPIO_WritePin, LL_TIM_EnableCounter. Имена регистров и битовых масок пишутся заглавными: RCC_APB2ENR_IOPCEN.

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


3.8 Грабли

Условие со знаковым char на STM32 не срабатывает никогда. На ARM char беззнаковый: (char)-1 равно 255. Нужен знак — int8_t (3.1).

Расчёт на M4 в десятки раз медленнее ожидаемого. double или константа без f уводят вычисление в программную библиотеку: FPU у M4 — одинарной точности (3.1).

1 << 31 ведёт себя странно. Литерал 1 знаковый, сдвиг в знаковый бит — неопределённое поведение; писать 1u << 31 (3.2).

sizeof структуры больше суммы полей. Компилятор выравнивает поля и вставляет пустые байты; протоколы разбирают побайтно или упакованной структурой (3.3).

Пустой цикл задержки исчез или длится не столько. volatile спасает от исчезновения, но не от произвольного времени — задержки делают таймером или SysTick (3.4).

volatile-счётчик теряет прибавления. volatile не даёт атомарности: flag++ — три операции, между ними влезает прерывание (3.4).


3.9 Итоги

Дальше: раздел 3а «Битовые операции».

Открыть в интерактивном справочнике