Module 0 Lessons — Toolchains, Targets, and the Availability Matrix

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

This page is the module’s teaching text. It establishes the vocabulary the rest of the course uses — the three runtime tiers, what “freestanding” and no_std actually remove, and how the Mac toolchain reaches every tier — and ends with the availability matrix, the table of which language facilities exist on which tier, which the exercises then fill in by experiment. 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. The Embedded Rust Book, the Embedonomicon, and Seacord’s first chapter remain available as optional deep-dives; nothing below requires them.


1 · Three tiers, three C libraries, three Rust crate sets

Embedded code is not one thing. The same C source can run against three very different runtimes, and the same Rust crate can be compiled with three very different amounts of the standard library. Everything in this course is placed on one of these three tiers, and the tier decides what the code may use.

Tier This site’s hardware What runs underneath the program C library Rust library
Bare metal STM32 NUCLEO-L476RG (Cortex-M4F, 80 MHz) Nothing: reset vector → your startup code → main newlib-nano (a freestanding-plus subset: no files, no processes, printf only if you provide _write) core only (#![no_std]) — optionally alloc with a heap you supply
RTOS the same STM32 A kernel library linked into the binary: FreeRTOS, or in Rust the RTIC or Embassy framework newlib-nano + the kernel’s API core + the framework’s crates; alloc optional
Embedded Linux Jetson Orin Nano, Raspberry Pi 5 A full Linux kernel with processes, virtual memory, and drivers glibc + POSIX full std

The Cortex-M4F is not an ARM64 core. It executes the 32-bit Thumb-2 instruction set of ARMv7E-M, has no MMU, no privilege separation beyond thread/handler mode, and a single-precision-only FPU. The Jetson and the Pi are AArch64 A-profile machines with an MMU, caches, and a kernel between the program and the hardware. The two ends of the table are different computers, and the middle tier is a library choice on the first one.

1.1 Hosted vs. freestanding C

The C standard defines two kinds of implementation. A hosted implementation provides the whole standard library and starts the program at main. A freestanding implementation is only required to provide the headers that contain no functions — <float.h>, <iso646.h>, <limits.h>, <stdalign.h>, <stdarg.h>, <stdbool.h>, <stddef.h>, <stdint.h>, <stdnoreturn.h> — and the program’s entry point is implementation-defined. A bare-metal STM32 is a freestanding environment onto which a partial hosted library, newlib-nano, is bolted. That is why printf exists but writes nowhere until you implement _write, why malloc exists but has no idea how big the heap is until _sbrk tells it, and why <threads.h> is missing entirely.

// Freestanding-safe: compiles for thumbv7em with -ffreestanding and no libc.
#include <stdint.h>
#include <stddef.h>

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;
}
// Hosted-only: <stdio.h> exists on the Mac and on the Jetson; on the STM32 it
// exists in name but its output goes nowhere until _write() is retargeted.
#include <stdio.h>
int main(void) { puts("hello"); return 0; }

Apple’s clang compiles the first file for the Cortex-M directly (--target=thumbv7em-none-eabihf -ffreestanding -c) with no ARM toolchain installed, and refuses the second for that target because there is no <stdio.h> in a freestanding sysroot. Keeping the kernel and the driver in separate files — one freestanding-safe, one hosted — is the habit this distinction produces, and the course’s C workspace is built around it.

1.2 core, alloc, std

Rust’s standard library is three crates stacked on each other:

Crate Needs Provides Available on
core Nothing — no OS, no allocator Primitive types and their methods, Option, Result, slices, iterators, fmt::Write, core::sync::atomic, core::ptr, core::mem, MaybeUninit, Cell/RefCell Every tier
alloc A global allocator (#[global_allocator]) Box, Vec, String, Rc, Arc, BTreeMap, format! Bare metal if you supply a heap (embedded-alloc); Linux
std An operating system Threads, files, sockets, println!, HashMap, Instant, environment, process Linux only

#![no_std] at the top of a crate means “link core instead of std”. It removes println!, Vec, String, Box, threads, files, and the default panic machinery. #![no_main] additionally removes the standard main — the entry point is then whatever the runtime crate (cortex-m-rt) names. Crucially, core is not a reduced language: ownership, traits, generics, iterators, match, and Result all live there, so the Rust you write on the Cortex-M is the same language, minus the parts that need an OS or a heap.

#![no_std]
// Everything below is `core`: no allocator, no OS, and the same language.
pub fn dot_q15(a: &[i16], b: &[i16]) -> i32 {
    a.iter().zip(b).map(|(&x, &y)| i32::from(x) * i32::from(y)).sum()
}

The slice version carries its length and is bounds-checked by construction — the (pointer, n) pair of the C version has become one type. That one change is the beginning of most of the Rust half of this course.

2 · The portability taxonomy

Seacord opens Effective C with four categories of behavior the C standard deliberately leaves open. Every embedded C bug of the “works on the Mac, fails on the board” kind is one of them, and Rust’s design can be read as a set of decisions about each.

Category Definition Embedded example What Rust does
Implementation-defined The implementation must choose and document sizeof(long) (8 on LP64 macOS/Linux, 4 on the Cortex-M4); right shift of a negative value; char signedness Fixed by the language: i64 is 64 bits everywhere, >> on signed is arithmetic, char is a Unicode scalar; only pointer width varies (usize)
Unspecified Any of several behaviors, need not be documented, may vary per evaluation Order of evaluation of function arguments; padding bytes’ contents Evaluation order is specified (left to right); padding is unobservable in safe code
Undefined No requirements at all — the compiler may assume it never happens Signed overflow, oversized shift, out-of-bounds access, data race, strict-aliasing violation Impossible in safe code; each becomes a contract you take on explicitly with unsafe (Module 7)
Locale-specific Depends on the locale isalpha, strtod decimal point, printf thousands grouping core has no locale; text is UTF-8

The consequence for this course: C code is written against the standard’s guarantees, not the compiler’s habits, and every implementation-defined choice that matters (widths, alignment, endianness) is pinned by _Static_assert at compile time. Rust code gets most of that for free and pays for it elsewhere — at the unsafe boundary, where the C-style obligations return in writing.

TipFirmware rule

Anything the standard calls implementation-defined is a build-time assertion, not a comment. Anything it calls undefined is a bug the optimizer is allowed to weaponize.

3 · The Mac toolchain, tier by tier

Everything below installs on an Apple Silicon Mac; the boards are optional at every step.

3.1 C

Tool Reaches Install
clang (Apple, from the Xcode command-line tools) Host builds with sanitizers; cross-compiles C to thumbv7em-none-eabihf for compile-and-disassemble forensics — no ARM toolchain needed xcode-select --install
arm-none-eabi-gcc (optional) Linking real STM32 firmware, newlib-nano, the linker scripts CubeMX generates brew install --cask gcc-arm-embedded — the same toolchain Course 3’s firmware uses
cmake + ninja The c/ workspace’s build driver brew install cmake ninja
A Linux compiler Linux-tier C is compiled where it runs — on the Jetson or the Pi, over SSH or CLion’s remote toolchain JetPack’s gcc; see Course 3’s Jetson setup essentials

The Cortex-M flag set, used identically by clang --target=thumbv7em-none-eabihf and by arm-none-eabi-gcc, names the core and its FPU exactly:

-mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard -std=gnu17

fpv4-sp-d16 says single-precision, sixteen double-word registers. There is no hardware double on this core; -Wdouble-promotion exists to catch the float expression that silently became a software-emulated double because of one unsuffixed literal.

3.2 Rust

Tool Reaches Install
rustup + stable toolchain Host builds, tests, Clippy, rustfmt curl https://sh.rustup.rs -sSf \| sh
Target thumbv7em-none-eabihf Bare-metal and RTOS tiers on the Cortex-M4F rustup target add thumbv7em-none-eabihf
Target thumbv7m-none-eabi The QEMU simulation board (a Cortex-M3) rustup target add thumbv7m-none-eabi
Target aarch64-unknown-linux-gnu cargo check of Linux-tier code on the Mac; the actual build happens on the board rustup target add aarch64-unknown-linux-gnu
llvm-tools + cargo-binutils cargo size, cargo objdump, cargo nm, cargo readobj on any target rustup component add llvm-tools · cargo install cargo-binutils --locked
probe-rs Flash and run on the NUCLEO through its ST-LINK; defmt log output over RTT brew tap probe-rs/probe-rs && brew install probe-rs (or the install script on probe.rs)
QEMU Run thumbv7m binaries with semihosting, no board brew install qemu
Miri Detect undefined behavior in unsafe code under an interpreter rustup component add miri (Module 7)

Rust targets are named <arch><sub>-<vendor>-<os>-<abi>: thumbv7em-none-eabihf is Thumb-2 on ARMv7E-M, no vendor, no OS, hard-float EABI. The four Cortex-M targets differ only in ISA level and FPU; picking the wrong one produces a binary that runs but does floating point in software, which is a Module 12 size-ladder experiment rather than a build error.

Miri, the interpreter Module 7 uses to check unsafe code, ships only on the nightly channel: rustup toolchain install nightly && rustup +nightly component add miri, then cargo +nightly miri test. Nothing else in the course needs nightly.

3.3 Python

The Python track runs on the host and, optionally, on the Linux boards; there is no Python on the microcontroller tiers, which is the point of the two compiled tracks.

Tool Reaches Install
uv The labs repo’s single root project (Python 3.13, uv.lock tracked): NumPy, SciPy, Matplotlib, pandas, scikit-learn, pytest, Jupyter, pyserial, soundfile/sounddevice/librosa, OpenCV, Pillow brew install uv, then uv sync at the repo root
The ml dependency group PyTorch (+ torchaudio, torchvision), ONNX, ONNX Runtime uv sync --group ml
Jupyter Notebooks for the exploratory half of every Python exercise uv run jupyter lab
pytest The scripted half — every reference implementation has a test that pins its output uv run pytest course2/python/tests
The boards CuPy, onnxruntime, TensorRT on the Jetson; onnxruntime on the Pi — installed per Course 3’s edge setup on the board, not in uv.lock

Two conventions from Course 3 carry over unchanged: uv run is the only way any Python is invoked (no global interpreter, no activated shells), and the accelerator is selected, never assumed — torch.backends.mps.is_available() on the Mac, torch.cuda.is_available() on the RTX 4090 desktop and the Jetson, CPU otherwise. Module 1 opens with the environment; Module 3 with the device probe.

3.4 One editor

CLion opens all three trees: the c/ CMake projects natively (with the same remote-toolchain workflow Course 3 uses for the Jetson), the rust/ workspace through its Rust plugin, and — as PyCharm or through CLion’s Python plugin — the uv project with its virtual environment as the interpreter. rust-analyzer needs one hint for cross targets — the mcu/ and qemu/ crates carry their own .cargo/config.toml with a [build] target, so they are analyzed against their Cortex-M target and not the Mac.

4 · The workspace

The labs repo’s course2/ folder is organized by tier, not by module, because a tier is a build configuration and a module is not:

course2/
  README.md
  m0/ … m12/                # notes.md per module: predicted vs. observed write-ups
  python/
    src/ex-M-N.py           # scripts — uv run python course2/python/src/ex-1-3.py (from the repo root)
    notebooks/ex-M-N.ipynb  # notebooks — uv run jupyter lab
    tests/test_ex_M_N.py    # pytest — uv run pytest course2/python/tests
  c/
    cmake/                  # shared warning set, the per-directory exercise rule, the thumbv7em toolchain file
    host/                   # CMake · clang · ASan+UBSan · one executable per src/ex-M-N/
    mcu/                    # CMake · clang --target=thumbv7em · freestanding objects + build/m4/<ex>/*.lst disassembly
                            #   (real-ELF linking lives in Course 3's firmware projects)
    linux/                  # CMake · POSIX · built on the Jetson/Pi; the macOS-common subset builds here too
  rust/
    Cargo.toml              # one workspace, four crates; default-members = host, linux
    rust-toolchain.toml     # pins the channel; installs the three targets, llvm-tools, rust-src on first build
    .cargo/config.toml      # per-target runners (QEMU, probe-rs) and link args
    host/                   # std · unit tests · Miri · Clippy
    mcu/                    # no_std · thumbv7em-none-eabihf · embassy-stm32 for the L476 · probe-rs runner
    qemu/                   # no_std · thumbv7m-none-eabi · lm3s6965evb · semihosting runner
    linux/                  # std + nix + gpiod + linux-embedded-hal · aarch64 check on the Mac

An exercise is a per-tier file: python/src/ex-1-3.py, c/host/src/ex-4-2/ (every .c in the directory links into build/ex-4-2), rust/qemu/src/bin/ex-9-3.rs (cargo run --bin ex-9-3 from inside qemu/ boots it in QEMU). A cross-tier exercise has one entry in each tier it touches, and the module’s notes.md holds the comparison. Each crate ships one smoke-<tier> binary — the toolchain check Exercise 0.1 runs — and a bare cargo build at the workspace root builds only the two host-runnable crates (host, linux); the Cortex-M crates are built with -p mcu --target thumbv7em-none-eabihf / -p qemu --target thumbv7m-none-eabi, or from inside their directories, where a crate-local .cargo/config.toml sets the default target.

The Rust side’s runner configuration is what makes cargo run mean “boot in QEMU” for one crate and “flash the NUCLEO” for another:

# rust/.cargo/config.toml
[target.thumbv7m-none-eabi]
runner = "qemu-system-arm -cpu cortex-m3 -machine lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel"
rustflags = ["-C", "link-arg=-Tlink.x"]

[target.thumbv7em-none-eabihf]
runner = "probe-rs run --chip STM32L476RGTx"
rustflags = ["-C", "link-arg=-Tlink.x", "-C", "link-arg=-Tdefmt.x"]

link.x is the linker script cortex-m-rt generates from your memory.x; defmt.x adds the log-string table defmt needs. The Linux crate needs no runner: on the Mac it is only ever cargo check --target aarch64-unknown-linux-gnu; on the board it is plain cargo build --release.

5 · The first bare-metal binary, end to end

The point of running one no_std program in QEMU before anything else is that it makes visible everything a hosted main hides: who sets up the stack, who zeroes .bss, who copies .data out of flash, what happens on panic, and where “output” goes when there is no terminal.

5.1 The Rust version

Four files make a bootable Cortex-M program.

# Cargo.toml (the qemu crate)
[package]
name = "qemu"
edition = "2024"

[dependencies]
cortex-m = "0.7"
cortex-m-rt = "0.7"
cortex-m-semihosting = "0.5"
panic-halt = "1"

[profile.release]
debug = 2          # keep symbols in release builds: probe-rs and objdump want them
lto = true
opt-level = "s"
/* memory.x — the LM3S6965 that QEMU emulates */
MEMORY
{
  FLASH : ORIGIN = 0x00000000, LENGTH = 256K
  RAM   : ORIGIN = 0x20000000, LENGTH = 64K
}
// src/bin/ex-0-2.rs
#![no_std]
#![no_main]

use cortex_m_rt::entry;
use cortex_m_semihosting::{debug, hprintln};
use panic_halt as _;           // the panic handler: link it, never call it

#[entry]
fn main() -> ! {
    hprintln!("hello from thumbv7m in QEMU");
    debug::exit(debug::EXIT_SUCCESS);   // tell QEMU to quit
    loop {}
}
cargo run --bin ex-0-2 --target thumbv7m-none-eabi     # builds, then the runner boots QEMU

What each piece does:

  • cortex-m-rt provides the vector table (initial stack pointer first, then the Reset handler), the Reset routine that zeroes .bss and copies .data from flash to RAM, default handlers for every exception, and the #[entry] attribute that names your function as the one Reset jumps to. fn main() -> ! is required: there is nothing to return to.
  • memory.x is the only board-specific input to the linker script. Change ORIGIN/LENGTH to the STM32L476’s flash (1 MB at 0x0800_0000) and RAM (96 KB at 0x2000_0000, plus 32 KB of SRAM2) and the same crate targets the NUCLEO; embassy-stm32’s memory-x feature writes the file for you from the chip name.
  • panic-halt is the #[panic_handler]: on panic, disable interrupts and spin. The bare-metal tier has no unwinding and no default handler, so a no_std binary without exactly one panic handler does not link — the error message is the first thing Exercise 0.5 asks you to read.
  • Semihosting is a debugger-mediated system call: hprintln! traps into the host (QEMU or a probe) which prints on its behalf. It is slow and stops the core while it runs, which is why real boards use defmt over RTT instead; in QEMU it is the right tool.

5.2 The C version, for comparison

The same four responsibilities exist in every CubeMX project, spread over files you did not write:

Responsibility Rust (cortex-m-rt) C (CubeMX / newlib)
Vector table and reset generated by the crate; #[entry] startup_stm32l476xx.s: g_pfnVectors, Reset_Handler
.data copy, .bss zero Reset in the crate the loop in Reset_Handler before main
Memory layout memory.xlink.x STM32L476RGTX_FLASH.ld
Panic / fault #[panic_handler]; HardFault via #[exception] HardFault_Handler (weak default: infinite loop)
Output semihosting / RTT + defmt retarget _write to a UART, or SWO, or semihosting

Reading the Course 3 startup file next to cortex-m-rt’s source is Exercise 0.3. The crate is short; the startup file is shorter; the point is that both are doing the same six things.

6 · Reading what you built

Sizes and disassembly are the course’s primary instruments — every “the compiler will…” claim on these pages is settled by looking. No numbers are given here; the exercises produce them.

# Rust, any target
cargo size --bin ex-0-2 --release -- -A          # .text/.rodata/.data/.bss by section
cargo objdump --bin ex-0-2 --release -- -d --no-show-raw-insn | less
cargo nm --bin ex-0-2 --release -- --size-sort | tail

# C on the Mac
clang -O2 -c kernel.c -o kernel.o && $(xcrun --find llvm-objdump) -d kernel.o
clang --target=thumbv7em-none-eabihf -mcpu=cortex-m4 -mfpu=fpv4-sp-d16 -mfloat-abi=hard \
      -O2 -ffreestanding -c kernel.c -o kernel-m4.o && $(xcrun --find llvm-objdump) -d kernel-m4.o

# C on the board toolchain
arm-none-eabi-size -A firmware.elf
arm-none-eabi-objdump -d firmware.elf | less

Three things to know before reading a Cortex-M disassembly. Addresses in flash are odd in the vector table because bit 0 of a Thumb function pointer is the Thumb-state flag — 0x0800_0139 is a function at 0x0800_0138. .data appears twice in a size report — its load address in flash and its run address in RAM — and only the flash copy costs storage. And .bss costs RAM but no flash at all, which is why a large static buffer that is never initialized is cheaper than one initialized to zeros in the source only if the compiler notices the zeros — it does, in both languages.

7 · Warnings and lints as a contract

Compiler defaults drift between releases, so the project pins its dialect and diagnostics in the build system and treats new diagnostics like failing tests: fix, or justify in a comment.

7.1 C

# Applied to every target in c/host, c/mcu, c/linux
set(CMAKE_C_STANDARD 17)
set(CMAKE_C_EXTENSIONS ON)           # gnu17: CMSIS and newlib headers assume GNU extensions
add_compile_options(-Wall -Wextra -Wpedantic -Wconversion -Wshadow
                    -Wstrict-prototypes -Wundef -Wdouble-promotion -Wformat=2)
Flag Catches
-Wall -Wextra The broad, low-noise defect set — always on
-Wpedantic Non-ISO constructs; the portability build’s flag
-Wconversion Implicit narrowing and sign changes — the big one for register and DSP math
-Wshadow An inner declaration silently hiding an outer one
-Wstrict-prototypes void f() (unspecified arguments) vs. void f(void)
-Wundef An undefined macro silently evaluating to 0 inside #if
-Wdouble-promotion float arithmetic quietly promoted to software double on the M4F
-Wformat=2 printf-family format/argument mismatches
-Werror=vla Variable-length arrays — banned outright in firmware (Module 6)
-fsanitize=address,undefined Host builds only: the runtime half of the contract (Module 7)

7.2 Rust

The compiler’s defaults are already most of the C list; what the project adds is policy:

// At the top of every crate in rust/
#![deny(unsafe_op_in_unsafe_fn)]   // an unsafe fn body still needs unsafe blocks
#![warn(missing_docs)]
// and in the no_std crates:
#![no_std]
cargo clippy --all-targets -- -D warnings                          # host crates: lints are errors
cargo clippy -p mcu --target thumbv7em-none-eabihf --lib --bins -- -D warnings   # no_std crates have no test target
cargo fmt --all --check                                            # formatting is not negotiable
cargo check -p mcu --target thumbv7em-none-eabihf
cargo check -p linux --target aarch64-unknown-linux-gnu

Clippy’s unwrap_used/expect_used lints, denied in the mcu and qemu crates, are how the course enforces the firmware panic policy of Module 5: a Result is handled, never unwrapped, in code that runs on the board.

TipFirmware rule

Pin the dialect, the edition, and the warning set in the build files. A warning you cannot fix is a comment explaining why, next to the code — never a flag that turns it off globally.

8 · The availability matrix

This is the module’s deliverable, stated as claims for the exercises to confirm. Each cell answers “can this program use it on this tier?” for the site’s actual hardware and the pinned toolchains. Where the answer is “yes, but…”, the “but” is the lesson.

Facility Bare metal, C Bare metal, Rust RTOS, C (FreeRTOS) RTOS, Rust (RTIC / Embassy) Linux, C Linux, Rust
Heap (malloc / Box) Exists via newlib; banned after init by project rule Only with alloc + embedded-alloc; banned in this course FreeRTOS heap schemes exist; static allocation preferred Same as bare metal Yes Yes
Threads No (__STDC_NO_THREADS__) No std::thread Tasks, from the kernel Tasks (RTIC) / async tasks (Embassy) pthread, C11 threads on glibc std::thread
Atomics, ≤ 4 bytes Lock-free (LDREX/STREX) Lock-free (core::sync::atomic) same same Lock-free Lock-free
Atomics, 8 bytes Not lock-free (library call) AtomicU64 absent on thumbv7em same same Lock-free Lock-free
float hardware Yes (FPv4-SP) Yes Yes Yes Yes Yes
double hardware No — software emulation No — f64 is software No No Yes Yes
Formatted output printf after retargeting _write; size cost core::fmt (large) or defmt (small, needs a host) same same printf println!
Files No No No (unless FatFS-style libraries) No Yes Yes
Monotonic time A hardware timer / DWT cycle counter you configure embassy-time or a monotonic you supply Tick count (xTaskGetTickCount) RTIC monotonic / embassy-time::Instant CLOCK_MONOTONIC std::time::Instant
Wall-clock time Only with an RTC Only with an RTC same same CLOCK_REALTIME SystemTime
Signals No No No No POSIX signals nix::sys::signal
Unwinding / exceptions No No — panic = "abort" No No glibc unwinding exists; C has no exceptions panic = "unwind" by default
Dynamic loading No No No No dlopen libloading (crate)

Three cells deserve a comment now. The 8-byte atomic row is why a 64-bit timestamp shared with an ISR is a Module 9 problem and not a volatile uint64_t. The double row is why -Wdouble-promotion is in the C warning set and why Rust code on the M4F uses f32 and micromath/libm rather than f64. And the formatted output row is why defmt exists: it compiles format strings out of the binary and ships only an index over RTT, which is the difference between a logging firmware that fits and one that does not.

9 · Lesson → exercise map

Section Exercise it feeds
§1 tiers, freestanding vs. hosted, core/alloc/std 0.1 (bring-up on every tier), 0.4 (the matrix by experiment)
§2 portability taxonomy 0.4, 0.5 (deliberate failures)
§3 toolchain 0.1, 0.7 (Python environment and device probe)
§4 workspace, runners 0.1, 0.2
§5 first no_std binary 0.2 (hello in QEMU), 0.3 (startup, side by side)
§6 sizes and disassembly 0.3, 0.6
§7 warnings and lints 0.5, 0.6
§8 availability matrix 0.4