Rust in Embedded and no_std Environments
Embedded Rust is regular Rust running on a microcontroller with no operating system underneath it.
That single fact reshapes almost everything about how a program is written, because the standard
library was designed assuming an OS is there to catch the program when it falls. This page builds
the mental model that the rest of the embedded-rust section leans on: what no_std really strips
away, why embedded-hal exists as a contract rather than a library, and how the two ideas fit
together to make one driver crate run unmodified on a dozen different chips.
Summary
no_stdremoves the operating-system-dependent parts of Rust's standard library, whileembedded-halsupplies a shared set of traits so driver code can be written once and reused across chips.- Insight: Microcontrollers have no OS to provide threads, heap allocation, or file descriptors, so code that assumes those things simply fails to link, not just fails at runtime.
- Key Concepts: core, alloc, std, panic handler, peripheral access crate (PAC), hardware abstraction layer (HAL), embedded-hal traits.
- When to Use: Bare-metal microcontrollers (Cortex-M, RISC-V MCUs), custom kernels, bootloaders, and any target where
stdcannot link because there is no OS underneath it. - Limitations/Trade-offs: You lose
std::collections::HashMap,std::thread, filesystem access, and the default panic-unwind machinery, and you take on manual responsibility for memory layout and panic behavior. - Related Topics: memory-constrained programming, cross-compilation, hardware abstraction layers, real-time systems.
Foundations
Rust's standard library is really three layers stacked on top of each other, and no_std only
removes the top one. The bottom layer is core, a subset of the standard library that has zero
dependencies on an operating system. It gives you primitive types, Option and Result,
iterators, slices, and the core::fmt traits behind write!. core is always available, even on
the smallest microcontroller, because none of it needs an allocator, threads, or a filesystem.
The middle layer is alloc, which adds heap-backed types like Vec and String. alloc does not
need a full OS, but it does need someone to provide a global allocator: a piece of code that knows
how to carve usable memory out of RAM. On a desktop, std supplies that allocator automatically by
calling into the OS. On a microcontroller, nothing does that for you, so alloc stays unavailable
until you plug in a crate like linked_list_allocator or embedded-alloc and register it with
#[global_allocator].
The top layer is std itself, which adds everything that assumes an operating system is present:
threads, TCP sockets, the filesystem, environment variables, and a default panic handler that
unwinds the stack and prints to stderr. Writing #![no_std] at the crate root tells the compiler
"do not link the std layer," which is a statement about which library gets linked, not a
statement about which language features are available. Ownership, borrowing, traits, generics,
closures, async/await, and macros all work exactly the same in no_std code; what changes is what
you can call, not what you can write.
A useful analogy: std is a fully furnished apartment (bed, kitchen, running water all supplied),
while core is an empty room with load-bearing walls. no_std doesn't take away your ability to
build furniture, it just means nobody delivered any for you. Everything you need, you bring in
explicitly, and everything you bring in is visible in your Cargo.toml and your imports rather than
hidden behind a default.
#![no_std]
#![no_main]
use panic_halt as _;
#[no_mangle]
pub extern "C" fn main() -> ! {
// No OS to return control to, so main never returns.
loop {}
}That snippet is the smallest possible no_std program. #![no_main] matters as much as
#![no_std] here: normal Rust programs assume the OS invokes fn main() and handles the return
value, but a microcontroller boots straight into your code from a reset vector, so the runtime
entry point (usually provided by the cortex-m-rt crate) is different from a hosted program's.
Mechanics & Interactions
Once std is off the table, the compiler needs two things from you that it normally gets for free:
a panic handler and, on most real targets, a memory layout. A panic handler is a function marked
#[panic_handler] that the compiler calls whenever a panic occurs, since there is no OS unwinding
machinery or process to exit. Crates like panic-halt (spin forever), panic-reset (reboot the
chip), and panic-probe (send a report over a debug probe, then halt) package common choices so you
rarely write one from scratch, but the point is that panic behavior is now an explicit crate
dependency rather than an implicit runtime detail.
Memory layout is the other piece. On a desktop, the OS's loader decides where code and data live in
virtual memory. A microcontroller has no loader, so a linker script (conventionally memory.x) has
to state exactly where flash starts, how large it is, where RAM starts, and how large that is. The
cortex-m-rt crate reads this script to place the vector table, the .text section, and the stack
correctly. Get the lengths wrong and you get a link error in the friendly case, or silent memory
corruption in the unfriendly one, because nothing at runtime is checking that your program's
footprint actually fits the chip.
This is also where embedded-hal enters, and it is worth being precise about what kind of thing
it is. embedded-hal is not a runtime, not a driver, and not a crate you can "run." It is a set of
traits: OutputPin, InputPin, I2c, SpiDevice, DelayNs, and a handful of others, each one
describing a capability a piece of hardware might have without saying anything about which chip
provides it. A chip-specific HAL crate, such as stm32f4xx-hal or embassy-nrf, implements those
traits for its own concrete peripheral types. A driver crate, say for an LSM6DS sensor, then writes
its logic against the trait bounds (SPI: SpiDevice) instead of against a concrete chip, so the
same driver source compiles unchanged whether it ends up wired to an STM32, an nRF52, or an RP2040.
use embedded_hal::digital::OutputPin;
// Generic over ANY chip's GPIO pin type that implements OutputPin.
pub fn blink<P: OutputPin>(led: &mut P, times: u32) -> Result<(), P::Error> {
for _ in 0..times {
led.set_high()?; // compiles to whatever the concrete HAL's set_high does
led.set_low()?;
}
Ok(())
}The trait-object-free, monomorphized nature of that generic function matters for embedded targets
specifically. Because blink is generic rather than taking dyn OutputPin, the compiler generates
a specialized copy of the function for each concrete pin type it's called with, at zero runtime
dispatch cost. Dynamic dispatch through a vtable is technically legal in no_std, but embedded code
avoids it by convention because indirect calls make timing less predictable and add a pointer
indirection on chips where every cycle at 48-168MHz is visible in the datasheet's timing budget.
Two crates are worth distinguishing here. The PAC (peripheral access crate) is machine-generated
directly from the chip vendor's register description file, and exposes raw register reads and
writes with almost no safety net beyond correct bit offsets. The HAL crate wraps the PAC in safe,
ergonomic APIs, enforces invariants like "you can only take ownership of a peripheral once," and
implements the embedded-hal traits on top. embedded-hal sits one level above both: it never
touches registers directly, it only defines the shape a HAL's safe wrappers must have.
Advanced Considerations & Applications
The biggest practical wrinkle in this ecosystem has been the transition from embedded-hal 0.2 to
1.0. The 0.2 traits used non-blocking, nb-crate-based signatures that returned a "would block"
error the caller had to retry; 1.0 switched to plain blocking traits with a separate
embedded-hal-async crate for async variants used by executors like Embassy. Because the whole
point of embedded-hal is that many independent crates depend on it, a version split means some
driver crates and some chip HALs may still be on 0.2 while others have moved to 1.0, and mixing them
in one project can require an adapter shim. This is a coordination cost of the trait-based
portability model, not a flaw unique to Rust: any shared-interface ecosystem pays a migration tax
when the interface itself needs to change.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
core-only, no allocator | Fully deterministic memory, no fragmentation risk | No Vec/String, must size everything statically | Hard real-time control loops, safety-critical firmware |
core + alloc with global allocator | Familiar collection types, faster prototyping | Allocation can fail or fragment with no OOM killer to fall back on | Data-heavy firmware with generous RAM (BLE stacks, protocol parsers) |
| Chip-specific HAL calls directly | Simplest for a single fixed board | Driver code is not portable to another chip | One-off products that will never target a second chip |
Generic code over embedded-hal traits | Driver crate compiles against any conforming HAL | Extra generic type parameters and trait bounds to reason about | Reusable sensor/actuator driver crates published for the community |
Memory safety in no_std deserves an honest caveat: Rust's ownership and borrow checker still
apply, and they still prevent data races and use-after-free in safe code. What they cannot do is
prevent hardware misuse, such as writing to a register that has physical side effects (starting a
DMA transfer, disabling a clock the debugger needs) even though the write itself is memory-safe from
the compiler's point of view. That gap is exactly why PACs mark most register access as safe but HAL
crates layer additional type-level invariants, like the singleton Peripherals::take() pattern, on
top to push more of that hardware-level correctness back into the type system.
The other place no_std shows its OS-shaped absence is concurrency. Without an OS scheduler, there
is no std::thread::spawn and no OS-level mutex, so shared state between an interrupt handler and
the main loop has to be protected with critical-section (briefly disabling interrupts) or
lock-free primitives, and async firmware needs an embedded executor like Embassy rather than Tokio.
This is not a language limitation; it is the direct consequence of there being no scheduler
underneath the program to coordinate concurrent access for you.
Common Misconceptions
- "
no_stdmeans I'm writing a stripped-down or restricted version of Rust" - the language is identical; only the linked library changes, so generics, traits, closures, and even async/await all still work exactly as they do in hosted Rust. - "
embedded-halis a HAL, likestm32f4xx-hal" - it's the opposite direction:embedded-haldefines the traits, and chip-specific crates likestm32f4xx-halare the HALs that implement them. - "Adding
allocgets me back everythingstdhad" -alloconly adds heap-backed collections; you still don't get threads, sockets, or a filesystem, because those need an OS, not just a heap. - "A panic on embedded behaves like a panic on desktop" - there's no default unwind-and-print; you choose the panic behavior explicitly by picking a
#[panic_handler]crate, and the default choice is often to spin forever or reset the chip. - "Generic driver code is slower because of the abstraction" -
embedded-haltraits are almost always used as static generics, so the compiler monomorphizes and inlines them away, leaving no runtime cost compared to calling the concrete HAL directly. - "You need
unsafeeverywhere in embedded Rust" - most register access is wrapped safely by the PAC and HAL layers;unsafeis typically confined to a small number of well-understood spots like allocator initialization or raw interrupt vector setup.
FAQs
What's the actual difference between core, alloc, and std?
core: OS-independent primitives, always available (types, traits, iterators,core::fmt)alloc: heap-backed collections (Vec,String,Box), needs a registered global allocatorstd: everything OS-dependent (threads, filesystem, sockets, default panic unwinding)
Does `#![no_std]` change what language features I can use?
No. Traits, generics, closures, async/await, and macros are language features, not library
features, so they all work the same. What disappears are specific types and functions that live
in the std crate and assume an OS is present underneath them.
Why can't I just use std::collections::HashMap on a microcontroller?
HashMap lives in std, not core or alloc, partly because its default hasher uses OS-provided
randomness for DoS resistance. In no_std you reach for alloc::collections::BTreeMap or a
fixed-capacity map from heapless instead.
How does a program even start without an OS to call main()?
A reset vector in the microcontroller's memory points at a startup routine, typically supplied by
the cortex-m-rt crate, which sets up the stack pointer and initial memory state and then calls
your #[entry] or extern "C" fn main. #![no_main] tells rustc not to generate the normal hosted
entry point, since this startup routine replaces it.
What actually happens when a no_std program panics?
The compiler calls whatever function you've marked #[panic_handler]. There's no default, so you
must supply one, commonly by depending on panic-halt (infinite loop), panic-reset (reboot), or
panic-probe (report over a debug probe, then halt).
What's the difference between a PAC and a HAL crate?
A peripheral access crate (PAC) is generated from the chip vendor's register descriptions and
exposes raw register access. A HAL crate wraps the PAC in safer, higher-level APIs and implements
embedded-hal traits on top, so most application and driver code should target the HAL, not the
PAC directly.
When should I avoid embedded-hal generics and just call the chip HAL directly?
When you're building firmware for exactly one board that will never target a second chip, the extra
generic type parameters and trait bounds add complexity without a portability payoff. Reach for
embedded-hal genericity specifically when you're writing a driver crate meant to be reused across
chips.
Why is there both embedded-hal and embedded-hal-async?
embedded-hal 1.0 defines blocking trait methods. embedded-hal-async defines async equivalents
for use with cooperative executors like Embassy, since there's no OS thread scheduler to block on
underneath a bare-metal target.
What is the 0.2 to 1.0 migration actually about?
embedded-hal 0.2 used non-blocking, retry-based (nb) trait signatures. 1.0 switched to plain
blocking method signatures and split async support into its own crate. Some older driver crates and
chip HALs haven't fully migrated, which can require compatibility shims when mixing dependencies.
Can no_std code use threads at all?
Not in the std::thread sense, since there's no OS scheduler. Concurrency on bare metal comes from
interrupt handlers plus critical-section-guarded shared state, or from a cooperative async
executor like Embassy running multiple tasks on one hardware thread.
Is unsafe code unavoidable in no_std programming?
Some unsafe is typically necessary (allocator initialization, raw interrupt vectors, sometimes
peripheral singleton patterns), but application and driver logic written against safe HAL and
embedded-hal APIs stays almost entirely in safe Rust.
Related
- Embedded Basics - hands-on toolchain setup: targets,
memory.x, and a firstno_stdcrate - no_std Programming - working recipes for
core,alloc, panic handlers, and global allocators - embedded-hal - concrete trait tables and driver patterns built on the concepts here
- Memory in Embedded - static allocation and
heaplesscollection patterns
Stack versions: This page is conceptual and not tied to a specific stack version, though examples assume Rust 1.97.0 (edition 2024) and
embedded-hal1.x trait signatures.