Module 7 Lessons — Modern C After K&R: C99, C11, and C17/18

Back to the Course 3 syllabus · Practice: Module 7 exercises

This page is the module’s teaching text: it assumes K&R 2nd edition is mastered ground — pointers, arrays, structs, functions, and the preprocessor are never retaught — and covers what the language became after that book, up to the C17/18 baseline that STM32 toolchains and the Course 2 firmware actually build. The MC reference guide and Seacord chapters remain available as optional deep-dives; nothing below requires them.


1 · How C got here

C evolved in a few uneven steps, and knowing which revision introduced a feature tells you which compiler flag enables it, why a vendor codebase restricts itself, and what a portability warning is really about.

flowchart LR
    KR["K&R C<br/>1978<br/><i>the book you read</i>"] --> C90["C89 / C90<br/>ANSI → ISO<br/><i>prototypes, void*,<br/>const/volatile</i>"]
    C90 --> C99["C99<br/><b>the big one</b><br/><i>stdint, // comments,<br/>designated init, inline,<br/>restrict, VLAs, FAM</i>"]
    C99 --> C11["C11<br/><b>compile-time & concurrency</b><br/><i>_Static_assert, alignas,<br/>_Generic, atomics,<br/>memory model</i>"]
    C11 --> C17["C17 / C18<br/><i>defect fixes only —<br/>no new features</i>"]

flowchart LR
    KR["K&R C<br/>1978<br/><i>the book you read</i>"] --> C90["C89 / C90<br/>ANSI → ISO<br/><i>prototypes, void*,<br/>const/volatile</i>"]
    C90 --> C99["C99<br/><b>the big one</b><br/><i>stdint, // comments,<br/>designated init, inline,<br/>restrict, VLAs, FAM</i>"]
    C99 --> C11["C11<br/><b>compile-time & concurrency</b><br/><i>_Static_assert, alignas,<br/>_Generic, atomics,<br/>memory model</i>"]
    C11 --> C17["C17 / C18<br/><i>defect fixes only —<br/>no new features</i>"]

Revision What it added STM32 relevance
C90 The standardized K&R 2nd-ed. language: prototypes, void *, const/volatile The baseline you already know
C95 Minor amendment (wide characters) None in practice
C99 Mixed declarations, // comments, fixed-width integers, designated initializers, compound literals, inline, restrict, variadic macros, flexible array members, long long, __func__, VLAs Most of what you’ll use daily
C11 _Static_assert, _Alignas/_Alignof, _Generic, anonymous structs/unions, _Noreturn, a formal memory model, _Atomic, optional threads The compile-time toolkit + concurrency vocabulary
C17/18 Defect corrections to C11 — deliberately nothing new The clean baseline to pin: -std=gnu17

Two names, one standard: the revision was approved in 2017 and published in 2018, so “C17” and “C18” are the same document (__STDC_VERSION__ == 201710L).

For STM32 work, “modern C” in practice means C99 plus selected C11, pinned as C17/18. Vendor code (CMSIS, HAL, generated CubeMX code) typically compiles in a GNU dialect such as gnu11/gnu17 because the headers intentionally use extensions.

2 · Pick the dialect; make warnings a contract

Compiler defaults drift between toolchain releases, so a reproducible project pins its dialect explicitly:

// The Course 2 labs repo builds with, in effect:
//   arm-none-eabi-gcc -std=gnu17 -mcpu=cortex-m4 -mthumb ...
  • -std=c17 — strict ISO. Useful for portability checks, but CMSIS/HAL headers may not compile under it.
  • -std=gnu17 — ISO C17 base plus GNU extensions (__attribute__, statement expressions, inline assembly). The practical production choice, because the vendor stack assumes it.

The dialect alone buys little without diagnostics. The warning set is the real contract — its job is to force implicit conversions, missing prototypes, and shadowed names into view at compile time, where they cost nothing:

Flag What it catches
-Wall -Wextra The broad, low-noise defect set — always on
-Wpedantic Non-ISO constructs (run on portability builds)
-Wconversion Implicit narrowing/sign conversions — the big one for DSP and register math
-Wshadow An inner declaration silently hiding an outer one
-Wstrict-prototypes void f() (unspecified args) vs. void f(void)
-Wundef Undefined macros silently evaluating to 0 in #if
-Wdouble-promotion float math silently promoted to double — costly on an FPU that is single-precision only, like the Cortex-M4F
-Wformat=2 printf-family format/argument mismatches
TipFirmware rule

