OTA 断电变砖——擦完旧区没备份就跳,加双备份 + 标记有效区再切
文章标签:#嵌入式开发 #单片机 #Flash #OTA升级 #OTA #远程升级 #Bootloader #固件 #双备份
摘要:OTA 升级最怕中途断电——运行区只剩残包,设备直接变砖。本文从真实事故切入,剖析原地覆盖的"真空期"缺陷,给出 A/B 双备份 + 有效标记 + 原子切换的防砖方案:新固件先写备份区,整包 CRC32 + magic 校验通过后才切换启动槽,断电自动回退原区。附错误/正确代码、时序图与实测对比。
一、开篇:那个"升级到一半断电"的噩梦
做一款联网设备,OTA 流程是:收到新固件 → 直接擦掉运行区 → 写入新固件 → 跳转。一次现场升级时,用户恰好在"擦完旧固件、新固件才写 30%"时断电。再次上电,运行区是半截残包,Bootloader 跳过去直接 HardFault,设备变砖,只能返厂烧录。复现率虽低,但一旦发生就是 100% brick。
如果你做 OTA 还是"原地覆盖",这一讲能救你的售后。我会从双备份分区讲起,给你一套"断电可恢复"的 A/B 升级方案,并附实测对比。
全文导航:入门认知(为什么原地升级会砖)→ 进阶原理(A/B 分区与有效标记机制)→ 高级应用(错误流程复现 + 正确 Bootloader 代码 + 实测对比)。
二、入门认知:OTA 的本质是"换座位"
OTA(Over-The-Air)升级,核心是"把新固件安全地放到能跑的地方,再切过去"。原地覆盖最大的问题是:在"擦旧"和"写新"之间存在一个"无可用固件"的真空期,此时断电必砖。
类比:你给行驶中的汽车换轮胎,直接把旧胎拆了才发现新胎没气——车只能停路边。正确做法是备好另一条满气胎,确认装好再拆旧的。
如图 1 展示原地覆盖(危险)与 A/B 双备份(安全)的差异:
图 1:原地覆盖 vs A/B 双备份升级流程
适用边界:Flash 够大(能容两份 App)才用 A/B;极小芯片可用"差分+外部 SPI Flash 备份"折中。
三、进阶原理:双备份 + 有效标记如何防砖
关键三要素:
- 双区:Slot A、Slot B 各一份 App,当前跑 A 时新包写 B。
- 有效标记:每个 Slot 头部存
magic + 版本 + CRC。Bootloader 只跳"magic 正确且 CRC 通过"的区。 - 原子切换:用独立的一小段"启动槽标志"(存另一页 Flash)记录"下次从哪启动",写好新包并校验通过后再更新该标志。
断电场景推演:写到 B 中途断电 → B 的 magic/CRC 不完整 → 下次启动 Bootloader 检测 B 无效,仍从 A 启动 → 设备照常工作,可重试升级。
易混淆点对比:
| 方案 | 断电可恢复 | 存储开销 | 复杂度 |
|---|---|---|---|
| 原地覆盖 | 否(变砖) | 1 份 | 低 |
| A/B 双备份 | 是 | 2 份 | 中 |
| 外部 SPI+回滚 | 是 | 1.5 份 | 高 |
[坑] 高频翻车:校验只算"长度对"不算 CRC,残包长度凑巧对就误判有效。必须整包 CRC32 校验 + magic 双保险。
断电场景推演(时序图):
四、高级应用:复现与根治
错误流程演示(伪代码)
voidota_bad(uint8_t*pkg,uint32_tlen){flash_erase(APP_ADDR,len);// 擦旧固件flash_write(APP_ADDR,pkg,len);// 这里断电 → 残包,变砖jump_to_app(APP_ADDR);}正确完整代码(双备份 Bootloader 核心)
#defineSLOT_A0x08004000#defineSLOT_B0x08020000#defineFLAG_ADDR0x08002000// 启动槽标志页typedefstruct{uint32_tmagic;uint32_tver;uint32_tcrc;}hdr_t;#defineGOOD_MAGIC0x544F5441// "TOFA"/* 校验某 slot 头部 + 整包 CRC */intslot_valid(uint32_taddr,uint32_tlen){hdr_t*h=(hdr_t*)addr;if(h->magic!=GOOD_MAGIC)return0;if(crc32((uint8_t*)(addr+sizeof(hdr_t)),len-sizeof(hdr_t))!=h->crc)return0;return1;}/* 升级:写另一个 slot,校验通过后切标志 */voidota_good(uint8_t*pkg,uint32_tlen,uint32_tcur_slot){uint32_tdst=(cur_slot==SLOT_A)?SLOT_B:SLOT_A;flash_erase(dst,len);flash_write(dst,pkg,len);// 写备份区,当前区照常跑if(!slot_valid(dst,len)){report_fail();return;}// 校验不过不切flash_write_word(FLAG_ADDR,dst);// 原子切启动槽}/* Bootloader 启动:只跳有效 slot */voidboot(void){uint32_tslot=*(uint32_t*)FLAG_ADDR;if(slot!=SLOT_A&&slot!=SLOT_B)slot=SLOT_A;if(!slot_valid(slot,APP_LEN))slot=(slot==SLOT_A)?SLOT_B:SLOT_A;// 无效回退if(slot_valid(slot,APP_LEN))jump_to_app(slot);elsehard_fault_trap();// 双区都坏,进恢复模式}Bootloader 启动决策流程:
改前 vs 改后实测对比
| 指标 | 原地覆盖 | A/B 双备份 |
|---|---|---|
| 升级中途断电 | 100% 变砖 | 0(回退原区) |
| 售后返厂率 | 高 | 趋近于 0 |
| 存储占用 | 1 份 | 2 份 |
[注意] 切换标志本身也要防断电:可写两副本 + 各自 CRC,读取时多数表决;或先把标志写入再擦旧区,保证"任何时刻至少一个可用固件"。
五、收尾:要点复盘与最佳实践
核心要点:① 原地覆盖有"真空期",断电必砖;② A/B 双备份 + 有效标记 + 原子切换;③ 校验必须整包 CRC,不只看长度。
最佳实践清单(可收藏):
- OTA 绝不原地覆盖,用 A/B 或外部备份。
- 每个固件包带 magic + 版本 + CRC,Bootloader 只跳校验通过者。
- 切启动槽是最后一步,且写前确保新包已完整校验。
- 双区都坏要进"恢复模式"(如 UART/USB DFU)而非直接崩。
- 升级成功建议软复位一次确认可启动(见第74讲)。
进阶延伸:可加"滚动版本号 + 签名验签"防降级攻击与固件篡改;A/B 还可配合"健康心跳"——新固件跑满 N 秒才确认提交,否则回滚。
完整 OTA 升级流程总览: