Embedded Rust Without `std`: Patterns for Writing Safe, Efficient Firmware on Tiny Devices

Learn embedded Rust with no_std: patterns for panic-free, memory-safe firmware on microcontrollers. From ring buffers to GPIO abstractions — start building today.

Embedded Rust Without `std`: Patterns for Writing Safe, Efficient Firmware on Tiny Devices

I still remember my first encounter with embedded Rust. I had just finished a big project involving web servers and databases, where memory was abundant and the standard library handled everything. Then I bought a small development board with a microcontroller. A few hundred kilobytes of Flash, a few tens of kilobytes of RAM. No operating system. No heap. The standard library wouldn’t even compile. I had entered the world of no_std.

This article is for you if you feel the same confusion I felt back then. I will show you patterns that helped me write code that runs on these tiny machines – code that is efficient, safe, and (mostly) panic‑free. I will speak as if we are sitting together, looking at a blinking LED, one step at a time. No jargon without explanation. No magic. Just plain Rust, stripped of everything except the bare essentials.


The first thing I learned was that println! is not your friend. It looks innocent: you write println!("Hello") and text appears on your console. But behind the scenes, println! pulls in std::io::Write, the formatting machinery, and a whole chain of dependencies that need a heap, a filesystem, and an operating system. On a microcontroller, you have none of those. So we need a different way to see what our code is doing.

I replaced println! with a custom logging macro that writes directly to a serial port or a debug interface. The simplest approach, if you use an ARM Cortex‑M chip, is to use semihosting. Semihosting uses a special instruction that the debugger interprets to output text. The cortex_m_semihosting crate provides a hprintln! macro that works exactly like println!, but without the standard library. Here is how I used it:

#![no_std]
#![no_main]

use cortex_m_semihosting::hprintln;

#[no_mangle]
fn main() -> ! {
    hprintln!("Hello from no_std").unwrap();
    loop {}
}

Notice that main returns ! – it never returns. That is a typical pattern in embedded code. The device runs forever or until reset. And the unwrap() call might scare you: what if the debug interface is not connected? In practice, semihosting is disabled if no debugger is present, and the hprintln call becomes a no‑op. So it is safe.

But semihosting only works with a debugger attached. For real hardware in the field, you need to talk to a serial port (UART). Then you write your own macro that calls a function to put a character into a transmit buffer. It is not hard, but it takes a few lines. For now, remember this: whenever you need to print something in no_std, you must provide your own output path. I usually define a macro log! that I can switch between semihosting for development and a real UART for production.


The second pattern forced me to think about types in a new way. In Rust for the desktop, we casually use usize everywhere. It is the natural size for array indices. But usize changes: on a 32‑bit system it is 4 bytes, on a 16‑bit system it is 2 bytes. When I moved my code to a tiny 8‑bit target (yes, Rust can target 8‑bit with some effort), usize became 1 byte. Suddenly my loop counters overflowed. I switched to explicit‑width integer types: u8, u16, u32, i8, etc. Now my code is portable, and I know exactly how much memory each variable uses.

const LED_PIN: u8 = 5;
let counter: u16 = 1000; // 1000 fits in 2 bytes, no waste

fn toggle_led(pin: u8) {
    // We assume a memory‑mapped register at address 0x4000_2000
    // The GPIO output register is 32 bits wide.
    const GPIO_OUT: *mut u32 = 0x4000_2000 as *mut u32;
    unsafe {
        GPIO_OUT.write_volatile(GPIO_OUT.read_volatile() ^ (1 << pin));
    }
}

The unsafe block bothers many beginners. I will get to safe abstractions later. For now, notice that I used a u8 for the pin number – because pins rarely go above 255. And u16 for a counter that I know will stay under 65535. That is deliberate. On an 8‑bit microcontroller, using u32 for the same counter would waste cycles and memory.


The third pattern taught me that errors are not exceptional; they are everyday events in hardware. When you read a button, the signal may bounce. When you send data over I2C, the slave may be busy. The standard library’s panic or unwrap is not acceptable because it halts the system. In no_std, we use the embedded_hal crate, which defines traits that return nb::Result. The nb stands for “non‑blocking”. A function returns Err(nb::Error::WouldBlock) if the operation is not complete. You must poll in a loop until it succeeds.

Here is how I blink an LED using embedded_hal:

use embedded_hal::blocking::delay::DelayMs;
use embedded_hal::digital::v2::OutputPin;

fn blink_led<D: DelayMs<u32>, P: OutputPin>(delay: &mut D, led: &mut P, period_ms: u32) {
    loop {
        // set_high() returns Result<(), Self::Error>
        // If the pin is misconfigured, we handle it
        if let Err(e) = led.set_high() {
            // Log error, maybe reset pin
        }
        delay.delay_ms(period_ms);
        if let Err(e) = led.set_low() {
            // handle error
        }
        delay.delay_ms(period_ms);
    }
}

No panicking. The if let Err(e) lets us decide what to do: we could ignore it, set a flag, or call a reset routine. On a real device, hardware errors are rare but possible; we must handle them gracefully. This pattern makes your firmware resilient.


