单分区 + 恢复分区策略:Recovery 分区设计
讲完双分区,咱们聊聊另一种常见方案——单分区加恢复分区。说白了,就是系统只有一个主分区跑固件,再单独划一块区域做“急救室”。这个急救室,就是 Recovery 分区。
我最早接触这个设计是在做智能家居网关的时候。那会儿 Flash 只有 4MB,双分区根本塞不下。后来发现,很多 IoT 设备其实都面临同样的尴尬——资源有限,但又不能没有安全升级的能力。
Recovery 分区长什么样?
Recovery 分区不跑业务逻辑,它的任务很纯粹:负责升级主分区,以及在主分区挂了的时候把人救回来。
它的典型布局是这样的:
| 分区 | 大小(典型值) | 内容 | 可升级? |
|---|---|---|---|
| Bootloader | 64KB | 启动引导,校验入口 | 一般不升级 |
| Recovery | 256KB ~ 512KB | 最小化固件内核 + 升级引擎 | 可升级(谨慎) |
| Main(主分区) | 剩余空间 | 完整业务固件 | 是 |
| Config / NVS | 64KB | 配置参数、升级标记 | 是 |
关键点:Recovery 分区必须足够小,但又得包含完整的升级能力——网络协议栈、Flash 驱动、校验算法,一个都不能少。我习惯把它控制在 256KB 以内,超过这个数,就得考虑是不是塞了不该塞的东西。
单分区升级流程
流程其实不复杂,但每一步都有坑。我画了张图,你先看个全貌:
流程看着挺顺,对吧?但实际跑起来,每一步都可能翻车。我一个个说。
第一步:下载固件到 Main 分区
这里有个容易忽略的问题——下载过程中 Main 分区还在跑旧固件。你想想看,一边写 Flash,一边从 Flash 读代码执行,搞不好就冲突了。我建议的做法是:把下载缓冲区放在 RAM 里,写完一块校验一块,再批量写入 Flash。这样能避免边跑边写的尴尬。
第二步:校验固件完整性
下载完别急着复位。先算一遍哈希,跟固件包里带的签名比对。我见过一个项目,下载完直接复位,结果发现固件包在传输过程中丢了一个包——设备直接变砖。从那以后,我坚持下载完成后必须做全量校验,少一步都不行。
第三步:设置升级标记
这个标记通常写在 Config 分区或者 Flash 末尾的某个固定扇区。Bootloader 启动时第一个读的就是它。标记的内容一般包括:
- 升级状态(待升级 / 升级中 / 升级完成)
- 固件版本号
- 校验值(可选)
小技巧:我习惯在标记里额外存一个“尝试次数”字段。如果 Recovery 升级失败,Bootloader 可以重试几次,而不是直接放弃。这个字段在后面的回滚机制里也有用。
失败回滚机制
单分区最让人头疼的就是升级失败怎么办。没有双分区那样的“备份区”可以切回去,所以回滚机制得靠 Recovery 分区来兜底。
常见的失败场景有三种:
- 下载过程中断电——Main 分区被写了一半,固件不完整。
- 校验失败——固件包损坏或签名不对。
- 新固件启动后跑不起来——比如硬件外设初始化失败,或者业务逻辑有 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 分区不是用来跑业务的,它是用来救命的。别往里面塞多余的东西。