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 не помогает — он одинарной точности |
⚠️
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здесь лечит только исчезновение, но не произвольность времени — для задержек есть таймеры (раздел 13) иSysTick(раздел 7).
Где volatile обязателен:
- указатели на регистры периферии (в заголовках CMSIS уже проставлен);
- переменные, которые меняет обработчик прерывания;
- переменные, разделяемые между задачами RTOS;
- программные задержки пустым циклом — иначе цикл выкинут.
Чего 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 Итоги
- Берите типы из
stdint.h.int— 4 байта,longтоже 4, аcharна ARM беззнаковый. - На Cortex-M4 пишите
floatи константы с суффиксомf: FPU одинарной точности,doubleсчитается программно. - Проверка бита возвращает маску, а не единицу. Сравнивайте с нулём, не с единицей.
- Сдвигайте беззнаковую единицу:
1u << 31, иначе неопределённое поведение. - Регистры описываются структурой из
volatile uint32_t— она ложится на карту памяти без дыр. Смешанные типы дают выравнивание и ломают соответствие. - Без
volatileцикл ожидания с-O2исчезает (armclangоставляет одноbx lr) или зависает навсегда (GCC читает флаг один раз и уходит в переход сам на себя). volatileне даёт ни атомарности, ни барьеров памяти.uint8_tв выражении становитсяint:200 + 100равно 300, но при записи обратно вuint8_tпревратится в 44.-1 > 1uистинно. Не смешивайте знаковое с беззнаковым, включите-Wsign-compare.constкладёт таблицы во Flash и экономит ОЗУ.
Дальше: раздел 3а «Битовые операции».