2. 固件存储结构:Flash存储器基础、Bootloader与App分区、分区表设计、冗余备份机制(A/B系统)

上一章我们聊了固件升级的整体概念。这一章,咱们得把目光聚焦到硬件底层——固件到底存在哪里?怎么组织的?

说白了,嵌入式设备的存储核心就是 Flash。你写的代码、跑的系统、存的配置,全得靠它。我刚开始做嵌入式那会儿,总觉得 Flash 就是个“大号 EEPROM”,结果踩了不少坑。今天咱们就把这块彻底讲透。

2.1 Flash 存储器基础

Flash 是一种非易失性存储器。断电不丢数据,这是它的基本盘。但跟 RAM 不一样,Flash 有三大特性你必须刻在脑子里:

  • 按扇区擦除:不能像写 RAM 那样直接覆盖。想改一个字节?得先把整个扇区擦干净,再写进去。
  • 擦写寿命有限:NOR Flash 一般 10 万次,NAND Flash 更少,几千到几万次。别拿它当日志盘用。
  • 读快写慢:读一个字节几十纳秒,写一个字节几十微秒,擦一个扇区可能要几百毫秒。

嵌入式里最常见的是 NOR Flash 和 NAND Flash。NOR 适合存代码,因为支持 XIP(片上执行),CPU 可以直接从 Flash 里取指令跑。NAND 密度大、成本低,但坏块多,一般用来存文件系统或大容量数据。

我的经验: 做 Bootloader 时尽量用 NOR Flash。我有个项目为了省成本用了 NAND,结果 Bootloader 还得先把自己拷贝到 RAM 里才能跑,启动时间多了 200ms,被客户投诉了。

2.2 Bootloader 与 App 分区

一个典型的固件存储布局,至少分成两大块:Bootloader 分区和App 分区。

Bootloader 是上电后第一个跑的程序。它负责初始化硬件、校验 App 的完整性,然后跳转到 App 执行。App 就是你的业务代码——传感器采集、通信协议、控制逻辑,全在里头。

为什么非要分开?

  • 安全隔离:App 写崩了,Bootloader 还能救回来。
  • 升级独立:升级时只擦 App 区,Bootloader 不动,保证设备不会变砖。
  • 启动链清晰:Bootloader → App,责任分明。

我见过一些新手把 Bootloader 和 App 编译成一个固件烧进去。嗯,升级一次就变砖一次。后来他们改成了分区方案,世界清净了。

2.3 分区表设计

分区表就是一张“地图”,告诉 Bootloader 和 App:哪个区域存什么,从哪里开始,有多大。

一个典型的分区表长这样:

分区名称 起始地址 大小 用途
Bootloader 0x08000000 64 KB 启动代码、升级入口
App A 0x08010000 512 KB 主应用(当前运行)
App B 0x08090000 512 KB 备用应用(升级目标)
Config 0x08110000 16 KB 设备参数、校准数据
Log 0x08114000 48 KB 运行日志(循环写入)

分区表的设计原则,我总结了几条:

  • 对齐扇区边界:每个分区的起始地址必须是 Flash 扇区大小的整数倍。否则擦除时会擦到隔壁分区,数据就乱了。
  • 预留冗余空间:App 分区别卡得太死。我有个项目 App 固件 480KB,分区只给了 512KB,结果加了两个功能就超了,还得改分区表重新烧 Bootloader。
  • 分区表本身要保护:最好把分区表放在 Bootloader 区里,或者单独划一个只读区域。App 不能随便改分区表,否则升级时可能把自己搞死。
避坑指南: 分区表里千万别写死 App 的入口地址。用偏移量或者相对地址,这样以后调整分区大小不用改代码。我曾经在分区表里硬编码了 0x08010000,后来想给 Bootloader 多留 16KB,结果所有 App 都得重新编译链接。

2.4 冗余备份机制(A/B 系统)

这是固件升级里最经典、最可靠的方案。说白了就是准备两份 App 分区:A 区和 B 区。当前运行的是 A,升级就写到 B;下次启动切到 B,如果 B 跑不起来,自动回退到 A。

为什么需要 A/B 系统?

  • 升级失败零风险:断电、传输错误、固件损坏,都不怕。旧的版本还在。
  • 支持回滚:新版本有 bug?Bootloader 可以自动切回旧版本。
  • 后台升级:设备正常运行时,在后台把新固件下载到备用分区,下次重启生效。

A/B 系统的核心逻辑,我用一段伪代码来描述:

// Bootloader 中的 A/B 切换逻辑
void bootloader_main() {
    // 读取启动标志(存在 Config 分区)
    uint8_t boot_flag = read_boot_flag();

    if (boot_flag == BOOT_FROM_A) {
        if (verify_app(PARTITION_A)) {
            jump_to(PARTITION_A);
        } else if (verify_app(PARTITION_B)) {
            // A 坏了,切到 B
            set_boot_flag(BOOT_FROM_B);
            jump_to(PARTITION_B);
        }
    } else { // BOOT_FROM_B
        if (verify_app(PARTITION_B)) {
            jump_to(PARTITION_B);
        } else if (verify_app(PARTITION_A)) {
            set_boot_flag(BOOT_FROM_A);
            jump_to(PARTITION_A);
        }
    }

    // 两个分区都坏了,进入恢复模式
    enter_recovery_mode();
}

App 侧在升级完成后,需要做一件事:设置“升级成功”标志。Bootloader 会在下次启动时检查这个标志,如果 App 没设置,就认为新版本启动失败,自动回滚。

注意: 升级成功标志一定要放在非易失存储里,而且写入时要保证原子性。我见过有人用两个字节分别写“升级中”和“升级完成”,结果写第一个字节后断电,Bootloader 读到“升级中”就以为升级失败了,来回切了三次才稳定。

下面这张图展示了 A/B 系统的完整工作流程:

A/B 系统升级流程 A/B 系统升级流程 设备上电 Bootloader 启动 读取启动标志 A 分区有效? B 分区有效? 跳转到 A 分区 跳转到 B 分区 进入恢复模式(等待升级) 升级完成,设置新启动标志 说明:虚线表示回退路径。当 A 分区无效时,自动尝试 B 分区;两个都无效则进入恢复模式等待升级。

A/B 系统的代价也很明显:Flash 容量翻倍。如果你的 Flash 只有 1MB,App 占 600KB,那就没法用 A/B。这时候可以考虑压缩固件,或者改用“单分区 + 外部存储”的方案。

不过话说回来,对于工业设备、汽车电子、医疗仪器这些场景,A/B 系统几乎是标配。多花一倍 Flash 的钱,换来的是现场零返修。你想想看,一个设备部署在偏远基站,升级失败变砖了,派人出差修的成本远不止那几块钱的 Flash。

我的习惯: 做 A/B 系统时,我会在 Config 分区里多存一个“升级计数器”。每次回滚就加一,连续回滚超过 3 次就锁定当前版本,不再自动切换。这样可以防止 Bootloader 在两个坏版本之间反复横跳。