Module 5 Lessons — The Rust Core for Embedded Work
Back to the Course 2 syllabus · Practice: Module 5 exercises
This page is the module’s teaching text: the Rust language for someone who already knows C well and has never written Rust. It is not a tour of the Rust Book — it is the Rust Book compressed to what a firmware and embedded-Linux programmer needs, and every section follows the same route: the C construct you know → the Rust construct that replaces it → what the compiler now enforces → what that buys on the target. Nothing here needs std; every construct on this page exists in core, which is why the same language runs on the Cortex-M4 and the Jetson. 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. Rust Book chapters 3–10, 13, 18, and 19 remain available as optional deep-dives.
1 · Values, bindings, and the surface syntax
In C a variable is a named region of storage with a type; it is mutable unless const-qualified, and initialization is optional.
In Rust a let binding is immutable unless declared mut, must be initialized before use (the compiler proves it), and its type is usually inferred from use:
let n = 4_usize; // immutable
let mut acc: i32 = 0; // mutable, explicitly typed
acc += 1;
let n = n * 2; // shadowing: a new binding, the old one is goneThe pieces that trip C programmers in the first hour, in one table:
| C | Rust | Note |
|---|---|---|
int x; then x = 3; |
let x = 3; |
Uninitialized use is a compile error, not UB |
const int N = 4; |
const N: usize = 4; |
A const is inlined everywhere; it has no address |
static int table[4]; |
static TABLE: [i32; 4] = [0; 4]; |
Has an address, lives in .data/.rodata; must be initialized by a constant expression |
x++, ++x |
x += 1 |
No increment operators |
if (x) … |
if x != 0 … |
Conditions are bool, never integers |
a ? b : c |
if a { b } else { c } |
if is an expression |
return f(x); |
f(x) as the last expression |
A block’s final expression, with no semicolon, is its value |
void f(void) |
fn f() |
The return type () is implied |
uint8_t, int32_t, size_t |
u8, i32, usize |
Fixed widths are the only integers; usize is pointer-sized |
float, double |
f32, f64 |
On the M4F, f64 is software (Module 0 §8) |
bool |
bool |
Exactly one byte, only true/false are valid |
char (a byte) |
u8 for bytes, char for a Unicode scalar (4 bytes) |
Never confuse them: a char is not a byte |
Two rules underlie all of that. First, there are no implicit numeric conversions: u8 + u16 is a type error, and so is indexing with an i32. Conversion is written — u16::from(x), x as u16, u16::try_from(x)? — and §8 is about which of those to write. Second, expressions have types the compiler checks end to end, so the value of an if, a match, or a block is checked the way a function’s return value is.
2 · Ownership, moves, and borrows
In C a pointer can alias anything, live longer than what it points to, and be freed twice; the conventions that prevent it (“the caller owns the buffer”, “don’t keep this pointer past the callback”) live in comments.
In Rust those conventions are the type system:
- Every value has exactly one owner.
- When the owner goes out of scope, the value is dropped.
- Assigning or passing a non-
Copyvalue moves it — the source is unusable afterward. - A value may be borrowed either by any number of shared references
&Tor by exactly one exclusive reference&mut T— never both at once. - A reference may not outlive the value it refers to.
struct Frame { data: [u8; 64], len: usize }
fn consume(f: Frame) { /* f is dropped here */ }
fn inspect(f: &Frame) -> usize { f.len }
fn fill(f: &mut Frame) { f.len = 64; }
let mut fr = Frame { data: [0; 64], len: 0 };
fill(&mut fr); // exclusive borrow, ends at the call's end
let n = inspect(&fr); // shared borrow
consume(fr); // moved: fr no longer exists
// inspect(&fr); // error[E0382]: borrow of moved valueCopy types — all the integers, f32/f64, bool, char, shared references, and arrays/tuples of Copy types — are copied instead of moved, exactly like C scalars. Everything that owns something (a struct with a buffer, a peripheral handle, an open file) moves.
2.1 The C equivalent, and what changed
/* C: who owns the DMA buffer? The comment says. */
void dma_start(uint8_t *buf, size_t n); /* caller must keep buf alive until dma_done() */// Rust: the signature says.
fn dma_start(buf: &'static mut [u8]) { /* … */ } // must outlive everything: a static buffer
fn dma_start(buf: &mut [u8]) { /* … */ } // borrowed only for this call
fn dma_start(buf: Buffer) { /* … */ } // ownership transfers; the caller cannot touch it againThe three signatures encode three different contracts C would put in prose. The embedded consequence is that “the ISR wrote into a buffer main had already reused” is a compile error rather than a Module 9 debugging session — provided the buffer’s ownership is expressed in the types, which is the whole craft of Rust driver design.
2.2 Slices
A slice &[T] (or &mut [T]) is a pointer plus a length — the (ptr, n) pair every C signature carries as two arguments, now one value that cannot disagree with itself:
int32_t dot_q15(const int16_t *a, const int16_t *b, size_t n);fn dot_q15(a: &[i16], b: &[i16]) -> i32 { /* a.len(), b.len() travel with the data */ }
let samples: [i16; 256] = [0; 256];
let s = dot_q15(&samples[..128], &samples[128..]); // sub-slices: no copy, bounds checked onceIndexing a slice is bounds-checked; a[i] on a bad i panics instead of reading past the end. In a hot loop the check is usually hoisted or eliminated (§7), and where it cannot be, get(i) returns an Option you decide about. Strings are the same idea over UTF-8 bytes: &str is a slice of u8 guaranteed to be valid UTF-8, and "literal" has type &'static str.
Write the ownership of every buffer into its type: borrowed for the call (&mut [u8]), borrowed for the driver’s lifetime (&'a mut [u8]), or owned forever (&'static mut [u8] / a moved value). If you cannot say which, the design is not finished.
3 · Structs, enums, Option, Result, and match
3.1 Structs and methods
In C a struct is data and the functions that operate on it are free functions taking a pointer. In Rust the same, with the functions attached in an impl block and the “this” pointer spelled out as self:
pub struct Adc { base: usize, resolution_bits: u8 }
impl Adc {
pub const fn new(base: usize) -> Self { Adc { base, resolution_bits: 12 } } // associated fn (a constructor)
pub fn full_scale(&self) -> u32 { (1u32 << self.resolution_bits) - 1 } // takes &self: read-only
pub fn set_resolution(&mut self, bits: u8) { self.resolution_bits = bits; } // &mut self: exclusive
pub fn release(self) -> usize { self.base } // self: consumes
}The receiver type — &self, &mut self, self — is the ownership rule of §2 applied to methods, and it is visible at every call site by whether the caller needed a mut binding. Fields are private to the module by default; pub opens them. Layout is unspecified unless you ask for #[repr(C)] (Module 6), which is what you ask for on anything hardware- or wire-facing.
3.2 Enums carry data
In C an enum is a set of integer names, and a “tagged union” is a struct with a tag field and a union, kept consistent by hand. In Rust an enum is a tagged union with the compiler keeping the tag:
pub enum Command {
Reset, // no payload
SetGain(u8), // one payload
Write { addr: u16, data: [u8; 4] }, // named payload
}Two enums from core carry most of the language’s error handling:
enum Option<T> { None, Some(T) }
enum Result<T, E> { Ok(T), Err(E) }Option<T> replaces the C null pointer, sentinel value, and “returns −1 on failure” conventions; Result<T, E> replaces the errno/return-code convention with the error in the return type. For references and non-zero integers the None case costs nothing: Option<&T> and Option<NonZeroU32> are the same size as &T and u32, with None encoded as the value that could not otherwise occur — the null pointer, or zero.
3.3 match is exhaustive
switch (cmd->tag) {
case CMD_RESET: reset(); break;
case CMD_SET_GAIN: set_gain(cmd->u.gain); break;
/* forgot CMD_WRITE — the compiler may warn, or not */
}match cmd {
Command::Reset => reset(),
Command::SetGain(g) => set_gain(g),
Command::Write { addr, data } => write_regs(addr, &data),
// omit an arm: error[E0004]: non-exhaustive patterns
}A match must cover every variant, binds payloads by pattern, and is an expression. if let and let…else are the two-arm shorthands — the first for “do this if it matches, else fall through”, the second for “bind, or bail out”:
if let Some(sample) = fifo.pop() { process(sample); }
let Some(hdr) = frame.first() else { return Err(Error::Empty) }; // hdr is bound from here onThe embedded consequence: adding a variant to a protocol enum breaks every match that did not handle it, at compile time, in every file. C’s equivalent is -Wswitch-enum and hope.
4 · Modules, crates, and visibility
In C a translation unit’s static functions are private, everything else is global, and headers are the interface by convention. In Rust a crate is the compilation unit (a library or a binary), a module is a namespace inside it, and everything is private unless marked pub:
// src/lib.rs
pub mod ads1115; // src/ads1115.rs
mod internal; // private to the crate
// src/ads1115.rs
pub struct Ads1115<I2C> { i2c: I2C, addr: u8 } // struct is pub, fields are not
pub(crate) fn checksum(b: &[u8]) -> u8 { /* visible inside this crate only */ }use brings paths into scope (use crate::ads1115::Ads1115;), pub use re-exports, and super:: names the parent module. There are no headers, no include guards, no link-time symbol collisions between crates (names are mangled per crate), and no “forgot to make it static” leaks. The workspace in rust/ (Module 0 §4) is several crates sharing one Cargo.lock; each tier is a crate so that its dependencies and its target are pinned separately.
5 · Why Vec and String are not here
Vec<T>, String, Box<T>, Rc, Arc, and format! all allocate, which means they live in alloc, not core (Module 0 §1.2). A #![no_std] crate without a #[global_allocator] does not have them, and the compiler says so at the first use. What replaces them on the bare-metal tier:
std/alloc type |
no_std replacement |
Where it lives |
|---|---|---|
Vec<T> |
[T; N], &mut [T], heapless::Vec<T, N> |
stack, static, or a caller’s buffer |
String |
&str, heapless::String<N>, [u8; N] + core::str::from_utf8 |
same |
Box<dyn Trait> |
&dyn Trait, or generics (§6) |
borrowed |
HashMap |
heapless::FnvIndexMap, or a sorted array + binary search |
static |
format! |
core::fmt::Write into a fixed buffer; defmt for logging |
fixed buffer |
VecDeque as a queue |
heapless::spsc::Queue, heapless::Deque |
static |
heapless collections are ordinary values with a compile-time capacity; push returns Err(item) when full instead of allocating. Module 6 is about designing with them; here the point is only that the language did not shrink — the same ownership, traits, and iterators apply to a heapless::Vec<u8, 64> as to a Vec<u8>.
6 · Error handling without exceptions, and the panic policy
6.1 Recoverable errors are values
In C a function reports failure through a return code, errno, or an out-parameter, and the caller may ignore all three. In Rust the failure is in the return type and ignoring it is a warning (#[must_use] on Result):
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Error {
Nack, // device did not acknowledge
Timeout,
BadConfig(u16), // carries the offending register value
}
pub fn read_raw(&mut self) -> Result<i16, Error> {
let mut buf = [0u8; 2];
self.i2c.write_read(self.addr, &[REG_CONV], &mut buf).map_err(|_| Error::Nack)?;
Ok(i16::from_be_bytes(buf))
}The ? operator is the whole of “check the return code and propagate”: on Err(e) it returns Err(e.into()) from the enclosing function; on Ok(v) it evaluates to v. The .into() is why a driver defines impl From<I2cError> for Error — bus errors convert into driver errors automatically at the ?. Compared with the C idiom:
int rc = i2c_write_read(bus, addr, ®, 1, buf, 2);
if (rc != 0) { return DRV_ERR_NACK; } /* every call site, by hand, or silently wrong */A Result is a plain enum: no heap, no unwinding, no hidden control flow, and Result<(), Error> with a one-byte error is as cheap as an int return. Option gets the same ? treatment inside functions returning Option.
6.2 Unrecoverable errors: the panic policy
A panic is Rust’s “this cannot continue”: a failed bounds check, an integer overflow in a debug build, an explicit panic!, an unwrap() on None/Err. On the host it unwinds the stack and prints a message. On the bare-metal tier there is no unwinder and no console, so the project decides what a panic means, and this course’s policy is:
| Setting | Value | Why |
|---|---|---|
[profile.*] panic |
"abort" |
No unwinding tables, no landing pads: smaller code, and unwinding is meaningless with no OS |
#[panic_handler] |
panic-halt (spin) in QEMU experiments, panic-probe (report over RTT, then breakpoint) on the NUCLEO, a project-specific reset handler in shipped firmware |
A panic is a bug report, not a recovery path |
Clippy unwrap_used, expect_used, panic |
denied in the mcu and qemu crates |
Every Result is handled in code that runs on the board |
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
cortex_m::interrupt::disable();
loop { cortex_m::asm::bkpt(); } // stop here under the debugger; a watchdog resets in the field
}The rule is not “never panic” — a bounds check that fails is a bug and stopping is correct — but “never write a panic where a Result belongs”. unwrap() in a driver turns a recoverable bus error into a halted product; the lint exists to make that impossible to do by habit.
Result for anything the hardware can do to you; panic only for what your own code cannot have done. Deny unwrap_used in every crate that runs on the board.
7 · Generics, traits, and monomorphization
7.1 Traits are interfaces the compiler resolves statically
In C an interface is a struct of function pointers, or a #define that selects an implementation at build time. In Rust a trait declares the methods a type must provide, an impl Trait for Type provides them, and a generic function takes “any T that implements the trait”:
pub trait Sensor {
type Raw; // an associated type
fn read(&mut self) -> Result<Self::Raw, Error>;
}
fn log_n<S: Sensor>(s: &mut S, n: usize) -> Result<(), Error>
where S::Raw: Into<i32>,
{
for _ in 0..n {
let v: i32 = s.read()?.into();
record(v);
}
Ok(())
}The compiler generates one copy of log_n per concrete S it is called with — monomorphization — so a generic call is a direct call, inlinable, with no function-pointer indirection. This is what makes embedded-hal work: a driver written against trait I2c is compiled for embassy_stm32::i2c::I2c on the STM32 and for linux_embedded_hal::I2cdev on the Jetson, and each build contains only its own bus code. Module 8 builds that driver.
7.2 Trait objects trade static dispatch for one code copy
&dyn Sensor is a fat pointer — data pointer plus vtable pointer — and calls through it are indirect, exactly like the C struct of function pointers. It is the right tool when the set of implementations is chosen at run time (a table of drivers, a plug-in) or when one copy of the calling code matters more than inlining:
Generics (T: Sensor) |
Trait objects (&dyn Sensor) |
|
|---|---|---|
| Dispatch | Static; inlinable | Virtual, through a vtable |
| Code size | One copy per instantiation | One copy |
| Heterogeneous collections | No ([T; N] is one T) |
Yes ([&dyn Sensor; N]) |
| Requirement | Any trait | The trait must be dyn-compatible (no generic methods, no Self-returning methods) |
| Typical embedded use | Drivers, HAL layers, DSP kernels | A command table, a logger sink, a display back-end chosen at boot |
Exercise 5.4 measures the difference in the disassembly rather than asserting it.
7.3 Derives and the traits you meet on day one
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] asks the compiler to write the obvious implementations. Copy is opt-in and is what makes a type behave like a C scalar (§2); Default gives T::default(), the {0} initializer of C; Debug is the {:?} formatter, and on the board defmt::Format is its cheap cousin. Operators are traits too: impl Add for Q15 is how a fixed-point type gets +, and impl Deref is how a smart pointer becomes transparent — both in core::ops.
8 · Integers, conversions, and overflow
This is the section a C programmer must read twice, because Rust’s decisions here are the opposite of C’s.
8.1 No implicit conversions, three explicit ones
let a: u8 = 200;
let b: u16 = u16::from(a); // lossless widening: always available, never fails
let c: u8 = u8::try_from(300u16)?; // fallible narrowing: Err(TryFromIntError)
let d: u8 = 300u16 as u8; // `as`: bit-truncating cast, 300 → 44, silently
let e: i8 = 200u8 as i8; // as: reinterpret, 200 → −56
let f: u8 = -1.5f32 as u8; // as from float: saturates and rounds toward zero, → 0| Conversion | Tool | Behavior on loss |
|---|---|---|
Widen (u8→u32, i16→i32) |
From/Into |
Cannot lose; the only kind the compiler will infer for you |
| Narrow, signed↔︎unsigned | TryFrom/TryInto |
Returns Err — handle it or ? it |
| Narrow, deliberately truncating (masks, low byte of a register) | as |
Truncates/reinterprets; write it only where truncation is the intent, and say so in a comment |
| Float → int | as |
Saturates to the target’s range; NaN → 0 |
| Bytes ↔︎ integer | to_le_bytes(), from_be_bytes(), to_ne_bytes() |
Exact; the endianness is in the name — this is the memcpy type pun of Module 7 done safely |
C’s “usual arithmetic conversions” — the promotions that turn uint8_t * uint8_t into an int multiply and a uint16_t << 20 into a signed-overflow bug (Module 4 §4) — do not exist. u8 * u8 is a u8 multiply and overflows as a u8, which brings up the second decision.
8.2 Overflow is defined, and its handling is a profile setting
Signed overflow in C is undefined behavior; unsigned overflow wraps. In Rust arithmetic overflow of any integer type is a defined error condition, and what happens is decided by the overflow-checks profile setting:
| Build | x + y overflows |
x << n with n ≥ width |
|---|---|---|
Debug (overflow-checks = true) |
panics | panics |
Release (overflow-checks = false) |
wraps two’s-complement | shift amount masked to the width |
Because a firmware that behaves differently in debug and release is unacceptable, arithmetic whose overflow is intended is written with the method that says so:
let elapsed = now.wrapping_sub(start); // timer deltas: wrap on purpose (the Module 7 timer bug, made correct)
let total = acc.checked_add(x).ok_or(Error::Overflow)?; // Option<T>: None on overflow
let level = level.saturating_add(step); // clamps at MAX: the Q15 saturating add
let (v, carried) = a.overflowing_add(b); // value plus the carry flag, for multi-word arithmetic
let q = (a as i32 * b as i32) >> 15; // widen first, as in C, then shift — the DSP idiomDivision by zero and i32::MIN / -1 panic in both profiles; checked_div returns None. Shifts by a negative or too-large amount are the same story as addition. A u32::MAX literal, i16::BITS, u8::MAX as u32 — every type carries its own constants and methods (count_ones, leading_zeros, rotate_left, pow, abs_diff, rem_euclid), so <limits.h> and hand-written bit tricks are gone.
Every wrapping_*/checked_*/saturating_* call is a statement of intent the reader and the compiler both see. Plain + on a value that can overflow is a bug that debug builds find and release builds hide — the C situation, opted back into. Exercise 5.3 puts numbers to this on both toolchains.
9 · Lifetimes, as they appear in driver APIs
Lifetimes are the part of the borrow rules (§2, rule 5) that shows up in signatures. Most of the time the compiler infers them (elision); they must be written when a function returns a reference and it is ambiguous which input it came from, and when a struct holds a reference:
// Returns a sub-slice of `frame`: the output lives as long as the input — elided, no annotation needed
fn payload(frame: &[u8]) -> &[u8] { &frame[2..] }
// Two inputs, one output: say which one the result borrows from
fn longer<'a>(a: &'a [u8], _b: &[u8]) -> &'a [u8] { a }
// A driver that keeps a caller's buffer for its whole life
pub struct Uart<'buf> {
rx: &'buf mut [u8],
}
impl<'buf> Uart<'buf> {
pub fn new(rx: &'buf mut [u8]) -> Self { Uart { rx } }
}
// A DMA transfer that must own its buffer forever: the 'static bound
pub fn start_dma(buf: &'static mut [u8]) { /* … */ }'a reads as “for some lifetime a the caller chooses”; 'static is “the whole program”. The compiler does not extend lifetimes to make code work — it checks that the annotations are consistent with how long things actually live, and rejects the program otherwise. A Uart<'buf> cannot outlive the buffer it was given; a &'static mut [u8] can only come from a static (Module 6’s StaticCell is how one is created safely). The embedded consequence: the “buffer freed while the peripheral was still writing to it” bug has no spelling.
10 · Iterators and closures compile to loops
In C a loop is an index and a bounds test written by hand. In Rust the idiomatic loop is an iterator chain, and the point that matters on the M4F is that the chain compiles to the same instructions as the hand loop — often better, because the compiler can see there is no out-of-bounds access to check.
int32_t dot_q15(const int16_t *a, const int16_t *b, size_t n) {
int32_t acc = 0;
for (size_t i = 0; i < n; ++i) acc += (int32_t)a[i] * b[i];
return acc;
}pub fn dot_q15(a: &[i16], b: &[i16]) -> i32 {
a.iter().zip(b).map(|(&x, &y)| i32::from(x) * i32::from(y)).sum()
}Reading the chain: iter() yields &i16s; zip pairs them and stops at the shorter slice (so the two lengths are reconciled once, not per element); map applies the closure |(&x, &y)| … — an anonymous function that may capture surrounding variables; sum() consumes the iterator into an i32. Nothing is allocated; the closure is monomorphized into the loop body; the indexing bounds checks disappear because the iterator never indexes.
Closures come in three capture modes the compiler picks for you — by shared reference (Fn), by exclusive reference (FnMut), or by move (FnOnce, or any closure marked move) — and a function that takes a closure is generic over which one it accepts (impl FnMut(i16) -> i16). They replace the C callback-plus-void *context pair with a single value that carries its own context, type-checked. Common adapters worth knowing cold: enumerate, take, skip, step_by, chunks(n)/chunks_exact(n) (frame-by-frame processing), windows(n) (FIR taps), rev, filter, fold, max_by_key, position, any/all, and copied()/cloned() to turn &T into T.
Where a plain for loop is still the right tool: when the body has early exits with side effects, when two slices are indexed with different strides, or when the chain would need to be read twice to understand. for x in &samples { … } is an iterator, so the choice is style, not mechanism. Exercise 5.6 compares the disassembly of three spellings of the same kernel.
11 · Patterns
match, let, if let, while let, for, and function parameters all take patterns. The vocabulary a firmware programmer actually uses:
match byte {
0x00 => Frame::Idle,
0x01..=0x7F => Frame::Data(byte), // inclusive range
b'\n' | b'\r' => Frame::End, // alternatives
x if x & 0x80 != 0 => Frame::Control(x & 0x7F), // guard
_ => Frame::Invalid, // catch-all
}
let [hdr, payload @ .., crc] = buf else { return Err(Error::Short) }; // slice pattern with a rest binding
let Status { ready: true, .. } = st else { return Ok(None) }; // struct pattern, ignore the rest
let (lo, hi) = (word & 0xFF, word >> 8); // tuple destructuringPatterns are refutable (may fail: Some(x), a range) or irrefutable (always match: (a, b), a plain name); let takes only irrefutable ones, if let/let…else take refutable ones, and match needs its arms to be exhaustive together. @ binds a name to a whole sub-pattern; .. skips fields or elements; ref/ref mut borrow inside a pattern when the value should not move. Protocol parsers, register decoders, and state machines are where this pays: the shape of the data is checked by the compiler rather than by a sequence of if tests that can disagree with one another.
12 · const, static, const fn, and arrays with compile-time sizes
Three things happen at compile time in Rust that C does with macros, static const, and sizeof arithmetic:
pub const SAMPLE_RATE_HZ: u32 = 48_000; // inlined; no storage
pub const fn ticks_for_ms(ms: u32) -> u32 { SAMPLE_RATE_HZ / 1000 * ms } // callable in const context
pub static SINE_Q15: [i16; 256] = build_table(); // computed at compile time by a const fn, lives in .rodata
static mut COUNTER: u32 = 0; // legal, but every access is `unsafe` and racy — banned; Module 9 has the replacements
pub struct Ring<T, const N: usize> { // const generic: capacity is part of the type
buf: [T; N],
head: usize,
len: usize,
}
impl<T: Copy + Default, const N: usize> Ring<T, N> {
pub const fn capacity(&self) -> usize { N }
pub fn new() -> Self { Ring { buf: [T::default(); N], head: 0, len: 0 } }
}
let mut rx: Ring<u8, 64> = Ring::new(); // [u8; 64] inline, no heap, size known to the linker[T; N] is a real array type — its length is part of the type, size_of::<[u8; 64]>() is 64, and it is Copy if T is. A const generic parameter const N: usize lets a struct or function be generic over that length, which is how heapless::Vec<T, N> and every fixed-capacity buffer in this course are written without macros. const fn is a function the compiler may evaluate at compile time — table builders, unit conversions, register-value computations — and static items are the .data/.rodata objects of C with the initializer required to be a constant expression. static mut exists and is the one place the language gives you C’s global-variable races back; the course bans it outright (Clippy has no lint for it, so it is a review rule), and Module 9 introduces the Mutex, AtomicU32, and StaticCell forms that replace it.
13 · The no_std idioms that follow
Everything above is core. What a bare-metal crate adds on top is small:
| Need | Idiom |
|---|---|
| Print a value | core::fmt::Write — write!(uart, "{}", v) works on any type with a write_str; on the NUCLEO, defmt::info!("{}", v) instead |
| Build a string without a heap | let mut s: heapless::String<32> = String::new(); write!(s, "{v}")?; |
| Force or forbid inlining | #[inline], #[inline(always)], #[inline(never)] — the last for anything you want to see by name in the disassembly |
| Say a value must not be dropped silently | #[must_use] on the type or function — how Result warns you |
| A type with no valid values (a divergent function’s return) | ! — fn main() -> !, fn panic(_: &PanicInfo) -> ! |
Math beyond core (sqrt, sin) |
libm (software) or micromath (fast approximations) — f32 only on the M4F |
Volatile access, barriers, MaybeUninit |
core::ptr, core::sync::atomic, core::mem — Modules 7–9 |
Tests for a no_std crate |
#[cfg(test)] modules run on the host with std (Module 12) |
What C gave you and Rust took away is the list on the left of §1’s table and the implicit conversions of §8; what it gave back is that the rules you used to keep in your head about ownership, nullability, variant coverage, and overflow are now checked at compile time, on every tier, by a compiler that produces the same loops.
14 · Lesson → exercise map
| Section | Exercise it feeds |
|---|---|
§1 surface syntax, §3 structs/enums/match |
5.1 (the same module in C and Rust) |
| §2 ownership, borrows, slices; §9 lifetimes | 5.1, 5.2, 5.5 |
§5 no Vec; §12 arrays and const generics |
5.5 (no_std ring buffer in QEMU) |
§6 Result, ?, panic policy |
5.2 (Result-based driver API), 5.7 |
| §7 generics vs trait objects | 5.4 (static vs dynamic dispatch, in the disassembly) |
| §8 conversions and overflow | 5.3 (overflow semantics predict-verify) |
| §10 iterators and closures | 5.6 (dot_q15 three ways) |
| §11 patterns | 5.1, 5.2 |
§13 no_std idioms; §6 lints |
5.7 (Clippy- and Miri-clean deliverable) |