Back

The Other Danger of Unwrap in Embedded Rust

2026-08-08

The Crash

Let's start with a simple Rust program. The usual "Hello, World!" but with a twist. The text will come from a function that returns a Result, though we know that we'll always be on the Ok branch.

#![no_std]
#![no_main]

use panic_halt as _;

#[inline(never)]
fn get_text(one: u8) -> Result<&'static str, &'static str> {
    if one == 1 {
        Ok("Hello, World!")
    } else {
        Err("Oh no!")
    }
}

#[arduino_hal::entry]
fn main() -> ! {
    let dp = arduino_hal::Peripherals::take().unwrap();
    let pins = arduino_hal::pins!(dp);
    let mut serial = arduino_hal::default_serial!(dp, pins, 57600);

    if let Ok(text) = get_text(1) {
        let _ = ufmt::uwriteln!(serial, "{}", text);
    }

    loop {}
}

This code should run and print "Hello, World!" as the if let matches Ok("Hello, World!") (as a &str).

Had we passed a different number to get_text, say get_text(2), then we would have seen no output.

Breaking It With unwrap

Let's handle that Result a different way, with unwrap. The function is never going to return an Err, so we should be safe.

fn main() -> ! {
    let dp = arduino_hal::Peripherals::take().unwrap();
    let pins = arduino_hal::pins!(dp);
    let mut serial = arduino_hal::default_serial!(dp, pins, 57600);

    let _ = ufmt::uwriteln!(serial, "{}", get_text(1).unwrap());

    loop {}
}

Run this, and we'll see no output! This kind of crash is what we'd expect to see from an unwrap when we hit an error, so at first glance, it seems normal.

However, we didn't hit the Err the first time with get_text(1), so why would the unwrap crash the code? Nothing changed in get_text, so we should be unwrapping Ok("Hello, World!"), not the error.

What's Really Going On?

If you watch cargo run while flashing the AVR, you should have first seen a message like:

Writing 1240 bytes to flash

Then, after the unwrap is added, you should have seen a similar message with a larger size.

Writing 30874 bytes to flash

That one unwrap makes the binary about 25 times larger! When working on a small chip, that could be a problem. Let's check in more detail.

avr-size --format=avr --mcu=atmega328p target/avr-none/debug/project_name.elf

This command will break down the sections of the program and how the size compares to what fits on the device.

AVR Memory Usage
----------------
Device: atmega328p

Program:   30874 bytes (94.2% Full)
(.text + .data + .bootloader)

Data:      13993 bytes (683.3% Full)
(.data + .bss + .noinit)

Those 30874 bytes barely fit, but they do. However, the data section blasts through what's available! The ATmega328P code tries to copy that data to RAM and completely fails.

Binary Size

Okay, let's see what's in the binary that's making it so big! We can use the nm command to dump the symbol table, though we'll need to use the appropriate one here for our chip. Since I'm using an AVR chip, I'll use avr-nm.

We'll need to check the debugging binary, rather than the code that's flashed to the chip, since we want to see the human-readable names. We'll get a lot of symbols, so let's just sort by size (--size-sort) and look at the largest.

avr-nm --print-size --size-sort -C target/avr-none/debug/project_name.elf | tail -10

Oh, did you get a bunch of weird rows that end in something like N .Lname25? Those are a bunch of debug-related things we don't care about. Let's filter them out this time.

avr-nm --print-size --size-sort -C target/avr-none/debug/project_name.elf | grep -vE ' t? *(\.|L0|__)' | tail -10

It should all be parts of core::fmt and maybe some core::unicode. However, if you were to run this after building the working code, you'd see no fmt anywhere but a lot more embedded AVR-related symbols.

Now technically we're mostly seeing the code pulled in, which is denoted by a T, but not the data, which is denoted by R. Unfortunately, a lot of those have anonymous names, but you may see one R from core::unicode, which has big lookup tables that eat up the memory.

The unwrap is pulling in core::fmt and core::unicode, which together are quite a large amount of code! That extra binary size is such a problem that there's even the ufmt crate to specifically avoid using it on microcontrollers.

Before we get into the reason unwrap pulls in core::fmt, I need to flag another weird thing in this code.

The Other unwrap

If you take another look at the working code, you'll notice that there's already one unwrap in the program that doesn't kill it.

let dp = arduino_hal::Peripherals::take().unwrap();

Technically, this is a different unwrap than the one added in to break the code. There are actually two different unwrap methods: one for Option and one for Result. The problem is with Result::unwrap, but take returns an Option, and we called Option::unwrap safely.

At a high level, the Result::unwrap massively inflates the binary size, but Option::unwrap does not.

How unwrap Works

Both Result::unwrap and Option::unwrap work broadly the same way, and reduce to the same function. However, a slight difference in how they get there will make all the difference between runnable code and a crash.

Option

The Option::unwrap method is as simple as you'd think: either return the value or handle the None case.

pub const fn unwrap(self) -> T {
    match self {
        Some(val) => val,
        None => unwrap_failed(),
    }
}

With an even simpler function for when the unwrap failed.

const fn unwrap_failed() -> ! {
    panic("called `Option::unwrap()` on a `None` value")
}

Would you believe that panic is just another one-line function? Yep. But it calls panic_fmt, which loads your panic handler. All in all, it's pretty simple.

pub const fn panic_fmt(fmt: fmt::Arguments<'_>) -> ! {...}

Result

The more complicated Result::unwrap is also not that crazy. It starts with another very simple method.

pub fn unwrap(self) -> T
where
    E: fmt::Debug,
{
    match self {
        Ok(t) => t,
        Err(e) => unwrap_failed("called `Result::unwrap()` on an `Err` value", &e),
    }
}

Note that this is a different, but similarly simple, function.

fn unwrap_failed(msg: &str, error: &dyn fmt::Debug) -> ! {
    panic!("{msg}: {error:?}");
}

Under the hood, panic! will end up calling the same panic_fmt, but the exact steps can vary a bit depending on the Rust version.

When fmt Appears

You might think that core::fmt is pulled in by panic_fmt, which explicitly uses core::fmt in its signature.

pub const fn panic_fmt(fmt: fmt::Arguments<'_>) -> ! {...}

However, it can't be the problem, since it appears in both versions. You can double check their symbol table with:

avr-nm --print-size --size-sort -C target/avr-none/debug/project_name.elf | grep 'panic_fmt'

The use of fmt::Arguments in the signature is just naming a type, which doesn't pull in code. Beyond that, panic_fmt doesn't do any actual formatting, and instead just turns the struct into a PanicInfo for whatever panic handler you use.

The real problem comes in the two unwrap_faileds. In the case of an Option, it panics with a fixed string: "called Option::unwrap() on a None value". In the case of a Result, it calls panic! with an explicit formatting string: "{msg}: {error:?}". At that point we need core::fmt machinery that we could have avoided with an Option::unwrap.

What Can Be Done

One option is that you can just avoid Result::unwrap and handle errors in other ways. You usually wouldn't want to actually unwrap in an embedded context, where there's limited debugging information. The more realistic scenario is that you included an unwrap on a path that you knew was safe, but the compiler pulled in the core::fmt code anyway.

Note that the compiler is pretty smart, and would have realized the Err branch was unused, had I not forced it to include the full function with #[inline(never)].

Option

If you love unwrap, switching from Result to Option will also do the trick. There's even Result::ok to convert them for you.

Custom Embedded Unwraps

You can't replace Result::unwrap, but you can create a version safe for small microcontrollers.

trait SafeUnwrap<T> {
    fn safe_unwrap(self) -> T;
}

impl<T, E> SafeUnwrap<T> for Result<T, E> {
    fn safe_unwrap(self) -> T {
        match self {
            Ok(t) => t,
            Err(_) => panic!("safe unwrap called on an error"),
        }
    }
}

As long as you have SafeUnwrap in scope, you can use it in place of the Result::unwrap.

let _ = ufmt::uwriteln!(serial, "{}", get_text(1).safe_unwrap());

What Can't Be Done

A fundamental problem here is that we're using a language (Rust) not generally designed for the type of chip we have. Rust assumes one memory block (von Neumann or Princeton architecture) but the AVR has separate blocks for program memory and RAM (Harvard architecture). The generated Rust program solves this by copying from program memory into RAM, which fails when it needs to copy more than could ever fit in RAM.

In C, there's the option to annotate constants for ROM. The core AVR Rust system doesn't have that support, and even if it did, we can't annotate code that's not ours in core.