Module 12 Lessons — Program Structure, Testing, Interop, and the Quality Gate

Back to the Course 2 syllabus · Practice: Module 12 exercises

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>

typedef struct ads1115 ads1115_t;            /* opaque: size and layout unknown to callers */

typedef enum { ADS1115_OK = 0, ADS1115_EBUS, ADS1115_ETIMEOUT, ADS1115_EARG } ads1115_status_t;

ads1115_t       *ads1115_init(ads1115_t *storage, const struct 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 {
    const struct 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)]

pub mod config;                       // public submodule: register field enums
mod regs;                             // private: raw register addresses

use embedded_hal::i2c::I2c;

/// Driver for one ADS1115 on an I²C bus `B`.
pub struct Ads1115<B> {
    bus: B,                           // private field: callers cannot reach the bus
    addr: u8,
    config: u16,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Error<E> { Bus(E), Timeout, Arg }

impl<B: I2c> Ads1115<B> {
    pub fn new(bus: B, addr: u8) -> Self { Self { bus, addr, config: config::DEFAULT } }
    pub fn free(self) -> B { self.bus }                     // C-FREE: hand the bus back
    pub(crate) fn write_config(&mut self, 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 error
    if (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-time

pub fn read_single(&mut self, channel: u8) -> Result<i16, Error<B::Error>> {
    debug_assert!(self.config != 0, "driver used before init");    // dropped in release
    if channel > 3 { return Err(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_TEST
extern 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.

# c/host/CMakeLists.txt (fragment)
enable_testing()
add_executable(test_ads1115 tests/test_ads1115.c src/ads1115/ads1115.c tests/fake_i2c.c)
target_compile_definitions(test_ads1115 PRIVATE HOST_TEST)
add_test(NAME ads1115 COMMAND test_ads1115)

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 {
    use super::*;
    struct FakeBus { written: Vec<u8>, reply: [u8; 2] }
    impl embedded_hal::i2c::ErrorType for FakeBus { type Error = core::convert::Infallible; }
    impl embedded_hal::i2c::I2c for FakeBus {
        fn transaction(&mut self, _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.

Stage C (this course’s c/ tree) Rust (rust/ workspace)
Format clang-format --dry-run -Werror cargo fmt --check
Compile with the contract the Module 0 warning set; -Werror in CI cargo build; #![deny(warnings)] in CI only
Lint clang-tidy (bugprone-*, cert-*, readability-*), cppcheck --enable=warning,portability cargo clippy --all-targets -- -D warnings; clippy::pedantic advisory; unwrap_used/expect_used denied in target crates
Deep static analysis 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.

5 · Profiles, size, and what a log line costs

5.1 The C build’s dials

Flag Effect Where
-O0 -g3 Debuggable, macro-aware symbols The edit–compile–debug loop
-Og -g Optimizations that do not hinder debugging Firmware debug configuration (Course 3’s debug preset)
-O2 / -Os / -Oz Speed / size / aggressive size Release; -Os is the usual firmware choice, -Oz when flash is tight
-flto Cross-translation-unit optimization at link time Release; usually a clear size win
-ffunction-sections -fdata-sections -Wl,--gc-sections Drop unreferenced code and data Always on bare metal
--specs=nano.specs 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 smaller
lto = "fat"            # whole-program LTO across crates, including the HAL
codegen-units = 1      # one unit = more inlining opportunity, slower build
panic = "abort"        # no unwinding tables; the panic handler is the end
debug = 2              # symbols in the ELF for probe-rs/defmt; not in flash
incremental = false
overflow-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 useful
debug = 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.lock committed (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)

  1. 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::ffic_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 claim
unsafe extern "C" {
    /// `n` samples at `x` and `y`; returns the Q15 dot product with 32-bit accumulation.
    pub fn dot_q15(x: *const i16, y: *const i16, n: usize) -> i32;
}

pub fn 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()) }
}
  1. 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.
// build.rs
fn main() {
    cc::Build::new().file("src/dot_q15.c").flag("-mcpu=cortex-m4").flag("-mfpu=fpv4-sp-d16")
        .flag("-mfloat-abi=hard").opt_level_str("s").compile("dot_q15");
    println!("cargo:rerun-if-changed=src/dot_q15.c");
}
  1. 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)

  1. 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 unsafe
pub extern "C" fn drv_read_single(dev: *mut Ads1115Handle, channel: u8, out: *mut i16) -> 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 }
}
  1. 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.

  2. 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/build
set(RUST_TARGET thumbv7em-none-eabihf)
set(RUST_LIB ${CMAKE_SOURCE_DIR}/../../course2/rust/target/${RUST_TARGET}/release/libcdrv.a)
add_custom_target(cdrv_cargo
  COMMAND cargo build --release --target ${RUST_TARGET} -p cdrv
  WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/../../course2/rust
  BYPRODUCTS ${RUST_LIB})
add_library(cdrv STATIC IMPORTED)
set_target_properties(cdrv PROPERTIES IMPORTED_LOCATION ${RUST_LIB})
add_dependencies(cdrv cdrv_cargo)
target_link_libraries(${PROJECT_NAME}.elf PRIVATE 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.

9 · Lesson → exercise map

Section Exercise it feeds
§1 components, opaque types, linkage, crates/modules 12.1 (opaque-handle module, both languages)
§2 assertions 12.1, 12.8
§3 dual-targeting, seams, harnesses, cfg(test) for no_std 12.2 (host-tested register fakes), 12.8
§4 the quality gate 12.3 (analysis + sanitizer gate), 12.8
§5 profiles and the size ladder; logging 12.4 (size/profile ladder), 12.5 (defmt vs log)
§6 interop both directions 12.6 (Rust crate in a CubeMX C project), 12.7 (C kernel in a Rust firmware)
§7 the rules checklist 12.8’s closing note
§8 the capstone design 12.8 (capstone)