Skip to content

The Rust Engineering Handbook / Chapter 94

no_std, alloc, Embedded Systems, and Firmware

Design Rust components when library, allocator, operating-system, interrupt, memory, and hardware assumptions must be explicit.

Remove std from a Rust crate and the interesting loss is not println!. You have removed a bundle of assumed services: an allocator, threads, files, sockets, environment variables, process startup and exit, platform error conventions, clocks, synchronization implementations, unwinding support, and often an operating system itself. Some of those services can be supplied independently. Some do not exist on the target. All must stop being invisible.

Part XV asks how Rust’s contracts travel across platforms and organizations. Its first platform is intentionally severe. The tiny-node reference target has 64 KiB of flash, 32 KiB of RAM, no heap, one main execution context, interrupts, memory-mapped peripherals, a watchdog, and a requirement to queue at most eight 64-byte packets. The design goal is not “make desktop code compile.” It is: every platform service, byte budget, execution context, hardware authority, and unsafe operation must have an explicit provider and failure contract.

Begin with three library layers, not two worlds

core supplies language-adjacent, allocation-free facilities: primitive types, slices, iterators, Option and Result, formatting traits, atomics where the target supports them, pointer operations, and much more. It does not require std. A #![no_std] crate substitutes the core prelude for the standard prelude and does not link std by default.

alloc adds heap-backed types and interfaces such as Box, Vec, String, Rc, and Arc. It is useful in a no_std environment only when the final program provides a functioning global allocator and memory from which that allocator can allocate. no_std therefore does not mean “no allocation”; conversely, using core does not require an allocator.

std re-exports core and alloc facilities and adds abstractions that normally depend on operating-system or equivalent platform services: files, networking, threads, environment access, process control, and system time. A target can support some services without providing the complete standard library. Crate capability should follow the actual product contract rather than using std availability as a proxy for every platform feature.

Figure 94-1 joins three facts that are easy to review separately and therefore easy to misalign: the library layer a crate assumes, the bytes the target actually owns, and the small unsafe kernel that holds hardware authority.

Constrained Rust platform map. The left lane distinguishes core as always available, alloc as dependent on an allocator, and std as dependent on operating-system services. The center lane allocates tiny-node's 64 KiB flash and 32 KiB RAM across vectors, code, persistent configuration, static state, fixed packet storage, stacks, and reserve with no heap. The right lane routes a short interrupt through a bounded critical section and fixed queue to the main loop while safe logic and HAL traits surround one audited unsafe MMIO authority.
A constrained target is coherent only when capability layers, byte budgets, execution contexts, and hardware ownership describe the same system.

The dependency direction matters. A portable packet parser can live in a no_std library using core; an optional feature can add alloc-backed convenience; a host application can add std adapters. If the core domain model imports files, clocks, threads, or heap collections everywhere, the platform boundary has already leaked.

Do not advertise no_std as a quality badge. It can increase portability and clarify dependencies, but it can also add bespoke allocators, weaker diagnostics, platform-specific unsafe code, and a test gap between host and hardware. Choose the narrowest capability set that improves the product.

The target triple is a contract input

Rust targets are named by target triples, but a recognized triple does not guarantee the same support level, prebuilt standard libraries, host tools, testing, atomic widths, ABI, linker, or runner. The Rust project classifies targets in tiers. Tier 1 is built and tested by project CI; Tier 2 is guaranteed to build but may not be tested; Tier 3 carries no official build guarantee. Host-tool availability is a separate designation. Recheck the current rustc platform-support page for the exact target.

The board contract also includes CPU architecture and revision, instruction-set features, floating-point ABI, endianness, pointer width, atomic capabilities, memory regions, exception and interrupt model, bootloader expectations, linker and binary format, debugger and flashing tools, and the board support package. A crate that compiles for an architecture may still use the wrong memory map or peripheral version for a board.

Record the Rust release and required target components in CI. Cross-compilation tests code generation and linking; it does not execute the program. If the official toolchain does not ship a precompiled core for a target, building the core libraries or using a custom target specification may involve unstable tooling. That choice belongs in the maintenance and release risk register, not in a hidden developer command.

Keep target-dependent code at the binary or platform-adapter edge. The tiny-node fixture is a host-testable #![no_std] library, while a real board-specific binary would provide reset entry, linker script, panic handler, interrupt bindings, and HAL implementations. This separation lets portable logic use stable language contracts while the board layer owns volatile, ABI, and toolchain facts.

Startup exists before Rust can enforce an invariant

On a hosted target, a runtime and operating system arrange a stack, initialize process state, and eventually call Rust’s entry path. Bare-metal firmware commonly begins at a reset vector. Startup code must establish the stack pointer as required by the architecture, initialize writable static data from flash, zero the BSS region, configure memory or clocks needed by later code, and transfer control to an entry function with the expected ABI.

