Coming Back to Embedded with Rust
I hadn’t touched a microcontroller in a while. Long enough that I’d forgotten which USB cable in the drawer actually carried data.
Then a project came along that needed a Teensy 4.1 talking to a host application. Instead of going back to the C++ setup I already knew, I picked Rust. The interesting part isn’t that Rust can do embedded. It’s how much of the work moves from “be careful” to “the compiler checks that,” and how much easier it becomes to treat firmware and host code as parts of the same system.
Embedded is inherently unsafe
At some point, firmware has to touch reality. You dereference a memory-mapped register. You configure DMA. You hand ownership of a buffer to a peripheral. An interrupt can arrive between two instructions. The hardware does not care about your language’s memory model.
Rust doesn’t make any of that safe in the absolute sense. What it gives you is a boundary. unsafe marks the code the compiler genuinely can’t verify. Those pockets can be kept small, explicit, and greppable. Above them sit ordinary Rust APIs whose invariants can be enforced by the type system. If a peripheral requires exclusive access, you don’t accidentally use it from two places. If a buffer has been moved somewhere, using the old value again doesn’t compile.
In C or C++, potentially unsafe operations can be scattered throughout the codebase. In Rust, the operations outside the compiler’s guarantees are explicitly marked. When something breaks in that territory, the audit surface can be a grep instead of the entire project. For embedded, that’s an unusually useful trade: stronger guarantees without giving up low-level control.
The compiler catches design mistakes
The biggest adjustment was how often the compiler stopped me. Not for syntax mistakes. For design mistakes. I would restructure some state, change an enum, move ownership of a buffer, or make a value optional, and suddenly five unrelated-looking places stopped compiling.
At first that feels like friction. Then you remember the alternative on a microcontroller. A runtime bug on a desktop application is annoying. A runtime bug in firmware might mean reflashing the board, reproducing a timing issue, attaching a logic analyzer, adding logging without disturbing the timing, or staring at an LED that is helpfully informing you that something somewhere has gone wrong. The compiler is a very comfortable debugger by comparison.
Rust obviously doesn’t eliminate runtime bugs. Hardware still has timing problems. Peripherals still behave unexpectedly. You can still write perfectly memory-safe code that does the wrong thing. But a surprisingly large class of mistakes never reaches the board at all, and that changed the development loop more than I expected.
Hardware can become an implementation detail
One of the nicest patterns was also one of the least “embedded” ones. The core logic on this project was Kalman filtering on voltage readings from a quad photodiode, some control theory, prediction, and uncertainty estimation. None of that cared what board it ran on. What it needed from the hardware was a narrow interface: sample voltages, set outputs, report time, move messages. Those became traits.
The firmware implemented those traits against real peripherals. A host-side version implemented them against recordings captured from the board. The algorithm above the traits was the same code. That meant I could iterate on the model on my laptop, against real recorded data, with no hardware attached. When a model worked, it went back onto the board unchanged. Nothing to rewrite and nothing to port.
None of this is unique to Rust. C++ interfaces, templates, HALs, dependency injection, and host-side simulation are well-established techniques. What I noticed was how naturally Rust pushed the code in that direction. Ownership makes dependencies visible. Traits make narrow interfaces easy to express. Generics are monomorphized, so structuring the code for testability doesn’t automatically mean paying for runtime dispatch or allocation on the target.
That’s important in embedded work, where abstractions eventually have to become instructions, stack usage, memory accesses, and bytes in flash. Rust lets you write at a fairly high conceptual level without necessarily carrying that abstraction into runtime. Iterators don’t imply allocated iterator objects. Traits don’t necessarily mean virtual dispatch. State machines encoded in types don’t need a runtime framework. “Zero-cost abstraction” is an overused phrase, but the practical result here was useful: I could structure the firmware for clarity and testability without feeling like I was sneaking a desktop architecture onto a microcontroller.
One type on both sides of the wire
The project also had a desktop application talking to the Teensy over serial. This was where Rust became particularly pleasant. I put the messages into a tiny shared crate:
#![no_std]
#[derive(Serialize, Deserialize)]
pub enum Command {
SetGains { kp: f32, ki: f32, kd: f32 },
SetSetpoint { axis: u8, value: f32 },
StartStream { period_us: u32 },
Stop,
Ping { seq: u16 },
}
#[derive(Serialize, Deserialize)]
pub enum Event {
Sample { t_us: u32, adc: [u16; 4] },
Estimate { t_us: u32, pos: [f32; 2], uncertainty: f32 },
Fault { code: u16 },
Pong { seq: u16 },
} The firmware compiled that crate for no_std. The host compiled the same crate for the desktop. Serialization used postcard, with COBS framing around the byte stream. What I liked wasn’t really the serialization format. The transport could become USB, Wi-Fi, Bluetooth, CAN, or something else and the important property would remain: both programs consume the same Rust types. There isn’t a firmware definition of the protocol and a host definition of the protocol. There is a definition.
When I added a field to a message, both programs immediately stopped compiling at the places that needed to care about the change. I fixed those sites, rebuilt, and both sides agreed again. A disciplined C or C++ project can get the same property with shared headers, generated bindings, schemas, or protocol systems. The interesting part was that in Rust I got there almost accidentally. The path of least resistance was also the path where protocol drift became difficult to introduce silently.
The rough edges are real
It isn’t magic. The Rust ecosystem around Teensy is much smaller than the Teensyduino ecosystem. Sometimes the C++ world has a mature driver and the Rust world has a GitHub issue. Without a debug probe, some of the nicer tooling around probe-rs, RTT, and defmt is also harder to use. Next I’m testing an RP2354 on a custom PCB with a debug probe.
Rust also makes you think carefully about ownership around interrupts, shared state, and concurrency. Something that might begin life as a static plus an ISR in C can turn into a small architectural discussion in Rust. But that complaint is revealing, because the questions are real either way. Who owns this memory? Can the interrupt observe it halfway through an update? Can two pieces of code mutate this peripheral? What happens if this operation fails? Experienced embedded programmers answer those questions in C and C++ too. Rust just has an unusually persistent way of refusing to let you avoid them.
Would I use it again?
For this kind of project, absolutely. Not because C and C++ are incapable of reliable firmware; decades of embedded systems prove otherwise. And not because Rust removes the difficult parts of hardware programming; it doesn’t. What changed was where the effort went. Less effort went into remembering invariants and keeping representations synchronized. More went into expressing the architecture in a way the compiler could understand, then letting it enforce that architecture everywhere else.
Add host-side simulation and shared types across the device boundary, and the effect compounds. Most of the interesting logic can be tested without hardware. Broad changes become less frightening. A large class of mistakes gets caught before anything is flashed. That was the part I hadn’t expected. Rust doesn’t make the hardware less dangerous. It gives the danger a much smaller place to live.