Embedded HandbookApps
All sections › XV. C in depth
STM32F103, STM32F407 · level intermediate · 14 min read
Open in the interactive handbook

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

Five versions of C on a timeline: the value of __STDC_VERSION__ and the key points for firmware

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:

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

Flexible array member: a two-byte header and four bytes of data right after it


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

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

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

Open in the interactive handbook