Pin the dialect and the warning set in the build system, not in your head. New warnings are triaged like test failures: fix, or justify in a comment. (Exercise 8.7 applies exactly this to a real Course 2 module.)

3 · Objects, storage, and linkage — the vocabulary underneath

Modern C discussions constantly reference three properties every object has. Getting the vocabulary straight once pays off everywhere (this is Seacord’s ch. 2 in one table):

Property Values Firmware meaning
Storage duration static (whole program), automatic (enclosing block), allocated (heap) Static objects live in .data/.bss and survive forever; automatic objects live on the stack; allocated ones are usually banned (Module 8 §11)
Scope file, block, function-prototype Smallest possible scope = documentation of ownership
Linkage external (visible across translation units), internal (static at file scope), none (locals) Everything that isn’t public API should be file-static — it shrinks the symbol table and enables better optimization

Two consequences worth internalizing:

  • Declare at first use. C99 removed the declarations-at-top-of-block rule. A variable declared where it’s first needed has a shorter lifetime, a visible invariant, and no chance of accidental reuse — this matters in firmware, where a local often represents temporary ownership of a peripheral state, a critical section, or a buffer index.
read_adc_status();

uint16_t sample = (uint16_t)(ADC1->DR);   // exists only from here on
if (sample > threshold) {
    uint32_t now = DWT->CYCCNT;           // exists only inside this block
    record_crossing(sample, now);
}
  • for-scope counters. The loop variable ceases to exist when the loop ends — multiple loops can reuse the conventional name without sharing state:
for (uint32_t i = 0; i < SAMPLE_COUNT; ++i) {
    output[i] = input[i] - dc_offset;
}

One caveat that recurs in DSP loops: unsigned counters can’t count down past zero — i >= 0 is always true for an unsigned i. Iterate upward, or test before decrementing.

The three qualifiers

Qualifier Promise Where it earns its keep
const This code won’t modify the object through this lvalue API contracts; placing tables in flash (.rodata)
volatile Every access is a real, observable load/store — the compiler may not cache, fold, or elide it Memory-mapped registers, ISR-shared flags — the full treatment is Module 8 §2
restrict During this block, the object is accessed only through pointers derived from this one DSP kernels and DMA-style copies — §8 below

4 · The integer toolbox

4.1 Fixed-width types

Peripheral registers, wire protocols, and DSP samples have exact widths that must not depend on the platform’s idea of int. <stdint.h> provides:

Type family Example Use for
Exact width uint8_t, int16_t, uint32_t, int64_t Hardware-facing layouts, samples, serialized data
Fastest ≥ width uint_fast8_t, … Loop-local arithmetic when width is a floor, not a contract
Pointer-sized uintptr_t, intptr_t Storing addresses as integers (register base addresses)
Maximum uintmax_t, intmax_t Rarely; printf of “whatever it is”

Plain int remains the natural choice for small local arithmetic where exact width is irrelevant — CMSIS itself mixes both deliberately.

The two platforms this course runs on disagree about everything except int:

Type Apple Silicon (LP64) Cortex-M4 (ILP32)
int 4 bytes 4 bytes
long 8 bytes 4 bytes
long long 8 bytes 8 bytes
size_t, uintptr_t, pointers 8 bytes 4 bytes

This is exactly why long never appears in portable firmware structs — and why Exercise 7.3’s table is worth filling in by hand once.

4.2 Constants, suffixes, and limits

A literal’s type affects overflow, shifts, and comparisons. Don’t guess whether a mask needs U, UL, or ULL — use the <stdint.h> constant macros, which produce a constant of the right type for the fixed-width category:

#define TIMER_WRAP   UINT32_C(0xFFFFFFFF)
#define SAMPLE_MASK  UINT16_C(0x0FFF)

_Static_assert(UINT32_MAX >= UINT32_C(4000000000), "32-bit timer math required");

An unsuffixed hex literal’s type depends on its value (0x7FFFFFFF is int; 0x80000000 is unsigned int on these targets) — a classic source of sign-extension surprises in register masks.

For printing, printf format specifiers must match the promoted type exactly, and uint32_t maps to different base types on different platforms. <inttypes.h> solves it with concatenated format macros:

#include <inttypes.h>
printf("ticks=%" PRIu32 "  addr=0x%" PRIxPTR "\n", ticks, (uintptr_t)buffer);

(On a tiny target, remember that a full formatted-I/O implementation is a flash-budget decision, not a given.)

4.3 Integer promotions and the usual arithmetic conversions

The single biggest source of “the C looks right but isn’t” in 32-bit firmware. Two rules cover almost everything:

  1. Integer promotion: in an expression, operands of rank lower than int (uint8_t, int16_t, bit-fields…) are first converted to int (or unsigned int if int can’t represent them). On a 32-bit target, all 8- and 16-bit math actually happens in 32-bit signed int.
  2. Usual arithmetic conversions: when signed meets unsigned at the same rank, the signed operand converts to unsigned.
flowchart TD
    A["operand narrower than int?<br/>(uint8_t, int16_t, bit-field…)"] -->|yes| B["promote to int<br/>(all values fit in int on ARM)"]
    A -->|no| C[keep type]
    B --> D{signed meets unsigned<br/>at same rank?}
    C --> D
    D -->|yes| E["signed converts to unsigned<br/>⚠ -1 becomes 0xFFFFFFFF"]
    D -->|no| F[arithmetic proceeds]
    E --> F

flowchart TD
    A["operand narrower than int?<br/>(uint8_t, int16_t, bit-field…)"] -->|yes| B["promote to int<br/>(all values fit in int on ARM)"]
    A -->|no| C[keep type]
    B --> D{signed meets unsigned<br/>at same rank?}
    C --> D
    D -->|yes| E["signed converts to unsigned<br/>⚠ -1 becomes 0xFFFFFFFF"]
    D -->|no| F[arithmetic proceeds]
    E --> F

The traps, concretely:

uint16_t u = 65535;
uint32_t r = u * u;             // UB! u promotes to int; 65535*65535 overflows int
uint32_t ok = (uint32_t)u * u;  // correct: cast BEFORE the multiply

if (-1 < 1U) { ... }            // false! -1 converts to 0xFFFFFFFF

uint8_t a = 0x80;
uint32_t s = a << 24;           // a promotes to (signed) int; 0x80<<24 sets the
                                // sign bit — shift into the sign bit is UB
uint32_t s2 = (uint32_t)a << 24; // correct
WarningThe cast-before-multiply rule

If two 32-bit operands multiply and the product needs 64 bits, cast an operand, not the result: (uint64_t)x * y. Assigning an already-overflowed 32-bit product to uint64_t does not repair it. Same logic one level down for 16-bit operands promoted to int.

long long (≥ 64 bits, C99) makes 64-bit accumulators portable — useful for energy sums and timestamp math — but on a Cortex-M4 each 64-bit add is a multi-instruction sequence and division calls a runtime helper. Use the width because correctness requires it, not because it’s free.

4.4 bool

C99’s _Bool with <stdbool.h>’s spellings bool/true/false. Any nonzero scalar converts to true. Use it for genuine two-state logic (bool uart_is_idle(...)); keep multi-state status as enums. A volatile bool shared with an ISR is not automatically race-free — it can represent a level, but repeated events collapse into one true (Module 8 §8).

4.5 Floating point on the M4F

The Cortex-M4F FPU is single precision only: float operations are hardware instructions, double operations are software library calls, hundreds of cycles each. Two habits follow:

  • Suffix your constants: 1000.0f, not 1000.0 — an unsuffixed constant is a double and drags the whole expression into software emulation. -Wdouble-promotion catches this.
  • When an exact binary value matters (thresholds, ULP reasoning from Course 1 §3), C99 hex float constants state it exactly: float quarter = 0x1.0p-2f; — exactly 0.25, no decimal-conversion ambiguity.

5 · Initialization, modern style

Designated initializers (C99) name the member instead of relying on declaration order — reordering the struct definition can no longer silently misassign values, and unnamed members are zero-initialized:

GPIO_InitTypeDef gpio = {
    .Pin   = GPIO_PIN_5,
    .Mode  = GPIO_MODE_OUTPUT_PP,
    .Pull  = GPIO_NOPULL,
    .Speed = GPIO_SPEED_FREQ_LOW,
};                                  // every other member: zero