Fourth pattern: the panic handler. When you remove the standard library, you also lose the default panic behavior (unwinding the stack and printing an error). In no_std, you must provide a #[panic_handler] function. This function is called when a panic occurs. It must never return (return type !). Most embedded projects define it as an infinite loop. That is the simplest and safest – the device stops, which is better than executing random memory.

use core::panic::PanicInfo;

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

If you want to know why the panic happened, you can write the panic message to a serial port using a UART driver. But that adds code and complexity. I usually keep the loop and rely on my test harness to catch panics. When I am confident the code is correct, I never see a panic.


The fifth pattern is about memory. Without std, you have no Vec, no String, no Box. All data must live either on the stack or in static memory. Stack is limited – a few kilobytes on a typical microcontroller. So we use static variables. But Rust’s safety rules prevent accessing a static mut from multiple contexts. The solution is core::cell::RefCell or its thread‑safe sibling Mutex (which in embedded often means a critical section). For fixed‑size collections, I use the heapless crate. It provides Vec, String, and other data structures that allocate at compile time.

use heapless::Vec;
use core::cell::RefCell;

static BUFFER: RefCell<Vec<u8, 256>> = RefCell::new(Vec::new());

fn log_char(c: u8) {
    // Push the character; if buffer is full, we drop it.
    let _ = BUFFER.borrow_mut().push(c);
}

The 256 in Vec<u8, 256> means the maximum capacity is fixed, known at compile time. No runtime reallocation. If you try to push a 257th byte, the push returns an Err. You decide what to do – ignore it, or overwrite the oldest byte. This is deterministic.


The sixth pattern uses const generics. You may know that Rust allows you to parameterize types with values instead of just types. For example, a ring buffer can be generic over its size:

struct RingBuffer<const N: usize> {
    data: [u8; N],
    head: usize,
    tail: usize,
}

impl<const N: usize> RingBuffer<N> {
    fn new() -> Self {
        Self {
            data: [0; N],
            head: 0,
            tail: 0,
        }
    }

    fn push(&mut self, byte: u8) -> Result<(), u8> {
        let next = (self.head + 1) % N;
        if next == self.tail {
            return Err(byte); // buffer full, reject the byte
        }
        self.data[self.head] = byte;
        self.head = next;
        Ok(())
    }
}

Now you can write let mut buf = RingBuffer::<32>::new(); and the compiler knows the exact size. No runtime checks, no dynamic memory. This is beautiful because it makes the code self‑documenting and safe.


The seventh pattern is about the compiler itself. When you are building for a tiny target, every byte of code matters. I configure Cargo.toml to optimize aggressively:

[profile.release]
lto = true
codegen-units = 1
opt-level = "z"

opt-level = "z" tells LLVM to optimize for size (instead of speed). Combined with link‑time optimization (LTO) and a single codegen unit, the binary shrinks significantly. I also disable Rust’s default panic abort behavior if I am using panic_immediate_abort. That reduces the panic infrastructure to a single udf instruction. But be careful: if you ever hit a panic, the device will reset or hang. That is fine for production firmware that should never panic.


The eighth and final pattern is the most important: wrap unsafe hardware access in safe abstractions. We already wrote unsafe code to write to a GPIO register. That is not maintainable. Instead, I create a GpioPort struct that hides the raw pointer:

pub struct GpioPort {
    base: *mut u32,
}

impl GpioPort {
    pub fn new(base: usize) -> Self {
        Self { base: base as *mut u32 }
    }

    pub fn set_pin_high(&self, pin: u8) {
        unsafe {
            self.base.write_volatile(self.base.read_volatile() | (1 << pin));
        }
    }

    pub fn set_pin_low(&self, pin: u8) {
        unsafe {
            self.base.write_volatile(self.base.read_volatile() & !(1 << pin));
        }
    }
}

Now the unsafe is contained inside the two functions. The rest of my code never needs unsafe. I can add checks – for example, verify that pin is less than the number of pins on the port. And I can implement the OutputPin trait from embedded_hal for my struct. That makes my code reusable with other drivers.


I learned these patterns one by one, often by breaking things. I wrote code that panicked silently, filled up buffers, and used the wrong integer size. But each mistake taught me something. Now I look at no_std Rust not as a stripped‑down version, but as a different way of thinking. Every allocation is explicit. Every error is handled. Every byte of memory is accounted for. It is not harder – it is just more honest.

If you are starting with embedded Rust, do not be afraid. Pick a small microcontroller, install the proper target, and write a blinking LED. Then add a panic handler. Then replace println! with a serial output. Step by step, you will internalize these patterns. And soon you will be writing efficient, safe firmware for devices that have no room for waste.

I still keep a checklist in my mind: no std, no println!, no unwrap, no heap, explicit types, panic handler, static memory, safe abstractions. Every time I start a new project, I run through that list. It has saved me many hours of debugging. Let it save you too.


// Keep Reading

Similar Articles

Rust's Const Fn: Revolutionizing Crypto with Compile-Time Key Expansion
Rust

Rust's Const Fn: Revolutionizing Crypto with Compile-Time Key Expansion

Rust's const fn feature enables compile-time cryptographic key expansion, improving efficiency and security. It allows complex calculations to be done before the program runs, baking results into the binary. This technique is particularly useful for encryption algorithms, reducing runtime overhead and potentially enhancing security by keeping expanded keys out of mutable memory.

Read Article →