8 Techniques for Embedded Rust Without an Operating System: A Practical Guide to Bare-Metal Firmware
Master 8 essential techniques for embedded Rust development without an OS—from panic handlers to async tasks. Build reliable firmware today.
8 Techniques for Embedded Rust Without an Operating System
I still remember the first time I tried to blink an LED on a microcontroller. I had years of desktop programming behind me, and I assumed it would take ten minutes. It took me three days. There was no operating system to fall back on, no println!, no memory allocator quietly doing my bidding in the background. It was just me, a bare chip, and a lot of confusion.
That experience taught me something valuable. When you strip away the operating system, you are left with the raw truth of how computers work. Every byte matters. Every register write has consequences. Rust turns out to be an excellent companion for this world, because it lets you write code that feels high level while compiling down to something a microcontroller can actually run.
Here are the eight techniques I wish someone had shown me on day one.
1. Building a no_std Binary with a Custom Panic Handler
The first thing you have to accept is that the standard library is gone. Rust’s std crate assumes an operating system exists. It assumes threads, file systems, and a heap. A microcontroller has none of these.
So you start every project with #![no_std]. This tells the compiler to only pull in the core crate, which is the tiny, OS-free heart of Rust. You also add #![no_main], because the usual main function is a convention set up by the C runtime, which you do not have.
#![no_std]
#![no_main]
use cortex_m_rt::entry;
use panic_halt as _;
#[entry]
fn main() -> ! {
loop {
// Your firmware lives here, forever.
}
}
The #[entry] macro from cortex-m-rt takes care of the messy startup work. It sets up the vector table, copies initialized data from flash to RAM, and then calls your function. Your function returns !, which means it never returns. Firmware runs until the power goes out.
You also need a panic handler. When something goes wrong, Rust wants to call a function that reports the failure. On a desktop that function prints a message and exits. On a microcontroller there is nowhere to print and nowhere to exit. You must define what happens yourself.
The lazy option is the panic-halt crate, which does exactly what its name says: it stops the chip. That is fine for learning. For real devices, I write my own handler that logs the panic location and resets the board.
use core::panic::PanicInfo;
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
// Log the file and line where the panic happened,
// then reset or halt the device.
defmt::error!("panic: {}", defmt::Display2Format(info));
cortex_m::peripheral::SCB::sys_reset()
}
This is the foundation. Everything else builds on it.
2. Talking to Hardware Through Memory-Mapped Registers
Here is the mental model that changed everything for me. On a microcontroller, hardware peripherals are controlled through memory addresses. A GPIO pin, a timer, a serial port — each one has a block of addresses, and writing a number to the right address turns the hardware on or off.
In C you would write something like *(volatile uint32_t*)0x40020C00 = 1. In Rust you can do the same, but you do not have to.
The cortex-m crate gives you access to the processor’s own features. Crates generated from vendor description files, often called PACs or peripheral access crates, give you typed, safe handles to every register on the chip.
let dp = stm32f4xx_hal::pac::Peripherals::take().unwrap();
let rcc = dp.RCC;
rcc.ahb1enr.modify(|_, w| w.gpioaen().enabled());
Notice what is happening. I am not writing raw addresses. I am asking the register to modify one field and leave the rest alone. The generated code makes sure I only use values the hardware understands. If I try to write a number that does not fit, the code will not compile.
Then there is embedded-hal, a set of shared traits that describe common hardware behavior. A pin that can be set high or low implements OutputPin. A serial port implements Write. This matters because it means a driver written for one chip can run on another chip that speaks the same traits.
The trade-off is real. Generated PACs can be verbose, and the full HAL layers add a little binary size. For tiny chips you might drop down to raw pointers with read_volatile and write_volatile for the hottest paths. I do that maybe once per project. The rest of the time, the typed registers save me from myself.
3. Writing Interrupt Handlers That Share State Safely
Interrupts are the heartbeat of embedded systems. Something happens in the world — a button press, a timer tick, a byte arriving on a wire — and the processor drops everything to run a small function called an interrupt handler.
The difficult part is sharing data between that handler and the main loop. In C you reach for volatile and hope. In Rust the compiler simply refuses to let you share mutable state unsafely.
The cortex-m crate provides a special Mutex type. It is not the operating system mutex you might know. It works by disabling interrupts while you hold it, which means no interrupt handler can run and no race can happen.
use cortex_m::interrupt::{free, Mutex};
use core::cell::RefCell;
static COUNTER: Mutex<RefCell<u32>> = Mutex::new(RefCell::new(0));
#[interrupt]
fn EXTI0() {
free(|cs| {
let mut count = COUNTER.borrow(cs).borrow_mut();
*count += 1;
});
}
fn main_loop_reads_it() {
free(|cs| {
let count = COUNTER.borrow(cs).borrow();
defmt::info!("count is {}", *count);
});
}
The free closure is the key. It only runs when interrupts are off, so the borrow checker has a proof that nothing else can touch the data at the same time. You get safety without a runtime cost.
Two gotchas I learned the hard way. First, you must clear the interrupt flag inside the handler, or the processor will immediately call it again forever. Second, keep handlers short. Set a flag, push a byte into a queue, and let the main loop do the slow work.
4. Logging with defmt Instead of println!
Debugging without println! feels like working with your eyes closed. The traditional solution is to print text over a serial port, but formatting strings is expensive. Turning numbers into human-readable text takes CPU cycles and code space.
defmt flips the problem. Your device sends compressed binary data, and a tool on your laptop decodes it into readable text. The strings are stored on the host side, not on the chip. A log statement becomes a tiny index plus a few bytes of argument data.
defmt::info!("sensor reading: {}", value);
defmt::warn!("temperature high: {}", temp_celsius);
defmt::error!("i2c error: {:?}", err);
The output travels over a debugging protocol called RTT, which runs through the same probe you use to flash the chip. No extra wiring, no serial adapter, no baud rate to get wrong.
When I switched to this, my firmware shrank. My logging got better. The only catch is that you need the defmt linker script and a defmt.x config file, and the decoded output only appears while a probe is attached. For production logging, where a customer might need to read output, plain text over UART still wins.
5. Managing Memory with Static Allocation and heapless
There is no heap on most microcontrollers. Calling Box::new or pushing to a Vec will not work unless you bring in an allocator, and bringing in an allocator usually means giving up predictability. Fragmentation and allocation failures are not things you want on a device running for years.
The alternative is static memory. Everything gets a fixed size decided at compile time. The heapless crate gives you familiar data structures with fixed capacity.
use heapless::{String, Vec};
let mut name: String<32> = String::new();
name.push_str("sensor-01").unwrap();
let mut readings: Vec<u16, 8> = Vec::new();
readings.push(1023).unwrap();
The .unwrap() is the interesting part. A Vec with capacity eight can only hold eight items. If you try to push a ninth, it returns an error instead of allocating more memory. There is no silent failure, no mysterious slowdown, and no chance the device runs out of RAM at three in the morning.
I resisted this at first. It felt like going backward. After one project where a heap-based design crashed after six days of uptime, I stopped resisting. Fixed buffers force me to ask real questions about the data I expect, and the answer usually makes the firmware simpler.
6. Writing Async Embedded Tasks with embassy
The classic way to structure firmware is a main loop and a pile of interrupt handlers. It works, but the logic ends up scattered. Async Rust offers a different shape.
The embassy framework lets you write small tasks that wait for things without blocking the processor. Each task is a future. The executor polls tasks when they have work to do and lets the chip sleep the rest of the time.
#[embassy_executor::task]
async fn blink(mut led: Output<'static>) {
loop {
led.set_high();
Timer::after_millis(500).await;
led.set_low();
Timer::after_millis(500).await;
}
}
That .await is where the magic lives. When the task hits it, the executor stops running the task. The timer interrupt later wakes it up. While waiting, the chip can handle other tasks or drop into a low-power sleep.
Tasks share data through channels and signals. One task reads a sensor and sends values into a channel. Another task receives them and updates a display. Each piece of code stays small and readable.
There are things to know. Tasks are statically allocated with a pool size, so if you spawn more than the pool holds, you get a panic at runtime. Borrows cannot be held across an .await unless they are safe, because the task might resume on a different stack frame. The compiler helps here, but the error messages take practice to read.
I now use embassy on every new project. The code reads top to bottom. The power draw drops. The bugs are easier to find.
7. Enforcing Hardware Invariants with Type States
This technique is my favorite and the one that took the longest to appreciate. The idea is to encode the rules of your hardware into the type system so that breaking a rule becomes a compile error.
Consider a pin that must be configured as an output before you can drive it. In C you would configure it and then write to it, and if you forget the configuration step, nothing works and nothing warns you. In Rust you can give the pin a type parameter that changes when you configure it.
struct Pin<State> {
number: u8,
_state: core::marker::PhantomData<State>,
}
struct Input;
struct Output;
impl Pin<Input> {
fn into_output(self) -> Pin<Output> {
// Configure the hardware as an output here.
Pin { number: self.number, _state: core::marker::PhantomData }
}
}
impl Pin<Output> {
fn set_high(&mut self) {
// Only reachable when the pin is an output.
}
}
Now a function that needs an output pin can ask for Pin<Output> and the compiler will reject an input pin passed by mistake. The check happens before the device ever runs.
The same trick works for protocol states. A driver for a radio chip can require Configured before Transmit is called. A filesystem can require Mounted before Read. The trade-off is that generic types get noisy in error messages, and you should not take this further than it earns its keep. Two or three type states per peripheral is usually the sweet spot.
8. Cross-Compiling and Flashing with probe-rs
Your laptop does not run ARM code natively, so you need a cross-compiler. Rust makes this easy. You add a target triple to your toolchain, point Cargo at it, and build.
rustup target add thumbv7em-none-eabihf
cargo build --target thumbv7em-none-eabihf --release
The target name tells you a lot. thumb is the instruction set, v7em is the architecture version, and none-eabihf means no operating system and hardware floating point. The resulting binary cannot run on your laptop, which is exactly the point.
Flashing used to mean OpenOCD, a config file, and a GDB session. Now I use probe-rs, which handles most common debug probes without any of that. A single command compiles, flashes, resets the chip, and opens a terminal for your defmt output.
# .cargo/config.toml
[target.thumbv7em-none-eabihf]
runner = "probe-rs run --chip STM32F411CEU6"
[build]
target = "thumbv7em-none-eabihf"
After that, cargo run does everything. When I am feeling efficient, I run cargo run --release and watch the LED blink within seconds of saving a file. That short feedback loop is what makes embedded development enjoyable instead of tedious.
What All of This Adds Up To
None of these techniques are glamorous on their own. A panic handler is twelve lines. A static buffer is a data structure from the 1970s. A type state is a trick with generics. But together they change the shape of what you can build. You stop fighting the hardware and start describing it.
The project that took me three days to blink an LED now takes about ten minutes. Not because I got smarter, but because I stopped pretending the operating system was there. Rust gave me the tools to work honestly with a bare chip, and the chip, in return, has done everything I have asked of it.
If you are starting today, pick one technique. Write the panic handler. Blink the LED. Add a type state to a pin. Ship something small. The rest will follow, one register at a time.