Rust has been gaining traction in the world of systems programming, and for good reason. Its memory safety, zero-cost abstractions, and modern toolchain make it an excellent candidate for embedded development—especially when working close to the metal. If you’re tired of debugging mysterious memory corruption in C, then Rust for embedded systems might just be your next obsession.
In this article, we’ll explore best practices for bare-metal development using Rust. We’ll look at tooling, common patterns, real-world use cases, and mistakes to avoid. Whether you’re porting firmware from C or starting fresh with a new IoT board, there’s a lot Rust brings to the table. So let’s get into it.
Why Use Rust for Embedded Systems?
Before diving into the nitty-gritty of bare-metal Rust development, it’s important to understand why developers are making the switch from C or C++. Here’s what Rust brings to the table:
- Memory Safety Without a Garbage Collector: Rust’s borrow checker enforces strict ownership and lifetime rules at compile-time.
- Zero-Cost Abstractions: You can write high-level constructs that compile down to optimal machine code.
- Fearless Concurrency: Rust helps you manage multithreaded code safely, which is increasingly important in embedded scenarios.
- Modern Tooling:
cargo
,rustup
, and excellent cross-compilation support make Rust pleasant to work with.
It’s not just hype. Projects like Ferros, Tock OS, and Drone OS demonstrate that Rust is already being used in real embedded applications.
Getting Started: Toolchain and Setup
Setting up Rust for embedded work is surprisingly easy once you know the steps.
1. Install Rust and Components
Start with rustup
, Rust’s toolchain installer. You’ll need:
rustup install stable
rustup target add thumbv7em-none-eabihf # or whatever your MCU target is
Install cargo-generate
and probe-rs
:
cargo install cargo-generate
cargo install probe-rs-cli
2. Choose Your Target
Rust supports many embedded targets like:
thumbv6m-none-eabi
(for Cortex-M0 and M0+)thumbv7em-none-eabihf
(for Cortex-M4F/M7)riscv32imac-unknown-none-elf
(for RISC-V boards)
Pick the one matching your MCU and add it using rustup
.
3. Hello, Blinky
Use templates like cortex-m-quickstart to bootstrap your project. These come with all the build files, memory layout, and startup code pre-configured.
Rust for Embedded Systems: Best Practices for Bare‑Metal Development
When writing bare-metal embedded code in Rust, you’re operating with no OS, limited memory, and tight hardware constraints. Here’s how to do it right.
Use #![no_std]
and Minimize Dependencies
Embedded targets often lack standard library support. You’ll need to write #![no_std]
at the top of your main crate to signal this.
Avoid pulling in unnecessary crates. Many popular crates have no_std
support, but always double-check before adding new dependencies.
Embrace Peripheral Access Crates (PACs) and Hardware Abstraction Layers (HALs)
Don’t write register-level code manually unless you absolutely must. Use the embedded ecosystem:
- PACs: Auto-generated from SVD files; they expose registers and bitfields for your specific microcontroller.
- HALs: Offer higher-level, platform-agnostic abstractions (e.g.,
embedded-hal
,stm32f4xx-hal
).
let gpioa = dp.GPIOA.split();
let led = gpioa.pa5.into_push_pull_output();
led.set_high().unwrap();
This kind of code is readable, safe, and maintainable.
Make Use of cortex-m-rt
and cortex-m
The cortex-m-rt
crate provides the runtime and startup code for Cortex-M processors. It sets up the stack, memory layout, and interrupt vector table.
Meanwhile, cortex-m
offers useful functions like asm::wfi()
(wait for interrupt) and atomic operations.
Define a Clear Memory Map
Embedded Rust relies on a memory.x
linker script to define Flash and RAM regions. Don’t mess with this unless you understand the implications.
Also, make sure your .cargo/config.toml
defines the target and runner clearly:
[build]
target = "thumbv7em-none-eabihf"
[target.’cfg(all(target_arch = “arm”, target_os = “none”))’]
runner = “probe-rs run –chip STM32F411CE”
Use Debug Probes like probe-rs
or OpenOCD
Rust integrates well with hardware debuggers. Use probe-rs
if your board supports CMSIS-DAP or J-Link. This makes flashing, breakpoints, and even RTT logging painless.
Error Handling in No-Std Rust
Error handling in embedded Rust has its quirks. Since there’s no standard I/O or heap, you need to think differently.
Here’s a minimal panic handler:
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
You can also route panics to an LED, UART, or use Real-Time Transfer (RTT) for debugging.
Avoid unwrap()
and expect()
in production. Use Result
and proper error propagation to maintain safe code paths, even in a limited environment.
Concurrency and RTIC
For more structured applications, consider using RTIC (Real-Time Interrupt-driven Concurrency). It provides:
- Task scheduling with deterministic priority
- Minimal runtime overhead
- Memory safety with zero-cost abstractions
RTIC allows you to write embedded multitasking code without worrying about race conditions.
#[app(device = stm32f4xx_hal::pac, peripherals = true)]
mod app {
#[task]
fn poll_sensor(cx: poll_sensor::Context) {
// Do something...
}
#[task(binds = EXTI0)]
fn button_pressed(cx: button_pressed::Context) {
// Handle interrupt...
}
}
Testing Bare-Metal Code
Unit tests are tricky in no_std
, but not impossible.
- For logic-heavy components, isolate them into
no_std
modules and test them on the host. - Use
cargo test
for non-hardware-specific code. - For integration tests, use hardware-in-the-loop (HIL) setups.
Also explore defmt-test
for embedded testing support.
Logging Without stdout
Embedded Rust doesn’t use println!
. But logging is possible:
- RTT (Real-Time Transfer) via
rtt-target
- Serial UART Logging with
core::fmt::Write
- LED Debugging (yes, blinking patterns!)
rtt_target::rprintln!("Sensor value: {}", value);
This makes a huge difference when you can’t attach a debugger.
Avoid These Common Mistakes
Here are some pitfalls to watch for:
- Ignoring the Watchdog: Always service or configure it properly.
- Using
unwrap()
in critical paths: Leads to panics in production. - Misaligned memory sections: Can cause strange behavior.
- Copying C patterns directly: Rust offers safer idioms. Learn them.
- Overusing
static mut
: UseMutex
,Cell
, orRefCell
instead.
Real-World Use Cases of Rust in Embedded
Rust has already proven itself in:
- Aerospace: Ferrocene and Dronology
- Consumer Electronics: Embedded audio devices
- Medical Devices: Where safety is paramount
- RISC-V Microcontrollers: Like the HiFive1 and K210
- Linux Bootloaders: Projects like
rustboot
andzboot
We’re just scratching the surface.
Rust for Embedded Systems: The Road Ahead
Rust isn’t just a hobby language anymore—it’s powering real devices. If you’re building bare-metal firmware or want to transition away from C, Rust for embedded systems should be on your radar.
With its focus on safety, concurrency, and performance, it’s changing how we think about microcontroller development.
And while the ecosystem is still maturing, especially for less common chips, the community is active and growing. As more companies adopt Rust for critical low-level work, support will only get better.
FAQs
1. Can I use Rust for all embedded targets?
Not yet. Rust works best with ARM Cortex-M and some RISC-V chips, but support for 8-bit or exotic MCUs is limited.
2. Is Rust faster than C in embedded systems?
In many cases, yes. Rust’s zero-cost abstractions often match or outperform C, especially when optimized.
3. How do I debug Rust firmware?
Use tools like probe-rs
, gdb
, or RTT to inspect memory, variables, and logs.
4. Does Rust have an RTOS?
Yes, you can use Rust with RTOSes like Tock, Drone, or FreeRTOS bindings. Or go fully bare-metal.
5. Can I reuse C libraries in embedded Rust?
Yes, via FFI. Rust can call C functions, but it requires careful handling for safety and performance.