uint8_t lut[16] = { [0] = 1, [15] = 255 };   // sparse array form

This is the idiom for HAL configuration objects, driver vtables, protocol descriptors, and lookup tables. (The GNU range form [0 ... 15] is an extension — recognize it in vendor code, don’t write it in portable code.)

Universal zero: = {0}. For any structure, union, or array, {0} zero-initializes the first member and everything else as if it had static storage:

DmaState state = {0};
uint16_t histogram[256] = {0};

Clearer and more type-aware than memset — this is the default way to clear driver state.

Compound literals (C99) create an unnamed object with a real type at the point of use — unlike a cast, it’s an addressable lvalue:

configure_filter(&(FilterConfig){ .cutoff_hz = 1000.0f, .order = 2 });
WarningThe compound-literal lifetime

At block scope a compound literal has automatic storage for the enclosing block. If the callee stores the pointer instead of consuming the object during the call, you’ve handed out a dangling pointer. Exercise 7.4 demonstrates the failure and what catches it.

6 · Small functions without function-like macros

static inline (C99) is the replacement for the classic unsafe helper macro: type-checked, single-evaluation, and — with static — safe to define in a header (internal linkage per translation unit, no duplicate-symbol issues). This is exactly how CMSIS wraps core registers:

static inline void gpio_set(GPIO_TypeDef *port, uint32_t pin)
{
    port->BSRR = pin;
}

static inline uint32_t irq_save(void)
{
    uint32_t primask = __get_PRIMASK();
    __disable_irq();
    return primask;
}

Two footnotes: inline is a request, not a command — the optimizer inlines ordinary functions and may out-line “inline” ones; and non-static inline in a header has subtle ISO-vs-historical-GNU linkage semantics — the project rule is simply “header helpers are static inline, always.”

__func__ (C99): every function contains a predefined static const char __func__[] with its own name — standardized, unlike __FUNCTION__. Useful in assertions and fault logs, but each referenced name costs flash; size-critical release builds compile detailed tracing out.

Variadic macros (C99): ... and __VA_ARGS__ in macro parameter lists make source-located logging practical:

#define LOG_ERROR(fmt, ...) \
    log_error(__FILE__, __LINE__, fmt, __VA_ARGS__)

(Calling one with zero variadic arguments is awkward in C99/C17 — GNU comma-swallowing handles it but isn’t portable ISO. Keep the format-plus-at-least-one-arg shape, or provide a separate no-args macro.) If a variadic function must traverse its va_list twice — say, formatting the same event to UART and to flash — va_copy (C99) is the only portable way to duplicate traversal state; simple assignment of a va_list is not guaranteed to work. Every initialized copy needs its va_end. That said: variadic APIs discard type information; deeply constrained firmware often prefers typed event structs.

_Noreturn (C11, noreturn via <stdnoreturn.h>): declares that a function never returns — reset paths, fatal fault handlers, bootloader handoffs:

noreturn void panic(uint32_t code)
{
    store_crash_record(code);
    __disable_irq();
    for (;;) { __WFI(); }
}

If a “noreturn” function actually returns, behavior is undefined — don’t declare it on functions that return in test builds.

_Pragma("...") (C99): a pragma you can emit from a macro — the portable wrapper mechanism for narrowly isolating unavoidable warnings from vendor/generated headers, diagnostic push/pop style. Local and justified only; never a blanket mute.

7 · Arrays and pointers after C99

restrict — the no-aliasing promise

The optimizer must normally assume any two pointers might overlap, which blocks reordering and vectorization. restrict is the programmer’s promise that, during the block, the pointed-to object is accessed only through this pointer (and derivatives):

void fir_block(float * restrict y,
               const float * restrict x,
               const float * restrict h,
               size_t n, size_t taps);

When the promise holds, loads hoist and loops vectorize (Exercise 7.7 measures exactly this on both machines). When it’s false, behavior is undefined — not “wrong answer,” undefined, even if tests pass. So: restrict is an API contract, documented (“does not support in-place operation”), not a style garnish. This is why the CMSIS-DSP sources you read in Module 6 use it on every kernel.

VLAs — know them, ban them

C99 added runtime-sized automatic arrays (int16_t scratch[n];); C11 made support optional. In firmware they are a stack-overflow generator with a runtime-evaluated sizeof, which is why most embedded coding standards ban them outright. Fixed maxima or caller-provided workspaces do the job deterministically. (Related, same verdict: alloca.) The variably modified type machinery — float a[rows][cols] parameters — is occasionally useful host-side; embedded teams usually prefer flat arrays plus explicit strides.

Flexible array members

A struct whose last member is an incomplete array (uint8_t payload[];) describes a fixed header followed by variable-length storage — the C99-blessed version of the old “struct hack”:

typedef struct {
    uint16_t length;
    uint8_t  type;
    uint8_t  payload[];      // contributes nothing to sizeof
} Packet;

Packet *p = pool_alloc(sizeof *p + payload_len);

The natural shape for packet descriptors, command frames, and RTOS message payloads carved from a static pool. Constraints: the containing struct can’t be an array element or nested mid-struct, and the allocation arithmetic is on you — validate it.

static in array parameters

void process(float in[static 32]) promises the caller provides at least 32 valid elements — a diagnosable, optimizable contract. Rarely seen in the wild; ordinary pointers plus documentation are often clearer. Recognize it; use sparingly.

8 · Compile-time contracts

The C11 additions that turn silent layout assumptions into build failures — for firmware, arguably the most valuable section of this module.

#include <stddef.h>     // offsetof
#include <stdalign.h>   // alignas, alignof

_Static_assert(sizeof(uint32_t) == 4, "driver requires 32-bit uint32_t");
_Static_assert(offsetof(RegisterBlock, DR) == 0x24, "register map mismatch");
_Static_assert((DMA_BUFFER_SIZE % 4u) == 0u, "DMA buffer must be word-sized");

alignas(16) static uint8_t adc_dma_buffer[512];
size_t a = alignof(DmaDescriptor);
  • _Static_assert(cond, "msg") — file or block scope, zero runtime cost. The static_assert macro spelling comes from <assert.h>. Use it on register layouts, protocol struct sizes, power-of-two ring capacities, configuration sanity.
  • _Alignof(type) / alignof — the alignment requirement, as a compile-time constant. Alignment ≠ size, and a DMA engine may demand stricter alignment than the C type does — check hardware requirements separately.
  • _Alignas(...) / alignasstrengthen an object’s alignment (never weaken). DMA descriptors, SIMD-friendly buffers, cache-line alignment on the larger STM32s. The C attribute doesn’t place the buffer in a DMA-visible memory bank — that’s the linker’s job (Module 8 §6).
  • max_align_t — the alignment sufficient for every ordinary scalar type; the baseline a static arena must honor to host any object type. (An over-aligned descriptor type still needs explicit handling.)

Assertion discipline (Seacord ch. 11): _Static_assert for anything decidable at compile time; runtime assert only for programming errors — preconditions, postconditions, invariants — never for conditions that occur in normal operation (sensor timeouts, CRC failures: those get real error handling). Release builds define NDEBUG; firmware projects often route a failed assert to the fault handler instead.

9 · Structs and unions, the C11 conveniences

Anonymous structs and unions promote their members into the containing scope — the natural shape for register overlays and mutually exclusive views, without artificial u.bits.x naming:

typedef struct {
    union {
        uint32_t word;
        struct {
            uint32_t enable : 1;
            uint32_t mode   : 2;
            uint32_t        : 29;
        };                        // anonymous: c.enable, not c.bits.enable
    };
} ControlValue;

CMSIS-style headers use this for alternative register views. But note the trap that sends you to Module 8: bit-field layout is implementation-defined, so bit-field overlays are not a portable MMIO strategy — masks and shifts are the robust idiom for hardware registers (Module 8 §4). Bit-fields remain fine for software-internal packed state on a single known ABI.

10 · _Generic — type dispatch without overloading

C11’s generic selection examines the (unevaluated) type of a controlling expression and selects an expression — the mechanism behind type-safe helper “overloads”:

static inline int16_t clamp_i16(int16_t x, int16_t lo, int16_t hi);
static inline float   clamp_f32(float x, float lo, float hi);

#define clamp(x, lo, hi) _Generic((x),     \
        int16_t: clamp_i16,                \
        float:   clamp_f32                 \
    )((x), (lo), (hi))

