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!
I work with small computers every day. Not the kind on your desk, but the ones hidden inside your car, your thermostat, or your watch. These embedded systems have a simple job: perform a specific task reliably, for years, with minimal power and memory. For decades, we’ve used languages like C for this. They give us control, but at a cost. A single misplaced pointer can cause a machine to malfunction, a sensor to report garbage, or a device to reset unexpectedly. I’ve spent too much time hunting for these kinds of bugs.
Then I found Rust. It promised the control of C but with a powerful guarantee: memory safety, checked at compile time. I was skeptical. Could a modern language really work on a chip with only 32 kilobytes of RAM? I decided to try. What I discovered transformed how I think about building software for the physical world.
The core challenge in embedded development is that there is no safety net. There’s usually no operating system to catch your errors, and often no way to update the software once it’s deployed. If your code writes to the wrong memory address, the device crashes. In a medical monitor or a brake controller, that’s not an option. Rust addresses this by enforcing rules about how memory is accessed before you even compile your program.
Let’s talk about how Rust does this without needing a powerful computer to run. We use something called #![no_std]. This tells the Rust compiler, "Don’t bring the standard library." The standard library assumes you have an operating system, a heap for dynamic memory, and other luxuries. A tiny microcontroller doesn’t have those. With no_std, you’re left with the core language—its types, its syntax, and most importantly, its ownership system. It’s like having the safety features of a modern car built into a go-kart frame.
Here is the most basic example: making an LED blink on a common microcontroller. This is the "Hello, world" of embedded.
// This is for an STM32 microcontroller. The specifics change,
// but the structure remains similar.
#![no_std]
#![no_main] // We define our own program start point
use cortex_m_rt::entry; // Provides the startup code
use panic_halt as _; // What to do on panic: just stop
use stm32f1xx_hal::{gpio::Output, pac, prelude::*};
// The `entry` macro tells the chip where our main function is
#[entry]
fn main() -> ! { // `!` means this function never returns
// Get access to the device's core peripherals
let dp = pac::Peripherals::take().unwrap();
let mut rcc = dp.RCC.constrain();
let mut gpioc = dp.GPIOC.split(&mut rcc.apb2);
// Configure pin PC13 as a digital output
let mut led = gpioc.pc13.into_push_pull_output(&mut gpioc.crh);
loop {
led.set_high(); // Turn the LED on
my_delay(500_000); // Wait
led.set_low(); // Turn the LED off
my_delay(500_000); // Wait
}
}
// A simple, blocking delay loop
fn my_delay(count: u32) {
for _ in 0..count {
cortex_m::asm::nop(); // Do nothing for one CPU cycle
}
}
This code is simple, but it showcases Rust's philosophy. The peripherals (like GPIOC) are accessed through a safe API. You can't accidentally configure a pin twice. The take() method ensures you have a single, unique reference to the peripheral block. This eliminates a whole class of bugs common in C, where multiple parts of a program might fight over control of the same hardware.
Now, consider a common task in C: reading a sensor value via an Analog-to-Digital Converter (ADC). In C, you might write to a magic memory address that represents the ADC's control register. If you get the address wrong, or write the wrong value, you get silent failure. In Rust, using a hardware abstraction layer (HAL), it looks more like this:
fn read_temperature(adc: &mut Adc<ADC1>) -> u16 {
// Configure and read from a specific ADC channel
let sensor_value: u16 = adc.read(&mut channel_pin).unwrap_or(0);
sensor_value
}
The adc here is a Rust struct. The read method is defined on it. I can’t call read on a GPIO pin by mistake; the compiler won’t allow it. The type system guides you to correct usage. This is what we call "making invalid states unrepresentable." If my code compiles, I have a higher degree of confidence it will interact with the hardware correctly.
One of Rust's most powerful features for embedded work is its approach to concurrency and interrupts. Interrupts are signals from hardware that tell the CPU to stop what it's doing and run a specific function—like when a button is pressed or a timer finishes. In C, sharing data between an interrupt and the main program is dangerous. You must remember to disable interrupts before accessing the data, then re-enable them. Forget once, and you have a rare, unpredictable bug.
Rust’s ownership model solves this. You can use safe abstractions like a Mutex (a mutual exclusion lock) designed for embedded use. The compiler enforces that you access the shared data only through the lock.
use cortex_m::interrupt::{self, Mutex};
use core::cell::RefCell;
use stm32f1xx_hal::pac::interrupt;
// A shared variable, wrapped for safe access
static COUNTER: Mutex<RefCell<u32>> = Mutex::new(RefCell::new(0));
// The main program
fn main() {
// ... setup a timer to trigger an interrupt ...
loop {
// Safely access the counter from the main loop
interrupt::free(|cs| {
let count = COUNTER.borrow(cs).borrow();
// Use `count` for something
});
}
}
// The Interrupt Service Routine
#[interrupt]
fn TIM2() {
// Safely modify the counter from inside the interrupt
interrupt::free(|cs| {
*COUNTER.borrow(cs).borrow_mut() += 1;
});
}
The interrupt::free function creates a critical section. The magic is that the Mutex requires you to provide the "token" (cs) from this critical section to access the data. The compiler’s rules make it physically impossible to access COUNTER outside of a critical section. This turns a complex timing problem into a simple rule checked by the compiler.
Let's compare this to a typical C pattern. A global variable volatile uint32_t counter; is declared. The interrupt routine increments it. The main loop reads it. It works, until one day you need to do something more complex than an increment in the interrupt, like adding to a queue. Then you introduce a buffer. Without careful discipline, the main loop might read the buffer while the interrupt is halfway through writing to it. This is a data race. In Rust, the type system would reject such unsafe concurrent access at compile time.
People often ask about performance and size. Does this safety come with a cost? In my experience, the answer is usually no. Rust has no runtime or garbage collector. The abstractions I’ve shown are "zero-cost." This means that after compilation, the safe Rust code that uses a Mutex and references looks almost identical to the hand-rolled, carefully correct C code. The checks happen when you compile on your laptop, not when the code runs on the device.
For example, a GPIO pin state abstraction might look like this in a HAL:
pub struct Pin<MODE> {
// pin details here
_mode: PhantomData<MODE>,
}
pub struct Output;
pub struct Input;
impl Pin<Output> {
pub fn set_high(&mut self) {
// write to register to set pin high
}
pub fn set_low(&mut self) {
// write to register to set pin low
}
}
impl Pin<Input> {
pub fn is_high(&self) -> bool {
// read from register
true // example
}
}
If I have a Pin<Input>, I cannot call set_high() on it. The method simply doesn't exist for that type. If I want to change it to an output, I call a method like .into_output(), which consumes the Pin<Input> and gives me back a Pin<Output>. The old variable is gone. I can no longer accidentally try to read from it as an input. This eliminates configuration mismatch errors entirely.
The ecosystem is a huge part of the story. Projects like embedded-hal define common traits—interfaces—for hardware blocks. A trait for a serial port, for example, defines a write function. A driver for a GPS module can be written to depend on this Serial trait, not on a specific STM32 or ESP32 chip. This means the GPS driver works on any microcontroller that has an implementation of the embedded-hal traits.
My workflow has changed. I now write most of my driver logic and application code on my regular computer, using "mock" hardware that implements these traits for testing. I can run unit tests and integration tests without ever touching a physical chip. This is faster and more reliable. When the logic works, I then compile it for my target device, swapping in the real hardware-specific HAL. The same code runs.
Deployment is also easier. Tools like probe-rs let me flash and debug a wide variety of ARM microcontrollers with a single command-line tool or IDE plugin. The experience is consistent, whether I’m using a $2 development board or a custom piece of industrial hardware.
Is it always the right choice? Not yet. For the smallest 8-bit microcontrollers with minuscule memory, the overhead of Rust's core library and monomorphization (how it handles generics) might still be too large compared to a tightly written C program. The ecosystem, while growing rapidly, is still younger than C's. There might be a specific chip or peripheral that doesn't have a good HAL crate yet. In those cases, you might need to write some lower-level code yourself, using Rust's unsafe blocks to directly manipulate registers. But even then, you can wrap that raw access in a safe API for the rest of your program, containing the risk to a small, clearly marked module.
For new projects, especially those where reliability, security, or long-term maintenance is a concern, Rust is an increasingly strong candidate. It shifts the focus from "did I remember all the rules to avoid crashing?" to "how do I correctly describe my device's behavior?" The compiler becomes a diligent partner, catching mistakes that would otherwise lead to late-night debugging sessions or field failures.
Building a connected sensor node, a motor controller, or a wearable device feels different in Rust. You spend less time defending against the machine and more time instructing it. You get the low-level access you need to make an LED blink or talk over I2C, but with a guardrail that keeps you from stepping off a cliff. For me, that’s not just a convenience; it’s a fundamental improvement in how we build the technology that operates in the background of our lives.
📘 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
Top comments (0)