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 the STM32 toolchain, JetPack’s GCC, and the Course 3 firmware actually build. Each feature is presented with its standard of origin, its embedded use, and its trap; each section closes with a one-line pointer to the Rust construct that plays the same role, so that the pair structure of the course is visible before Module 5 teaches the other language. As with every lessons page in this course, it is AI-drafted teaching text reviewed by me; see the syllabus’s note on AI use. The MC reference guide and Seacord’s chapters 2–5 and 9 remain available as optional deep-dives; nothing below requires them, and material in Seacord newer than the C17/18 baseline is skipped.
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
Embedded 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).
On every tier of this course, “modern C” in practice means C99 plus selected C11, pinned as C17/18. Vendor code (CMSIS, HAL, generated CubeMX code, the Linux kernel headers a Jetson program includes) compiles in a GNU dialect such as gnu11/gnu17 because the headers intentionally use extensions.
Rust counterpart: one language, versioned by editions (the course uses 2024) that change surface syntax without splitting the ecosystem — Module 5 §1.
2 · Pick the dialect; make warnings a contract
Compiler defaults drift between toolchain releases, so a reproducible project pins its dialect explicitly:
-std=c17 — strict ISO. Useful for portability checks, but CMSIS/HAL headers and glibc’s feature macros may not compile cleanly under it.
-std=gnu17 — ISO C17 base plus GNU extensions (__attribute__, statement expressions, inline assembly, #pragma once). The practical production choice on all three tiers, because the vendor stack assumes it.
The dialect alone buys little without diagnostics. Module 0 §7 gave the warning set the c/ workspace pins (-Wall -Wextra -Wpedantic -Wconversion -Wshadow -Wstrict-prototypes -Wundef -Wdouble-promotion -Wformat=2); this module adds two that catch the expression-level traps of §5–6: -Wparentheses (in -Wall; chained comparisons, &&/|| mixing) and -Wimplicit-fallthrough / -Wswitch-enum for switch discipline.
The standard also tells you what an implementation is, through predefined macros, and a portable file checks them instead of guessing:
#if __STDC_VERSION__ < 201112L# error "this module requires C11 or later"#endif#if !__STDC_HOSTED__/* freestanding: no <stdio.h>, retargeted or absent printf — Module 0 §1 */#endif#ifdef __STDC_NO_ATOMICS__# error "atomics required"#endif
__STDC_NO_THREADS__ and __STDC_NO_VLA__ are the other two that matter here: the first is defined on arm-none-eabi (no <threads.h>), the second is what a firmware project would like the compiler to define and instead enforces with -Werror=vla.
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.
Rust counterpart:edition = "2024" in Cargo.toml, rust-toolchain.toml, and cargo clippy -- -D warnings — Module 0 §7.
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 banned after init on the bare-metal tier (Module 6)
Scope
file, block, function, 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.
3.1 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 §1
restrict
During this block, the object is accessed only through pointers derived from this one
DSP kernels and DMA-style copies — §9 below
A const object at file scope is not a constant expression in C — const uint32_t N = 64; uint8_t buf[N]; is a VLA, not a fixed array. Compile-time constants are enum members or #defines; static const tables belong in flash.
Rust counterpart: bindings are immutable by default and mut is the qualifier; const items are constant expressions; static is the file-scope object — Module 5 §2.
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 three machines this course runs on disagree about everything exceptint:
Type
Apple Silicon Mac (LP64)
Jetson / Pi, aarch64 Linux (LP64)
Cortex-M4 (ILP32)
int
4 bytes
4 bytes
4 bytes
long
8 bytes
8 bytes
4 bytes
long long
8 bytes
8 bytes
8 bytes
size_t, uintptr_t, pointers
8 bytes
8 bytes
4 bytes
char signedness
signed
unsigned
unsigned
Two rows bite. long never appears in portable firmware structs because of the LP64/ILP32 split. And plain char is unsigned on every ARM ABI — a char holding 0xFF compares greater than zero on the Jetson and the STM32 and less than zero on an x86 machine, which is why byte buffers are uint8_t and text is char, never interchanged.
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 the bare-metal tier, a full formatted-I/O implementation is a flash-budget decision, not a given — Module 0 §8.)
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 every target here, 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 Safe conversions
Seacord’s Ch 3 rule for narrowing: a conversion to a narrower or differently signed type is safe only when the value is known to fit, and the check must precede the conversion. Two idioms cover it:
// Narrowing with a range check — the value is data, not a constant.staticinlinebool to_u8(uint32_t v,uint8_t*out){if(v > UINT8_MAX){returnfalse;}*out =(uint8_t)v;returntrue;}// Sign change: never compare signed against unsigned directly.int32_t delta =...;if(delta <0||(uint32_t)delta > limit){...}
-Wconversion reports every implicit narrowing; the explicit cast is the place where you certify the range check happened. A cast that exists only to silence the warning is Seacord’s “casts disable diagnostics” trap (§5.3).
4.5 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 9 §3).
4.6 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 Lesson 37), C99 hex float constants state it exactly: float quarter = 0x1.0p-2f; — exactly 0.25, no decimal-conversion ambiguity.
The <math.h> functions come in three widths (sinf, sin, sinl); calling sin on a float argument on the M4F promotes to double, computes in software, and truncates back. <tgmath.h> or a _Generic wrapper (§12) selects the right one by type.
Rust counterpart:u8…u64, i8…i64, usize; no implicit conversions at all — every widening is from/into, every narrowing is try_from or an explicit as; arithmetic on u8 stays u8 and overflow is a defined event — Module 5 §7.
5 · Expressions: evaluation, sequencing, and what a cast hides
5.1 Evaluation and side effects
Evaluating an expression means two things: a value computation and the initiation of side effects — writing an object, reading or writing a volatile object, I/O, or calling a function that does any of these. The standard fixes when the value is computed relative to the operands (operands first), but says much less about when side effects land, and that gap is where a category of firmware bug lives.
5.2 Sequence points and unsequenced evaluation
Between two sequence points, side effects may be applied in any order. The full set of sequence points is short: the end of a full expression (;), the &&, ||, ?:, and comma operators, the call of a function after its arguments are evaluated, and the return from it. Two side effects on the same object with no sequence point between them are unsequenced, and that is undefined behavior:
i = i+++1;// UB: two writes to i, unsequenceda[i]= i++;// UB: i is written and read for a[i] with no orderingf(g(), h());// NOT UB, but g and h run in an unspecified order —// "indeterminately sequenced": one then the other, either wayuart_write(next_byte(), next_byte());// two reads of a queue: order unspecified
The last line is the embedded form of the trap: two calls with side effects in one argument list, with the byte order depending on the compiler. The fix is always the same — one side effect per full expression:
uint8_t lo = next_byte();uint8_t hi = next_byte();uart_write(lo, hi);
-Wsequence-point (in -Wall) catches the textbook cases; it cannot see through function calls. Reading volatile registers counts as a side effect, so x = REG_A + REG_B reads the two registers in an unspecified order — harmless for status registers, wrong for a FIFO read that pops on access.
5.3 Precedence, associativity, and the three classic misreads
C’s precedence table has a handful of rows that read differently from arithmetic. The ones that recur in register code:
Expression as written
Parsed as
Usually meant
a < b < c
(a < b) < c — compares 0 or 1 with c
(a < b) && (b < c)
x & MASK == MASK
x & (MASK == MASK) — == binds tighter than &
(x & MASK) == MASK
x << 2 + 1
x << (2 + 1) — + binds tighter than <<
(x << 2) + 1
!x & y
(!x) & y
usually !(x & y)
p = q = r ? s : t
p = (q = (r ? s : t)) — assignment is right-associative
itself, but often misread
-Wparentheses diagnoses the first two shapes; the rule that removes the class is to parenthesize every bitwise sub-expression in a register update, whether or not the compiler needs it.
5.4 Casts — what they do, what they hide
A cast converts a value to the unqualified named type; for integers it may reinterpret bits ((intptr_t)ptr), for floating-to-integer it truncates toward zero and is undefined if the value does not fit. Two rules from Seacord’s Ch 4 govern firmware use:
A cast is an assertion that the range has been checked or that the bit pattern is exactly what is wanted. Write the check next to it (§4.4).
A cast that exists to make a warning go away has not fixed anything: while ((c = (char)fgetc(in)) != EOF) can never see EOF once c is a char on an unsigned-char ARM ABI. The diagnostic was right.
The ?: operator’s result type is the common type of its two branches — cond ? 1 : 1.0f is a float, cond ? -1 : 1U is unsigned — and that conversion is silent. The comma operator sequences its operands and yields the right one; its only respectable firmware use is inside a for header.
5.5 sizeof, alignof, and pointer arithmetic
sizeof is evaluated at compile time and does not evaluate its operand (except for VLAs, which is one more reason to ban them): sizeof *p is safe even when p is null, and sizeof buf / sizeof buf[0] is the element count of an array, not of a pointer — passed to a function, an array parameter is a pointer and the idiom silently yields the pointer size divided by the element size.
Pointer arithmetic is scaled by the element size and is defined only within one array object plus the one-past-the-end (“too-far”) pointer. Comparing or subtracting pointers into different objects is undefined, p - q has type ptrdiff_t, and a pointer that steps past the too-far element is undefined even if never dereferenced. Register blocks are structs, not arrays; arithmetic on their base pointer is exactly this UB in disguise, which is why Module 8’s overlays index by member name.
Rust counterpart: evaluation order is fully specified (left to right, arguments in order), there are no implicit conversions and as is the only cast, and pointer arithmetic exists only on raw pointers inside unsafe — Module 5 §6, Module 7 §5.
6 · Control-flow idioms
6.1 switch with discipline
switch on an enum is the workhorse of protocol decoders and state machines, and it has two failure modes the compiler can police:
typedefenum{ ST_IDLE, ST_ARMED, ST_SAMPLING, ST_FAULT } AcqState;static AcqState step(AcqState s, Event e){switch(s){case ST_IDLE:return(e == EV_START)? ST_ARMED : ST_IDLE;case ST_ARMED:if(e == EV_TRIGGER){ start_dma();return ST_SAMPLING;}/* fall through */// deliberate — the comment satisfies -Wimplicit-fallthroughcase ST_SAMPLING:return(e == EV_DONE)? ST_IDLE : s;case ST_FAULT:return ST_FAULT;}return ST_FAULT;// reached only if s holds a non-enumerator value}
-Wswitch-enum warns when a case is missing for an enumerator, whether or not there is a default. Adding an enumerator then breaks the build in every switch that must handle it — the effect you want. A default:suppresses that warning, so prefer no default plus a statement after the switch for the impossible value, or default: fault(); only when the controlling value comes from outside the program.
Fall-through is a silent default; -Wimplicit-fallthrough makes every unmarked one a diagnostic, and a /* fall through */ comment (recognized by both GCC and Clang) marks the intended ones.
Integer promotions apply to the controlling expression, so a uint8_t selector compares as int; case constants outside the promoted range are diagnosed.
6.2 Loops
for for counted loops with the counter declared in the header; while for sentinel loops; do…while for “try, then check” shapes — a retry-with-timeout on a status bit is the embedded example:
A for header’s third clause runs after the body, which is what makes for (p = head; p; p = p->next) free(p) a use-after-free — save next before releasing the node. Loops over unsigned counters count up; loops that must count down test before decrementing (§3).
6.3 Single-exit cleanup with goto
C has no destructors. When a function acquires several resources in sequence — a mutex, a DMA channel, a buffer from a pool, a peripheral clock — and any step can fail, the correct release order is the reverse of acquisition, and the cleanest way to express it without nesting is a goto chain to labels placed in reverse order (Seacord Ch 5, and the pattern the Linux kernel uses throughout):
int capture_block(Capture *cap){int rc =0;if(!dma_channel_acquire(&cap->dma)){return-EBUSY;}if(!pool_alloc(&cap->buf, BLOCK_BYTES)){ rc =-ENOMEM;goto fail_buf;}if(!adc_start(cap)){ rc =-EIO;goto fail_adc;} rc = wait_for_completion(cap);// the actual work adc_stop(cap);fail_adc: pool_free(cap->buf);fail_buf: dma_channel_release(&cap->dma);return rc;}
The labels name what has not yet been undone; every goto jumps forward only; there is one return. This is the single idiom for which goto is the right tool, and the working subset allows it for exactly this shape. longjmp is not an alternative on any tier here — it bypasses the chain, and on bare metal it corrupts the interrupt-nesting state it knows nothing about.
Rust counterpart:match must be exhaustive, so the missing-enumerator case is a compile error; ? propagates errors and Drop runs the cleanup chain in reverse order automatically — Module 5 §4–5.
7 · 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. Padding bytes are not guaranteed zero by {0} (they are unspecified), which matters only when a struct is hashed or sent byte-wise; then memset first, then assign.
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 4.5 demonstrates the failure and what catches it.
Rust counterpart: struct literals name every field (there is no positional form for named fields), ..Default::default() fills the rest, and a temporary lives to the end of the enclosing statement with the borrow checker refusing any pointer that would outlive it — Module 5 §3.
8 · 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, va_copy (C99) is the only portable way to duplicate traversal state, and every copy needs its va_end. 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.
Rust counterpart:#[inline] functions, the never type ! for functions that don’t return, and macro_rules! for the genuinely syntactic cases — Module 5 §5, §9.
9 · Arrays and pointers after C99
9.1 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 4.8 reads 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. The CMSIS-DSP kernels Course 3 calls use it on every input and output pointer.
9.2 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 — the c/ workspace passes -Werror=vla. 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 code prefers flat arrays plus explicit strides.
9.3 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 (Module 6 §4).
9.4 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.
Rust counterpart:&mut Tis the restrict promise, enforced (two live mutable references to one object don’t compile); slices &[T] carry their length; there are no VLAs; a header-plus-payload is a dynamically sized type behind a reference — Module 5 §4, §8.
10 · 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 Linux tier. The C attribute doesn’t place the buffer in a DMA-visible memory bank — that’s the linker’s job (Module 8 §4).
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, treated fully in Module 12): _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.
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. Bit-fields remain fine for software-internal packed state on a single known ABI. Reading a union member other than the one last written is the C-sanctioned type pun (it reinterprets the bytes); it is the one place where C is more permissive than strict aliasing suggests, and Module 7 §3 sets the rule for using it.
Rust counterpart:#[repr(C)] structs match the C layout exactly; union exists but every read of a field is unsafe; there are no bit-fields — masks and shifts, or the PAC’s generated field accessors — Module 8 §5.
12 · _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” and behind <tgmath.h>:
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 (an int16_t * 2 argument is an int), the result type still depends on the argument so the caller must know it, and diagnostics get ugly. Keep generic macros small, tested, and rare — type-safe register/DSP helpers, not a framework. Exercise 4.8 builds precisely this.
Rust counterpart: traits and generic functions — fn clamp<T: PartialOrd>(x: T, lo: T, hi: T) -> T — with monomorphization producing the same per-type code — Module 5 §6.
13 · Preprocessor hygiene
Seacord’s Ch 9 is the preprocessor as a tool with sharp edges; the embedded subset is small and every item has a reason.
13.1 See what the translator sees
The preprocessor runs in the early translation phases and the compiler proper never sees your source, only its output. clang -E -o tu.i tu.c (same for GCC) writes that output; reading a .i file once for a CMSIS-using translation unit shows how much of “your” code is the vendor’s. It is also the first tool to reach for when a macro expands wrongly.
13.2 Include discipline
Include what you use; never rely on a header that arrived transitively — the vendor header that drags in <stdlib.h> today may not tomorrow.
Header guards on every header (#ifndef DRIVER_ADS1115_H … #define … #endif), named from the path, never starting with an underscore-capital (reserved). #pragma once is a universally supported extension and acceptable under gnu17; guards are the portable form.
Angle brackets for system and vendor headers, quotes for the project’s own; the search-path difference is implementation-defined but consistent on all three toolchains.
13.3 Conditional inclusion as configuration checking
#if, #elif, #else, #ifdef, #ifndef, and defined select code per target; #error refuses to build when no branch fits. The -Wundef flag in the warning set exists because an undefined identifier in #if silently evaluates to 0 — a misspelled CONFIG_USE_DMA disables the DMA path without a diagnostic:
#if !defined(CONFIG_SAMPLE_RATE_HZ)# error "CONFIG_SAMPLE_RATE_HZ must be set by the build"#elif CONFIG_SAMPLE_RATE_HZ > 200000# error "sample rate exceeds the ADC's rated maximum"#endif
Configuration belongs in one header or on the compiler command line (-DCONFIG_…), never sprinkled through sources.
13.4 Macros: object-like, function-like, and the rules
Object-like macros for constants are fine, but an enum or a static const object is type-checked and debugger-visible; prefer them when the value need not be a preprocessor-time constant.
Function-like macros: parenthesize every parameter use and the whole replacement list; never pass an argument with side effects (the bad_abs(i++) double evaluation from Seacord’s Table 9-9); wrap multi-statement macros in do { … } while (0) so they behave as one statement after an if. When a static inline function can do the job, it does the job (§8).
A comma inside a macro argument is an argument separator unless parenthesized — MACRO({1, 2}) is two arguments; MACRO(({1, 2})) is one.
Macro names are uppercase, and a macro name is poisoned for the rest of the translation unit: #define foo … followed by void foo(int) does not compile.
13.5 Stringizing, pasting, and X-macros
#x stringizes an argument; a ## b pastes tokens. Their sanctioned embedded use is the X-macro table — a list defined once and expanded several ways, so that an enumeration, its string names, and its case labels can never drift apart:
Register tables, command dispatchers, and state-name tables all take this shape. It is the one place where #undef and re-#define of a macro is idiomatic.
13.6 Predefined macros worth knowing
__FILE__, __LINE__, __func__ (§8) for diagnostics; __STDC_VERSION__, __STDC_HOSTED__, __STDC_NO_ATOMICS__, __STDC_NO_THREADS__, __STDC_NO_VLA__ for what the implementation provides (§2); the toolchain’s own — __ARM_ARCH, __ARM_FP, __aarch64__, __linux__, __APPLE__ — for target selection, checked with defined, never assumed.
Rust counterpart:#[cfg(...)] and cfg! for conditional compilation, Cargo features for configuration, const items for constants, and macro_rules! for the X-macro shape — Module 5 §9, Module 12 §3.
14 · Concurrency in the language, in one paragraph
C11 defined a memory model, _Atomic, and <stdatomic.h>, and made a data race — two conflicting non-atomic accesses without a happens-before relationship — undefined behavior. On the bare-metal tier the “other thread” is an interrupt handler; volatile does not create atomicity or ordering; 1-, 2-, and 4-byte atomics are lock-free on the Cortex-M4 (LDREX/STREX) and 8-byte ones are not; atomic_signal_fence orders the compiler against a handler on the same core and emits nothing, atomic_thread_fence may emit DMB. <threads.h> is absent on arm-none-eabi and _Thread_local has no bare-metal meaning. All of this is Module 9’s subject; it is listed here so that the availability half of it is part of the working subset from the start.
Rust counterpart:core::sync::atomic with explicit Ordering, and Send/Sync making the data race a compile error — Module 9 §5.
15 · 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 · one side effect per full expression · every bitwise sub-expression parenthesized · casts only beside their range check · switch on enums without default, fall-through marked · goto chains for reverse-order cleanup · 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 · header guards, -Wundef-clean #if, #error for configuration, X-macro tables · 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), plain char for bytes (unsigned on ARM), 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 bare-metal), longjmp, function-like macros where a static inline will do, and everything Module 6 adds about the heap.