Module 7 covered the language; this module covers the situation: code that shares an address space with peripherals, an interrupt controller, and a DMA engine, compiled by an optimizer that assumes none of them exist unless told. Everything here is provable on the Mac — host clang plus sanitizers for the software claims, arm-none-eabi-gcc disassembly for the hardware-facing ones.
1 · The as-if rule: why the optimizer is the protagonist
The C standard doesn’t promise your statements execute as written. It promises the program’s observable behavior matches the abstract machine — and the optimizer may do anything that preserves it (the “as-if” rule). Loads get cached in registers, stores get combined, loops get rewritten, and code whose effects “can’t matter” gets deleted.
That model is exactly right for computation and exactly wrong for hardware, where a load from an address can clear a flag and a store can start a conversion. The whole module is about the small set of tools that reconcile the two: volatile marks accesses as observable, atomics and fences establish ordering, linker attributes control placement, and the UB rules define where the optimizer’s assumptions come from.
2 · volatile: exactly what it guarantees — and what it doesn’t
A volatile-qualified access is an observable side effect: the compiler must actually perform it, as written, in source order relative to other volatile accesses. That’s the entire contract.
Without volatile, a flag-polling loop at -O2 becomes a single test or an infinite loop — the compiler hoists the load because “nothing in this loop changes the flag.” Exercise 8.1 makes you watch this happen in the disassembly, which is the only way it truly sticks.
Direction discipline with const volatile: hardware registers are read-only, write-only, or read-write from the CPU’s side, and the type can encode that:
typedefstruct{volatileconstuint32_t IDR;// hardware writes it; software only readsvolatileuint32_t ODR;// software may read and writevolatileuint32_t BSRR;// write semantics defined by hardware} GpioView;
CMSIS encodes the same intent as __I, __O, __IO macros. C’s const doesn’t enforce hardware permissions — some write-only registers technically read as garbage — but the qualifier documents the contract and turns misuse into a compile error.
Final caution: sprinkling volatile on every shared variable hides design problems. The correct concurrency tool — critical section, atomic, RTOS primitive — depends on the actual sharing pattern (§8), and volatile is only the right whole answer for MMIO and the simplest level-flags.
3 · MMIO mechanics: width, side effects, and read-modify-write
A peripheral register is not RAM. Three physical realities drive the idioms:
Access width is part of the interface. The typed volatile pointer determines the emitted instruction: uint8_t* → LDRB/STRB, uint16_t* → LDRH/STRH, uint32_t* → LDR/STR. Some peripherals require full-word access; the reference manual, not the C type system, is the authority. Exercise 8.2(b) confirms the instruction selection by type.
Reads and writes have side effects. Reading a status register can clear flags. Writing 1 to a bit can clear it — the write-1-to-clear convention for interrupt/status flags:
USART2->ICR = USART_ICR_ORECF;// clear ONE flag: write its bit, alone
Now the trap: USART2->ICR |= USART_ICR_ORECF;reads the register, ORs, and writes back every bit that read as 1 — clearing every pending flag, not just yours. A compound assignment is always a read and a write, however atomic the source looks.
Read-modify-write races against everything else that touches the register. The classic: GPIOA->ODR |= GPIO_PIN_5; is three steps — load, OR, store. If an ISR flips another pin between the load and the store, its update is silently undone:
sequenceDiagram participant M as main: ODR |= PIN_5 participant I as ISR: ODR |= PIN_7 participant R as ODR register M->>R: load ODR (reads 0x0000) Note over I,R: interrupt fires I->>R: load ODR (reads 0x0000) I->>R: store 0x0080 (PIN_7 set) Note over I,R: ISR returns M->>R: store 0x0020 (PIN_5 set — PIN_7 LOST)
sequenceDiagram
participant M as main: ODR |= PIN_5
participant I as ISR: ODR |= PIN_7
participant R as ODR register
M->>R: load ODR (reads 0x0000)
Note over I,R: interrupt fires
I->>R: load ODR (reads 0x0000)
I->>R: store 0x0080 (PIN_7 set)
Note over I,R: ISR returns
M->>R: store 0x0020 (PIN_5 set — PIN_7 LOST)
Hardware’s answer is dedicated set/reset registers: GPIOA->BSRR = GPIO_PIN_5; is a single store, no read, no window — the hardware performs the bit update. When the peripheral offers one, it’s always the right choice. When it doesn’t, the write needs a critical section (§8). Exercise 8.2(a) counts the memory operations in both forms.
4 · Bit-fields vs. masks and shifts
Bit-fields look like the datasheet diagram, which is exactly why they’re tempting for registers — and C leaves too much of their layout implementation-defined to trust for MMIO: allocation order within the unit, straddling behavior, padding, and the width of the generated access can all vary by compiler and ABI. Worse, a bit-field store may compile to a read-modify-write of the whole containing word — wrong for side-effecting registers (§3).
The robust idiom is the one CMSIS itself publishes — position and mask macros:
Explicit, portable, and the access width is visible in the code. Bit-fields remain acceptable for software-internal packed state on one known ABI — never for wire formats (§5) and never as an automatic MMIO solution. Exercise 8.2(c) diffs the generated code both ways.
5 · Layout: alignment, padding, packing, and the wire
The compiler inserts padding so every member sits at its natural alignment. Predicting it is mechanical: each member goes to the next multiple of its alignment; the struct’s total size rounds up to its strictest member’s alignment.
struct Bad {uint8_t a;uint32_t b;uint16_t c;};// 12 bytesstruct Good {uint32_t b;uint16_t c;uint8_t a;};// 8 bytes
struct Bad — 12 bytes struct Good — 8 bytes
┌────┬────┬────┬────┐ ┌────┬────┬────┬────┐
│ a │ × │ × │ × │ 0–3 │ b │ b │ b │ b │ 0–3
├────┼────┼────┼────┤ ├────┼────┼────┼────┤
│ b │ b │ b │ b │ 4–7 │ c │ c │ a │ × │ 4–7
├────┼────┼────┼────┤ └────┴────┴────┴────┘
│ c │ c │ × │ × │ 8–11 × = padding
└────┴────┴────┴────┘
First tool, always: order members by descending alignment — the padding disappears with no attributes at all.
__attribute__((packed)) (GNU/Clang extension, not ISO) removes padding to match an external format — at the price of unaligned members. On Cortex-M4, ordinary LDR/LDRH tolerate unaligned addresses (slower, and only if UNALIGN_TRP is off), but LDM/LDRD/exclusive accesses fault. Taking the address of a packed member hands out a pointer whose type promises alignment it doesn’t have — the compiler warns, and the fault arrives later. Use packing only at explicit boundaries: protocol headers, flash images, hardware descriptors whose ABI requires it.
The wire is bytes, not structs. For protocols, serialize explicitly — byte-wise or via memcpy — so byte order and bit positions are in the code, not in the ABI’s hands:
uint8_t frame[6];frame[0]= type;frame[1]=(uint8_t)(value);// little-endian on the wire,frame[2]=(uint8_t)(value >>8);// by decision, not by accident..._Static_assert(FRAME_LEN ==6,"wire format is 6 bytes");
Both machines in this course are little-endian, which is exactly how endianness bugs survive until a big-endian peer appears — state the order explicitly. Pair every layout assumption with a _Static_assert (Module 7 §8); Exercise 8.3 walks the whole progression.
6 · The linker’s world: sections, weak symbols, and the map file
The ISO object model ends at “objects have addresses.” Firmware needs more: the vector table at address zero, .data initializers in flash but the objects in RAM, DMA buffers in the right bank, a default handler for every unimplemented interrupt. That’s the linker’s world:
flowchart LR subgraph FLASH["Flash (0x0800 0000)"] V[".isr_vector<br/>(KEEP — vector table)"] T[".text — code"] R[".rodata — const tables"] LI[".data load image"] end subgraph RAM["SRAM (0x2000 0000)"] D[".data — initialized globals"] B[".bss — zeroed globals"] H["heap (if any) ↑"] S["stack ↓ (from top)"] end LI -- "startup: copy" --> D B -. "startup: zero" .- B
flowchart LR
subgraph FLASH["Flash (0x0800 0000)"]
V[".isr_vector<br/>(KEEP — vector table)"]
T[".text — code"]
R[".rodata — const tables"]
LI[".data load image"]
end
subgraph RAM["SRAM (0x2000 0000)"]
D[".data — initialized globals"]
B[".bss — zeroed globals"]
H["heap (if any) ↑"]
S["stack ↓ (from top)"]
end
LI -- "startup: copy" --> D
B -. "startup: zero" .- B
This diagram is the startup file you annotated in Exercise 6.4: the reset handler copies the .data image from flash to RAM, zeroes .bss, then calls main. const tables live in .rodata in flash — one more reason const-correctness is a memory-budget tool, not just style.
The GNU attributes that direct placement (extensions — kept behind project macros in disciplined codebases):
section("...") — put the object in a named section the linker script places (the DMA-visible RAM bank, a no-init region that survives reset…). The attribute alone creates nothing: the linker script must place the section, and the map file is the proof.
weak — a default definition that a strong definition elsewhere silently replaces. This is how the STM32 startup file provides a default handler for every vector: your ADC_IRQHandler wins over the weak Default_Handler alias by name — which is also why a typo’d handler name compiles clean and loops forever in the default handler.
used and the linker’s KEEP() — resist garbage collection (-ffunction-sections -fdata-sections + --gc-sections); the vector table survives only because the script says KEEP(*(.isr_vector)).
TipFirmware rule
The map file is the ground truth for “where did it actually go” — section addresses, symbol placement, which weak symbol lost, flash/RAM budgets. Reading it is a first-class skill, not a last resort (Exercise 8.5).
7 · Sharing state with an ISR
An interrupt handler is not a thread, but it creates the same problem: asynchronous interleaving. On a Cortex-M, a single aligned word load/store is indivisible — but almost nothing interesting is a single word:
Torn access: a 64-bit timestamp read as two words; the ISR updates between them.
Lost update:count++ is load–add–store; an ISR increment between load and store vanishes (§3’s race, now on a variable).
Lost events: a volatile bool is a level. Three events before the main loop looks = one true.
Broken protocol: flag published before payload — compiler or hardware reorder makes the reader see the flag without the data (volatile on the flag alone does not fix this: it orders nothing around the non-volatile payload).
The standard patterns, in escalating capability:
Level flag — volatile bool (or better, atomic_bool): only when “has it happened at least once” is genuinely the whole question, and only one word is shared.
Critical section (PRIMASK save/restore) — the general-purpose tool for multi-word state:
uint32_t primask = __get_PRIMASK();// save current mask state__disable_irq();/* touch the shared multi-word state */__set_PRIMASK(primask);// RESTORE — never blindly enable
Restoring (not blindly re-enabling) matters because the code may already run with interrupts masked. Keep sections short — they add latency to every interrupt on the core. And never call blocking APIs inside one.
Atomic operations — Module 7 §11’s LDREX/STREX counters and flags: lock-free for ≤ 4 bytes, ideal for event counts (atomic_fetch_add in the ISR, atomic_exchange(&n, 0) to drain in main) — no masking, no lost events.
Single-producer/single-consumer ring buffer — the workhorse for streams (UART bytes, ADC samples): ISR writes head, main reads tail, each index written by exactly one side, capacity a power of two, indices masked. With relaxed/release-acquire atomics on the indices, no critical section is needed at all — this is what Course 2’s DMA/UART pipelines are made of.
Shared state shape
Pattern
“It happened” (level, 1 word)
volatile/atomic flag
Event count
Atomic counter (relaxed)
Multi-word snapshot (config, 64-bit time)
PRIMASK critical section
Byte/sample stream
SPSC ring buffer
Under an RTOS
The RTOS’s ISR-safe primitives (...FromISR), which exist precisely for this
Exercise 8.6 cross-compiles the first three and annotates what each disassembly does and doesn’t protect against.
8 · DMA and compiler visibility
DMA is a second bus master that changes memory without executing a single C assignment — the compiler cannot see it, and volatile on a completion flag only forces the flag load. The robust design is an ownership protocol:
sequenceDiagram participant CPU as CPU (firmware) participant DMA as DMA engine CPU->>CPU: prepare buffer A CPU->>DMA: publish A (start transfer) Note over CPU,DMA: A is DMA-owned — CPU must not touch it CPU->>CPU: meanwhile: process buffer B DMA-->>CPU: transfer-complete IRQ CPU->>CPU: consume A, swap roles (double buffering)
sequenceDiagram
participant CPU as CPU (firmware)
participant DMA as DMA engine
CPU->>CPU: prepare buffer A
CPU->>DMA: publish A (start transfer)
Note over CPU,DMA: A is DMA-owned — CPU must not touch it
CPU->>CPU: meanwhile: process buffer B
DMA-->>CPU: transfer-complete IRQ
CPU->>CPU: consume A, swap roles (double buffering)
Rules that survive contact with hardware: never read or write a DMA-owned buffer; don’t assume “transfer complete” means every peripheral-side condition is complete (consult the DMA and peripheral chapters); place buffers with §6’s tools (alignment + the right RAM bank); and insert the ordering point — a DSB/atomic_thread_fence or the completion interrupt itself — between “DMA wrote it” and “CPU reads it.” On the STM32L476 there’s no data cache to manage, which simplifies coherency; on cached parts (M7-class) clean/invalidate and cache-line-aligned buffers become mandatory — file that for later hardware. Circular ADC-DMA in Course 2’s Lab 5.3 is this diagram running continuously, with half- and full-transfer interrupts marking ownership handoffs.
9 · The undefined behavior that matters in firmware
Optimizers assume UB never happens and transform accordingly — code that “worked at -O0” fails after an upgrade or a flag change. The short list that actually bites firmware:
Hazard
The trap
The correct idiom
Signed overflow
int32_t delta = now - then; wraps → UB; optimizer may delete your overflow check
Timer math in unsigned: uint32_t elapsed = now_ticks - then_ticks; — wraparound is defined, and the subtraction is correct across wrap
Shifts
Shift by ≥ promoted width; left-shift into/past the sign bit (1 << 31)
UINT32_C(1) << n, with n range-checked or masked
Strict aliasing
*(uint32_t *)&f — reading a float through a uint32_t lvalue
memcpy(&bits, &f, sizeof bits); — compiles to the same single instruction, without the UB; unsigned char* may inspect anything
Out-of-bounds
Ring index arithmetic off by one
Mask with a power-of-two capacity; assert the invariant
Unaligned via packed
&packed->member handed to code expecting alignment
§5: copy out with memcpy
Uninitialized reads
Stack garbage in a “mostly assigned” struct
= {0} (Module 7 §5)
Deleted-check flavor, concretely: if (x + 1 < x) as a signed-overflow test is UB-dependent, so the optimizer may replace it with false — the check compiles away exactly when you need it. Unsigned arithmetic, or explicit range checks before the operation, are the honest versions.
Effective types, and the blessed pun. The aliasing rule exists so the optimizer can reason about which lvalues might refer to the same storage. The sanctioned escape hatches: character-type access, compatible types, and — the workhorse — memcpy between unrelated representations, which compilers optimize to nothing for fixed small sizes. That’s the idiom for float-bit diagnostics, protocol decoding, and byte streams. Do not cast a uint8_t buffer to uint32_t* unless alignment, size, and effective type are all actually valid — explicit decoding is clearer anyway.
None of this is theoretical on the bench: timer wraparound, Q15 saturation edges, register masks, and packed frames all sit within one increment of these boundaries. Exercise 8.4 hunts all four classes host-side, where the sanitizers live.
10 · Memory discipline: the heap, the stack, and what replaces them
Why firmware bans the general-purpose heap (usually after init, often entirely): allocation can fail at the worst moment and every call site needs a plan; fragmentation grows unboundedly over uptime measured in months; malloc timing is non-deterministic — poison for real-time deadlines; and leaks/double-frees that a server shrugs off brick a device. Seacord’s own baseline points the same direction: objects whose size is known at compile time belong in automatic or static storage.
What replaces it, in order of preference:
Static allocation — sized at compile time, visible in the map file, budgeted at link time. The default.
Static pools / arenas — fixed-size blocks carved from a static array (message pools, packet buffers); allocation is O(1), exhaustion is a counted, testable condition. A max_align_t-aligned arena (Module 7 §8) can host mixed object types; flexible array members (Module 7 §7) ride on top naturally.
RTOS-provided allocators — FreeRTOS heap variants exist on a spectrum (heap_1’s allocate-only through heap_4/5’s coalescing schemes); choosing one is a design decision about determinism, documented per project.
If the heap is used at all: check every return, free in the same module and abstraction level that allocated, null the pointer after free, and remember sizeof *p beats sizeof(Type) in allocation expressions.
The stack needs the same rigor: it’s just a static budget you didn’t write down. Per-task stacks under an RTOS are explicit numbers; worst-case depth (deepest call chain + ISR nesting + FP context) has to fit. No VLAs, no alloca, no unbounded recursion (Module 7 §7) — plus watermarking at runtime (Course 2’s Module 7 territory).
11 · Preprocessor and program structure, the professional baseline
Distilled from Seacord ch. 9–10, firmware-flavored:
Headers. Every header: include guard, self-contained (includes what it uses — don’t rely on transitive includes; they make refactors brittle), exposes one cohesive interface. Implementation-detail headers live in a separate location from the public API so they can’t be included by accident.
Linkage hygiene. Public-interface identifiers get external linkage, declared in the header. Everything else at file scope is static — private by default, better optimization, cleaner map file. (You’ve now seen the same rule from three directions: Seacord’s, the symbol table’s, and the linker’s.)
Macro hygiene. Parenthesize the replacement list and every parameter; multi-statement macros wear do { ... } while (0); but the modern reflex is that static inline replaces most function-like macros outright (Module 7 §6). What legitimately remains for the preprocessor: conditional compilation, _Static_assert-style contracts, log-site capture (__FILE__, __LINE__, __func__), and X-macro-style tables when one list must generate several artifacts.
Configuration.#if sees undefined identifiers as 0 — -Wundef (Module 7 §2) turns that silent wrong answer into an error. Use #error for fatal configuration problems (“no clock source selected”) so impossible builds fail loudly rather than link quietly.
Opaque handles. The C way to hide implementation: the header declares typedef struct UartDriver UartDriver; and functions over pointers; only the .c defines the struct. Callers can’t reach inside, and the ABI boundary is explicit — the shape of every serious C driver API, including the pattern HAL approximates with its handle structs.
12 · The quality gate: proving firmware C on the host
The discipline that ties the module together (Seacord ch. 11 + MC’s migration checklist): the target build proves hardware behavior; the host build proves language-level correctness, because that’s where the tooling lives:
flowchart LR SRC["shared C sources<br/>(DSP kernels, protocols,<br/>ring buffers, state machines)"] --> HOST["HOST build — clang<br/>-O0 -g3 · -Wall -Wextra -Wconversion …<br/>UBSan + ASan · unit tests"] SRC --> TGT["TARGET build — arm-none-eabi-gcc<br/>-std=gnu17 · same warning set<br/>-O2 · map file · disassembly review"] HOST -->|"correctness proven"| SHIP["flash with confidence"] TGT -->|"placement & codegen proven"| SHIP
flowchart LR
SRC["shared C sources<br/>(DSP kernels, protocols,<br/>ring buffers, state machines)"] --> HOST["HOST build — clang<br/>-O0 -g3 · -Wall -Wextra -Wconversion …<br/>UBSan + ASan · unit tests"]
SRC --> TGT["TARGET build — arm-none-eabi-gcc<br/>-std=gnu17 · same warning set<br/>-O2 · map file · disassembly review"]
HOST -->|"correctness proven"| SHIP["flash with confidence"]
TGT -->|"placement & codegen proven"| SHIP
Warnings maximized on both builds (§2’s table) — the cheapest analysis there is.
Debug cycle at -O0 -g3, so instructions correspond to source; release at -O2, re-tested, because §9’s UB cliffs live there.
Sanitizers host-side only — UBSan and ASan don’t exist bare-metal, which is precisely why testable logic is factored out of register-touching code (the Course 2 labs repo’s simulate-first layout is this principle as a directory structure).
Static analysis (clang --analyze, cppcheck, compiler -fanalyzer) on the shared sources.
Runtime assert for programming errors only (Module 7 §8); production error paths for everything the world can legitimately do.
The migration checklist, distilled — what Exercise 8.7 applies to a real Course 2 module:
NoteModernizing a firmware codebase, in one pass
Pin -std=gnu17; turn on the full warning set; triage every diagnostic — fix or justify.
Adopt <stdint.h>/<stdbool.h>; kill long in portable structs.
Convert positional initializers to designated; state-clearing memsets to {0}.
Replace helper macros with static inline; add _Static_assert under every silent layout/width assumption.
Audit every volatile: MMIO and level-flags stay; everything else becomes the correct pattern (§7).
Audit every register RMW for §3’s hazards; prefer set/reset registers.
Ban VLAs, alloca, post-init heap; account for every buffer in the map file.
Isolate packing to true wire/ABI boundaries; serialize explicitly.
Stand up the host build with sanitizers for everything that doesn’t touch a register.
Modern syntax alone does not make firmware modern — the checklist’s later items are worth more than the earlier ones.