Rust on Bare Metal: Rewriting Our Bootloader Without a Single Page Fault

Why we rewrote Tainuo’s MCU bootloader in Rust, eliminating an entire class of memory bugs that had plagued firmware OTA updates for years.

1. The Bootloader Problem

Tainuo’s industrial control boards ship with an MCU that manages power sequencing, watchdog timers, and over-the-air (OTA) firmware updates. The bootloader — roughly 8,000 lines of C — had been stable for years, but “stable” in the embedded sense means “the bugs are known and we have workarounds.”

Every 6–9 months, a new OTA edge case would surface: a buffer overflow during firmware image verification, an off-by-one in the flash erase routine, a use-after-free in the CAN bus command dispatcher. Each of these bugs required a full recall-cycle OTA fix, which on a deployed fleet of industrial controllers is a multi-week ordeal.

2. Why Rust for Bare Metal

In early 2026, we decided to rewrite the bootloader in Rust targeting thumbv7em-none-eabihf (ARM Cortex-M7). The decision wasn’t ideological — it was economic:

  • Memory safety at compile time: the borrow checker eliminates the entire class of use-after-free, double-free, and buffer overflow bugs that accounted for ~70% of our bootloader CVEs.
  • Zero-cost abstractions: Rust’s no_std ecosystem gives us RAII, typed state machines, and exhaustive pattern matching without pulling in a runtime or allocator.
  • Ecosystem maturity: embedded-hal, cortex-m, cortex-m-rt, and probe-rs are now production-grade, with active maintenance and commercial support.

3. The Architecture

Our bootloader is structured as a finite state machine enforced at the type level:

enum BootState {
    PowerOn(Peripherals),
    VerifyFirmware(Peripherals, FlashHandle),
    ReadyToBoot(VerifiedImage),
    Fallback(Error),
}

impl BootState {
    fn transition(self) -> Self {
        match self {
            Self::PowerOn(p) => self.init_peripherals(p),
            Self::VerifyFirmware(p, f) => self.verify_image(p, f),
            // Compiler guarantees every state is handled
        }
    }
}

The key insight is that invalid states are unrepresentable. You cannot accidentally call jump_to_firmware() while the image verification is still in progress, because jump_to_firmware only accepts a VerifiedImage — and the only way to construct one is through the verification path.

4. Flash Safety

The flash writing routine uses Rust’s ownership model to enforce that the flash peripheral handle (pac::FLASH) is moved into the erase/write function and returned only after unlocking the controller. There’s no global mutable state, no volatile uint32_t*, and no accidental stray writes to protected sectors.

The resulting binary, compiled with opt-level = "s" and LTO, came in at 14 KB — slightly smaller than the 16 KB of the original C bootloader, primarily due to more aggressive dead-code elimination enabled by Rust’s stricter aliasing rules.

5. What Changed

Since deploying the Rust bootloader to our test fleet (50 boards, 3 months), we’ve observed:

  • Zero memory-related crashes during OTA updates (previously: ~2 per 1000 update cycles)
  • 30% faster firmware verification due to compiler autovectorization of checksum routines using iterators
  • Significantly simpler CIcargo test with QEMU emulation covers 85% of boot paths without physical hardware

The Rust rewrite didn’t just fix bugs — it changed how the team approaches firmware development. New contributors now reach for Rust first on bare-metal projects, and we’ve started porting the main application firmware (currently ~40,000 lines of C) module by module.