3. 恢复策略核心设计:双备份机制(A/B分区)、恢复分区设计、版本回滚策略、断点续传机制
固件升级一旦中断,设备变砖的风险就摆在眼前。我做了这么多年嵌入式,踩过最大的坑就是——升级到一半断电,系统起不来了。用户骂娘,售后返厂,成本全砸在自己手里。
所以这一章,咱们把恢复策略的核心设计掰开揉碎讲清楚。说白了,就是四个东西:双备份分区、恢复分区、版本回滚、断点续传。这四个组合起来,基本能覆盖 99% 的中断场景。
3.1 双备份机制(A/B分区)
双备份,也叫 A/B 分区。原理很简单:系统里放两份固件,一份跑着,一份待命。升级时写空闲的那份,写坏了也不影响当前运行。
核心思想:永远保留一个可启动的固件副本。升级失败时,直接切回旧版本,用户无感。
3.1.1 分区布局
我习惯把 Flash 分成这么几块:
| 分区 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| Bootloader | 0x08000000 | 64 KB | 启动引导,决定从 A 还是 B 启动 |
| Slot A | 0x08010000 | 512 KB | 主固件副本 A |
| Slot B | 0x08090000 | 512 KB | 主固件副本 B |
| Recovery | 0x08110000 | 128 KB | 最小恢复系统 |
| Config | 0x08130000 | 16 KB | 启动计数、升级状态标记 |
嗯,这里要注意:Bootloader 必须足够小且稳定。它只做一件事——判断从哪个 Slot 启动。我见过有人把 Bootloader 写得过于复杂,结果 Bootloader 自己先挂了,那才是真·变砖。
3.1.2 启动决策逻辑
Bootloader 的决策流程,我用伪代码表示一下:
void bootloader_main() {
// 读取升级状态标记
upgrade_status_t status = read_config(UPGRADE_STATUS_KEY);
if (status == UPGRADE_SUCCESS) {
// 升级成功,从目标 Slot 启动
boot_from_slot(status.target_slot);
} else if (status == UPGRADE_IN_PROGRESS) {
// 升级中断!回滚到另一个 Slot
rollback_to_previous_slot();
// 标记回滚事件
write_config(ROLLBACK_EVENT, 1);
} else {
// 正常启动,从当前活跃 Slot 启动
boot_from_slot(get_active_slot());
}
}
为什么要有 UPGRADE_IN_PROGRESS 这个状态?我在项目里遇到过:升级时突然断电,固件只写了一半。下次上电,Bootloader 发现标记还在,就知道「哦,上次没搞完」,直接切回旧版本。用户甚至不知道发生过升级失败。
个人经验:升级状态标记建议写两次。第一次写「准备升级」,第二次写「升级完成」。万一写标记时断电,至少不会出现「标记已清除但固件没写完」的尴尬局面。
3.2 恢复分区设计
双备份虽然好,但有个前提——两个 Slot 都得是好的。如果 A 和 B 都坏了呢?比如用户强行刷了错误的固件,两个分区都覆盖了。这时候就需要一个「兜底」方案:恢复分区。
恢复分区,说白了就是一个最小化的固件。它只包含:
- 基本的硬件初始化(时钟、内存、Flash)
- USB 或 UART 通信驱动
- 固件下载和烧写功能
我把它叫做「救生艇」。平时用不上,但真到了 A/B 都挂了的时候,它就是最后一根稻草。
3.2.1 恢复分区触发条件
Bootloader 里我会加一个「启动计数」逻辑:
- 每次启动,在 Config 分区里记一笔。
- 如果连续 3 次启动都失败(比如系统跑飞、看门狗复位),Bootloader 就不再尝试从 A/B 启动了。
- 直接跳转到恢复分区,等待用户通过 USB 或串口重新烧录固件。
注意:恢复分区必须是只读的。我见过有人把恢复分区放在可擦写的区域,结果升级时不小心把恢复分区也覆盖了……嗯,那设备就真成砖了。
3.3 版本回滚策略
版本回滚,不是简单的「切回旧版本」就完事了。你想想看,如果新版本有严重 bug,用户升级后设备反复重启,这时候自动回滚是好事。但如果新版本只是界面变了,功能没问题,用户想保留新版本呢?
所以回滚策略要分场景:
| 场景 | 触发条件 | 回滚行为 |
|---|---|---|
| 升级中断 | 升级过程中断电或通信断开 | 自动回滚到旧版本,无需用户干预 |
| 启动失败 | 新版本连续 3 次启动失败 | 自动回滚,并记录错误日志 |
| 用户主动回滚 | 用户通过界面或指令触发 | 手动回滚,保留新旧版本供选择 |
| 版本兼容性检查失败 | 新固件与硬件版本不匹配 | 拒绝升级,提示用户下载正确版本 |
我个人习惯在 Config 分区里维护一个版本链表。每次升级成功,就把版本号、时间戳、CRC 校验值写进去。回滚时,Bootloader 遍历这个链表,找到上一个可用的版本。这样即使回滚多次,也不会丢失历史记录。
避坑指南:我曾经遇到过一个问题——回滚后,新版本的一些配置参数(比如 Wi-Fi 密码、校准数据)被旧版本覆盖了。后来我加了一个「配置分区隔离」,把用户配置和固件镜像分开存储。回滚时只换固件,不动配置。
3.4 断点续传机制
断点续传,说白了就是「上次传到哪里,这次接着传」。这个在 OTA 升级里特别重要。你想,一个 2MB 的固件,通过蓝牙传可能要 10 分钟。传了 9 分钟断开了,从头再来?用户不骂娘才怪。
3.4.1 实现思路
断点续传的核心是分块传输 + 进度记录:
- 把固件分成固定大小的块(比如 1KB 一块)。
- 每传完一块,在 Flash 里记录当前块号。
- 如果传输中断,下次连接时先读取已完成的块号,从下一块开始传。
代码实现大概长这样:
// 断点续传状态结构体
typedef struct {
uint32_t total_blocks; // 总块数
uint32_t completed_blocks; // 已完成块数
uint32_t block_size; // 每块大小(字节)
uint32_t crc32_partial; // 已接收数据的 CRC
uint8_t slot_target; // 目标分区(A 或 B)
} resume_state_t;
// 保存断点状态
void save_resume_state(resume_state_t *state) {
write_flash(RESUME_STATE_ADDR, (uint8_t*)state, sizeof(resume_state_t));
}
// 恢复断点
resume_state_t load_resume_state() {
resume_state_t state;
read_flash(RESUME_STATE_ADDR, (uint8_t*)&state, sizeof(resume_state_t));
return state;
}
3.4.2 校验策略
断点续传最怕什么?怕续传时数据对不上。比如上次传的块有 bit 翻转,这次接着传,最后拼出来的固件是坏的。
我的做法是:
- 每块单独校验:每个数据块都带 CRC,接收端收到后立即校验。校验失败就重传这一块。
- 整体校验:所有块传完后,对整个固件做一次 SHA256 校验。如果对不上,说明中间有块出了问题,全部重传。
一个小技巧:断点续传的进度记录要写在独立的 Flash 扇区里。为什么?因为进度记录要频繁擦写,如果和固件放在同一个扇区,擦除时会连带把固件数据也抹掉。我一般单独划一个 4KB 的扇区专门存进度。
3.5 核心逻辑总览
说了这么多,咱们用一张图把整个恢复策略串起来:
这张图把整个流程串起来了。你从「升级开始」往下走,遇到中断走左边,正常完成走右边。校验失败回退重传,校验通过写入分区。最后重启,Bootloader 根据状态标记决定从哪个分区启动。
嗯,这套设计我在三个量产项目里验证过,覆盖了智能家居网关、工业控制器和车载 T-Box。到目前为止,没有因为升级中断导致返厂的案例。说白了,就是「宁可多花一点 Flash 空间,也要把容错做足」。