单分区 + 恢复分区策略:Recovery 分区设计

讲完双分区,咱们聊聊另一种常见方案——单分区加恢复分区。说白了,就是系统只有一个主分区跑固件,再单独划一块区域做“急救室”。这个急救室,就是 Recovery 分区。

我最早接触这个设计是在做智能家居网关的时候。那会儿 Flash 只有 4MB,双分区根本塞不下。后来发现,很多 IoT 设备其实都面临同样的尴尬——资源有限,但又不能没有安全升级的能力。

Recovery 分区长什么样?

Recovery 分区不跑业务逻辑,它的任务很纯粹:负责升级主分区,以及在主分区挂了的时候把人救回来。

它的典型布局是这样的:

分区 大小(典型值) 内容 可升级?
Bootloader 64KB 启动引导,校验入口 一般不升级
Recovery 256KB ~ 512KB 最小化固件内核 + 升级引擎 可升级(谨慎)
Main(主分区) 剩余空间 完整业务固件 是
Config / NVS 64KB 配置参数、升级标记 是

关键点:Recovery 分区必须足够小,但又得包含完整的升级能力——网络协议栈、Flash 驱动、校验算法,一个都不能少。我习惯把它控制在 256KB 以内,超过这个数,就得考虑是不是塞了不该塞的东西。

单分区升级流程

流程其实不复杂,但每一步都有坑。我画了张图,你先看个全貌:

单分区 + Recovery 分区升级流程 1. 下载固件到 Main 2. 校验固件完整性 3. 设置升级标记 4. 系统复位 5. Bootloader 检查标记 6. 跳转 Recovery 执行升级 7. 覆盖 Main 分区 8. 清除标记,重启进入 Main ⚠ 注意:下载时 Main 分区 还在跑旧固件,不能断电。 我习惯在下载完成后做一次 CRC 全量校验再复位。 💡 复位后,Bootloader 看到 升级标记,直接跳 Recovery。 不经过 Main 分区。

流程看着挺顺,对吧?但实际跑起来,每一步都可能翻车。我一个个说。

第一步:下载固件到 Main 分区

这里有个容易忽略的问题——下载过程中 Main 分区还在跑旧固件。你想想看,一边写 Flash,一边从 Flash 读代码执行,搞不好就冲突了。我建议的做法是:把下载缓冲区放在 RAM 里,写完一块校验一块,再批量写入 Flash。这样能避免边跑边写的尴尬。

第二步:校验固件完整性

下载完别急着复位。先算一遍哈希,跟固件包里带的签名比对。我见过一个项目,下载完直接复位,结果发现固件包在传输过程中丢了一个包——设备直接变砖。从那以后,我坚持下载完成后必须做全量校验,少一步都不行。

第三步:设置升级标记

这个标记通常写在 Config 分区或者 Flash 末尾的某个固定扇区。Bootloader 启动时第一个读的就是它。标记的内容一般包括:

  • 升级状态(待升级 / 升级中 / 升级完成)
  • 固件版本号
  • 校验值(可选)

小技巧:我习惯在标记里额外存一个“尝试次数”字段。如果 Recovery 升级失败,Bootloader 可以重试几次,而不是直接放弃。这个字段在后面的回滚机制里也有用。

失败回滚机制

单分区最让人头疼的就是升级失败怎么办。没有双分区那样的“备份区”可以切回去,所以回滚机制得靠 Recovery 分区来兜底。

常见的失败场景有三种:

  1. 下载过程中断电——Main 分区被写了一半,固件不完整。
  2. 校验失败——固件包损坏或签名不对。
  3. 新固件启动后跑不起来——比如硬件外设初始化失败,或者业务逻辑有 bug。

针对这些场景,回滚策略是这样的:

失败场景 检测时机 回滚动作
下载中断 / 校验失败 下载阶段或校验阶段 不清除旧固件,直接复位回 Main
Recovery 升级过程掉电 Bootloader 启动时 重新进入 Recovery,重试升级(最多 3 次)
新固件启动后崩溃 Watchdog 超时或应用层心跳丢失 Bootloader 检测到启动失败,回退到 Recovery

⚠ 我曾经踩过的坑:有一次,新固件启动后看起来一切正常,但跑了 5 分钟才崩溃。Watchdog 虽然能复位,但 Bootloader 没有判断“这次启动是否成功”的机制。结果设备反复重启,每次都在新固件里跑 5 分钟再挂掉。

后来我加了一个“启动成功标记”——新固件启动后,必须在 30 秒内主动写一个“我活着”的标记到 Config 分区。如果 Bootloader 没读到这个标记,就认为启动失败,直接跳 Recovery 等用户干预。

回滚的代码骨架

下面是一个简化的 Bootloader 回滚逻辑,你可以参考:

// Bootloader 启动流程(伪代码)
void bootloader_main() {
    upgrade_flag_t flag = read_upgrade_flag();

    if (flag.state == UPGRADE_PENDING) {
        // 有升级任务,跳 Recovery
        jump_to_recovery();
    }

    if (flag.state == UPGRADE_IN_PROGRESS) {
        // 上次升级被中断了
        flag.retry_count++;
        if (flag.retry_count < MAX_RETRY) {
            write_upgrade_flag(flag);
            jump_to_recovery();  // 再试一次
        } else {
            // 重试次数用完了,回滚到旧固件
            flag.state = UPGRADE_ROLLBACK;
            write_upgrade_flag(flag);
            jump_to_main();      // 用旧固件启动
        }
    }

    if (flag.state == UPGRADE_DONE) {
        // 新固件启动成功标记?
        if (check_boot_success_flag()) {
            // 一切正常,清除标记
            clear_upgrade_flag();
            jump_to_main();
        } else {
            // 新固件没跑起来,回滚
            flag.state = UPGRADE_ROLLBACK;
            write_upgrade_flag(flag);
            jump_to_recovery();  // 进 Recovery 等用户处理
        }
    }

    // 默认情况:正常启动
    jump_to_main();
}

核心思想:单分区 + Recovery 的回滚,本质上是“保底”——不保证升级成功,但保证设备不彻底变砖。Recovery 分区就是最后一道防线。只要它还在,设备就有救。

Recovery 分区自身的升级

Recovery 分区要不要升级?我的答案是:尽量不升,如果必须升,要格外小心。

为什么?因为 Recovery 是救命的。如果升级 Recovery 本身失败了,那整个设备就真的变砖了——连救自己的程序都坏了。

我见过一个方案,Recovery 升级采用“双备份”策略:在 Flash 里保留两份 Recovery 镜像,一份当前运行,一份用于升级。升级时先写备份区,校验通过后再切换。代价是多占一份空间,但安全系数高很多。

如果你的 Flash 实在挤不出这个空间,那就只在 OTA 升级时顺带升级 Recovery,并且必须保证 Recovery 的升级过程是原子操作——要么全写完,要么不动。断电保护、校验、重试,一个都不能少。

我个人习惯:Recovery 分区只在设备出厂时烧录一次。后续的 OTA 升级只更新 Main 分区。除非遇到必须改 Recovery 的严重 bug,否则不动它。你想想看,Recovery 本身越稳定,整个升级体系就越可靠。

小结

单分区 + Recovery 策略,适合 Flash 资源紧张、但又需要安全升级的场景。它的核心是:用一个小而精的 Recovery 分区,守护一个大而全的 Main 分区。回滚机制不复杂,但细节决定成败——升级标记怎么写、重试几次、启动成功怎么判断,这些都得想清楚。

嗯,这一节就到这里。记住一句话:Recovery 分区不是用来跑业务的,它是用来救命的。别往里面塞多余的东西。