Skip to content
rusthacks.dev
Systems

Rust Firmware Size Optimization Down to Tens of Kilobytes

Microcontrollers have kilobytes, not megabytes. Most of the savings come from five profile settings, avoiding the formatting machinery and finding the large symbols with cargo-bloat.

13 min read
Summarize in AI
Flat illustration for the post “Rust Firmware Size Optimization Down to Tens of Kilobytes”, composed around perf.

A microcontroller with 64 KiB of flash does not have room for a surprise. A firmware that fit last week stops fitting after someone adds a line that prints a temperature, and the linker reports an overflow of a few kilobytes with no hint about which line did it. Shrinking firmware is detective work: measure, find the biggest consumer, replace it with something smaller, measure again. The detective part is easy to get wrong because the intuitive suspects, the optimization level and your own code, are rarely the culprits.

This guide runs that process on a real firmware. It starts at 1,160 bytes, adds the features that real firmware adds, watches the size jump to 32,128 bytes because of one innocent-looking write!, finds the cause with a symbol listing, and brings it back down to 1,368 bytes without losing the feature. Every number below was measured on Rust 1.99 with edition 2024, building for thumbv7em-none-eabihf (Cortex-M4F) with cortex-m-rt 0.7.7, cortex-m 0.7.10 and panic-halt 1.0.0. The code was not run on a board, since size depends on what is compiled, not on running it.

What counts as size

Three numbers get called "size", and mixing them up wastes hours. The ELF file that Cargo produces includes debug information, symbol tables and section headers. None of that goes to the chip. The flash footprint is the code, read-only data and initial values of variables: in the llvm-size output, text plus data. The RAM footprint is data plus bss, plus the stack and any heap you chose to add.

Shell
rustup component add llvm-tools
$(rustc --print sysroot)/lib/rustlib/aarch64-apple-darwin/bin/llvm-size target/thumbv7em-none-eabihf/release/fwsize

Replace aarch64-apple-darwin with your host's triple. The rust-size command from the cargo-binutils package is a shorter way to run the same tool. For this guide, the number reported is the first column, text, which on Cortex-M includes the 1,024-byte interrupt vector table that the runtime places at the start of flash. Subtract it if you want to see only your code; it is constant for a given chip.

The experiment: one firmware, several features

The test firmware reads an analog sample from a register and writes a pin. Three Cargo features add the things real firmware adds. index smooths the readings with indexed array access. fmt reports a temperature as text with write! and a floating-point conversion, and fmtint does the same with an integer. A feature called manual prints the same temperature using hand-written digit conversion. The build script sets the optimization level through environment variables so the same source can be measured under different profiles.

The core of the formatting variant is ordinary Rust that any developer would write:

src/main.rsrust
use core::fmt::Write;

pub struct Uart;

impl Write for Uart {
    fn write_str(&mut self, s: &str) -> core::fmt::Result {
        for b in s.bytes() {
            unsafe { core::ptr::write_volatile(0x4001_1004 as *mut u32, u32::from(b)) };
        }
        Ok(())
    }
}

pub fn temperature(raw: u32) {
    let celsius = raw as f32 * 0.0806 - 50.0;
    let _ = write!(Uart, "temp {:.1} C (raw {})\n", celsius, raw);
}

That is eighteen lines, and it implements the standard core::fmt::Write trait on a serial port so that write! works. It looks like the kind of code you would write in the first hour of a project.

The results

Text
plain, opt-level 3                           1184
plain, opt-level s                           1160
plain, opt-level z                           1168
indexing, opt-level s                        1436
fmt + float, opt-level s                     32128
fmt + float, opt-level z                     32120
fmt + float, z, lto, cgu=1                   17364
integer-only write!, opt-level s             4148
hand-written digits, opt-level s             1368

The numbers are bytes of flash. Read them as a sequence of lessons, because each row teaches one thing.

The optimization level barely matters here

The first three rows differ by 24 bytes. Choosing opt-level = "s" over 3 saved 24 bytes, and "z", the most aggressive size setting, was larger than "s" by 8. On a program with almost no code, there is nothing for the optimizer to shrink. The optimization level matters on large programs with real loops and inlining, and even there it is a tuning step, not the solution. If someone's answer to "the firmware is too big" is "set opt-level = z", this table is the reply: measure what is big first. The same table also shows why: when a program grew by thirty kilobytes, switching from s to z recovered 8 bytes.

Bounds checks have a price

The fourth row adds a smoothing function with three indexed array accesses. The firmware grew by 276 bytes. Each samples[i % 16] can panic if the index is out of range, so the compiler emits a comparison and a branch to a panic routine for each one. The panic routine takes the file and line number as arguments, which means the string constants for the location are stored in flash too. Rust code with many indexing expressions pays that per site. You can remove the check where you can prove it unnecessary: iterate with iter() instead of indexing, use get(i) and handle None, or use chunks_exact. Avoid get_unchecked unless profiling proves it matters, since it trades 20 bytes for undefined behavior if you are wrong.

One write! costs 31 kilobytes

The fifth row is the one that ruins projects. Adding a function that formats one floating-point number grows the firmware from 1,160 bytes to 32,128. The code you wrote is a few dozen bytes. The rest is the standard formatting machinery, linked in because {:.1} on a float needs to turn a binary number into decimal digits correctly, and correct float printing is an algorithm, two algorithms in fact, with large lookup tables.

You do not have to guess which parts. The llvm-nm tool, run with --size-sort, lists symbols by size, and the biggest entries tell the story:

Shell
$(rustc --print sysroot)/lib/rustlib/aarch64-apple-darwin/bin/llvm-nm --size-sort --demangle --print-size target/thumbv7em-none-eabihf/release/fwsize | tail -8
Text
08000040 000003c0 R __INTERRUPTS
08001cdc 00000490 T core::num::imp::flt2dec::strategy::grisu::format_exact_opt
08001658 000004ea T core::str::count::do_count_chars
08007410 00000510 R core::num::imp::flt2dec::strategy::grisu::CACHED_POW10
0800216c 00000862 T core::num::imp::flt2dec::strategy::grisu::format_shortest_opt
0800538e 00000a78 T core::num::imp::flt2dec::strategy::dragon::mul_pow10
080029ce 0000146c T core::num::imp::flt2dec::strategy::dragon::format_exact
08003e3a 00001554 T core::num::imp::flt2dec::strategy::dragon::format_shortest

The second column is the symbol's size in hexadecimal. The two largest are dragon::format_shortest (0x1554, 5,460 bytes) and dragon::format_exact (0x146c, 5,228 bytes), followed by dragon::mul_pow10 (2,680 bytes), the grisu variants (2,146 and 1,168 bytes) and a table of cached powers of ten (1,296 bytes). Grisu is the fast path for float to decimal conversion and Dragon is the exact fallback when Grisu cannot decide. The standard library includes both so that every float prints correctly, and your firmware pays for both whether or not it ever reaches the fallback. This is the single most common cause of an embedded Rust binary that is far larger than its source suggests.

Row six repeats the formatting build with lto = true and codegen-units = 1, and the size falls from 32,120 to 17,364 bytes, a 46 percent drop. With a single compilation unit and whole-program optimization, the compiler can see that parts of the formatting code are unreachable from your use and discard them. It is the cheapest big win available, it costs only build time, and it belongs in every firmware release profile:

Cargo.tomlTOML
[profile.release]
opt-level = "s"
lto = true
codegen-units = 1
panic = "abort"
debug = true      # stays in the ELF, never reaches flash

Note debug = true. Debug information does not increase the flash footprint, as the first section explained, and it lets a debugger show source lines. Do not strip it from the development profile to chase size.

Integers are cheaper than floats, and nothing is cheaper than no formatting

Row seven formats only an integer with write!(Uart, "raw {}\n", raw), and the size falls to 4,148 bytes. The integer path of core::fmt is small compared to the float path but still brings the Formatter, the padding logic and the Arguments machinery: about 3 KiB over the plain program. For many devices that is acceptable, and it keeps your code readable. If it is not, the last row prints the same temperature with no core::fmt at all.

src/main.rsrust
/// Print tenths of a degree as "-12.3" with no `core::fmt` and no floats.
pub fn temperature(raw: u32) {
    let tenths = (raw as i32).wrapping_mul(806) / 1000 - 500; // 0.0806 C per count, minus 50 C
    let mut n = tenths.unsigned_abs();
    if tenths < 0 {
        put(b'-');
    }
    let mut digits = [0u8; 10];
    let mut len = 0;
    for slot in digits.iter_mut() {
        *slot = b'0' + (n % 10) as u8;
        len += 1;
        n /= 10;
        if n == 0 && len >= 2 {
            break;
        }
    }
    for (k, d) in digits.iter().take(len).rev().enumerate() {
        if k + 1 == len {
            put(b'.');
        }
        put(*d);
    }
    put(b'\n');
}

It computes in fixed point: counts times 806 over 1000 gives tenths of a degree as an integer, with no floating point anywhere, and digits are produced by repeated division by ten. The result is 1,368 bytes, an increase of only 208 over the plain firmware, against 30,968 for the version that used {:.1}.

The first version of that function was 3,920 bytes

That 1,368 deserves a footnote, because the first attempt at the manual formatter measured 3,920 bytes, almost three times larger, and the reason is the lesson of the "indexing" row in disguise. The first draft wrote digits[len] = ... and sliced digits[..len]. Both can panic, and a panic from an out-of-range index is built with core::fmt::Arguments, which references the formatting code for usize. One indexing expression in a function was enough to link in the integer formatting path that the function was written to avoid. Replacing the indexing with iterators, iter_mut() and take(len), which cannot go out of range, removed every panic site, and the size fell to 1,368. The same applies to unwrap, expect, slice ranges and arithmetic when overflow checks are on. If you are fighting for bytes, search the disassembly for calls to core::panicking, or list the symbols that mention fmt, and trace each one back to the line that required it.

The tools for finding out

Three commands cover most investigations, and you should use them before editing anything.

llvm-size -A prints every section with its size, which shows whether code, constant data or RAM is the problem. llvm-nm --size-sort --demangle --print-size, shown above, ranks symbols; the tail is the answer. And cargo bloat --release --crates (from the cargo-bloat crate) groups the same information by crate, which answers "is it my code, core, or this one dependency?" in one table. In a typical oversized firmware the crate table shows core or compiler_builtins near the top, and the next step is to ask which of your lines pulled it in. Work from the biggest symbol down, change one thing, and rebuild; the measurement loop takes seconds.

Monomorphization: does a trait object save space?

Generic functions are compiled once for every type they are used with, and the usual advice for shrinking a binary is to replace a generic with a trait object so that one copy serves all types. That advice is sometimes right and often untested, so test it. The experiment calls one small function with four element types, u8, u16, u32 and bool, in two forms: a generic weighted<T: Copy + Into<u32>> marked #[inline(never)], so that each copy stays visible, and a version taking &dyn Fn(usize) -> u32 that fetches each element through a closure.

Text
none       1160
generic    1416
dynamic    1600

The generic version added 256 bytes to the baseline, about 64 bytes per instantiation. The trait-object version added 440, nearly twice as much, because each call site now builds a closure and a vtable and the function body has an indirect call inside its loop. The abstraction that was supposed to save space cost more than the duplication it removed. The rule that survives is narrower: a trait object pays off when the function body is large, hundreds of bytes or more, and the per-type difference is small. A 64-byte function copied four times is cheaper than any indirection. Measure with the symbol listing, which shows each monomorphized copy as its own entry (weighted<u8>, weighted<u16> and so on), and only then decide.

Put a budget in CI

Size regressions arrive one innocent commit at a time, and the commit that crosses the line is never the one that caused the problem. A budget turns the slow drift into a failing check. The script below builds the release binary, reads the text column, and fails if it exceeds a limit you set a little above today's number.

ci/check-size.shShell
#!/bin/sh
set -eu

LIMIT=${1:?usage: check-size.sh <max-bytes>}
BIN=target/thumbv7em-none-eabihf/release/fwsize
SIZE=$(rustc --print sysroot)/lib/rustlib/$(rustc -vV | sed -n 's/host: //p')/bin/llvm-size

cargo build --release --quiet
used=$("$SIZE" "$BIN" | awk 'NR==2 {print $1 + $2}')   # text + data = flash footprint

echo "flash used: $used bytes (limit $LIMIT)"
if [ "$used" -gt "$LIMIT" ]; then
    echo "over budget by $((used - LIMIT)) bytes" >&2
    exit 1
fi

Run against the plain firmware with a generous and then a tight limit, it behaves like this:

Text
flash used: 1184 bytes (limit 2000)

flash used: 1184 bytes (limit 1000)
over budget by 184 bytes

The second run exits with status 1. Wire it into the pipeline and a change that adds a float format fails loudly with the overage, on the pull request that caused it, instead of surfacing at a linker error on release day. Set the limit about 5 percent above the current size so ordinary work does not trip it, and move the limit down when you win bytes back. Printing the number on every run, passing or not, gives you a free history in the CI logs. For a more detailed view on pull requests, attach the output of cargo bloat --release --crates as a comment, so reviewers see which crate grew.

The order to work in

Compress the work into a fixed order and you waste less time. First turn on lto = true, codegen-units = 1 and panic = "abort", which is five minutes of work and, as the formatting rows showed, can remove nearly half of an oversized binary. Second, list the biggest symbols and look at the top ten; if core::fmt or core::num::flt2dec appears, you have found the budget. Third, remove or replace the culprit: drop float formatting, move text to the host with defmt, or hand-write the conversion. Fourth, hunt panic sites, because each one carries a location string and may drag formatting along, as the 3,920-byte formatter showed. Only fifth, tune opt-level, and measure s against z and 3 rather than assuming, since the best choice differs for code that is mostly branches and code that is mostly arithmetic.

Habits that keep a firmware small

  • Keep floating-point formatting off the device. Send raw integers and format on the host, or use fixed-point as above.
  • Prefer defmt for logging. It sends an index and the arguments in binary and leaves the text on your computer, so a log statement costs a few bytes of flash instead of a format string plus the formatting code.
  • Be wary of generic functions called with many types. Each type gets its own copy (monomorphization), and a function used with eight types is eight functions. When the body is large and the type difference is tiny, take a &dyn Trait or convert to a common type early.
  • Choose panic-halt or panic-abort for release builds. A panic handler that formats a message with the location pulls in formatting code, and panic-semihosting is a debugging tool, not a shipping one.
  • Review dependencies before adding them. A crate that parses JSON or does date math can add tens of kilobytes. Check with cargo bloat after adding one.
  • Enable lto = true and codegen-units = 1 and keep them on. They cost nothing at runtime.

On nightly there is more to try. -Z build-std rebuilds core with your settings, and panic_immediate_abort removes the panic message machinery entirely. Those options were not measured here, and they require a nightly toolchain, so adopt them only once the stable options are exhausted.

When size is the wrong thing to optimize

If the firmware fits with room to spare, stop. A 20 percent smaller binary that took a week to achieve and made the formatting code harder to read is a bad trade on a chip with 512 KiB of flash. Optimize size when you hit a limit, when a smaller chip would cut the bill of materials across thousands of units, or when an over-the-air update has to fit a tight radio budget. Optimize speed or power instead when those are the constraints, since the settings do not always agree: opt-level = 3 can be faster and larger, and wfi sleeping saves more battery than any code size win. For the groundwork these numbers rest on, see embedded Rust from blinking an LED to real firmware.

One last caution about the budget number itself. It is only comparable between builds that use the same toolchain, the same dependency versions and the same profile. A compiler upgrade can move the total by a few hundred bytes in either direction, so record the Rust version next to the limit and re-baseline deliberately when it changes. Pinning the toolchain in rust-toolchain.toml makes that a decision you take on purpose, not a failure that appears when a new stable release lands on a Tuesday.

Taggedsize
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.