The linker script places the vector table, code and read-only data, initialized data image, zeroed data, uninitialized reserved regions, stacks, and sometimes persistent configuration. The source types do not detect an overlapping stack and packet pool. Link-time assertions and post-link size checks should fail when a region exceeds its budget. Preserve the linker map and final image size with the release artifact.

Modern Rust editions require some attributes that affect symbol and section invariants to be acknowledged as unsafe attributes. Board frameworks often wrap this machinery. Understand which generated entry symbol, section placement, and interrupt table they create; the framework does not remove the hardware contract.

Destructors are not a reset strategy. On firmware that runs forever or resets through a watchdog, process-exit cleanup never occurs. Power loss or reset can interrupt any operation. Durable configuration therefore needs an explicit flash update protocol—version, checksum, erase/write granularity, wear budget, and recovery from partial update—rather than relying on Drop.

Panic behavior is a platform decision

A final no_std binary needs exactly one panic handler in its dependency graph. The Rust Reference specifies the handler signature as fn(&PanicInfo) -> !. A handler might record a bounded diagnostic then halt, trigger a watchdog reset, enter a safe state, or invoke a bootloader. It must not assume a working heap, filesystem, network, lock held by the panicking context, or interrupt configuration.

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo<'_>) -> ! {
    disable_outputs_to_safe_state();
    request_watchdog_reset();
    loop {
        core::hint::spin_loop();
    }
}

This is architecture pseudocode, not the fixture: the functions must be target-specific, must be callable in the possible panic contexts, and must not return. Development firmware may transmit a compact panic location over a debug port; production firmware may avoid formatting secrets or waiting indefinitely for unavailable I/O. If the handler resets, detect boot loops and preserve a bounded reset reason outside ordinary volatile RAM where the platform permits.

Select panic strategy deliberately. Unwinding is unavailable or undesirable on many constrained targets; abort-style behavior can reduce machinery, but a panic still represents an invariant failure with device-specific consequences. Make recoverable environmental failures Result values. Reserve panic for defects or violated internal invariants, then test the safe-state and reset path on hardware.

Budget memory before choosing containers

For tiny-node, the illustrative memory plan reserves 4 KiB for static system state, 16 KiB for fixed packet storage and buffers, 8 KiB for the main stack including guard margin, 2 KiB for interrupt stack or worst-case interrupt nesting, and 2 KiB for measured reserve. The exact diagram values are a review model; the executable queue itself consumes no more than 560 bytes on the verified host for eight 64-byte payload slots plus indexes. Target layout must be measured separately.

Static allocation makes the maximum visible. Arrays, fixed-capacity queues, object pools, generational slot maps, and caller-provided buffers trade general flexibility for bounded memory and failure at a designed edge. They still require arithmetic checks: packet count multiplied by payload size, alignment padding, enum layout, DMA alignment, and simultaneous buffer ownership can defeat an informal estimate.

The fixture encodes a packet limit before copying:

pub fn try_from_slice(input: &[u8]) -> Result<Packet, PacketError> {
    if input.len() > MAX_PACKET_BYTES {
        return Err(PacketError::TooLarge);
    }
    let mut packet = Packet { len: input.len() as u8, bytes: [0; 64] };
    packet.bytes[..input.len()].copy_from_slice(input);
    Ok(packet)
}

The queue returns the rejected packet when full. That preserves ownership and lets policy decide whether to drop, replace an older sample, set an overflow flag, or retry from a slower context. It never allocates in an interrupt and never silently overwrites accepted data.

Dynamic allocation can be valid when the platform has sufficient memory and the product benefits. The allocator contract then includes its memory region, initialization order, alignment, maximum latency, fragmentation behavior, out-of-memory handling, concurrency or interrupt safety, diagnostics, and proof that no early code allocates before initialization. Vec::try_reserve can expose some allocation failure, but not every convenience operation is fallible. A heap does not eliminate the overall byte budget.

Stack use needs equal attention. Recursion, large local arrays, formatting machinery, interrupt nesting, and debug builds can change demand. Use link maps, compiler or external stack analysis where trustworthy, canaries or high-water measurements on hardware, and deliberate worst-case call paths. A successful average run does not establish stack safety.

Interrupts create concurrency without threads

An interrupt can interleave with main code at almost any enabled instruction. Shared mutable state is therefore concurrent even on one core. On multicore hardware, disabling local interrupts does not stop another core. DMA and peripherals can modify memory independently of either CPU context.

Keep interrupt handlers short, bounded, nonblocking, and allocation-free. A common design acknowledges the device, copies or records the minimum information into fixed storage, signals deferred work, and returns. The main loop performs parsing, retries, logging, and slower I/O. Define what happens when the handoff is full; “it cannot happen” is a capacity assumption requiring proof.

A critical section establishes exclusion only according to its platform implementation. It may disable some interrupts on one core, raise a priority mask, or use a multicore primitive. Its contract includes which contexts it excludes, maximum duration, nesting behavior, memory ordering, priority inversion, and restoration on exit. Do not hold it while formatting, waiting for hardware, or invoking unknown callbacks.

The fixture deliberately does not call its PacketQueue interrupt-safe. A board adapter may place a very short critical section around push and pop, or replace it with a proven single-producer/single-consumer structure whose atomic requirements the target supports. This avoids smuggling platform synchronization into an innocent-looking container.

Atomics are target-specific. Query the target’s supported atomic widths and use conditional compilation when necessary. An atomic operation addresses CPU memory ordering for that location; it does not by itself order device I/O, make a compound protocol atomic, or flush a peripheral bus. Use the architecture and device specifications for barriers and register sequences.

Volatile access is narrow, unsafe, and insufficient

Memory-mapped I/O maps device registers into addresses. Ordinary loads and stores may be removed or combined when the compiler cannot observe a language-level effect. Volatile operations preserve the access behavior required for such externally observed memory. They do not make the address valid, prove alignment, grant ownership, create atomicity, lock out interrupts, order independent agents, or validate a device-specific read/write sequence.

The fixture concentrates the raw address in one type:

pub struct MmioRegister {
    address: core::ptr::NonNull<u32>,
}

impl MmioRegister {
    /// Caller proves mapping, alignment, lifetime, and device authority.
    pub const unsafe fn new(address: core::ptr::NonNull<u32>) -> Self {
        Self { address }
    }

    pub fn read(&self) -> u32 {
        // SAFETY: established by the constructor contract.
        unsafe { self.address.as_ptr().read_volatile() }
    }
}

A production register abstraction also models whether reads have side effects, writes are one-to-clear, fields are reserved, access width is fixed, ordering barriers are required, and ownership can be split safely. A generated peripheral-access crate may encode register fields and singleton ownership better than handwritten addresses. Generated code is still tied to a device description and tool version; verify both.

Never create a long-lived Rust reference to storage a peripheral or DMA engine can mutate behind the compiler’s model. DMA buffers need an ownership state machine: CPU prepares and relinquishes; cache maintenance occurs where required; device owns during transfer; completion and barriers return ownership; only then may safe CPU code read or reuse. Chapter 95 develops that boundary further.

Put hardware abstraction above the unsafe kernel

Portable logic should depend on capability-shaped traits, not on a universal “board” object. A watchdog needs an alarm or reset capability; a sensor driver may need specific bus transactions and delay behavior; storage needs read/write/erase geometry. Small traits make ownership, error, timing, and blocking behavior reviewable.

The fixture defines:

pub trait Alarm {
    fn set_deadline_ticks(&mut self, ticks: u32);
    fn clear(&mut self);
}

pub fn arm_watchdog(alarm: &mut impl Alarm, budget_ticks: u32) {
    alarm.clear();
    alarm.set_deadline_ticks(budget_ticks);
}

&mut expresses exclusive logical use during the call. It does not prove the implementation’s register sequence or timing. The hardware implementation owns those obligations; a host fake proves only that portable logic calls clear before setting the requested deadline.

Choose abstraction level deliberately. A register-level crate offers control and broad device access but exposes more unsafe and hardware detail. A device HAL can encode pins, clocks, buses, and ownership typestates. A platform service can hide the entire peripheral behind messages. Higher abstraction improves substitution and testability but may obscure timing, allocation, or error detail. Document the escape hatch and keep it reviewable.

Concentrating unsafe code means safe callers cannot violate the stated hardware invariant, not merely that all unsafe tokens live in one file. Audit raw pointer construction, register aliases, interrupt bindings, static mutation, DMA, startup assembly, linker symbols, FFI, and generated accessors as one safety case. Count unsafe operations and authority paths over time; reject convenience methods that manufacture duplicate peripheral ownership.

Test the portable core on hosts and the contract on hardware

Host tests are fast and expressive. The tiny-node tests verify packet size rejection, queue wraparound, full-queue ownership, alarm call order, and the documented queue-layout ceiling. Because the library is #![no_std] but enables extern crate std only under tests, the same portable code is compiled without a standard-library dependency while the test harness runs on the host.

Host tests cannot validate the target linker script, startup initialization, panic safe state, interrupt preemption, priority, actual atomic support, MMIO side effects, bus ordering, clock accuracy, flash interruption, stack margin, power loss, watchdog reset, or electrical behavior. Add layers:

  • compile and link the board binary for every supported target and feature set;
  • inspect section sizes and fail budget overruns;
  • run portable property and state-machine tests on the host;
  • emulate only where the emulator models the relevant peripheral behavior;
  • execute hardware-in-the-loop tests for register, interrupt, timing, reset, and power-failure claims;
  • test worst-case queue fill, interrupt rate, stack depth, and watchdog margins;
  • retain toolchain, board revision, firmware digest, fixture wiring, and measured output.

