裸机 Rust 实战:重写 Bootloader,零内存错误的背后

解密泰诺使用 Rust 重写 MCU Bootloader 的过程,如何从根本上消除困扰固件 OTA 多年的内存类缺陷。

1. Bootloader 的困局

泰诺的工业控制板卡上搭载一颗 MCU,负责电源时序管理、看门狗和 OTA 固件升级。Bootloader 约 8000 行 C 代码,已"稳定"运行多年——但在嵌入式语境下,“稳定"往往意味着"已知的坑都补上了,未知的等待下一次现场翻车”。

每隔 6 到 9 个月,新的 OTA 边界场景就会浮现:固件镜像校验时的缓冲区溢出、Flash 擦除例程的 off-by-one、CAN 总线命令分发器中的 use-after-free。每一次修复都需要全量 OTA 推送,而针对已部署的工控设备群,一次全量 OTA 是以周为单位的痛苦过程。

2. 为什么要在裸机上用 Rust

2026 年初,团队决定用 Rust 重写 Bootloader,目标平台为 thumbv7em-none-eabihf(ARM Cortex-M7)。这个决定不是出于技术信仰,而是工程经济学的考量:

  • 编译期内存安全:借用检查器在编译阶段就消灭了 use-after-free、double-free 和缓冲区溢出——这几类问题占据了我们 Bootloader CVE 的约 70%。
  • 零成本抽象:Rust 的 no_std 生态提供了 RAII、类型化状态机和穷尽模式匹配,却不引入任何运行时和分配器。
  • 生态成熟度embedded-halcortex-mcortex-m-rtprobe-rs 已达到生产级水准,有活跃维护和商业支持。

3. 架构设计

我们的 Bootloader 被建模为一个类型级强制的有限状态机:

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),
            // 编译器保证每个状态都被处理
        }
    }
}

核心设计原则:非法状态不可表达。你不可能在镜像校验尚未完成时意外调用 jump_to_firmware(),因为该函数只接受 VerifiedImage 类型——而构造该类型的唯一路径就是通过校验流程。

4. Flash 安全写入

Flash 写入例程利用 Rust 的所有权模型来保证安全:Flash 外设句柄(pac::FLASH)被移动到擦除/写入函数中,仅在解锁控制器后才被返回。不存在全局可变状态,不存在裸指针 volatile uint32_t*,更不存在对受保护扇区的意外写入。

编译时启用 opt-level = "s" 和 LTO 后,最终二进制大小仅 14 KB——略小于原始 C 版本(16 KB)。缩小的主因是 Rust 更严格的别名规则启用了更激进的死代码消除。

5. 部署后的变化

自 Rust Bootloader 部署到测试机群(50 块板卡,持续 3 个月)以来,我们观察到:

  • OTA 更新期间零内存相关崩溃(此前:每千次更新约 2 次)
  • 固件校验速度提升 30%,得益于迭代器模式触发的编译器自动向量化
  • CI 流水线大幅简化——cargo test + QEMU 仿真覆盖了 85% 的启动路径,无需物理硬件

Rust 重写不仅消灭了 Bug,更改变了团队的固件开发范式。新加入的工程师在裸机项目上会首先考虑 Rust,主应用固件(目前约 4 万行 C 代码)的模块化移植也已经开始。