Module 12 Exercises — Structure, Testing, Interop, and the Capstone

Back to the Course 2 syllabus. Read first: Module 12 lessons (Seacord 10–11, Rust Book 7/11/14, Grenning, and the Embedded Rust Book’s Interoperability chapter remain available as optional deep-dives).

Work in the labs repo’s course2/ tree; record everything in m12/notes.md. Host work runs on the Mac (c/host under CTest with sanitizers; rust/host and the driver crate under cargo test, Clippy, and Miri). Bare-metal work is cross-compiled from the Mac (c/mcu, rust/mcu), run in QEMU where the exercise says so (rust/qemu), and flashed to the NUCLEO-L476RG with probe-rs as the optional last rung. Linux-tier work (c/linux, rust/linux) is cargo checked on the Mac and built on the Jetson or Pi 5 over SSH. Predicted cells are filled in before building; observed cells at the machine. Exercises 12.6–12.8 also touch the Course 3 firmware tree (course3/firmware/) in the same repo.

Exercises

Exercise 12.1 — An opaque-handle module, twice. Design a small event log module — a fixed-capacity ring of timestamped (source, code) records with push, pop, count, and clear — as a data abstraction in both languages. In C (c/host/src/ex-12-1/): a public header exposing only an incomplete struct evlog and the API, a private header with the definition, caller-supplied storage (no heap), a status enum for every error, every non-API function static. In Rust (rust/host/src/bin/ex-12-1.rs, with the module in rust/host/src/evlog.rs): a struct with private fields and a const-generic capacity, Result returns, pub(crate) helpers. Then attack both from a second translation unit / a second module: try to read the record count without the API, try to construct an instance by hand, try to copy a handle and use both. Complete the table:

Attempt C: what stops it (compile error / nothing) Rust: what stops it
Read a private field
Construct without the constructor
Duplicate the handle and use both
Ignore an error return

Build the C module with and without -ffunction-sections -Wl,--gc-sections and confirm in the symbol table that the static helpers never appear as global symbols and that an unused API function is dropped only with the section flags. Deliverable: both modules, the table, and a notes.md paragraph on which “cannot” rows are enforced by each language and which are still conventions.

Exercise 12.2 — Host-tested register fakes. Take a driver that touches hardware — the GPIO marker toggling from Module 8 is enough — and make it dual-target. C (c/host/src/ex-12-2/ and c/mcu/src/ex-12-2/): the register block’s base is selected by #ifdef HOST_TEST between the real MMIO address and a static GPIO_TypeDef fake_gpioa; write a test executable with a thirty-line TEST macro set (or Unity, if you prefer) that exercises set/clear/toggle and asserts on the fake’s BSRR/ODR contents and on a spy that records the order of writes; register it with CTest; confirm the identical driver source cross-compiles for thumbv7em-none-eabihf untouched. Rust (rust/host for tests, driver in a new library crate rust/gpio-fake-demo/ or inside rust/host/src/): make the driver generic over embedded_hal::digital::OutputPin, write a hand-rolled fake pin that records transitions, and put four-phase tests in a #[cfg(test)] module; then add the #![cfg_attr(not(test), no_std)] line and prove the crate still cargo checks for thumbv7em-none-eabihf. Run the C tests under -fsanitize=address,undefined and the Rust tests under cargo miri test. Deliverable: both test suites green, the CTest and cargo test output captured, and a notes.md note on which seam each language used and what the other could not express.

Exercise 12.3 — The quality gate, applied to real code. Choose one Course 3 firmware module (course3/firmware/m5-daq/ is the richest) and one crate in rust/ (the mcu crate, once it holds Module 8–10 work). Run the full gate from the lessons page’s §4 table on each — format check, the pinned warning set with -Werror, clang-tidy (bugprone-*,cert-*,readability-*) and cppcheck for the C, cargo clippy --all-targets -- -D warnings plus clippy::pedantic advisory for the Rust, clang --analyze / scan-build for the C, host tests under sanitizers / Miri where they exist, cargo check on every target. Triage every finding into fix, justify in a comment, or false positive, and record the counts:

Stage C module: findings Fixed / justified / false Rust crate: findings Fixed / justified / false
Format
Warnings as errors
Lint (clang-tidy+cppcheck / Clippy)
Deep static analysis
Instrumented host tests
Every target checks

Then script the gate: a Makefile or shell target gate in course2/ that runs every stage and fails on the first red one. Deliverable: the table, the script, and a notes.md paragraph on which stages found something the compiler’s own warnings had not — and whether the Rust gate’s shorter list reflects fewer defects or fewer tools.

Exercise 12.4 — The size and profile ladder. Build the same small bare-metal program — a blink with one defmt/printf line per second — in both languages, and climb a ladder of one build change at a time, recording section sizes after each rung. C (c/mcu/src/ex-12-4/, linked with arm-none-eabi-gcc if installed, otherwise object sizes with llvm-size on the freestanding compile): -O0-Og-Os-Oz → add -flto → add -ffunction-sections -fdata-sections -Wl,--gc-sections--specs=nano.specs-DNDEBUG. Rust (rust/mcu/src/bin/ex-12-4.rs): dev default → opt-level = 1release default → opt-level = "s""z"lto = "fat"codegen-units = 1panic = "abort"debug = 0 (to prove flash size does not change). Predict the ordering of the rungs by effect before building — which rung you expect to matter most, which to do nothing — then fill the table:

Rung C .text / .data / .bss Rust .text / .data / .bss Predicted effect Observed
Baseline

Use cargo bloat --release -n 20 (install cargo-bloat) and arm-none-eabi-nm --size-sort to name the ten largest symbols at the top and bottom of each ladder. Deliverable: the two ladders with a notes.md explanation of every rung that did nothing (and why --gc-sections or LTO had already done its work) and of the one that mattered most.

Exercise 12.5 — defmt vs. log vs. printf. Extend Exercise 12.4’s program with ten log statements of mixed argument types (integers, a float, a fixed-size byte array, a &str/string literal). Build three Rust variants — defmt + defmt-rtt, log + a minimal RTT writer using core::fmt, and no logging — and two C variants — newlib-nano printf retargeted to RTT or the USART2 VCP, and the same with -u _printf_float linked so the float prints. Predict which variant’s .text is largest and which the float line costs the most, then measure:

Variant .text .rodata .data+.bss Where the format strings live Observed
Rust defmt
Rust log + core::fmt
Rust, no logging
C printf (nano)
C printf + _printf_float

Confirm with cargo objdump/strings that the defmt build’s flash image contains no format-string text and the ELF does. Deliverable: the table and a notes.md decision: which logging mechanism each tier of the capstone uses, and what a fielded device with no host attached would use instead.

Exercise 12.6 — A Rust crate inside a CubeMX C project. Make a no_std staticlib crate (rust/cdrv/, added to the workspace) exporting a C API for the Module 8 typestate LED/GPIO driver: #[unsafe(no_mangle)] pub extern "C" functions taking and returning #[repr(C)] types and integer status codes, panic = "abort", and a #[panic_handler] that calls a C drv_panic_hook the project provides. Generate the header with cbindgen. Link libcdrv.a into a Course 3 CubeMX CMake project (course3/firmware/m2-timing/ is the simplest) with an imported library and a custom command that runs cargo build --release --target thumbv7em-none-eabihf; call the driver from main.c. Record every linker complaint and its resolution — duplicate memcpy/memset between compiler_builtins and newlib, the missing panic hook, unresolved __aeabi_* helpers — in the table:

Linker symptom Cause Fix Observed

Deliberately make the C side violate one documented contract (pass a null handle) and record what the Rust side does — it must return an error code, never panic. Flash it if the toolchain and board are present; otherwise stop at a clean link and a --gc-sections map showing which Rust symbols survived. Deliverable: the crate, the header, the CMake glue, the table, and a notes.md note on where the safety boundary sits and which rules the C caller is trusted to follow.

Exercise 12.7 — A C kernel inside a Rust firmware. Take a Q15 dot product or a biquad section written in freestanding C17 (a Course 3 shared/ kernel qualifies) and call it from the rust/mcu and rust/qemu crates: a unsafe extern "C" declaration written by hand and one generated by bindgen --use-core, diffed; a build.rs using the cc crate with the Cortex-M flags; a safe wrapper that takes slices, asserts lengths, and carries a // SAFETY: line; a host test in rust/host that compares the wrapped C kernel against a pure-Rust implementation on random inputs (and, under Miri, confirms the wrapper’s pointer handling is sound — note that Miri cannot execute the C itself). Run the QEMU variant and confirm the kernel executes. Then measure the wrapper’s cost: disassemble the call site at opt-level = "s" with and without lto = "fat" and record whether the C function was inlined across the language boundary (it cannot be without LTO across cc’s archive — check what actually happens).

Configuration Call site: bl to the C symbol or inlined? Extra instructions in the wrapper Observed
opt-level = "s", no LTO
opt-level = "s", lto = "fat"

Deliverable: both declarations and their diff, the build.rs, the wrapper, the host test, and the table.

Exercise 12.8 — Capstone: one driver, three languages, three tiers. Write the ADS1115 driver from Course 3 Lab 3.4 three times — the Python reference first, then C17, then Rust — and build the compiled versions everywhere.

The reference (Python first). In python/src/ex-12-8.py and its notebook: a pure-Python/NumPy model of the ADS1115 — the Config register as a bit-field packer/unpacker (np.uint16 arithmetic, every field named), the code-to-volts conversion for each PGA setting, sign extension of the 16-bit conversion, and a simulated bus that records every byte written and replies with canned conversions or a timeout. Drive it from pyserial against the real device on the bench (optional rung) or against Course 3 Lab 3.4’s recorded captures, and save the reference artifacts a test can pin: the exact byte sequence of a single-shot AIN0 read at ±4.096 V / 128 SPS, the expected code and voltage for each canned conversion, and the timeout path’s expected error. uv run pytest pins all of it (python/tests/test_ex_12_8.py). The C and Rust host tests below load these artifacts and must agree with them byte for byte and to the assert_allclose tolerance Module 2 taught for the voltage conversion.

The driver. Address 0x48; Config register (OS, MUX, PGA, MODE, DR, comparator fields) and Conversion register (signed 16-bit); single-shot read with the OS-bit poll, continuous mode with the ALERT/RDY line as data-ready; the code-to-volts conversion left to the caller (the driver returns codes and the selected full-scale range). C17: a shared source directory c/ads1115/ (opaque handle, caller-owned storage, a struct ads1115_bus function-pointer seam, status enums, _Static_asserts on every layout and width assumption), pulled into c/host, c/mcu, and c/linux by CMake. Rust: a new workspace library crate rust/ads1115/ generic over embedded_hal::i2c::I2c (#![cfg_attr(not(test), no_std)], private fields, Result<_, Error<B::Error>>, a state type or typestate for single-shot vs. continuous, free(self)), depended on by rust/host, rust/mcu, and rust/linux.

The tiers.

Tier C Rust Where it runs
Host fake bus + CTest under sanitizers; a fake that replies with the Python reference’s canned conversion and one that times out fake I2c + cargo test + Miri; the same two fakes, loading the same artifacts Mac
Bare metal STM32 HAL I²C behind the seam; single-shot read printed over the VCP embassy-stm32 blocking I²C; defmt over RTT cross-compiled on the Mac; NUCLEO optional
RTOS / async FreeRTOS: sampler task → queue → consumer task, RDY line as an EXTI ISR giving a semaphore Embassy: sampler task → Channel → consumer, RDY line as an ExtiInput awaited same
Embedded Linux i2c-dev ioctls behind the seam; RDY on header pin 7 through libgpiod edge events + epoll linux-embedded-hal::I2cdev; RDY through the gpiod crate built on the Jetson or Pi 5

The gate. Both drivers pass Exercise 12.3’s gate; the mcu build has a size ladder (Exercise 12.4) and a pinned release profile; the C build has its warning set and -Werror in the CMake presets. Predicted-vs-observed for the driver itself — Observed cells are for the bench, and the bench is Course 3 Lab 3.4’s setup:

Check Predicted Observed C Observed Rust
Bytes written for a single-shot AIN0 read at ±4.096 V, 128 SPS (register, then two config bytes, MSB first) derive from the datasheet’s Config bit fields; must equal the Python reference’s recorded sequence
Host fake: single-shot returns the canned code sign-extended exact value from the canned bytes
Host fake: timeout path returns the timeout error, never panics/aborts error enumerator / Err(Error::Timeout)
mcu .text size, release profile, driver + blocking read only qualitative: dominated by the HAL’s I²C, not the driver
Continuous mode at 128 SPS: RDY edges per second observed by the consumer ≈ DR

The note. Close with “My working subset, in three languages” in m12/notes.md: for each language, the pinned dialect, edition, or interpreter and toolchain files; the warning set, lints, and test conventions; the features and libraries actually used in the capstone, by module; the constructs banned on each tier and why (heap, double, VLAs, unwrap, static mut, volatile-as-atomic, unwinding across FFI, Python anywhere near a deadline, …); the interop rules from Exercises 12.6–12.7 and Module 2’s bridges; and, in one paragraph per hand-off, what writing the driver the second and third times caught in the earlier versions — a width, an unchecked error, an ordering assumption, an aliasing question, a tolerance that was too loose — that the previous language’s tools had let through. Deliverable: the Python reference and its artifacts, the two compiled driver sources, the three tiers’ entry points in each compiled language, the host test suites, the gate output, the table, and the note.