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
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):
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 onif(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 exceptint:
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:
(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:
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.
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 intuint32_t ok =(uint32_t)u * u;// correct: cast BEFORE the multiplyif(-1<1U){...}// false! -1 converts to 0xFFFFFFFFuint8_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 UBuint32_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: zerouint8_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:
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:
Two footnotes: inline is a request, not a command — the optimizer inlines ordinary functions and may out-line “inline” ones; and non-staticinline 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:
(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:
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):
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”:
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)staticuint8_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(...) / alignas — strengthen 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 assertonly 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:
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”:
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:
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.