Do not mock away the property under review. A fake alarm is excellent for call order and useless for proving the watchdog clock, unlock sequence, or reset latency. A memory array can test flash-record recovery logic and cannot reproduce erase wear or brownout behavior. State the boundary with every result.

Port the bounded component into tiny-node

The practical exercise begins with a desktop ingestion component that accepts Vec<u8> into a growing VecDeque, logs on overflow, and retries on a background thread. Port it under these constraints:

  • #![no_std] and no alloc;
  • at most eight queued packets and 64 payload bytes each;
  • interrupt admission must complete in a fixed bounded path without blocking;
  • a full queue returns ownership and records a bounded overflow signal;
  • the main loop drains and performs effectful work;
  • no shared mutable access exists outside the chosen critical-section or SPSC contract;
  • all MMIO construction remains in one audited board adapter;
  • host tests cover portable states, and hardware tests cover interrupt and device behavior.

The supplied fixture completes the portable portion with Packet, PacketQueue<8>, Alarm, and MmioRegister. Extend it in four steps.

First, write a byte ledger: packet slots, metadata, main and interrupt stacks, static drivers, telemetry counters, persistent staging, and reserve. Add link-time and CI thresholds. Second, define overflow semantics—drop newest, drop oldest, coalesce, or enter a degraded mode—and connect them to the product’s data-loss contract. Third, implement the board adapter with one documented peripheral owner and a measured critical section. Fourth, run a hardware campaign at maximum interrupt rate, slow main-loop service, watchdog expiry, reset during persistent update, and queue overflow.

Compare two alternatives. Adding alloc can simplify variable payloads, but it introduces allocator initialization, fragmentation, latency, OOM, and concurrency questions. Moving the queue behind a small RTOS service can centralize synchronization and scheduling, but adds kernel, stack, priority, timer, and dependency contracts. The fixed queue is not automatically superior; it is superior only when its byte ceiling and overflow behavior match the product.

Constrained-platform review questions

  • Does each crate declare whether it needs core, alloc, std, or a named platform capability?
  • Is the exact target tier, toolchain, ABI, CPU feature, linker, board, and runner recorded?
  • Do startup and linker artifacts establish stack, data initialization, vector placement, and region bounds?
  • Is exactly one panic handler present, with tested safe-state, diagnostic, reset, and boot-loop behavior?
  • Are flash, RAM, stack, queue, DMA, telemetry, and reserve budgets measured on the final target?
  • Can every allocation path name its allocator, initialization, latency, fragmentation, OOM, and context contract?
  • Are interrupt handlers bounded, nonblocking, allocation-free, and explicit about overflow?
  • Does each critical section identify what it excludes, for how long, and on how many cores?
  • Are volatile access, atomic ordering, device barriers, and DMA ownership kept as distinct concepts?
  • Can safe code construct duplicate peripheral owners or invalid register aliases?
  • Do HAL traits expose errors, timing, ownership, and blocking behavior instead of hiding them?
  • Do host, emulator, and hardware tests each claim only the properties they can observe?

Constrained Rust succeeds when absence becomes architecture. No allocator means capacity and overflow are visible. No operating system means startup, synchronization, clocks, and failure recovery have named owners. Memory-mapped hardware makes unsafe authority explicit. Those are not exceptions to the handbook’s contract method; they are its clearest form—and they prepare the same audit for WebAssembly hosts, kernels, drivers, DMA, and other runtimes where familiar user-space assumptions fail differently.

Sources and version notes

  • The Rust alloc crate documentation describes its heap-backed collections and interfaces for no_std crates. Allocator API details and stabilization status must be checked against the selected toolchain.
  • The Rust Reference panic chapter specifies the #[panic_handler] signature and uniqueness requirement and distinguishes panic strategy from handler behavior.
  • The rustc target-tier policy and platform-support list define current build/test guarantees. Target tier does not certify a board, HAL, or application.
  • The Embedded Rust Book concurrency chapter introduces critical sections and warns that multicore access is not excluded merely by local interrupt control. Architecture, device, HAL, and RTOS documents remain authoritative for concrete hardware behavior.
  • The executable examples/rust-engineering-handbook/part-15/tiny-node fixture targets Rust 2024, declares MSRV 1.85, and uses no third-party dependencies. Host formatting, locked/offline checks, five tests, doctests, Clippy, and a release build passed on Rust/Cargo 1.97.0; locked/offline checks, the same tests, doctests, and a release build also passed on Rust/Cargo 1.85.0. These host passes are not cross-target, linker, or hardware proof.