Embedded Rust
Firmware bugs are disproportionately memory bugs: a buffer overrun that corrupts an adjacent variable, a use-after-free in an interrupt handler, a race between an ISR and the main loop. On a microcontroller with no MMU there's no hardware to catch any of it — the corruption is silent, and the symptom appears somewhere unrelated, hours later, on a device in the field.
Rust eliminates that class at compile time with no runtime, no garbage collector, and code generation competitive with C. Its ownership model also maps unusually well onto hardware: a peripheral is a resource that exactly one piece of code should own, a pin configured as an output shouldn't be readable as an input, and shared mutable state across an interrupt boundary is exactly what the borrow checker exists to police. The type system encodes those rules, so misuse becomes a compile error rather than a field failure.
The tradeoffs are real: a smaller ecosystem than C, vendor HALs that are community-maintained rather than official, and a language that takes longer to learn.
TL;DR
#![no_std]drops the OS-dependent standard library and keepscore— no heap, no files, no threads by default.- The stack is PAC → HAL → BSP: generated register access, then a safe abstraction, then board specifics.
embedded-haltraits make drivers portable — one SPI display driver works on every chip family.- Typestate: a pin's configuration lives in its type, so misuse is a compile error.
Peripherals::take()returnsSomeexactly once — the singleton pattern enforced by the type system.- Sharing data with an ISR requires
Mutex<RefCell<T>>in a critical section, or a framework that handles it. - RTIC gives priority-based tasks with compile-time-verified resource access; Embassy gives
async/await. - Interop with C is straightforward via
bindgen— Rust doesn't require rewriting the vendor SDK.
Quick Example
Blinking an LED and reading a button, with the type system enforcing correctness:
Two things there are impossible in C. Peripherals::take() returning Option means two modules cannot both believe they own GPIOA. And a pin configured as an output has a different type from one configured as an input, so reading it isn't a runtime bug — it's a compile error.
Core Concepts
no_std
heapless provides fixed-capacity Vec, String, and queues with no allocator at all — usually the right answer. If you genuinely want a heap, embedded-alloc provides one, with the usual embedded caveats about fragmentation.
The layer stack
The PAC is machine-generated from the vendor's SVD description and gives you type-checked access to every register and bit field — no magic addresses, no wrong-width writes. The HAL wraps it in something usable and implements the embedded-hal traits. Applications live at the HAL level; you drop to the PAC for a peripheral the HAL doesn't cover yet, which still happens.
embedded-hal — the portability layer
This is embedded Rust's strongest practical advantage over C. Because every HAL implements the same traits, a driver crate written once runs on every chip family — so the ecosystem of sensor, display, and radio drivers is genuinely shared rather than duplicated per vendor SDK. There is no equivalent in the C embedded world.
Typestate
Configuration state lives in the type, checked at compile time and erased at runtime. There is no cost — the generated code is identical to the equivalent C register write.
Sharing data with an interrupt
This is where the borrow checker is most visibly doing work, and where the ceremony is highest:
The cs token is proof that interrupts are disabled — you cannot access the data without one. The C equivalent is a volatile global you're trusted to guard correctly; here the compiler checks it. For simple counters, AtomicU32 is lighter and avoids the critical section entirely.
Frameworks
The raw interrupt::free pattern is correct and verbose. Two frameworks handle it for you, with different philosophies.
RTIC
Real-Time Interrupt-driven Concurrency: tasks are interrupt handlers, resources are declared, and the framework computes the locking at compile time.
RTIC uses the Stack Resource Policy, so priority inversion and deadlock are impossible by construction — the analysis happens at compile time and costs nothing at runtime. It has essentially zero overhead and is the strongest choice for hard real-time work.
Embassy
async/await on bare metal, with no allocator and no OS:
Each .await is a yield point where the executor can run another task or put the core to sleep. Embassy also ships async drivers for Wi-Fi, BLE, USB, and networking, which makes connected firmware substantially less code than the callback-driven C equivalent. It's less strict about real-time guarantees than RTIC and considerably more ergonomic for I/O-heavy work.
Choosing
Interop with C
Rust doesn't require abandoning the vendor SDK.
bindgen generates Rust declarations from C headers automatically, which is how esp-idf-hal wraps Espressif's C SDK. The realistic adoption path in an existing codebase is incremental: write a new parsing or protocol module in Rust, link it into the existing C firmware, and expand from there.
Best Practices
Use heapless instead of an allocator
Capacity is a compile-time constant, memory use is knowable from the binary, and there's no fragmentation to fail on week six.
Prefer defmt to println!-style logging
defmt sends format string indexes over RTT and does the formatting on the host, so a log line costs a few bytes of flash and microseconds instead of pulling in a full formatter. On a part with 64 KB of flash this is the difference between having logging and not.
Handle Result explicitly
.unwrap() in firmware is a panic, and a panic on a device in the field is a hang or a reset. Use it during bring-up, replace it with real handling before shipping, and configure the panic handler to record the location to flash so a field failure is diagnosable on next boot.
Reach for atomics before critical sections
AtomicU32 and friends are cheaper than interrupt::free and don't block interrupts. Use the Mutex<RefCell<T>> pattern only for data that genuinely can't be expressed as an atomic.
Set the panic behavior deliberately
panic-halt stops, panic-reset reboots, panic-probe reports over the debug probe, and panic-persist writes the message to RAM that survives a reset. Development and production want different choices, and a product should record panics rather than silently rebooting.
Enable optimizations even in debug builds
Debug-profile Rust with opt-level = 0 frequently doesn't fit in flash at all, which surprises newcomers into thinking Rust is bloated.
Use probe-rs for the whole toolchain
cargo embed and probe-rs run handle flashing, RTT logging, and GDB in one tool across ST-Link, J-Link, and CMSIS-DAP probes. It's a noticeably better experience than the OpenOCD-plus-scripts setup common in C, and it's a genuine reason people stay.
Common Mistakes
Calling Peripherals::take() twice
Fighting the borrow checker with static mut
Blocking inside an interrupt
Panicking as error handling
Building unoptimized firmware
Assuming a HAL exists for your exact part
FAQ
Is embedded Rust production-ready?
For well-supported families — STM32, nRF, RP2040, ESP32, i.MX RT — yes, and it's shipping in commercial products. The realistic caveats: HALs are community-maintained rather than vendor-official, so a niche peripheral may need PAC-level work; certified toolchains for safety-critical domains (ISO 26262, DO-178C) exist through Ferrocene but are newer than the C equivalents; and hiring is harder. For a new project on a mainstream chip, it's a reasonable choice.
Does it produce bigger or slower code than C?
Neither, in release builds. Rust and C both use LLVM, and the abstractions here — typestate, iterators, Option — are compiled away entirely. Binaries are typically comparable, occasionally smaller because unused generic instantiations don't exist. Debug builds are much larger, which is what causes the misconception.
Do I still need to learn C?
Yes. Every datasheet code sample, every vendor SDK, every reference driver, and most existing firmware you'll maintain is C. Embedded Rust interoperates with C rather than replacing it, and understanding what volatile and a register write mean is prerequisite knowledge either way. See Embedded C.
RTIC or Embassy?
RTIC for hard real-time work where you need compile-time guarantees about priorities and resource access — motor control, safety systems, precise timing. Embassy for connected, I/O-heavy devices where async makes concurrent network and sensor work dramatically simpler, and where its driver ecosystem for Wi-Fi, BLE, and USB saves substantial effort. They can be combined, though most projects pick one.
How does it handle interrupts safely?
The cortex-m-rt #[interrupt] attribute binds a function to a vector, and the safety comes from data access: you cannot touch shared state without proof that it's protected, either a critical-section token, an atomic, or a framework-generated lock. The compiler rejects the code that would be a data race in C, which is exactly the class of bug that's hardest to find on hardware.
What about unsafe?
You'll write some — memory-mapped I/O and FFI are inherently unsafe operations. The point isn't eliminating unsafe but confining it: a HAL wraps unsafe register access in a safe API, and application code contains none. When something does go wrong, the audit surface is a handful of reviewed blocks rather than the entire program.
Related Topics
- Embedded C — The incumbent, and what you'll interoperate with
- Microcontrollers — Registers, interrupts, and peripherals underneath
- Real-Time Operating Systems — RTIC and Embassy as alternatives to a traditional RTOS
- Rust — Ownership, traits, and the language fundamentals
- ESP32 —
esp-halandesp-idf-halfor Espressif parts - Memory Management — Why
heaplessbeats an allocator here - Concurrency Patterns — Shared state and data races generally
- IoT Security — Memory safety as a security property