Rust on microcontrollers: a pragmatic field report after 12 months

A year ago we made a deliberate decision: on any new embedded project where the brief allowed it, we’d reach for Rust instead of C++ and keep honest notes. Three production deployments later, here’s the accounting - the wins, the scar tissue, and the cases where we still happily write C++. No evangelism. If you’re weighing the same decision, this is the field report we wish we’d had.
First, what problem is Rust even solving?
Embedded C++ has run the world’s firmware for decades, and it still does. We’re fluent in it and we’re not mad at it. But across years of shipping firmware, the same family of bugs kept showing up in production - the kind static analysis flags in theory and ships in practice:
A pointer that the main loop frees while an interrupt is mid-way through dereferencing it. Two tasks that both nudge the same variable and corrupt it once a week, on Tuesdays, only in the field. A protocol parser that reads four bytes past the end of a buffer when a malformed packet arrives. These bugs share a personality: they’re intermittent, they survive code review, and they cost you the worst kind of debugging - the kind where you can’t even reproduce it.
Rust’s pitch is not “faster than C++.” At the machine-code level the two are neck and neck; they compile through similar backends to similar instructions. The pitch is narrower and, honestly, more compelling: Rust makes that entire category of bug a compile error instead of a field incident. You pay for it up front, in compiler arguments you’ll lose, and you stop paying for it later, in 2 a.m. pages you won’t get.
The ownership idea, without the jargon
If you’ve never written Rust, here’s the whole core idea in one analogy. Think of every piece of data as a library book. At any moment either one person has it checked out and can write in it, or several people are reading the same copy and nobody can write. Never both. The compiler is the librarian, and it refuses - at build time - to let you hand a book to someone while another person is still scribbling in it.
That single rule is what kills use-after-free (you can’t read a book that’s been returned and pulped), data races (two writers can’t hold the same book), and a surprising amount of aliasing weirdness. It’s also why newcomers fight the compiler for a few weeks: the librarian is strict, and your C++ instincts assume you can lend out books however you like. Once it clicks, you stop writing the comment ”// remember to take a critical section here” because the type system simply won’t compile the version where you forgot.
Where Rust genuinely earns its place
Memory safety with zero runtime cost. The ownership and borrow checker eliminate use-after-free and double-free at compile time - no garbage collector, no runtime checks, nothing that costs you a cycle or a byte of flash. This matters most exactly where C++ hurts most: interrupt-driven code. In the cortex-m ecosystem, shared state lives behind a Mutex that you can only access inside a critical section, and the type system enforces that. You don’t remember to disable interrupts before touching the shared buffer - the code that touches it without doing so doesn’t exist, because it doesn’t compile.
use core::cell::RefCell;
use cortex_m::interrupt::{free, Mutex};
// Shared between the main loop and an interrupt handler.
static SENSOR_BUFFER: Mutex<RefCell<[u16; 8]>> = Mutex::new(RefCell::new([0; 8]));
fn read_latest() -> u16 {
// `free` enters a critical section. You CANNOT touch the buffer
// outside of it - the borrow won't typecheck. The bug class is gone.
free(|cs| {
let buf = SENSOR_BUFFER.borrow(cs).borrow();
buf[7]
})
} The real story behind this section: on one project we had a heisenbug where a DMA-completion interrupt occasionally read a buffer the main loop was reallocating. In C++ it took two engineers the better part of a day to even reproduce. When we rebuilt that module in Rust, the equivalent mistake didn’t make it past cargo build - the borrow checker rejected the shared access on the first try, with a clear error pointing at the exact line.
Fearless concurrency. Rust’s type system tags types as Send (safe to move between tasks) and Sync (safe to share by reference). An async embedded framework like RTIC checks these at compile time, so a task that tries to hold something it shouldn’t share across a priority boundary is rejected before it runs. The phrase the community uses - “fearless concurrency” - sounds like marketing until the first time you refactor an interrupt priority and the compiler immediately lists every place that change would have broken. It feels less like a language feature and more like a colleague who read all your code overnight.
Error handling that you can’t ignore. In embedded, “just panic and reboot” is often unacceptable - a medical or industrial device can’t crash because a packet was malformed. Rust’s Result type forces every fallible call to be handled, and the ? operator makes propagating errors a single character rather than a nested-if pyramid. The correct path becomes the easy path, which is the only kind of correctness that survives a deadline.
fn parse_frame(bytes: &[u8]) -> Result<Frame, ProtocolError> {
let header = bytes.get(0..2).ok_or(ProtocolError::Truncated)?; // can't read past the end
let len = u16::from_le_bytes([header[0], header[1]]) as usize;
let payload = bytes.get(2..2 + len).ok_or(ProtocolError::Truncated)?;
Ok(Frame::new(payload))
} That bytes.get(..) returns an Option instead of letting you index off the end of the slice - the buffer-overflow-in-the-parser bug, defused by construction.
defmt, the logging you didn’t think you could afford. Logging on a microcontroller usually means burning flash on format strings and CPU on printf, so most firmware ships nearly mute. The defmt framework flips this: format strings stay on the host, and the device transmits only compact integer IDs over RTT. You get structured, level-filtered logging that costs almost nothing in flash or cycles - genuinely something most embedded C++ projects simply don’t have, and it changes how you debug in the field.
Where C++ is still the right call
We want to be just as concrete about the losses, because they’re real.
Vendor SDK depth. Chip vendors ship their HALs in C. The Rust community HALs for STM32, nRF, RP2040, and ESP32 (via esp-idf-hal) are genuinely excellent - but the moment you need a peripheral or an erratum workaround the community HAL doesn’t cover, you’re writing unsafe FFI bindings into C headers by hand. On a chip with a deep, well-trodden C SDK and an exotic peripheral, that friction can erase the safety dividend you came for.
Legacy codebases. If you have 100K lines of battle-tested C++ firmware that works, rewriting it in Rust introduces real risk and captures zero functional value. Rust is a tool for new projects and new modules, not a reason to throw away a decade of hard-won bug fixes. The right migration is usually “new module in Rust, called from the existing C++,” not a rewrite.
Team ramp-up. The borrow checker is genuinely unintuitive coming from C++, and pretending otherwise wastes everyone’s time. We budget 4–6 weeks before an experienced C++ engineer is productive in embedded Rust - meaning writing features rather than wrestling the compiler. That’s a real line item. On a tight schedule with an existing C++ team, it’s often simply the wrong trade, and we say so.
Hard real-time with certification. When you need interrupt latency measured in nanoseconds, formal worst-case-execution-time (WCET) analysis, and a toolchain that certification bodies already understand, bare-metal C with a real-time OS and static allocation is still the more predictable, better-documented, better-audited choice. Rust’s async/.await on embedded is maturing fast, but “maturing” is not what you write on a safety case.
What the day-to-day actually feels like
The toolchain surprised us by being pleasant, which is not a word anyone uses about embedded tooling. probe-rs drives the common debug probes - J-Link, CMSIS-DAP, ST-Link - and integrates with VS Code through probe-rs-debugger. Flashing and live RTT log streaming come from the same tool, so there’s no juggling three vendor utilities.
# Build, flash, and stream live logs in one command. The feedback loop is genuinely fast.
$ cargo embed --release
Flashing ✔ (12.4 KiB in 0.3s)
RTT ┊ [INFO ] boot: firmware v1.4.0, 3 sensors online
┊ [DEBUG] sample: t=23.4C rh=41% (buffer 8/8, flushing) One thing Rust does not abstract away: the linker script. You’ll still hand-define your chip’s memory map in a memory.x file, just like in C++. This isn’t worse than C++, but it surprises people who assumed Rust would hide it. It won’t. The metal is still the metal.
Our heuristic, in one table
After three projects, the decision rule has stabilized into something we can almost apply by reflex:
| Reach for Rust when… | Stick with C++ when… |
|---|---|
| The project is greenfield, no legacy C/C++ to wrap | You have a large, working C++ codebase to extend |
| The MCU has a mature community HAL (STM32, nRF52x, RP2040, ESP32) | The MCU’s Rust HAL support is thin or missing |
| Bugs are concurrency/parsing/interrupt-heavy - the dangerous kind | You need hard real-time with WCET analysis and certification |
| The schedule has room for 4–6 weeks of ramp-up | The timeline is tight and the team is already fluent in C++ |
Notice neither column is empty, and that’s the honest summary. We’re not Rust evangelists; we’re engineers who now have one more very good tool, with a clear sense of when it’s the right one. On the projects where memory-safety bugs were the thing keeping us up at night, Rust moved them from “field incidents” to “compile errors,” and that trade was worth every week of borrow-checker tuition. On the projects where it wasn’t, we wrote C++ and slept fine.
If you’re weighing Rust versus C++ for an embedded project and want a second opinion grounded in shipped firmware rather than blog hype, get in touch.