128. Стандарты C99–C23: что изменилось для прошивки
Язык C меняется медленно, но меняется: за четверть века вышли C99, C11,
C17 и C23. Многие прошивки до сих пор написаны так, будто на дворе 1990 год:
#define вместо констант, ручное обнуление структур, unsigned char вместо
uint8_t. Раздел показывает, что из каждой версии пригодится на Cortex-M,
что опасно и что ломает старый код. Всё проверено: C99 и C11 — на gcc, clang
и gcc для ARM с ключом -pedantic, C23 — на armclang 6.24 для F103 и F407.
Как это называется в документации
Оригинал Перевод ISO/IEC 9899 стандарт языка C __STDC_VERSION__номер версии стандарта, который выбран при сборке designated initializer именованный инициализатор: .поле =,[индекс] =compound literal составной литерал: безымянный объект (тип){ … }flexible array member гибкий массив в конце структуры variable length array (VLA) массив переменной длины anonymous structure / union анонимная структура / объединение attribute [[…]]атрибут стандарта C23 prototype прототип: объявление функции с типами параметров GNU extensions ( gnu11)расширения GNU к стандарту
128.1 Какой стандарт у вас сейчас
Версию выбирает ключ -std, а программа узнаёт её по макросу
__STDC_VERSION__. Опыт на трёх компиляторах:
| Ключ | armclang 6.24 | gcc 12, clang 14 |
|---|---|---|
-std=c99 |
199901 | 199901 |
-std=c11 |
201112 | 201112 |
-std=c17 |
201710 | 201710 |
-std=c23 / c2x |
202311 | 202000 — черновик |
| без ключа | 201710 | 201710 |
armclang по умолчанию собирает C17 с расширениями GNU: макрос
__STRICT_ANSI__ появляется только при явном -std=c17. В Keil стандарт
выбирают в поле «Language C» на вкладке C/C++. gnu11 и c11 отличаются
не версией, а расширениями: с c11 и -pedantic компилятор предупредит
о любой конструкции вне стандарта. gcc 12 и clang 14 знают C23 только
в черновике — 202000, — и половины нового там нет.
Новые версии это меняют. gcc 14 без ключа — ещё 201710, с -std=c23 —
всё ещё 202000, но constexpr, nullptr, typeof и перечисление с
типом уже собираются: они появились в gcc 13. #embed — только с gcc 15
и clang 19. А gcc 15 без ключа собирает уже по gnu23 — и старый
код из 128.9 (int f(); с аргументами, свой bool) перестаёт
собираться от одного обновления компилятора. Поэтому стандарт в проекте
задают явно: -std=gnu17 или -std=c11.
Какие расширения GNU встречаются в прошивках. Чаще всего — макрос
MAX на выражении-блоке ({ … }), который вычисляет аргументы один раз:
#define MAX_NAIVE(a, b) ((a) > (b) ? (a) : (b))
#define MAX_ONCE(a, b) ({ __typeof__(a) _a = (a); __typeof__(b) _b = (b); _a > _b ? _a : _b; })
В опыте MAX_NAIVE(i++, j) при i = 5 вернул 6, а i стал 7 — i++
вычислился дважды; MAX_ONCE вернул 5, i стал 6. Дальше — упаковка
__attribute__((packed)) (структура из байта и 32-битного слова занимает
5 байт вместо 8), диапазоны в case 0 ... 49:, массив нулевой длины
data[0] в конце структуры, двоичные константы 0b1010 и встроенные
функции вроде __builtin_popcount.
С -std=c11 -pedantic gcc и clang предупредили о четырёх из них: массив
нулевой длины, диапазон в case, выражение-блок и двоичная константа.
__attribute__, __typeof__ и __builtin_popcount прошли молча: имена с
двумя подчёркиваниями зарезервированы для компилятора, и -pedantic их
не проверяет. Поэтому «собирается с -pedantic» ещё не значит «переносится
на другой компилятор» — packed и встроенные функции ищут отдельно.
128.2 C99: то, без чего прошивка уже не пишется
C99 дал то, что сегодня кажется частью языка:
<stdint.h>—uint8_t,uint32_t: размеры, не зависящие от компилятора (раздел 122);<stdbool.h>—bool,true,false;- объявление переменной в
forи в любом месте блока, комментарии//; inline-функции вместо макросов (раздел 71.11);- именованные инициализаторы и составные литералы;
- гибкий массив в конце структуры;
- массивы переменной длины — единственное, от чего на плате лучше отказаться.
Одна тонкость bool: приведение к нему — сравнение с нулём.
(bool)0.5 — 1, а (int)0.5 — 0: флаг, полученный делением, при переходе
с int на bool может поменять значение.
128.3 Именованные инициализаторы и составные литералы
Настройка периферии — структура, в которой важны два поля из пяти:
struct uart_cfg {
uint32_t baud;
uint8_t data_bits;
uint8_t parity;
uint8_t stop_bits;
bool flow;
};
static const struct uart_cfg console = { .baud = 115200, .data_bits = 8 };
На всех трёх машинах поля вышли 115200, 8, 0, 0, 0: всё, что не названо, получает ноль. Порядок полей в структуре можно менять — инициализация не сломается.
У массивов то же с индексами:
static const char *const state_name[] = { [2] = "run", [0] = "idle", [5] = "fault" };
Массив получил 6 элементов — по наибольшему индексу, — а пропущенные
стали NULL. Это удобно для таблиц «код → текст», где коды идут
с пропусками.
Составной литерал — безымянный объект прямо в выражении:
printf("compound=%d\n", sum((const int[]){ 1, 2, 3, 4 }, 4));
Функция получила массив, которого нет в объявлениях, и вернула 10. Время жизни такого объекта — до конца блока, как у обычной локальной переменной.
128.4 Гибкий массив в конце структуры
Пакет переменной длины: заголовок и данные сразу за ним.
struct packet {
uint8_t type;
uint8_t len;
uint8_t data[];
};
sizeof(struct packet) — 2: массив без размера места не занимает.
Память выделяют как sizeof *p + длина, и p->data[3] читается как
обычный элемент. Раньше писали data[1] и вычитали единицу — это
неопределённое поведение при выходе за объявленный размер (раздел 123).
Если перед гибким массивом стоит поле короче его элемента, появится
заполнение: у uint16_t и массива uint32_t размер уже 4 (раздел 126).
128.5 Массивы переменной длины
VLA — массив в стеке с размером из переменной. Выглядит невинно:
uint8_t buf[n];
memcpy(buf, src, n);
process(buf, n);
armclang с ключом -Wvla предупредил: «variable length array used».
В объектном файле видно, чем это плохо: стек сдвигается командой
SUB r5, sp, r0 — на величину из регистра. У той же функции с массивом
[64] — SUB sp, sp, #0x40: размер известен при сборке, и запас стека
можно посчитать заранее (разделы 11.4 и 35.4). Большое n от собеседника по UART молча
проходит сквозь стек задачи в чужую память. C11 сделал VLA необязательными,
в правилах для прошивки их запрещают. Замена — массив наибольшего размера
с проверкой длины.
128.6 C11: проверки, выравнивание, атомарность
_Static_assertи_Generic— раздел 127._Alignas,_Alignofи<stdalign.h>:_Alignof(uint16_t)— 2,_Alignof(uint32_t)— 4 на всех трёх машинах; буфер DMA выравнивают через_Alignas(4)(раздел 126.9).<stdatomic.h>— атомарные переменные и барьеры (раздел 124)._Noreturnдля функций вроде обработчика фатальной ошибки.- Анонимные структуры и объединения — удобно для «одного регистра в двух видах»:
union reg {
uint32_t word;
struct {
uint16_t lo;
uint16_t hi;
};
};
После .word = 0x12345678 поле r.hi — 0x1234, r.lo — 0x5678: к половинам
обращаются без лишнего имени вложенной структуры. Так раскладывают машины
с младшим байтом вперёд — Cortex-M и x86; на big-endian половины поменяются
местами. Для своих регистров приём годится, для пакетов и файлов — нет
(раздел 126.4).
Что из C11 на плате не пригодится: потоки <threads.h>. В библиотеке
armclang такого заголовка нет — задачи создаёт ОСРВ (раздел 24).
128.7 C17: только исправления
C17 не добавил ничего нового — только уточнил формулировки C11. Для программиста это та же версия языка с другим номером: 201710. Именно её armclang выбирает по умолчанию.
128.8 C23: что пригодится в прошивке
C23 — самое большое обновление со времён C99. Файл для платы собран
armclang с -std=c23:
constexpr uint32_t PCLK_HZ = 72'000'000;
constexpr uint32_t BAUD = 115'200;
static_assert((PCLK_HZ + BAUD / 2) / BAUD == 625);
enum mode : uint8_t { MODE_IDLE, MODE_RUN, MODE_FAULT = 0b1000'0000 };
static_assert(sizeof(enum mode) == 1);
constexpr— настоящая константа с типом вместо#define; делитель UART посчитан при сборке, функция вернула готовое0x271— 625.- Разделители разрядов
72'000'000и двоичные литералы0b1000'0000. - Перечисление с заданным типом:
enum mode : uint8_tзанимает 1 байт. static_assertбез сообщения,bool,true,falseиnullptr— ключевые слова, без заголовков.typeofиautoдля выведения типа.- Атрибуты:
[[nodiscard]]на функции запуска DMA заставил компилятор предупредить о забытой проверке — «ignoring return value of function declared with 'nodiscard' attribute»;[[fallthrough]]помечает нарочно пропущенныйbreak:classify(0)вернул 11,classify(1)— 2. #embedвставляет файл в массив при сборке — шрифт, картинку, таблицу:
static const uint8_t logo[] = {
#embed "logo128.bin"
};
static_assert(sizeof logo == 4);
Четыре байта файла легли во флеш-память сразу за кодом — STM2.
Без #embed для этого нужна утилита, которая превращает файл в текст на Си.
Тот же файл с -std=c11 не собрался: «unknown type name 'constexpr'»,
всего 7 ошибок. Новые возможности работают только
с новым ключом, и переходить на них можно по одной.
128.9 Что C23 ломает в старом коде
Объявление без параметров до C23 значило «параметры не указаны»:
int legacy();
return legacy(1, 2);
С -std=c17 armclang собрал это с предупреждением: «passing arguments
to 'legacy' without a prototype is deprecated in all versions of C and is
not supported in C23». С -std=c23 — ошибка: «too many arguments to function
call, expected 0, have 2». В C23 пустые скобки значат то же, что (void).
Старые определения функций в стиле K&R — с типами параметров после скобок —
C23 не принимает вовсе.
Кроме этого, bool, true, false, nullptr, constexpr, typeof
и static_assert стали ключевыми словами: старый код, где так названы
свои макросы или переменные, придётся переименовать.
128.10 Грабли
Библиотека собирается у автора и не собирается у вас. Разные -std
или расширения GNU. Задавать стандарт явно, проверять -pedantic.
Флаг поменял значение после перехода на bool. (bool)0.5 — 1,
(int)0.5 — 0. Сравнивать явно: x != 0.
Задача портит чужую память при длинном пакете. Массив переменной длины
на стеке. -Wvla и массив наибольшего размера с проверкой.
После перехода на C23 — «too many arguments». Объявление f() без
void. Написать прототип.
После обновления gcc старый проект перестал собираться. gcc 15 по
умолчанию собирает по gnu23. Задать -std явно.
constexpr не собирается. Ключ ниже C23 или компилятор без C23:
gcc 12 и clang 14 его не знают.
Структура настройки с мусором в полях. Поля присвоены по одному, остальные не тронуты. Именованный инициализатор обнуляет всё неназванное.
128.11 Итоги
__STDC_VERSION__: 199901 — C99, 201112 — C11, 201710 — C17, 202311 — C23; armclang 6.24 по умолчанию — C17 с расширениями GNU.- C99:
stdint.h,bool, именованные инициализаторы (неназванные поля — нули), составные литералы, гибкий массив (sizeofбез него). - VLA на плате —
SUB r5, sp, r0, стек неизвестной глубины;-Wvla. - C11:
_Static_assert,_Generic,_Alignas, атомарность, анонимные структуры в объединениях;threads.hна плате нет. - C23:
constexpr,72'000'000,0b1000'0000,enum : uint8_t,nullptr,[[nodiscard]],#embed; с-std=c11тот же файл — 7 ошибок. - C23 ломает
int f();с аргументами — теперь это функция без параметров.
Полные исходники
gnu128.c — расширения GNU, которые встречаются в прошивках, — для сборки с gnu11 и с c11 -pedantic.
/* Подраздел 128.1: расширения GNU, которые встречаются в прошивках, и что с ними делает
* -pedantic. Собирается дважды: -std=gnu11 (тишина) и -std=c11 -pedantic (предупреждения). */
#include <stdint.h>
#include <stdio.h>
#define MAX_NAIVE(a, b) ((a) > (b) ? (a) : (b))
#define MAX_ONCE(a, b) ({ __typeof__(a) _a = (a); __typeof__(b) _b = (b); _a > _b ? _a : _b; })
struct __attribute__((packed)) frame {
uint8_t type;
uint32_t value;
};
struct msg {
uint8_t len;
uint8_t data[0];
};
static int grade(int x)
{
switch (x) {
case 0 ... 49:
return 2;
case 50 ... 100:
return 5;
default:
return 0;
}
}
int main(void)
{
int i = 5, j = 3;
int naive = MAX_NAIVE(i++, j); /* i++ вычисляется дважды */
printf("naive=%d i=%d\n", naive, i);
i = 5;
int once = MAX_ONCE(i++, j); /* один раз */
printf("once=%d i=%d\n", once, i);
printf("packed=%u\n", (unsigned)sizeof(struct frame));
printf("grade=%d,%d\n", grade(42), grade(77));
printf("bin=%d\n", 0b1010);
printf("msg=%u\n", (unsigned)sizeof(struct msg));
printf("popcount=%d\n", __builtin_popcount(0xF0F0u));
printf("done=1\n");
return 0;
}
std128.c — что дали C99 и C11: именованные инициализаторы, составные литералы, гибкий массив, анонимные структуры, _Alignof, bool.
/* Раздел 128: что дали C99 и C11 — опыт на трёх машинах.
* Именованные инициализаторы, составные литералы, гибкий массив в конце
* структуры, анонимные структуры в объединении, _Alignof, bool. Собирается
* с -std=c11: всё показанное — переносимый язык, без расширений. */
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* ── C99: именованные инициализаторы — неназванные поля станут нулями ──── */
struct uart_cfg {
uint32_t baud;
uint8_t data_bits;
uint8_t parity;
uint8_t stop_bits;
bool flow;
};
static const struct uart_cfg console = { .baud = 115200, .data_bits = 8 };
/* ── C99: индексы в инициализаторе массива — пропуски тоже нули ────────── */
static const char *const state_name[] = { [2] = "run", [0] = "idle", [5] = "fault" };
/* ── C99: гибкий массив в конце структуры ──────────────────────────────── */
struct packet {
uint8_t type;
uint8_t len;
uint8_t data[];
};
/* ── C11: анонимная структура внутри объединения ───────────────────────── */
union reg {
uint32_t word;
struct {
uint16_t lo;
uint16_t hi;
};
};
static int sum(const int *v, size_t n)
{
int s = 0;
for (size_t i = 0; i < n; i++)
s += v[i];
return s;
}
int main(void)
{
printf("cfg=%u,%u,%u,%u,%d\n", (unsigned)console.baud, console.data_bits, console.parity, console.stop_bits,
console.flow);
printf("states=%zu,%s,%s\n", sizeof state_name / sizeof state_name[0], state_name[5],
state_name[1] ? state_name[1] : "NULL");
struct packet *p = malloc(sizeof *p + 4);
if (!p)
return 1;
p->len = 4;
memcpy(p->data, "\x0A\x0B\x0C\x0D", 4);
printf("fam=%zu,%u\n", sizeof(struct packet), p->data[3]);
free(p);
printf("compound=%d\n", sum((const int[]){ 1, 2, 3, 4 }, 4));
union reg r = { .word = 0x12345678u };
printf("anon=%X,%X\n", r.hi, r.lo);
printf("alignof=%zu,%zu\n", _Alignof(uint16_t), _Alignof(uint32_t));
printf("bool=%d,%d\n", (bool)0.5, (int)0.5);
printf("done=1\n");
return 0;
}
vla128_arm.c — массив переменной длины на стеке.
/* Раздел 128: массив переменной длины на стеке. В C99 он обязателен,
* в C11 стал необязательным; размер стека функции становится неизвестен
* при сборке. -Wvla находит такие места. */
#include <stdint.h>
#include <string.h>
void process(uint8_t *buf, unsigned n);
void handle(const uint8_t *src, unsigned n)
{
uint8_t buf[n];
memcpy(buf, src, n);
process(buf, n);
}
void handle_fixed(const uint8_t *src, unsigned n)
{
uint8_t buf[64];
if (n > sizeof buf)
n = sizeof buf;
memcpy(buf, src, n);
process(buf, n);
}
c23_128_arm.c — возможности C23 на плате, собирается armclang с -std=c23.
/* Раздел 128: C23 на плате — armclang -std=c23 для F103 и F407.
* constexpr и разделители разрядов, перечисление с заданным типом, двоичные
* литералы, bool и nullptr как ключевые слова, typeof и auto, атрибуты
* [[nodiscard]] и [[fallthrough]], static_assert без сообщения, #embed.
* Тот же файл с -std=c11 обязан не собраться. */
#include <stdint.h>
constexpr uint32_t PCLK_HZ = 72'000'000;
constexpr uint32_t BAUD = 115'200;
static_assert((PCLK_HZ + BAUD / 2) / BAUD == 625);
enum mode : uint8_t { MODE_IDLE, MODE_RUN, MODE_FAULT = 0b1000'0000 };
static_assert(sizeof(enum mode) == 1);
static const uint8_t logo[] = {
#embed "logo128.bin"
};
static_assert(sizeof logo == 4);
bool ready = false;
[[nodiscard]] int start_dma(void);
uint32_t *first_or_null(uint32_t *p)
{
return p ? p : nullptr;
}
uint32_t uart_div(void)
{
auto d = (PCLK_HZ + BAUD / 2) / BAUD;
typeof(d) copy = d;
return copy;
}
int classify(int x)
{
switch (x) {
case 0:
x += 10;
[[fallthrough]];
case 1:
return x + 1;
default:
return -1;
}
}
uint8_t logo_byte(unsigned i)
{
return i < sizeof logo ? logo[i] : 0u;
}
void kick(void)
{
start_dma();
}
Дальше: раздел 129 «MISRA C и защитное программирование».