This page is the module’s teaching text. Modules 4–11 were about writing a correct unit of embedded code in either language; this module is about what surrounds that unit — how a program is split into components, how those components are tested without a board in the loop, what the build enforces before code is allowed onto a target, how small the result is, and how C and Rust are mixed inside one firmware image on purpose. It closes by laying out the capstone: one driver, three languages, three tiers — the Python reference first, then C and Rust. 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 10–11, the Rust Book’s Chapters 7, 11, and 14, Grenning’s TDD for Embedded C, and the Embedded Rust Book’s Interoperability chapter remain available as optional deep-dives; nothing below requires them.
1 · Components, interfaces, and what a module hides
Seacord opens his program-structure chapter with two measures that every later decision serves. Cohesion is how related the things in one interface are: a header exposing string length, string concatenation, and substring search is cohesive; one exposing string length, tangent, and thread creation is not. Coupling is how much interfaces depend on each other: a header that only compiles if three others were included first, in order, is tightly coupled, and every change to it ripples. Firmware fails these tests constantly — the main.c that knows every peripheral, the globals.h that every file includes — and pays in untestability, which is the reason §3 exists.
1.1 The data abstraction, in C
A data abstraction separates a public interface (type definitions, function declarations, constants — in a header) from an implementation (a source file plus, if needed, a private header kept away from the public one). The strongest form uses an opaque type: the public header forward-declares a struct without defining it, so users can only hold pointers to it and can never depend on its layout.
/* ads1115.h — public interface: the struct is incomplete here */#ifndef ADS1115_H#define ADS1115_H#include <stdint.h>#include <stdbool.h>typedefstruct ads1115 ads1115_t;/* opaque: size and layout unknown to callers */typedefenum{ ADS1115_OK =0, ADS1115_EBUS, ADS1115_ETIMEOUT, ADS1115_EARG } ads1115_status_t;ads1115_t *ads1115_init(ads1115_t *storage,conststruct ads1115_bus *bus,uint8_t addr);ads1115_status_t ads1115_read_single(ads1115_t *dev,uint8_t channel,int16_t*code);#endif
/* ads1115_priv.h — included only by ads1115.c and its tests */struct ads1115 {conststruct ads1115_bus *bus;uint8_t addr;uint16_t config;/* last written Config register */};
Two firmware-specific notes on Seacord’s pattern. Because there is no heap (Module 6), the “constructor” does not malloc — the caller owns storage and passes it in, either as a static ads1115_t adc0; or as a member of a larger static object; the opaque header can still hide the layout by exposing an alignas-correct byte array of a _Static_asserted size, or, more simply, by keeping the definition in the private header and letting only the implementation file allocate instances. And every error path is a named enumerator, never -1, because an int return that mixes error codes and values is the C habit the Rust half of this module exists to contrast.
1.2 Headers and linkage
A header includes what it uses (<stdint.h> for uint8_t) so it compiles standalone; that is the concrete meaning of low coupling.
Every header has an include guard (#ifndef/#define/#endif; #pragma once is universal in practice but not standard).
Everything not in the public header is static. File-scope functions and objects default to external linkage; declaring them static gives them internal linkage, which keeps the symbol table small, prevents accidental name collisions between translation units, and lets the optimizer inline and drop freely. Seacord’s rule of thumb is exactly this: file-scope entities that need not be visible outside the file are static.
extern on a function declaration in a header is redundant but harmless; extern on an object declaration is how a header names a global defined in one .c file — and a global that needs this is usually a design smell to route through an accessor instead.
The linker’s contribution is -ffunction-sections -fdata-sections at compile time and -Wl,--gc-sections at link time: each function and object lands in its own section, and the linker discards every section nothing references. A well-factored module costs flash only for what the program calls. A static library (ar rcs libdrv.a *.o) is the same idea packaged: the linker pulls only the archive members it needs, and with -flto it can optimize across them.
1.3 The same shape in Rust
Rust builds the abstraction into the module system. A crate is the compilation unit (a library or a binary); a module is a namespace inside it; pub is the interface and everything else is private by default — the reverse of C’s default, which is why Rust code needs no static discipline.
// rust/ads1115/src/lib.rs#![cfg_attr(not(test), no_std)]pubmod config;// public submodule: register field enumsmod regs;// private: raw register addressesuseembedded_hal::i2c::I2c;/// Driver for one ADS1115 on an I²C bus `B`.pubstruct Ads1115<B>{ bus: B,// private field: callers cannot reach the bus addr:u8, config:u16,}#[derive(Debug,Clone,Copy,PartialEq,Eq)]pubenumError<E>{ Bus(E), Timeout, Arg }impl<B: I2c> Ads1115<B>{pubfn new(bus: B, addr:u8) ->Self{Self{ bus, addr, config:config::DEFAULT }}pubfn free(self) -> B {self.bus }// C-FREE: hand the bus backpub(crate) fn write_config(&mutself, v:u16) ->Result<(),Error<B::Error>>{/* … */Ok(()) }}
The private-fields struct is the opaque type: the layout is invisible outside the crate, and pub(crate) marks helpers shared across the crate’s modules but not exported. Three conventions from the Embedded Rust Book’s HAL checklist carry over to any driver crate: provide a free(self) that returns the resources the driver consumed (C-FREE); re-export the crates callers need to name your types; and implement the embedded-hal traits on anything that is a bus or a pin (C-HAL-TRAITS), so the driver is generic over B: I2c rather than tied to one HAL. Generic-over-trait is what lets the capstone driver compile unchanged against embassy-stm32’s I²C on the NUCLEO and linux-embedded-hal’s I2cdev on the Jetson.
Concern
C
Rust
Hide layout
incomplete struct in public header; full definition in private header
private fields (default)
Public API
header declarations; everything else static
pub items; everything else private by default
Share within component only
private header
pub(crate), pub(super)
Error reporting
enum status return + out-parameter
Result<T, Error> — value and error in one type
Dependency on a bus/pin
function-pointer table or #ifdef per platform
generic parameter bounded by an embedded-hal trait
Dead-code removal
-ffunction-sections -Wl,--gc-sections
default (LLVM + lto); #[inline] on small cross-crate fns
TipFirmware rule
A module’s header is its whole contract. If a test needs to reach past it, the module is not finished — add the accessor or the injection point to the interface rather than exposing the internals.
2 · Assertions on every tier
An assertion states a proposition the code relies on. Seacord’s division is the one to keep: what can be checked at compile time is a static assertion; what is checked at run time during development is a runtime assertion; what can happen in normal operation — bad input, a bus timeout, a full queue — is not an assertion but ordinary error handling that ships in the release build.
2.1 C
_Static_assert(sizeof(struct ads1115_frame)==3,"wire frame must be 3 bytes");// C11; contract on layout_Static_assert(ADS1115_QUEUE_LEN >0&&(ADS1115_QUEUE_LEN &(ADS1115_QUEUE_LEN -1))==0,"queue length must be a power of two");ads1115_status_t ads1115_read_single(ads1115_t *dev,uint8_t channel,int16_t*code){ assert(dev != NULL &&"caller must pass an initialized device");// precondition: programming errorif(channel >3)return ADS1115_EARG;// input validation: ships/* … */}
assert expands to nothing when NDEBUG is defined, which is how release builds drop it — so an assert argument must never have side effects, and nothing that can happen in the field may be guarded by one. On bare metal the standard assert calls abort, which newlib implements as an infinite loop with interrupts still enabled; a firmware project replaces it with its own handler that disables interrupts, records file/line somewhere a debugger or the next boot can read (a noinit RAM section — Module 8), and either breaks (__BKPT) or resets through the watchdog (Course 3 Lab 7.1). FreeRTOS’s configASSERT is the same idea inside the kernel and is what catches a call to a FromISR API from the wrong context.
2.2 Rust
const _: () =assert!(QUEUE_LEN.is_power_of_two(),"queue length must be a power of two");// compile-timepubfn read_single(&mutself, channel:u8) ->Result<i16,Error<B::Error>>{debug_assert!(self.config !=0,"driver used before init");// dropped in releaseif channel >3{returnErr(Error::Arg);}// ships/* … */}
assert! stays in release builds; debug_assert! is compiled out unless debug-assertions is on. Both panic, and on the bare-metal tier a panic reaches the #[panic_handler] — panic-halt spins, panic-probe prints the location over RTT and breaks, and a project handler can reset. The policy set in Module 5 is the one that decides which to use: an assertion is for a violated invariant that no input can cause; everything else is a Result. unreachable!() documents a match arm the type system cannot prove impossible; unsafe { unreachable_unchecked() } is Module 7 territory and needs a // SAFETY: line.
3 · Testing off the target
3.1 Why the board is the bottleneck
Grenning’s argument for dual-targeting — designing code from day one to run on both the final target and the development machine — is a throughput argument first: a test that needs the board waits for flashing, for the bench, and for the board to be right, and when it fails it is not clear whether the software or the hardware is at fault. The design benefit follows: code that runs on two targets has had its hardware boundary made explicit, which is the same boundary §1’s opaque modules draw. His embedded TDD cycle runs the fast tests on the host on every change, periodically compiles for the target to catch dialect and width differences (LP64 vs. ILP32 is the classic — Module 4), runs the unit tests on the target occasionally to catch what the host could not, and only then tests against hardware. The Course 3 firmware already has the shape: portable kernels in shared/, built into every module and into the host/ CTest harness under sanitizers (Course 3’s STM32 workflow). This module makes the pattern deliberate.
3.2 A testable C module and its doubles
Grenning’s elements of a testable C module are the ones §1 produced: a header that is the whole interface, no hidden global state, creation and destruction functions (so a test can start clean), and dependencies on hardware routed through a seam. The seams — places where a test can substitute a collaborator — are what make register-level code testable:
Seam
Mechanism
Use it for
Link-time substitution
The test build links fake_i2c.c instead of stm32_i2c.c; same header, different definitions
Whole bus/peripheral layers
Preprocessor
#ifdef HOST_TEST selects a static fake register block over the MMIO base address
Register overlays
Function pointer
The module takes a struct ads1115_bus { int (*write)(…); int (*read)(…); }
Per-instance injection; also how one driver serves two buses
Spy
The fake records calls and arguments for the test to inspect
Verifying the byte sequence a driver sent
Mock
The fake is pre-loaded with the expected sequence and fails on deviation (CppUTest’s CppUMock)
Protocol drivers with strict ordering
The register fake is the seam this course’s C exercises use most:
/* gpio_regs.h */#ifdef HOST_TESTextern GPIO_TypeDef fake_gpioa;/* an ordinary struct in test RAM */#define GPIOA (&fake_gpioa)#else#define GPIOA ((GPIO_TypeDef *)0x48000000UL)/* the real block */#endif
The driver source is identical on both targets — it writes GPIOA->BSRR either way — and a host test can assert on fake_gpioa.BSRR after the call, or on a spy that logs every write. Because the fake is host RAM, volatile still forces every access to happen, so the test observes exactly the sequence the hardware would.
3.3 Harnesses
Seacord surveys Google Test, CUnit, Unity, and others; Grenning works in Unity (pure C) and CppUTest (C++ harness, C tests). For this course the harness is whatever the build already drives: CTest registers each test executable, and a test is any program that returns non-zero on failure — a thirty-line TEST(name) macro set is enough for the exercises, and Unity is the step up when a project grows. The four-phase test structure (setup, exercise, verify, cleanup) and the FIRST properties (fast, isolated, repeatable, self-verifying, timely) are the discipline, not the framework. Every test executable in c/host is built with -fsanitize=address,undefined, so a test that passes has also passed the dynamic-analysis pass of §4 for the code it reached.
3.4 Rust tests, and how a no_std crate runs them on the Mac
The Rust Book’s test model is built in: #[test] functions in a #[cfg(test)] mod tests block at the bottom of the file they test (unit tests, with access to private items), tests/*.rs files that link the crate as an external user (integration tests), and doc tests that compile every ``` block in a /// comment. assert!, assert_eq!, assert_ne!, #[should_panic], and tests that return Result are the whole vocabulary. cargo test builds them for the host and runs them in parallel.
A no_std crate cannot run tests as-is — the test harness needs std. The idiom is to make no_std conditional:
#![cfg_attr(not(test), no_std)]// no_std for the target; std when `cargo test` builds it on the Mac
The crate is still no_std for thumbv7em-none-eabihf and for cargo check --target …; only the host test build links std. The seam is the trait bound: a test implements embedded_hal::i2c::I2c on a fake that records transactions and replies with canned bytes, and passes it to Ads1115::new exactly as the firmware passes the real bus. The embedded-hal-mock crate provides ready-made expectation-based fakes for the embedded-hal traits; writing one by hand is a short exercise and the better teacher.
#[cfg(test)]mod tests {usesuper::*;struct FakeBus { written:Vec<u8>, reply: [u8;2] }implembedded_hal::i2c::ErrorType for FakeBus {type Error =core::convert::Infallible;}implembedded_hal::i2c::I2c for FakeBus {fn transaction(&mutself, _addr:u8, ops:&mut [embedded_hal::i2c::Operation<'_>]) ->Result<(),Self::Error>{/* record writes, fill reads from self.reply */Ok(())}}#[test]fn single_shot_writes_config_then_reads_conversion() {/* four phases */}}
Two more host-side runs matter for unsafe-bearing crates: cargo miri test executes the tests under the interpreter that detects undefined behavior (Module 7), and cargo test --release catches the overflow-checks difference between profiles (Module 5). Neither has a C equivalent short of the sanitizers, which is the honest comparison: C’s dynamic analysis is bolted on at the build; Rust’s is a component of the toolchain.
3.5 Running tests on the target
Dual-targeting does not mean the target never runs a test. Grenning’s cycle includes an occasional run of the unit-test suite on the board itself, because a handful of failures only exist there: int widths and struct padding under ILP32 (a _Static_assert catches most of these at the periodic cross-compile, but not all), alignment faults that host hardware forgives, timing that depends on the real clock, and library differences — newlib-nano’s printf formats differently from glibc’s. The mechanics are the same harness with a different output channel:
C: Unity and the hand-rolled TEST macros both compile freestanding if their reporting goes through a function pointer you point at the VCP or RTT; the test binary is just another firmware image, and its exit code becomes a final line the host greps for. Semihosting in QEMU (Module 0) gives the same result without a board for the code that does not touch peripherals.
Rust: the defmt-test crate provides a #[tests] module attribute and a runner so that cargo test --target thumbv7em-none-eabihf flashes a test image with probe-rs, runs each test on the core, and reports over RTT — the host cargo test experience with the board as the executor. In rust/qemu, the same tests run under the QEMU runner with semihosting as the exit channel.
What runs on the target is a subset: the register-level and timing tests. The bulk of the suite stays on the host, where it runs in seconds under the sanitizers or Miri, and the split is recorded in the module’s notes.md as part of the quality gate.
TipFirmware rule
Every driver has two callers from the day it is written: the firmware and a host test. If the second one is hard to write, the first one is hard to trust.
4 · Static and dynamic analysis: the quality gate
Seacord’s last chapter separates the tools by when they run. Static analysis examines the source without executing it and is judged by soundness (no false negatives) and completeness (no false positives) — real tools trade one for the other. Dynamic analysis instruments the running program and finds only what the tests reach. A project’s quality gate is the fixed sequence of both that every change must pass before it is allowed on a target.
clang --analyze / scan-build; GCC -fanalyzer on the Linux build
rustc’s own analysis (borrow checker, exhaustiveness, unused_must_use on every Result)
Host tests, instrumented
CTest under -fsanitize=address,undefined (and thread for Module 11 code)
cargo test; cargo miri test for crates with unsafe
Cross-check every target
clang --target=thumbv7em-none-eabihf -c on every freestanding file
cargo check --target for mcu, qemu, linux
Size budget
arm-none-eabi-size against a limit in the CMake script
cargo size --release against a limit in CI
Docs
Doxygen if the project wants it
cargo doc --no-deps with #![warn(missing_docs)]; doc tests run under cargo test
Three notes. First, -Werror belongs in continuous integration and in the release configuration, not in a developer’s default build — a new compiler release that adds a warning should not stop local work, but it must stop a merge. Second, several of Seacord’s recommended hardening flags are hosted flags: -fstack-protector-strong, -D_FORTIFY_SOURCE=2, -fpie, and -Wl,-z,noexecstack apply to the Linux tier (and _FORTIFY_SOURCE needs optimization on), while on bare metal a stack protector needs a canary source and a failure handler you write yourself. Third, the sanitizers are host-only: ASan needs an allocator and shadow memory, UBSan’s runtime needs printf. On the STM32 the equivalents are -fstack-usage plus the stack-overflow hook (Module 6) and the fault handlers (Module 8). The host test build is therefore where every dynamic check runs, which is one more argument for §3’s dual-targeting.
Rust’s gate is smaller because the compiler already refuses what the C tools search for — aliasing violations, use-after-scope, data races on non-Sync state, ignored Results. The residual risk is concentrated in unsafe blocks, which is why the gate treats them specially: Miri on every test that reaches one, #![deny(unsafe_op_in_unsafe_fn)] so that no unsafe operation hides inside an unsafe fn body, and a review rule that every unsafe block carries a // SAFETY: comment naming the invariant it relies on.
newlib-nano: smaller printf (no float formatting unless asked), smaller malloc
Always on the STM32
-DNDEBUG
Removes assert
Release only, and only after §2’s replacement handler exists
-g
Debug info in the ELF — not in the flash image
Always; the .bin/.hex does not contain it
The last row is the one that surprises: debug information lives in ELF sections that never reach flash, so there is no size reason to build firmware without it, and every reason (backtraces, addr2line, the map file) to keep it.
5.2 Cargo profiles
Cargo has dev and release profiles with sensible defaults (opt-level = 0 vs. 3), and a [profile.*] table overrides any subset. The firmware release profile this course pins is:
[profile.release]opt-level="s"# or "z"; measure — "z" is not always smallerlto="fat"# whole-program LTO across crates, including the HALcodegen-units=1# one unit = more inlining opportunity, slower buildpanic="abort"# no unwinding tables; the panic handler is the enddebug=2# symbols in the ELF for probe-rs/defmt; not in flashincremental=falseoverflow-checks=false# the default in release; set true if the project prefers panics to wraps[profile.dev]opt-level=1# -O0 Rust firmware is often too slow and too large to be usefuldebug=2
Two things the C build does automatically that cargo makes explicit: panic = "abort" is the Rust equivalent of building without exception support, and it is required for a staticlib linked into C (§6) because there is nothing to unwind into; and debug = 2 in release exists because defmt decodes its wire format from the ELF’s symbols — strip the symbols and the log becomes numbers.
5.3 Reading the size ladder
cargo size --release -- -A (from cargo-binutils) prints sections; cargo bloat --release -n 30 (a separate cargo install cargo-bloat) attributes .text to functions and crates; arm-none-eabi-size -A and nm --size-sort do the same for C. The exercises walk a ladder — one profile change at a time, size after each — and the point is the ordering and the why, not the numbers: which change removed formatting machinery, which one inlined the HAL away, which one did nothing because --gc-sections had already done it.
5.4 Pinning: the same bits next year
A firmware build that cannot be reproduced cannot be debugged from a field report. Each side has a small set of files whose job is to freeze the toolchain and every dependency:
What is pinned
C
Rust
Compiler version
CMakePresets.json naming the toolchain file; the toolchain file records the expected arm-none-eabi-gcc version and fails loudly on mismatch
rust-toolchain.toml (channel = "1.xx", targets = [...], components = [...]) — rustup installs it on first use
Language dialect
CMAKE_C_STANDARD 17, CMAKE_C_EXTENSIONS ON
edition = "2024" in every Cargo.toml
Dependencies
Vendored sources (CMSIS, HAL, FreeRTOS) checked in, or FetchContent with a commit hash
Cargo.lockcommitted (for binaries and for this workspace); cargo update is a reviewed change
Build flags
The preset’s CMAKE_C_FLAGS_*; nothing set in the IDE
[profile.*] in the workspace root; .cargo/config.toml for target flags and runners
Linker script
Checked in beside the sources
memory.x in the crate; build.rs copies it to the linker search path
The map file and the ELF (with symbols) are archived with every release image in both worlds — they are the only way to turn a fault address from a report back into a line of source.
5.5 Logging: printf, core::fmt, log, defmt
Formatted output is usually the single largest thing in a small firmware image, in both languages. C’s printf through newlib-nano is smaller than full newlib and omits floating-point formatting unless -u _printf_float is linked. Rust’s core::fmt is general and correspondingly large; the log crate is a façade over it. defmt takes the other route: format strings are compiled out of the binary into the ELF’s symbol table, the target transmits only an index and the raw argument bytes over RTT, and probe-rs reconstructs the text on the host. The target never formats anything. The cost is that defmt needs a host attached to read, so it is a development and bench tool; a fielded device that must log to its own flash or UART uses a fixed-format binary record instead — in either language.
6 · Mixing the languages on purpose
Both directions cross one boundary, the C ABI, because it is the only stable one; Rust’s own ABI is unstable and C++’s is not portable. Everything at the boundary is #[repr(C)] data, extern "C" functions, and raw pointers — and every call across it is unsafe from Rust’s side, because the compiler cannot see what C does.
6.1 C inside a Rust firmware (a DSP kernel, say)
Declare the interface in Rust. By hand for a small header, or with bindgen for a large one (bindgen --use-core kernel.h > bindings.rs; --use-core keeps the output no_std). C types map through core::ffi — c_int, c_uint, c_char, c_void — and every C struct becomes #[repr(C)].
// Rust 2024: extern blocks are declared `unsafe extern`, and each item's safety is your claimunsafeextern"C"{/// `n` samples at `x` and `y`; returns the Q15 dot product with 32-bit accumulation.pubfn dot_q15(x:*consti16, y:*consti16, n:usize) ->i32;}pubfn dot(x:&[i16], y:&[i16]) ->i32{assert_eq!(x.len(), y.len());// SAFETY: both slices are valid for `len` reads and outlive the call; the C side reads only.unsafe{ dot_q15(x.as_ptr(), y.as_ptr(), x.len()) }}
Compile the C. A build.rs at the crate root runs before the crate builds and uses the cc crate to compile src/dot_q15.c into a static archive that cargo links automatically. For a cross target, cc picks the compiler from CC_thumbv7em_none_eabihf or falls back to arm-none-eabi-gcc; it needs the same -mcpu/-mfpu flags the Rust target implies.
Wrap it safely. The unsafe extern declaration is the raw edge; the public API is a safe function that turns slices into pointer/length pairs and states the contract in a // SAFETY: comment. That wrapper is where Rust’s guarantees resume.
6.2 Rust inside a C firmware (a driver, say)
Build a static library.crate-type = ["staticlib"] in [lib] makes cargo emit libdrv.a for the target; the release profile must say panic = "abort", and the crate must supply a #[panic_handler] — a C project has no Rust runtime to provide one.
#![no_std]#[unsafe(no_mangle)]// Rust 2024 spelling: exporting a symbol is unsafepubextern"C"fn drv_read_single(dev:*mut Ads1115Handle, channel:u8, out:*muti16) ->i32{// SAFETY: the header documents that `dev` came from drv_init and `out` is writable; we check for null.let (Some(dev),Some(out)) = (unsafe{ dev.as_mut() },unsafe{ out.as_mut() }) else{return-1};match dev.inner.read_single(channel) {Ok(v) =>{*out = v;0}Err(_) =>-2}}
Generate the header.cbindgen reads the crate and writes drv.h with the #[repr(C)] structs and the extern "C" prototypes; the C side includes it like any other module header from §1.
Link it. In CMake the archive is an imported library, and a custom command runs cargo build --release --target thumbv7em-none-eabihf so a Rust edit rebuilds the C project (the Corrosion CMake module packages this; the hand-written version is a dozen lines). Watch for duplicate memcpy/memset: Rust’s compiler_builtins and newlib both provide them; the linker takes the first definition it sees, which is usually fine, and -Wl,--allow-multiple-definition is the escape hatch if it is not.
A minimal CMake integration, without the Corrosion module, is a custom target that invokes cargo and an imported library that depends on it:
# Build the Rust staticlib as part of the C project's configure/buildset(RUST_TARGET thumbv7em-none-eabihf)set(RUST_LIB ${CMAKE_SOURCE_DIR}/../../course2/rust/target/${RUST_TARGET}/release/libcdrv.a)add_custom_target(cdrv_cargoCOMMAND cargo build --release --target ${RUST_TARGET} -p cdrvWORKING_DIRECTORY${CMAKE_SOURCE_DIR}/../../course2/rustBYPRODUCTS${RUST_LIB})add_library(cdrvSTATICIMPORTED)set_target_properties(cdrvPROPERTIESIMPORTED_LOCATION${RUST_LIB})add_dependencies(cdrvcdrv_cargo)target_link_libraries(${PROJECT_NAME}.elfPRIVATE cdrv)
The header cbindgen generated is added to the project’s include path, and from main.c’s point of view the Rust driver is a module like any other from §1 — which is the goal.
6.3 Where safety goes at the boundary
Rule
Why
No unwinding across the boundary — panic = "abort" in any staticlib; extern "C" functions never panic (catch, return an error code)
A panic unwinding into C frames is undefined behavior
Every pointer that crosses is checked for null, alignment, and length before use
C makes no promises; Rust code must re-establish its invariants
Ownership is documented per function in the header: who allocates, who frees, who may call from an ISR
Neither language can check the other’s discipline
#[repr(C)] on every shared struct and enum; _Static_assert/const _: () = assert! on sizes both sides
Default Rust layout is unspecified; a silent mismatch is a silent corruption
Integer types are core::ffi aliases or fixed-width (i16, uint16_t) — never int/long across the line
Module 4’s LP64/ILP32 problem, now between languages
The safe wrapper is the API; the extern declaration is pub(crate) at most
Safety is re-established once, in one place, and tested there
A firmware that mixes the two ends up with a thin C ABI layer and safety re-established on the Rust side of it — Rust does not become less safe by being called from C, but a C caller can violate every contract, so the contracts are written down in the header where the C programmer will read them.
7 · The course’s rules, as a checklist
This is the working subset the modules have argued for, stated as rules a review can check. It is MISRA-flavored in spirit — the same concerns of decidability, bounded resources, and no reliance on undefined or implementation-defined behavior — but it is this course’s list, not a citation. The Rust column says whether the rule is enforced by the compiler, by a lint, or still by review.
Rule
C
Rust
Fixed-width integer types at every hardware, wire, and API boundary
<stdint.h>; -Wconversion
Built in; usize only for indexing
No implicit narrowing or sign change
-Wconversion, explicit casts
Compiler error; as casts reviewed, From/TryFrom preferred
Layout and width assumptions asserted at compile time
_Static_assert + offsetof
const _: () = assert!(); #[repr(C)] where layout matters
No heap after initialization (bare metal, RTOS)
Project rule; link without _sbrk to make it a link error
No alloc in mcu/qemu crates — a compile error
No VLAs, no alloca
-Werror=vla
Do not exist
No recursion in firmware paths
Review; -fstack-usage shows it
Review; same tool via cargo call-stack-style analysis
Every switch/match handles every case
-Wswitch-enum, default: with assertion
Compiler error (exhaustiveness)
Every error return is checked
__attribute__((warn_unused_result)); review
#[must_use] on Result — a warning by default, denied in CI
No unwrap/expect on target
—
clippy::unwrap_used, clippy::expect_used denied in target crates
volatile only for MMIO and ISR-shared objects; never as a substitute for atomics
Review; Module 9
read_volatile/write_volatile only inside PAC/HAL; atomics or Mutex<RefCell> for sharing — Module 9
No static mut; shared state through a documented mechanism
static + critical section
static mut references denied in Rust 2024; Mutex, StaticCell, atomics
unsafe only in leaf modules, each block with a // SAFETY: line, each crate #![deny(unsafe_op_in_unsafe_fn)]
—
Lint + review; #![forbid(unsafe_code)] on crates that need none
No undefined behavior relied on, ever
Sanitizers + UBSan-clean host tests; Module 7
Safe code: by construction; unsafe: Miri
goto only for single-exit cleanup
Review
Does not exist; ? and let … else
Interrupt handlers do the minimum and hand off through a documented structure
Module 9 patterns; FromISR APIs under FreeRTOS
RTIC/Embassy tasks; #[interrupt] handlers with Mutex<RefCell> or heapless::spsc
Pinned dialect/edition and warning set in the build files, -Werror/-D warnings in CI
-std=gnu17 + the Module 0 set
edition = "2024", rust-toolchain.toml, Clippy set
8 · The capstone, laid out
The capstone is the ADS1115 driver from Course 3 Lab 3.4 — a 16-bit delta-sigma ADC at I²C address 0x48 (ADDR to ground) with a 16-bit Config register (OS start/ready bit, MUX, PGA full-scale range, MODE single-shot vs. continuous, DR data rate, comparator fields) and a 16-bit signed Conversion register, plus an ALERT/RDY pin that in continuous mode can serve as a data-ready line. It is chosen because it is small enough to finish and rich enough to touch every module:
flowchart LR subgraph driver["ADS1115 driver — one source per language"] API["public API<br/>init · configure · read_single · start_continuous · read_last"] CORE["register logic<br/>config word build/parse, sign-extend, poll OS bit"] API --> CORE end CORE -- "bus seam: fn-ptr table (C) / I2c trait (Rust)" --> BUS subgraph BUS["bus implementations"] HOST["host fake<br/>records writes, replies canned bytes"] MCU["STM32<br/>HAL I2C (C) · embassy-stm32 (Rust)"] LNX["Jetson<br/>i2c-dev ioctls (C) · linux-embedded-hal (Rust)"] end HOST --> T1["c/host + rust/host<br/>CTest · cargo test · Miri"] MCU --> T2["bare metal · FreeRTOS / Embassy<br/>c/mcu · rust/mcu"] LNX --> T3["c/linux · rust/linux<br/>built on the board"]
flowchart LR
subgraph driver["ADS1115 driver — one source per language"]
API["public API<br/>init · configure · read_single · start_continuous · read_last"]
CORE["register logic<br/>config word build/parse, sign-extend, poll OS bit"]
API --> CORE
end
CORE -- "bus seam: fn-ptr table (C) / I2c trait (Rust)" --> BUS
subgraph BUS["bus implementations"]
HOST["host fake<br/>records writes, replies canned bytes"]
MCU["STM32<br/>HAL I2C (C) · embassy-stm32 (Rust)"]
LNX["Jetson<br/>i2c-dev ioctls (C) · linux-embedded-hal (Rust)"]
end
HOST --> T1["c/host + rust/host<br/>CTest · cargo test · Miri"]
MCU --> T2["bare metal · FreeRTOS / Embassy<br/>c/mcu · rust/mcu"]
LNX --> T3["c/linux · rust/linux<br/>built on the board"]
Requirement
Module it exercises
Config word built with fixed-width types and masks, asserted at compile time; sign-extended 16-bit result
1, 2
Caller-owned storage in C, no_std crate in Rust — no heap anywhere
3
No UB: the Config bytes go over the wire big-endian through explicit shifts, never a pointer pun
4
Bus seam generic over the platform; a typestate or state enum for single-shot vs. continuous
5
Continuous mode with the RDY line: an ISR/#[interrupt] (STM32) or a gpiod edge event (Linux) hands the reading to the main context safely
6, 8
A pipeline variant under FreeRTOS (C) and Embassy (Rust): sample task → queue/channel → consumer
7
Host tests against a fake bus in both languages; the full quality gate; a size ladder for the mcu build; profiles pinned
9
The closing document — “My working subset, in three languages” — is the course’s deliverable: one page per language (Python included) recording the pinned dialect or edition, the warning set and lints, the features actually used, the constructs banned per tier and why, the interop rules, and what the second language caught that the first did not. It is the note Course 3’s later firmware and Course 4’s C++20 engine code are written against.