128. The C99–C23 standards: what changed for firmware
C changes slowly, but it does change: in a quarter of a century we got C99, C11,
C17 and C23. Many firmware projects are still written as if it were 1990:
#define instead of constants, structures zeroed by hand, unsigned char instead
of uint8_t. This section shows what each version offers on a Cortex-M,
what is dangerous and what breaks old code. Everything is tested: C99 and C11 on gcc, clang
and gcc for ARM with -pedantic, C23 on armclang 6.24 for the F103 and F407.
Terms used in the documentation
Term Meaning ISO/IEC 9899 the C language standard __STDC_VERSION__the number of the standard version selected for the build designated initializer an initializer that names its target: .field =,[index] =compound literal an unnamed object written as (type){ … }flexible array member an array without a size at the end of a structure variable length array (VLA) an array whose length is a run-time variable anonymous structure / union a nested structure / union without a member name attribute [[…]]a C23 standard attribute prototype a function declaration with parameter types GNU extensions ( gnu11)GNU additions to the standard
128.1 Which standard you are on now
The -std option selects the version, and a program reads it from the
__STDC_VERSION__ macro. A test on three compilers:
| Option | armclang 6.24 | gcc 12, clang 14 |
|---|---|---|
-std=c99 |
199901 | 199901 |
-std=c11 |
201112 | 201112 |
-std=c17 |
201710 | 201710 |
-std=c23 / c2x |
202311 | 202000 — draft |
| no option | 201710 | 201710 |
By default armclang builds C17 with GNU extensions: the
__STRICT_ANSI__ macro appears only with an explicit -std=c17. In Keil you
select the standard in the "Language C" field on the C/C++ tab. gnu11 and c11 differ
not in version but in extensions: with c11 and -pedantic the compiler warns
about any construct outside the standard. gcc 12 and clang 14 know C23 only
as a draft — 202000 — and half of what is new is missing there.
Newer versions change this. gcc 14 with no option is still 201710, with -std=c23 it is
still 202000, but constexpr, nullptr, typeof and enumerations with a fixed
underlying type already compile: they appeared in gcc 13. #embed needs gcc 15
or clang 19. And gcc 15 with no option already builds as gnu23 — so old
code from 128.9 (int f(); called with arguments, a home-made bool) stops
compiling after nothing more than a compiler update. That is why a project
sets the standard explicitly: -std=gnu17 or -std=c11.
Which GNU extensions show up in firmware. Most often it is a
MAX macro built on a statement expression ({ … }), which evaluates its arguments once:
#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; })
In the test, MAX_NAIVE(i++, j) with i = 5 returned 6 and i became 7 — i++
was evaluated twice; MAX_ONCE returned 5 and i became 6. Next come packing with
__attribute__((packed)) (a structure of a byte and a 32-bit word takes
5 bytes instead of 8), ranges in case 0 ... 49:, a zero-length array
data[0] at the end of a structure, binary constants 0b1010 and built-in
functions such as __builtin_popcount.
With -std=c11 -pedantic, gcc and clang warned about four of them: the zero-length
array, the case range, the statement expression and the binary constant.
__attribute__, __typeof__ and __builtin_popcount passed silently: names with
two leading underscores are reserved for the compiler, and -pedantic does
not check them. So "builds with -pedantic" does not yet mean "ports
to another compiler" — packed and built-in functions have to be searched for separately.
128.2 C99: what firmware can no longer do without
C99 gave us things that today feel like part of the language:
<stdint.h>—uint8_t,uint32_t: sizes that do not depend on the compiler (section 122);<stdbool.h>—bool,true,false;- variable declarations in
forand anywhere in a block,//comments; inlinefunctions instead of macros (section 71.11);- designated initializers and compound literals;
- a flexible array member at the end of a structure;
- variable length arrays — the one thing you are better off avoiding on a board.
One subtlety of bool: converting to it is a comparison with zero.
(bool)0.5 is 1, while (int)0.5 is 0: a flag obtained by division can change its value
when you switch from int to bool.
128.3 Designated initializers and compound literals
Peripheral configuration is a structure in which two fields out of five matter:
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 };
On all three machines the fields came out as 115200, 8, 0, 0, 0: everything not named gets zero. You can reorder the fields in the structure and the initialization will not break.
Arrays do the same with indices:
static const char *const state_name[] = { [2] = "run", [0] = "idle", [5] = "fault" };
The array got 6 elements — from the largest index — and the skipped ones
became NULL. This is handy for "code → text" tables where the codes have
gaps.
A compound literal is an unnamed object right inside an expression:
printf("compound=%d\n", sum((const int[]){ 1, 2, 3, 4 }, 4));
The function received an array that appears in no declaration and returned 10. Such an object lives until the end of the block, like an ordinary local variable.
128.4 A flexible array member at the end of a structure
A variable-length packet: a header with the data right after it.
struct packet {
uint8_t type;
uint8_t len;
uint8_t data[];
};
sizeof(struct packet) is 2: an array without a size takes no space.
You allocate memory as sizeof *p + length, and p->data[3] reads like
an ordinary element. People used to write data[1] and subtract one — that is
undefined behavior once you go past the declared size (section 123).
If the field before the flexible array is shorter than its element, padding
appears: with a uint16_t and a uint32_t array the size is already 4 (section 126).
128.5 Variable length arrays
A VLA is an array on the stack whose size comes from a variable. It looks innocent:
uint8_t buf[n];
memcpy(buf, src, n);
process(buf, n);
armclang with -Wvla warned: "variable length array used".
The object file shows why this is bad: the stack is moved by the
SUB r5, sp, r0 instruction — by an amount from a register. The same function with an array
of [64] has SUB sp, sp, #0x40: the size is known at build time, and the stack margin
can be calculated in advance (sections 11.4 and 35.4). A large n from the peer on the UART silently
goes through the task stack into someone else's memory. C11 made VLAs optional,
and firmware coding rules forbid them. The replacement is an array of the largest size
with a length check.
128.6 C11: checks, alignment, atomics
_Static_assertand_Generic— section 127._Alignas,_Alignofand<stdalign.h>:_Alignof(uint16_t)is 2,_Alignof(uint32_t)is 4 on all three machines; a DMA buffer is aligned with_Alignas(4)(section 126.9).<stdatomic.h>— atomic variables and barriers (section 124)._Noreturnfor functions such as a fatal error handler.- Anonymous structures and unions — handy for "one register seen in two ways":
union reg {
uint32_t word;
struct {
uint16_t lo;
uint16_t hi;
};
};
After .word = 0x12345678, the field r.hi is 0x1234 and r.lo is 0x5678: you access the halves
without an extra name for the nested structure. This is how machines
with the least significant byte first lay it out — Cortex-M and x86; on big-endian the halves swap
places. The trick is fine for your own registers, but not for packets and files
(section 126.4).
What from C11 you will not need on a board: threads from <threads.h>. The armclang
library has no such header — tasks are created by the RTOS (section 24).
128.7 C17: fixes only
C17 added nothing new — it only clarified the wording of C11. For a programmer it is the same language version with a different number: 201710. This is the one armclang selects by default.
128.8 C23: what is useful in firmware
C23 is the biggest update since C99. A file for the board was built with
armclang and -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— a real typed constant instead of#define; the UART divisor is calculated at build time, and the function returned a ready0x271— 625.- Digit separators
72'000'000and binary literals0b1000'0000. - An enumeration with a fixed type:
enum mode : uint8_ttakes 1 byte. static_assertwithout a message;bool,true,falseandnullptrare keywords, no headers needed.typeofandautofor type inference.- Attributes:
[[nodiscard]]on the DMA start function made the compiler warn about a forgotten check — "ignoring return value of function declared with 'nodiscard' attribute";[[fallthrough]]marks an intentionally missingbreak:classify(0)returned 11,classify(1)returned 2. #embedinserts a file into an array at build time — a font, an image, a table:
static const uint8_t logo[] = {
#embed "logo128.bin"
};
static_assert(sizeof logo == 4);
The four bytes of the file landed in Flash memory right after the code — STM2.
Without #embed you need a utility that turns the file into C source text.
The same file with -std=c11 did not build: "unknown type name 'constexpr'",
7 errors in total. The new features work only
with the new option, and you can move to them one at a time.
128.9 What C23 breaks in old code
Before C23, a declaration without parameters meant "parameters not specified":
int legacy();
return legacy(1, 2);
With -std=c17, armclang built this with a warning: "passing arguments
to 'legacy' without a prototype is deprecated in all versions of C and is
not supported in C23". With -std=c23 it is an error: "too many arguments to function
call, expected 0, have 2". In C23 empty parentheses mean the same as (void).
Old K&R-style function definitions — with parameter types after the parentheses —
C23 does not accept at all.
Besides that, bool, true, false, nullptr, constexpr, typeof
and static_assert became keywords: old code that uses these names for
its own macros or variables will have to rename them.
128.10 Pitfalls
The library builds for its author but not for you. Different -std
or GNU extensions. Set the standard explicitly and check with -pedantic.
A flag changed its value after the switch to bool. (bool)0.5 is 1,
(int)0.5 is 0. Compare explicitly: x != 0.
A task corrupts someone else's memory on a long packet. A variable length array
on the stack. Use -Wvla and an array of the largest size with a check.
After moving to C23 — "too many arguments". A declaration f() without
void. Write a prototype.
After a gcc update an old project stopped building. gcc 15 by
default builds as gnu23. Set -std explicitly.
constexpr does not compile. The option is below C23 or the compiler has no C23:
gcc 12 and clang 14 do not know it.
A configuration structure with garbage in its fields. The fields were assigned one by one, the rest were left untouched. A designated initializer zeroes everything not named.
128.11 Summary
__STDC_VERSION__: 199901 — C99, 201112 — C11, 201710 — C17, 202311 — C23; armclang 6.24 defaults to C17 with GNU extensions.- C99:
stdint.h,bool, designated initializers (unnamed fields are zeros), compound literals, flexible array member (sizeofwithout it). - A VLA on a board is
SUB r5, sp, r0, a stack of unknown depth; use-Wvla. - C11:
_Static_assert,_Generic,_Alignas, atomics, anonymous structures in unions; there is nothreads.hon a board. - C23:
constexpr,72'000'000,0b1000'0000,enum : uint8_t,nullptr,[[nodiscard]],#embed; with-std=c11the same file gives 7 errors. - C23 breaks
int f();called with arguments — it is now a function with no parameters.
Full source code
gnu128.c — GNU extensions found in firmware — to build with gnu11 and with 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 — what C99 and C11 gave us: designated initializers, compound literals, flexible array member, anonymous structures, _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 — a variable length array on the stack.
/* Раздел 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 features on the board, built with 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();
}
Next: section 129 "MISRA C and defensive programming".