Module 7 Lessons — Undefined Behavior and unsafe
Back to the Course 2 syllabus · Practice: Module 7 exercises
This page is the module’s teaching text. It is about the contracts underneath both languages: what a C compiler is allowed to assume about your program, how the optimizer turns those assumptions into transformations, what the Rust compiler proves instead — and how unsafe hands the C-style obligations back to you, in writing. The C half is the firmware undefined-behavior list with one concrete optimizer exploitation each, plus the tools that catch what the compiler will not; the Rust half is the difference between safe and sound, the unsafe superpowers, and the discipline (// SAFETY: comments, Miri, #[repr(C)]) that keeps a small amount of unsafe from contaminating a large amount of code. 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. Seacord’s chapters 4 and 11, Rust Book chapter 20, and the Rustonomicon remain available as optional deep-dives; nothing below requires them.
1 · The as-if rule: the optimizer is the protagonist
The C standard does not promise that your statements execute as written. It promises that the program’s observable behavior — volatile accesses, I/O, and the final result — matches what the abstract machine would produce, and the optimizer may do anything that preserves that (the as-if rule). Loads are cached in registers, stores are combined or delayed, loops are rewritten, and code whose effects “cannot matter” is deleted.
The rule has one premise that makes everything in this module follow: the abstract machine never executes undefined behavior. So when the optimizer sees a construct whose result would be undefined for some input, it is entitled to assume that input never occurs — and to simplify the surrounding code accordingly. That is not a compiler bug and not malice; it is the standard’s own definition of “undefined”: no requirements at all. A program that relies on a particular outcome of UB is relying on something the language never said.
// The optimizer's reasoning, made explicit.
int f(int x) {
return x + 1 > x; // signed overflow is UB, so x + 1 never overflows,
} // so this is always 1 — and -O2 compiles it to `mov w0, #1`The consequence for firmware is specific: the code “worked at -O0” is not evidence of anything. The same source fails after a compiler upgrade, a flag change, or an inlining decision that exposes the assumption. Module 4 fixed implementation-defined behavior with _Static_assert; this module is about the undefined kind, which no assertion can fix after the fact.
Undefined behavior is not “what the hardware does when you overflow” — it is a license the language grants the compiler. Write to the standard’s guarantees, and let sanitizers, not the bench, be the first place a violation is observed.
2 · The firmware UB list, one exploitation each
The C standard lists well over a hundred undefined behaviors. This is the short list that actually bites embedded code, each with the optimization it enables and the idiom that removes it.
2.1 Signed integer overflow
Signed arithmetic that overflows is undefined; unsigned arithmetic wraps modulo \(2^N\) by definition. The classic exploitation is loop-bound folding:
// Intended: run n+1 times. With n == INT_MAX, `i <= n` is never false unless i wraps —
// and signed wrap is UB, so the compiler may assume it never happens and emit an
// infinite loop (or a loop that ignores the bound entirely).
for (int i = 0; i <= n; ++i) { work(i); }The second exploitation is deleted checks: if (x + 1 < x) as an overflow test compiles to false, exactly when the check was needed. The idiom is to do wrap-tolerant math in unsigned types — timer deltas especially:
uint32_t elapsed = now_ticks - then_ticks; // correct across the 32-bit wrap, by definitionand to range-check before the operation, not after, when the operands are signed.
2.2 Oversized shifts and shifting into the sign
Shifting by a count ≥ the promoted width, or by a negative count, is undefined; left-shifting a signed value into or past the sign bit is undefined. The promotion is the trap: uint8_t b = 1; b << 9 is a shift of an int, so it is fine on paper and surprising in value; 1 << 31 is a shift of a signed int into its sign bit — UB, even though 1U << 31 is exactly what you meant. On the Cortex-M4 the hardware masks the shift count to 8 bits and produces something, which is the worst case: it “works”, unpredictably. Idiom: UINT32_C(1) << n with n masked or asserted; use the fixed-width constant macros so the literal’s type is never int by accident.
2.3 Strict aliasing and effective types
Every object has an effective type — the declared type of the object, or for allocated storage the type last written through. Reading it through an lvalue of an incompatible type is undefined, with three exceptions: character types (char, unsigned char) may inspect anything, compatible/qualified versions of the type are fine, and a struct or union containing the type may access it. The optimizer uses this to decide which loads and stores can be reordered:
// The optimizer may assume *p (a float) and *q (a uint32_t) never overlap,
// so it can keep *p in a register across the store through q — and the
// "bit pattern" you read is the stale one, or a fresh one, depending on -O.
uint32_t bits_of(float *p, uint32_t *q) { *q = 0; return *(uint32_t *)p; }The blessed pun is memcpy between unrelated representations — for fixed small sizes it compiles to a single move, and the compiler tracks the copy precisely:
uint32_t bits; float f = 1.5f;
memcpy(&bits, &f, sizeof bits); // defined, and free at -O2Do not cast a uint8_t buffer to uint32_t * — alignment (§2.7), size, and effective type all have to be right, and explicit byte decoding is clearer anyway. No sanitizer reliably catches aliasing violations; the disassembly at -O2 versus -O0 is the instrument, which is why Exercise 7.4 is a forensics exercise and not a test.
2.4 Out-of-bounds and uninitialized reads
Indexing outside an array, forming a pointer more than one past the end, or reading an object whose value is indeterminate — all undefined. The exploitation is bounds-check elimination: a loop that indexes buf[i] for i up to n lets the compiler assume n <= sizeof buf, and delete a later if (n > BUF_LEN). Uninitialized reads let it treat the value as anything, including a different value at each use. Idioms: mask ring-buffer indices with a power-of-two capacity (idx & (N - 1)) and assert the invariant; initialize aggregates with = {0} (Module 4); make the length part of the API (a (pointer, len) pair, the thing Rust turns into a slice).
2.5 Sequencing
Modifying an object twice without an intervening sequence point, or modifying it and reading it for another purpose, is undefined: i = i++ + 1, a[i] = i++, f(x++, x). Function-argument evaluation order is unspecified (any order, need not be documented), which is a separate hazard: printf("%d %d", read_reg(), read_reg()) reads the two registers in an order the compiler chooses. Idiom: one side effect per full expression; read registers into named locals in the order the datasheet requires.
2.6 Data races
Two threads (or an ISR and the main loop) accessing the same object, at least one writing, without synchronization — undefined by the C11 memory model. volatile does not fix it; atomics or a critical section do. This is Module 9’s subject; it is on the list here because the exploitation is the same: the optimizer assumes non-atomic, non-volatile memory does not change under it, and hoists the load out of the loop.
2.7 Alignment
Accessing an object through a pointer not correctly aligned for its type is undefined. The Cortex-M4 muddies this in practice: it supports unaligned LDR/STR/LDRH/STRH (unless the UNALIGN_TRP bit in CCR is set), but not LDM/STM, LDRD/STRD, or the exclusive LDREX/STREX — so code that “works” with a misaligned uint32_t * faults the moment the compiler chooses a multi-register load for it, or the moment an atomic touches it. The AArch64 boards permit unaligned data access in general; the point is that the language does not, and the compiler may emit an aligned-only instruction whenever it likes. __attribute__((packed)) members are the usual source: taking &packed->field produces a pointer the type system says is aligned and the object says is not (GCC warns with -Waddress-of-packed-member). Idiom: copy packed fields out with memcpy, or decode the wire format byte by byte.
2.8 restrict that lies
restrict is a promise that, for the pointer’s lifetime, the object is accessed only through pointers derived from it. If the promise is false, the behavior is undefined — not “a wrong answer”: the optimizer may have vectorized the loop, reordered loads before stores, or dropped a reload, and the result depends on -O and the vector width. Module 4 §8 showed why DSP kernels want the promise; this module’s point is that the promise is a contract with the caller, unverifiable by the callee.
2.9 Null on a microcontroller
Dereferencing a null pointer is undefined, and on a hosted system it also faults, which is why the two are confused. On the Cortex-M, address 0 is the start of the vector table (or its alias into flash), so a null read returns the initial stack-pointer value and succeeds. The hazard is therefore not the crash but the assumption: having seen *p executed, the compiler may conclude p != NULL and delete a later if (p == NULL) return; — the “null-check elimination” that -fno-delete-null-pointer-checks (§3) exists to disable. Idiom: validate pointers before the first dereference, at the API boundary, and assert them there.
2.10 A worked exploitation, start to finish
The safari in Exercise 7.1 is easier to read after seeing one case narrated. Take the tick-delta pattern with the check written after the subtraction:
bool expired(int32_t now, int32_t then, int32_t timeout) {
int32_t delta = now - then;
if (delta < 0) { return true; } // "handles wrap" — it does not
return delta > timeout;
}At -O0 the compiler emits the subtraction, a compare against zero, a branch, a second compare. If now has wrapped past INT32_MAX, the hardware SUBS produces a negative delta and the first branch is taken; the function appears to work. At -O2 the compiler reasons: now - then is signed, so it cannot overflow, so delta < 0 is equivalent to now < then — and it may rewrite the first test as a direct compare of the arguments, or fold it into the second test as a range check, or delete it if the surrounding code makes now >= then provable. The negative-delta path now depends on which rewrite it chose. The unsigned version has no such freedom: uint32_t delta = now - then is defined to wrap, the compare is against a value the standard specifies, and the disassembly at both levels is a SUBS and one compare. Exercise 7.1(a) asks for exactly those two listings side by side.
2.11 The list, as a table
| Hazard | What the optimizer may assume | Typical firmware trigger | Correct idiom |
|---|---|---|---|
| Signed overflow | a + b never wraps → loop bounds, overflow checks deleted |
int32_t timer/tick deltas |
Unsigned wrap-tolerant math; range-check before |
| Oversized / sign shift | Count < width, no sign-bit entry |
1 << 31, uint8_t masks |
UINT32_C(1) << n, masked n |
| Strict aliasing | Differently typed lvalues never overlap → loads reordered/cached | *(uint32_t *)&f, uint8_t buffer cast to uint32_t * |
memcpy; byte decoding; unsigned char * |
| Out-of-bounds | Indices are in range → later checks deleted | Ring index off by one | Power-of-two mask; asserted invariant; length in the API |
| Uninitialized read | Value is anything, and stable across nothing | Partially assigned config struct | = {0}, designated initializers |
| Unsequenced modification | Any evaluation order | a[i] = i++; two register reads in one call |
One side effect per expression; named locals |
| Data race | Non-atomic memory is stable between synchronizations | ISR-shared counter | Atomics, critical section (Module 9) |
| Misaligned access | Pointers are aligned for their type → LDM/LDRD allowed |
Packed structs, cast byte buffers | memcpy out; _Alignas/_Static_assert |
False restrict |
No aliasing through other pointers | In-place filter called with x == y |
Honest API contract; assert(x != y) |
3 · Crutches, and what they cost
Compilers offer flags that turn some undefined behaviors into defined ones. They are legitimate tools with real costs, and the cost should be a written decision, not a habit.
| Flag | Defines | Cost |
|---|---|---|
-fwrapv |
Signed overflow wraps two’s-complement | Loops with signed induction variables lose range information; some bound checks stay; portability — the code is still UB under the standard, only this compiler’s behavior changed |
-fno-strict-aliasing |
Any lvalue may alias any other | Loads cannot be cached across stores through unrelated pointers; DSP loops re-load operands; it hides the puns that should have been memcpy |
-fno-delete-null-pointer-checks |
Dereferencing null does not imply the pointer is non-null later | Mostly harmless codegen-wise; matters on the Cortex-M, where address 0 is the vector table and a null read succeeds — the compiler deleting a later if (p) is the real hazard |
-ftrapv |
Signed overflow traps at runtime | A call per signed operation; a debugging flag, not a production one |
The Linux kernel builds with -fno-strict-aliasing and -fno-delete-null-pointer-checks as a deliberate, documented policy. A firmware project may make the same choice; what it may not do is make it silently in one module’s CMakeLists.txt.
4 · The tools: what catches what
Seacord’s chapter 11 organizes the defenses by when they run. The important fact is that the host build is where all of them run, even for firmware code — which is why the C workspace has a host/ tier with sanitizers on by default.
4.1 Compile time: warnings and assertions
The Module 0 warning set (-Wall -Wextra -Wpedantic -Wconversion -Wshadow -Wstrict-prototypes -Wundef -Wdouble-promotion -Wformat=2) catches the shapes of several hazards — a shift by a constant that is too large, an obviously uninitialized variable, a sequencing violation in one expression (-Wsequence-point), the address of a packed member. It cannot catch anything that depends on a runtime value.
Assertions come in three strengths, and the course uses all three:
| Assertion | Runs | Cost | Use for |
|---|---|---|---|
_Static_assert(expr, "msg") (C11; static_assert via <assert.h>) |
Compile time | None | Widths, offsets, alignment, enum ranges, buffer sizes — every implementation-defined fact the code depends on |
assert(expr) (<assert.h>) |
Runtime, unless NDEBUG |
A compare and branch; the message table in flash | Preconditions in host builds and debug firmware; know whether your release build defines NDEBUG |
configASSERT(expr) (FreeRTOS) |
Runtime, always | Same, plus your handler | Kernel API misuse, and — by project rule — any invariant that must hold in the field, with a handler that logs and resets |
An assertion that fires in the field is a design decision: halt, log and reset, or degrade. Making that decision once, in one handler, is a Module 12 topic; the module’s point here is that an assertion documents a contract the optimizer will otherwise exploit silently.
4.2 Runtime: sanitizers
UndefinedBehaviorSanitizer and AddressSanitizer instrument the host build to detect, at the moment of violation, most of the list in §2:
clang -std=gnu17 -O1 -g -fsanitize=address,undefined -fno-sanitize-recover=all \
-fno-omit-frame-pointer ex.c -o ex && ./ex| Hazard | UBSan | ASan | Notes |
|---|---|---|---|
| Signed overflow | ✓ signed-integer-overflow |
Also -fsanitize=unsigned-integer-overflow for wanted wrap audits — noisy on timer code |
|
| Oversized / negative shift | ✓ shift |
||
| Misaligned access | ✓ alignment |
Reports even where the CPU would tolerate it | |
| Out-of-bounds (known bound) | ✓ bounds for arrays of known size |
✓ heap, stack, globals | ASan uses redzones; misses intra-object overflow |
| Uninitialized read | Neither — MemorySanitizer (clang, Linux only) does | ||
| Null dereference | ✓ null |
✓ | |
| Strict aliasing | Not detected; read the disassembly | ||
| Data race | ThreadSanitizer, host-only, and only for pthread-style races |
||
| Use-after-scope / use-after-return | ✓ | The compound-literal lifetime bug of Module 4 |
-fno-sanitize-recover=all makes the first violation abort the process, so a test run is pass/fail. -O1 (not -O0) keeps the instrumentation from drowning in unoptimized noise while leaving line numbers usable.
Sanitizers run only on the host: they need an allocator, shadow memory, and a runtime library, none of which exists on the Cortex-M. The implication for how firmware is structured is the whole reason Course 3’s firmware/shared/ exists — computational kernels are portable C with no HAL dependency, compiled into the firmware and into a host test binary under ASan/UBSan.
4.3 Without running: static analysis
Static analysis reasons about paths the tests never take, at the cost of false positives (and, since correctness is undecidable, false negatives). Two analyzers ship with the compilers already installed:
scan-build cmake --build build # clang's analyzer over a whole CMake build
clang --analyze -Xanalyzer -analyzer-output=text ex.c
arm-none-eabi-gcc -fanalyzer -c module.c # GCC ≥ 10: path-sensitive analysis, works crossThe clang analyzer finds null dereferences, uninitialized values, dead stores, use-after-free, and a class of API-misuse bugs; GCC’s -fanalyzer adds double-free, file-descriptor leaks, and taint tracking. Neither replaces the sanitizers; they run where the sanitizers cannot — on the cross build, before any code executes.
Warnings and static analysis: every build, every tier. Sanitizers: host tier only, so kernels are written to be host-buildable. Assertions: all tiers, with a decided policy for what a failure does in the field. Nothing in this list runs on the STM32 except the assertions — that is the design constraint, not an oversight.
5 · Rust: safety, soundness, and the five superpowers
Rust’s proposition is that the entire §2 list is impossible in safe code: the compiler proves the absence of aliasing violations, out-of-bounds access, uninitialized reads, data races, and use-after-free, and defines the behaviors (overflow, shifts) that C leaves undefined. The proof is conservative — some correct programs are rejected — and unsafe is the mechanism for telling the compiler “I have checked this myself.”
Two words are precise here. Safe code cannot cause undefined behavior, full stop. Sound code is unsafe code (or a safe API wrapping it) that cannot cause UB for any input a safe caller can supply. The goal is never “no unsafe”; a PAC, a DMA driver, and an allocator are unsafe by nature. The goal is that every unsafe block is sound, and that soundness is argued in a comment next to it.
5.1 What unsafe unlocks — and what it does not
Inside an unsafe block or function, five extra operations are permitted:
| Superpower | Firmware use | What can go wrong |
|---|---|---|
Dereference a raw pointer (*const T, *mut T) |
Memory-mapped registers, DMA descriptors, FFI buffers | Dangling, misaligned, aliased-with-&mut, wrong provenance |
Call an unsafe fn or method |
ptr::read_volatile, slice::get_unchecked, PAC Peripherals::steal() |
Violating the function’s documented preconditions |
Access or modify a static mut |
Legacy pattern for ISR-shared state — banned in this course (§5.5) | Data races; aliased &mut |
Implement an unsafe trait (Send, Sync, GlobalAlloc) |
Marking a peripheral handle as movable across contexts | Lying about thread safety |
Access a union field |
FFI with C unions, register overlays | Reading the wrong variant |
What unsafe does not do: it does not disable the borrow checker, does not relax type checking, and does not change what safe code elsewhere may assume. Ownership and borrowing apply inside the block exactly as outside; the block only permits the five operations above.
5.2 Raw pointers
A raw pointer is what a C pointer is: an address with a type, no lifetime, no aliasing rule enforced, nullable, and freely convertible. Creating one is safe; dereferencing it is not.
let mut v: u32 = 0;
let p: *mut u32 = &raw mut v; // no reference is created, so no aliasing claim is made
// SAFETY: `p` points to a live, aligned, initialized `u32` that nothing else borrows here.
unsafe { p.write(5); }&raw const / &raw mut (or the older ptr::addr_of!/addr_of_mut!) are the idiom for producing a raw pointer without first creating a reference, which matters because a reference asserts alignment, validity, and exclusivity the moment it exists — creating &packed.field or &mut STATIC is itself undefined even if never used. Raw pointers to registers additionally need the volatile accessors:
const GPIOA_ODR: *mut u32 = 0x4800_0014 as *mut u32;
// SAFETY: fixed peripheral address on the STM32L476; the access is the observable effect.
unsafe { core::ptr::write_volatile(GPIOA_ODR, core::ptr::read_volatile(GPIOA_ODR) | 1 << 5); }read_volatile/write_volatile are the exact counterpart of C’s volatile (Module 8): the access is emitted once, in place, at the declared width — and, like C’s, it promises nothing about atomicity or ordering against other memory. The PAC generates exactly these calls behind its read()/modify()/write() API, so hand-written register pointers appear only in bring-up code and in this module’s exercises.
5.3 Reinterpreting bytes
C’s memcpy pun has three Rust equivalents, in order of preference:
let bits: u32 = 1.5f32.to_bits(); // safe: float ↔ integer bits
let word = u32::from_le_bytes([b[0], b[1], b[2], b[3]]); // safe: bytes ↔ integer, endianness explicit
let raw: [u8; 4] = word.to_ne_bytes(); // safe: integer → native-order bytes
// last resort, unsafe: reinterpret a value of one type as another of the same size
// SAFETY: both types are plain-old-data of identical size and alignment, with no invalid bit patterns.
let same: [u8; 4] = unsafe { core::mem::transmute(word) };transmute is the sharp knife: it checks only that the sizes match, and reinterpreting into a type with invalid bit patterns (bool, char, an enum, a reference) is immediate UB. Wire-format decoding never needs it; the from_*_bytes family covers integers and floats, and the bytemuck-style crates provide checked casts for #[repr(C)] plain-old-data structs when a whole packet header is to be viewed at once (Exercise 7.3 builds that view by hand).
5.4 unsafe as a documented contract
The convention that makes unsafe reviewable has three parts:
- An
unsafe fnstates its preconditions in a# Safetydoc section. The caller is responsible for them; the function body may rely on them. - Every
unsafeblock carries a// SAFETY:comment stating why the preconditions of what it calls hold here. A block without one is a review failure. #![deny(unsafe_op_in_unsafe_fn)]in every crate (the 2024 edition warns by default; the course denies): the body of anunsafe fnis not automatically anunsafecontext, so each dangerous operation inside it is marked and justified individually.
/// Reads a `u16` at `offset` from a packet buffer.
///
/// # Safety
/// `buf` must be at least `offset + 2` bytes long. (The bound is the caller's — this
/// function exists to avoid a bounds check in a loop that has already established it.)
pub unsafe fn read_u16_unchecked(buf: &[u8], offset: usize) -> u16 {
// SAFETY: the caller guarantees `offset + 1 < buf.len()`, so both reads are in bounds;
// `read_unaligned` makes no alignment claim.
let p = unsafe { buf.as_ptr().add(offset) };
u16::from_le_bytes([unsafe { *p }, unsafe { *p.add(1) }])
}The safe version of that function — u16::from_le_bytes(buf[offset..offset + 2].try_into().unwrap()) — is what the course writes by default; the unsafe one exists only after a measurement shows the bounds check matters, and Module 12’s size ladder is where such measurements are made.
5.5 static mut is banned
A static mut is a global the compiler cannot protect: any two &mut to it alias, and in the 2024 edition taking a reference to one is a hard error (static_mut_refs). Every legitimate use has a sound replacement:
| Need | Replacement | Module |
|---|---|---|
| A counter or flag shared with an ISR | static COUNT: AtomicU32 = AtomicU32::new(0); |
6 |
| A larger shared object | static STATE: critical_section::Mutex<RefCell<Option<T>>> |
6 |
A one-time-initialized 'static object (a peripheral handle, a DMA buffer) |
static CELL: StaticCell<T> — CELL.init(value) returns &'static mut T exactly once |
3 |
Uninitialized 'static storage |
static BUF: MaybeUninit<[u8; N]> behind a StaticCell or an unsafe init with a // SAFETY: argument |
3 |
MaybeUninit<T> is the type-level statement “this storage may not hold a valid T yet”: reading it is unsafe (assume_init), and writing it is safe. It is how Rust expresses the .bss-resident, initialize-on-first-use buffer that C simply declares — with the difference that the “not yet initialized” state is visible in the type.
5.6 The safe-abstraction boundary
The pattern that keeps unsafe from spreading is a type whose fields are private, whose invariants are stated once, and whose public methods are all safe. Every unsafe block inside relies on the invariants; every public method maintains them; and nothing outside the module can break them because nothing outside can touch the fields.
/// Fixed-capacity byte ring. Invariant: `head` and `tail` are always `< N`,
/// and the number of readable bytes is `(tail - head) mod N`.
pub struct Ring<const N: usize> { buf: [u8; N], head: usize, tail: usize }
impl<const N: usize> Ring<N> {
pub fn pop(&mut self) -> Option<u8> {
if self.head == self.tail { return None; }
// SAFETY: `head < N` is the struct invariant, maintained by every constructor
// and every mutating method; the bounds check is provably redundant here.
let b = unsafe { *self.buf.get_unchecked(self.head) };
self.head = (self.head + 1) % N;
Some(b)
}
}Whether that get_unchecked is worth an unsafe block is a measurement (Module 12); whether it is sound is a review question, and the review has a fixed checklist:
- Is every invariant the
unsafecode relies on written down on the type or the function? - Can safe code, from outside the module, put the value into a state that violates it? (Public fields, a
Defaultthat skips a check, aFromconversion, aClonethat duplicates a unique handle.) - Does every
unsafeblock’s// SAFETY:comment refer only to invariants and preconditions that are actually established — not to “this is fine in practice”? - Is the block as small as it can be? Anything that does not need the superpower goes outside it.
- Has Miri executed the block, under a test that reaches its edge cases?
A PAC, a HAL, heapless, and critical-section are all instances of this pattern; the ecosystem’s rule is that a crate with a safe API and an unsound implementation is a bug, not a caveat.
5.7 Integer semantics, recapped
Rust defines what C leaves open, but the definition has two modes:
| Operation | Debug (overflow-checks = true) |
Release (default false) |
Explicit |
|---|---|---|---|
a + b overflows |
panic | wraps | wrapping_add, checked_add, saturating_add, overflowing_add |
1u32 << 40 |
panic | shift count masked to width | checked_shl, wrapping_shl |
i32::MIN / -1, x / 0 |
panic | panic (always) | checked_div |
-i32::MIN |
panic | wraps | wrapping_neg, checked_neg |
Firmware sets overflow-checks = true in the release profile when the size and speed cost is acceptable, and uses the explicit methods in the paths where wrap is intended (timer deltas: now.wrapping_sub(then)) so that the intent survives either profile. unreachable_unchecked() is the one place the course lets the optimizer assume a fact it cannot prove — with a // SAFETY: comment stating why the branch is impossible, and only after unreachable!() has been measured to cost something.
6 · Miri: a sanitizer for the abstract machine
Miri is an interpreter for Rust’s mid-level IR that executes tests on the abstract machine and reports undefined behavior when it happens: out-of-bounds, use-after-free, misaligned raw-pointer access, invalid values (a bool that is 2), violations of the aliasing model between raw pointers and references, data races in threaded tests, and uninitialized reads.
rustup +nightly component add miri
cargo +nightly miri test -p host # runs the crate's tests under the interpreterMiri is a nightly tool; it does not change the crate’s own toolchain. Its limits define how the course uses it:
- It runs only code that runs — a dynamic tool, so the
unsafepaths need tests that exercise them. - It has no MMIO, no interrupts, no
no_stdtarget semantics: register access and ISR code are out of scope; the pure-logic core of a driver (packet parsing, ring buffers, state machines) is in scope, which is another reason to keep that core in a host-testable crate. - If Miri reports UB, there is a bug. If it reports nothing, the tests did not reach one.
- Pointer-to-integer casts weaken what it can track (the “strict provenance” APIs —
ptr.addr(),ptr.with_addr()— exist so that address arithmetic does not launder a pointer’s origin).
Two settings sharpen it for this course’s code. MIRIFLAGS="-Zmiri-strict-provenance" turns any integer-to-pointer cast into an error, which is the right default for host-testable driver logic that has no business forging addresses. -Zmiri-symbolic-alignment-check reports a misaligned raw-pointer access even when the host allocator happened to hand back an aligned address — the same “the CPU tolerated it” trap as §2.7, caught before the cross build.
MIRIFLAGS="-Zmiri-strict-provenance -Zmiri-symbolic-alignment-check" cargo +nightly miri test -p hostMiri is slow — an interpreter, tens to hundreds of times slower than native — so the unsafe-bearing crate keeps its test inputs small and its unsafe paths reachable from short tests. That is a design pressure in the right direction.
The pairing with the C tools is exact: ASan/UBSan check a compiled host binary against the C abstract machine’s rules; Miri checks the Rust program against Rust’s — including the aliasing rules that no C sanitizer can check.
7 · #[repr(C)] and the FFI boundary
Rust’s default struct layout is unspecified — the compiler may reorder fields. Anything that crosses a language boundary, overlays hardware, or is decoded from a wire needs a declared layout:
| Attribute | Meaning | Use |
|---|---|---|
#[repr(C)] |
C’s layout rules: declaration order, C alignment and padding | FFI structs, register blocks, DMA descriptors, packet headers |
#[repr(u8)] / #[repr(u32)] on an enum |
The discriminant’s exact type and size | Enums passed to C or written to registers |
#[repr(packed)] |
No padding; fields may be misaligned; references to fields are compile errors | Wire formats — read fields by copy, never by reference |
#[repr(align(N))] |
Minimum alignment | DMA buffers, cache-line separation |
#[repr(transparent)] |
Same layout as the single field | Newtypes that must be ABI-identical to the inner type |
At the boundary itself, an extern "C" function is unsafe to call because the compiler cannot see the C side’s contract, and a Rust function exported to C (#[no_mangle] pub extern "C" fn) must not let a panic unwind across the boundary (the panic = "abort" profile of the bare-metal tiers makes this moot; on Linux, catch or abort). Module 12 builds both directions.
8 · The same hazard, two languages
| Hazard | C: what the compiler does | C: what catches it | Rust: safe code | Rust: unsafe code |
Rust: what catches it |
|---|---|---|---|---|---|
| Signed overflow | UB; assumes it never happens | UBSan (host); -Wall for constants |
Defined: panic or wrap by profile | same | Debug panic; overflow-checks |
| Oversized shift | UB | UBSan | Defined: panic or masked | same | Debug panic |
| Type pun | UB unless memcpy/char |
Nothing — disassembly | to_bits, from_le_bytes |
transmute — UB if invalid bit pattern |
Miri (invalid values) |
| Out-of-bounds | UB; may delete checks | ASan, UBSan bounds |
Panic, always | get_unchecked — UB |
Debug bounds check; Miri |
| Uninitialized read | UB | MemorySanitizer only | Compile error | MaybeUninit::assume_init — UB |
Miri |
| Aliasing | UB; reorders accesses | Nothing — disassembly | Compile error (&mut is exclusive) |
Raw pointers may alias; must not coexist with a live &mut |
Miri (aliasing model) |
| Data race | UB | ThreadSanitizer (host, pthreads) | Compile error (Send/Sync) |
static mut, raw pointers across contexts |
Miri (threads only); code review |
| Misaligned access | UB; may emit LDM/LDRD |
UBSan alignment |
Compile error for packed refs; read_unaligned is safe |
ptr::read on misaligned pointer — UB |
Miri |
| Null dereference | UB; may delete if (p) |
UBSan, ASan | Option<&T> — no null |
*p on null — UB |
Miri |
The pattern in the table is the module’s thesis. In C the compiler assumes and the tools observe, on the host, after the fact. In Rust the compiler proves for safe code, and for unsafe code the same C obligations return — now confined to marked blocks, each with a written argument, and with Miri able to check the argument against the abstract machine’s actual rules.
9 · Lesson → exercise map
| Section | Exercise it feeds |
|---|---|
| §1 as-if rule, §2 the UB list, §3 crutches | 7.1 (UB safari in C) |
| §4 tools: warnings, assertions, sanitizers, static analysis | 7.1, 7.7 (static analysis on a Course 3 module) |
| §5.1–5.2 superpowers, raw pointers | 7.2 (the safari in Rust), 7.6 (register pointer, volatile) |
§5.3 reinterpreting bytes, §5.4 contracts, §7 #[repr(C)] |
7.3 (a sound packet view) |
| §2.3 aliasing, §5.4 | 7.4 (aliasing forensics, restrict vs. &mut) |
§2.7 alignment, §7 repr(packed) |
7.5 (alignment cross-compiled for the M4) |
§5.5 static mut, §5.6 the abstraction boundary, §5.7 integers, §6 Miri |
7.2, 7.3, 7.6 |