Compared to the classic macro, arguments are evaluated once and types are checked. Compared to real overloading, it’s a blunt tool: qualifiers drop and arrays decay in the match, integer promotions can select a surprising association, and diagnostics get ugly. Keep generic macros small, tested, and rare — type-safe register/DSP helpers, not a framework. Exercise 7.6 builds precisely this.

11 · Concurrency in the language: the C11 memory model and atomics

Before C11, “what happens when an ISR and main share a variable” was folklore. C11 made it precise, and the vocabulary transfers directly to firmware even though an ISR is not a thread:

  • Data race: two conflicting non-atomic accesses without a happens-before relationship → undefined behavior. On a single-core Cortex-M, interrupts still create asynchronous interleavings — volatile alone does not create ordering or atomicity (the full ISR-patterns treatment is Module 8 §8).

_Atomic and <stdatomic.h> (C11): objects with indivisible operations and explicit memory ordering — load, store, exchange, compare-exchange, fetch-and-modify:

#include <stdatomic.h>

static atomic_uint event_count;

void EXTI0_IRQHandler(void)
{
    atomic_fetch_add_explicit(&event_count, 1u, memory_order_relaxed);
}

On the Cortex-M4, 1-, 2-, and 4-byte atomics compile to LDREX/STREX retry loops — genuinely lock-free. 8-byte atomics do not: ARMv7-M has no 64-bit exclusive pair, so they lower to libatomic library calls — unacceptable inside an ISR. Never assume; check atomic_is_lock_free and read the generated code (Exercise 7.8’s table). CMSIS exclusive-access intrinsics are the other common route to the same instructions.

Two fences, two jobs — related but distinct:

Fence Constrains Cortex-M reality
atomic_signal_fence Compiler reordering vs. an asynchronous handler on the same core Emits no instruction; still load-bearing
atomic_thread_fence Participates in the atomic ordering model; may emit hardware barriers May emit DMB depending on order

Firmware additionally uses the CMSIS __DMB/__DSB/__ISB intrinsics, whose meanings are architectural. Neither substitutes for the other: a C fence isn’t automatically a device barrier, and a CPU barrier doesn’t make racy non-atomic C well-defined.

What the language offers but bare metal doesn’t:

Facility Bare-metal STM32 status
<threads.h> Absent (__STDC_NO_THREADS__); FreeRTOS/CMSIS-RTOS own threading, with their own ISR-safe APIs
_Thread_local No meaning without a thread runtime; FreeRTOS task-local storage is a different mechanism
timespec_get Needs an RTC + C-library integration; cycle timing uses DWT/SysTick instead — and never wall-clock time for deadlines

12 · C17/18, and the working subset

C17/18 is a corrected C11 — defect resolutions, no new syntax. Its value is exactly that: a stable, clean baseline to pin (#if __STDC_VERSION__ >= 201710L). Code written to a sensible C11 subset needs no rewrite.

NoteThe everyday subset — what actually gets used

Fixed-width integers and their constant/format macros · bool · declarations at first use and for-scope · designated initializers and {0} · compound literals (consumed, not stored) · static inline helpers · variadic logging macros · __func__ · restrict on DSP/copy kernels as a documented contract · flexible array members over pools · _Static_assert + offsetof layout contracts · alignas/alignof where DMA/SIMD care · anonymous unions in overlays · a small, tested _Generic here and there · relaxed atomics and the two fences where ISRs share state · -std=gnu17 and the warning set, pinned.

Banned or avoided, with reasons you can now state: VLAs and alloca (stack determinism), long in portable structs (LP64/ILP32), bit-fields for MMIO (implementation-defined layout), unsuffixed float constants on the M4F (double promotion), 64-bit atomics in ISRs (not lock-free), <threads.h>/_Thread_local (absent), and everything Module 8 adds about the heap.

Where the exercises hook in

Lesson section Exercise
§1–2 timeline, dialects, warnings 7.1 — the dialect ladder
§3–6 the whole modernization kit 7.2 — modernize a K&R-era module
§4 widths, promotions 7.3 — LP64 vs. ILP32 predict-verify
§5 initialization, lifetimes 7.4 — designated init & compound-literal lifetime
§8 compile-time contracts 7.5 — layout contracts
§10 _Generic 7.6 — type-safe clamp
§7 restrict 7.7 — restrict on the dot product
§11 atomics & fences 7.8 — atomics without an OS