As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!
Let me take you back to my first embedded project. I needed to read a temperature sensor every second, display the value on a tiny screen, and if the temperature went too high, cut power to a heater. The microcontroller had 2KB of RAM and 16KB of flash. I chose C because that was what everyone used. Three months later, the heater stayed on when it should have turned off because of a buffer overflow that corrupted a global variable. The fix took two lines of code. Finding the bug took three weeks of staring at oscilloscope traces.
That experience made me look for better ways. I found Rust.
Embedded systems are computers that do one thing, in one place, with very little memory and no operating system. They run your microwave, your car's brakes, and the tiny sensors in smart light bulbs. Because they control physical things, a software bug can burn your house down or crash your car. C and C++ give you full control over every byte, but they also give you enough rope to hang yourself. One stray pointer write and the system behaves randomly until you happen to reset it.
Rust offers a different deal. It gives you the same control over hardware that C does, but it checks your code at compile time for memory mistakes. This checking does not slow down your program. The compiled Rust code runs just as fast as C. The difference is that Rust will not let you write a dangling pointer or a buffer overflow.
When I started using Rust for a flight controller project, I was worried about the learning curve. The borrow checker felt like a strict teacher who never let me get away with anything. But I soon realized that every error it caught was a bug I would have spent days debugging later. One time I accidentally forgot to disable an interrupt before modifying a shared variable. The Rust compiler refused to compile. In C, that would have been a data race that only showed up at random times.
Embedded Rust starts with the #![no_std] attribute. This removes the standard library that assumes you have an operating system and a heap. Instead, you get only the core library, which contains basic types, iterators, and memory operations that do not need an OS. You cannot use Vec or String unless you write your own allocator, which is possible but uncommon. The absence of heap makes your program deterministic. Every allocation is decided at compile time. No out-of-memory errors can happen at runtime.
Here is what a minimal embedded program looks like. This is the classic blinking LED example.
#![no_std]
#![no_main]
use cortex_m_rt::entry;
use panic_halt as _;
use stm32f1::stm32f103;
#[entry]
fn main() -> ! {
let peripherals = stm32f103::Peripherals::take().unwrap();
let rcc = &peripherals.RCC;
let gpioc = &peripherals.GPIOC;
rcc.apb2enr.write(|w| w.iopcen().set_bit());
gpioc.crh.write(|w| w.mode13().output());
gpioc.bsrr.write(|w| w.bs13().set_bit());
loop {
gpioc.bsrr.write(|w| w.br13().set_bit());
for _ in 0..8_000_000 { }
gpioc.bsrr.write(|w| w.bs13().set_bit());
for _ in 0..8_000_000 { }
}
}
The #![no_main] tells Rust that we will define our own entry point, because there is no operating system to call a regular main function. The #[entry] attribute from the cortex-m-rt crate sets up the correct interrupt vector table and calls our function. The loop {} at the end ensures the program never exits. The core::hint::spin_loop() inside the busy loops tells the compiler we are intentionally wasting cycles.
The peripheral access code looks like direct register manipulation, but the write methods are safe. They use types that prevent writing wrong bit patterns. For example, the mode13().output() method is generated from the microcontroller's SVD file, so the bit positions are correct. Mistakes like writing output to the wrong register or setting both mode and cnf bits incorrectly become compile errors instead of silent misbehavior.
Memory management in embedded Rust relies on static allocation. You declare global buffers with static mut and accept that you must use unsafe to access them. The cortex-m-interrupt crate provides a Mutex that makes shared access safe without runtime cost. I use this pattern for a state machine that runs from a timer interrupt.
use core::cell::RefCell;
use cortex_m::interrupt::{self, Mutex};
static STATE: Mutex<RefCell<StateMachine>> = Mutex::new(RefCell::new(StateMachine::Idle));
fn timer_interrupt_handler() {
interrupt::free(|cs| {
if let Some(mut state) = STATE.borrow(cs).borrow_mut().ok() {
state.transition();
}
});
}
The interrupt::free block prevents any other interrupt from running during the critical section. The borrow checker inside the closure ensures that only one handler holds the reference at a time. This is safe and does not require any heap allocation or runtime type checking.
Peripheral access libraries generate types from vendor data. The svd2rust tool reads an SVD file and produces a Rust module with register structs and bitfield enums. I have used it for an STM32 L4 chip in a battery monitor. The generated code looks like this:
// Generated from stm32l4x2.svd
pub mod gpio {
pub struct GpioA {
_marker: PhantomData,
}
impl GpioA {
pub fn moder(&self) -> &MODER {
unsafe { &*(self.ptr as *const MODER) }
}
}
pub struct MODER {
_reg: u32,
}
impl MODER {
pub fn read(&self) -> u32 { self._reg }
pub fn write<F>(&self, f: F) where F: FnOnce(&mut W) -> &mut W {
let mut w = W { bits: self._reg };
f(&mut w);
unsafe { self._reg = w.bits; }
}
}
}
The unsafe is inside the library, not in your application code. You call moder().write(|w| w.moder0().output()) and the compiler checks that you are writing a valid configuration. No chance of accidentally clearing other bits because the writer closure uses a bitmask technique.
Interrupt handling becomes predictable. The #[interrupt] attribute from cortex-m-rt tells the linker to place the function in the interrupt vector table. The attribute also ensures that the function saves and restores the CPU's registers correctly. You do not have to worry about writing assembly stubs.
#[interrupt]
fn TIM2() {
static mut COUNTER: u32 = 0;
*COUNTER += 1;
if *COUNTER >= 1000 {
// toggle LED
}
}
The static mut inside the interrupt handler is special. It is initialized to zero, lives in flash, and every time the interrupt fires it is safe because the handler is the only code that can access it. No need for a mutex when the variable is private to the handler.
Testing embedded code on a desktop machine saves enormous time. The embedded-tests crate lets you write unit tests that compile for your host computer but still check logic. You can use proptest to generate thousands of random inputs for your sensor fusion algorithm and verify that the output stays within limits. I test my control loop PID gains this way before ever flashing the board.
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn pid_never_diverges(setpoint in -100.0f32..100.0,
feedback in -100.0f32..100.0) {
let mut pid = PID::new(1.0, 0.1, 0.05);
let output = pid.update(setpoint, feedback);
assert!(output.is_finite());
}
}
}
For hardware-in-the-loop testing, the cargo test can be extended with a runner script that flashes the binary and uses a serial port for results. The probe-rs tool allows running unit tests on the device with real hardware peripherals. This does not replace end-to-end testing, but it catches many mistakes before integration.
The embedded ecosystem provides traits that abstract hardware differences. The embedded-hal crate defines traits for digital I/O, analog-to-digital conversion, serial communication, and more. I wrote a library for an I2C temperature sensor that uses these traits. The library works on any microcontroller that implements the i2c::Read and i2c::Write traits.
use embedded_hal::i2c::{I2c, Error};
pub struct Tmp102<I2C> {
i2c: I2C,
address: u8,
}
impl<I2C: I2c> Tmp102<I2C> {
pub fn new(i2c: I2C) -> Self {
Tmp102 { i2c, address: 0x48 }
}
pub fn read_temperature(&mut self) -> Result<f32, <I2C as I2c>::Error> {
let mut buf = [0u8; 2];
self.i2c.read(self.address, &mut buf)?;
let raw = u16::from_be_bytes(buf);
Ok((raw >> 4) as f32 * 0.0625)
}
}
The ? operator propagates errors without needing a heap allocation. The generic parameter I2C allows the library to work with any chip that provides an I2C implementation. This portability is rare in embedded C, where you typically write separate drivers for each family.
Rust's performance in embedded systems matches or exceeds C. The LLVM backend optimizes code aggressively. I compared the generated assembly for a CRC computation and found that Rust produced identical instructions to C with the same flags. The zero-cost abstractions mean that using a for loop or an iterator does not add overhead. The overhead only exists in your code if you write it in.
One place where Rust shines is compile-time testing of timing requirements. The const evaluation allows computing prescaler values for timers at compile time. If your desired baud rate cannot be achieved with the current clock, the compiler tells you before you flash the chip.
const fn compute_prescaler(clock_hz: u32, desired_hz: u32) -> u32 {
let prescaler = clock_hz / desired_hz;
assert!(prescaler > 0, "Desired baud rate too high");
assert!(prescaler < 65536, "Desired baud rate too low");
prescaler - 1
}
If you call compute_prescaler(16_000_000, 115_200) and the result overflows or is out of range, the program will not compile. This is safer than computing at runtime and hoping the hardware accepts it.
Commercial products use Rust for safety-critical subsystems. The Tock operating system is written in Rust and used in research sensors. Spire's satellite radios use Rust to prevent memory corruption in low-earth orbit, where a reboot is impossible. I know a team that replaced a C-based motor controller with Rust and eliminated intermittent failures that had plagued the product for years.
The learning curve is real, but it is shorter than you might think. I started by reading the Embedded Rust Book and following the examples for a popular development board. The first week was frustrating because the borrow checker kept rejecting my code. By the second week, I understood ownership and could write drivers that compiled on the first try.
Two years later, I have more than twenty completed embedded projects in Rust. None of them have had memory corruption bugs. I still have logic errors, but the compiler catches the dangerous ones. The ability to sleep at night knowing my firmware will not crash because of a stray pointer is worth every hour I spent learning the language.
The embedded Rust community is active and helpful. The embedded-wg working group maintains the embedded-hal traits and produces excellent documentation. Libraries are available for most common sensors and peripherals. The tooling, especially probe-rs and cargo-embed, makes flashing and debugging easier than vendor-specific IDEs.
If you are still using C for embedded work, give Rust a try on a simple project. Start with blinking an LED, then move to a sensor, then to a closed-loop controller. The compiler will fight you at first, but the fight is a gift. Every error is a lesson. Every successful compile is a guarantee that your memory is safe.
The smallest computers deserve the same level of software quality we demand for server code. Rust delivers that safety without sacrificing performance. That is the deal. I took it, and I have never looked back.
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva