
做 BLE 设备的 OTA 升级最怕的不是传输慢而是传输到一半连接断了。我之前遇到过一个现场项目设备放在会展现场做固件更新手机和开发板隔了堵墙用户走着走着蓝牙就断了升级进度条直接卡在 72%。更麻烦的是那块板子因为写入了一半的固件数据已经无法正常启动最后只能靠现场重新烧录器救回来。那次之后我就明白一件事BLE OTA 升级的设计真正的技术含量不在“怎么写固件”而在“断连之后怎么活下来、怎么自己恢复”。这篇文章就专门聊聊 BLE 断连后的 OTA 恢复设计包括分区规划、升级标志位、断点续传、回滚机制和踩坑排查。适合正在做嵌入式设备联网升级的工程师参考尤其是负责量产设备维护的朋友。1. 为什么 BLE OTA 断连会直接“要命”1.1 BLE 连接本身的脆弱性BLE 链路跟 Wi-Fi、以太网完全不是一回事。BLE 的物理层带宽上限本来就只有 2Mbps实际有效数据速率去掉协议开销、应答开销之后往往只有几十 KB/s 到一百多 KB/s。传输一个大几十 KB 的固件包已经很慢了如果固件超过 256KB整个升级过程可能需要几分钟到十几分钟。在这段时间里BLE 连接的稳定性受很多因素影响手机和设备的距离稍微拉开信号强度衰减非常快。人体遮挡、金属外壳、现场其他 2.4G 设备的干扰都会导致丢包率上升。手机系统后台可能冻结 App 进程导致连接事件长时间没有应答。连接参数连接间隔、从设备延迟配置不合理时传输窗口会非常碎。这就是问题所在OTA 升级需要长时间保持连接而 BLE 恰恰是短距离、低功耗、容易断的无线链路。一旦在数据传输过程中断连设备端可能正处于 Flash 写入的半程状态固件数据不完整应用无法启动最终变成一块需要人工救回的砖。1.2 升级中断的三种典型后果我把升级中断导致的结果分为三类第一类是“数据不完整但系统还能跑”。比如升级包还没写完就断了此时旧固件还没有被覆盖复位后设备仍能进入旧固件正常运行。这种情况危害最小但要注意 Flash 中残留的临时数据会不会被误判为可用固件。第二类是“正在擦写主固件分区时断电或断连”。Flash 擦除和写入过程中如果突然停止分区里可能既有旧数据又有新数据或者全是 0xFF。此时引导程序无法判断哪个镜像可用设备可能卡死在启动阶段。第三类是“新固件写完但校验失败旧固件已经被覆盖”。这种情况最麻烦因为旧固件已经被破坏新固件又不可用设备没有可启动镜像只能靠外部烧录器恢复。所以恢复设计的核心目标非常明确在任何异常中断条件下设备都能自动回到一个已知的可用状态至少保证还有可启动的固件最坏情况下也能重新进入升级模式等待新一轮 OTA。1.3 恢复设计的本质是“兜底思维”做 OTA 升级的恢复逻辑跟写普通业务代码不一样。普通代码假设输入正常、状态正确就行最多处理几个 return false。OTA 恢复设计必须假设每一刻都可能出问题连接可能断、Flash 可能写坏、校验可能失败、设备可能随时被断电。我个人的设计原则是把“出问题”当成默认路径把“顺利完成”当成意外来对待。所有关键节点都要有状态记录所有跳转都要有回退方案。这不是悲观而是量产设备运维的基本素养。2. 恢复设计第一步把 Flash 存储分区规划清楚2.1 分区规划是所有恢复策略的地基很多开发者在设计 OTA 时优先写传输协议、写下载逻辑却忽略了最基础的事情固件和临时数据在 Flash 里怎么放。没有清晰的分区规划所有断连恢复都是空中楼阁。一个典型的支持安全恢复的 Flash 分区布局是这样的分区用途大小建议Bootloader启动引导、升级决策、恢复入口16KB ~ 32KBApp 主区当前运行的应用程序固件根据固件大小定App 备份区上一版本固件备份与主区一致Download 临时区新固件下载暂存≥ 单个固件包大小Flag 信息区升级标志、状态记录4KB ~ 16KB2.2 双区备份与单区方案的取舍双区备份方案App A / App B是目前最保险的做法。新固件先下载到 Download 区校验通过后写入备份区或非活动区最后切换启动标志。如果新固件启动失败Bootloader 可以回滚到另一个区。代价是 Flash 占用翻倍成本高一些。单区加 Download 临时区的方案是性价比更高的选择。固件先完整写入 Download 区校验通过后擦除 App 区再把新固件搬过去。整个过程只有一段“擦除 App 区 搬运”的脆弱窗口如果这段过程发生异常恢复手段就比较有限。所以这套方案必须配合良好的启动标志和回滚机制。我的建议是如果 Flash 容量允许尽量上双区备份。尤其是产品已经进入量产阶段现场没法靠人肉烧录器救砖双区是省心的基础。如果 Flash 实在紧张也要保证 Download 区能完整容纳一个固件包不要在 App 区直接做边擦边写。2.3 Flag 信息区必须单独划出来升级标志Flag绝对不能放在 App 区内部也不能和 Download 区混在一起。标志位要放在一个独立的、极少擦除的小分区里专门记录当前升级状态。这个分区里的数据在每次状态切换时更新保证设备复位后能正确判断下一阶段该做什么。一个合理的升级状态机需要记录以下信息当前升级状态空闲、下载中、下载完成、写入中、启动切换固件版本号和回滚版本号已接收数据块序号用于断点续传总数据长度和校验值用于完成性校验很多人会忽略标志位的存储可靠性。Flash 在有电压波动或有异常写操作时是有可能写坏单个字节的。关键标志位最佳实践是同时存三份读取时三个字节取多数一致的那个值投票机制。不要小看这个细节它能避免很多莫名其妙的“为什么设备重启后进入了错误状态”的诡异问题。3. 断连后的核心恢复机制怎么设计3.1 Bootloader 的升级决策流程恢复设计的执行者是 Bootloader不是 App 固件。因为 App 固件可能已经损坏无法启动所有恢复逻辑必须放在独立于 App 的启动引导区。设备上电后的启动流程应该是读取 Flag 信息区的升级状态。若状态为“空闲”直接跳转启动 App 主区。若状态为“下载中”说明上次升级在下载阶段就中断了。此时直接擦除 Download 区重置状态为“空闲”并启动 App 主区。除非你想支持断点续传否则 Download 区残留数据没有保留意义。若状态为“下载完成但未写入”说明网络传输已完成但还没开始搬固件。可以进入升级流程的写入阶段。若状态为“写入完成待启动”先校验目标 App 区数据校验通过则切换启动否则回滚到另一个可用分区。这里的关键是Bootloader 永远不要假设 App 区是完整的。每次启动前都做一次镜像完整性校验至少校验头部、长度和 CRC成本不高但能避免很多启动问题。3.2 断点续传到底该不该做断点续传是 BLE OTA 恢复设计里一个经常被过度设计的点。很多人一听到断连就想到断点续传觉得传了一半不能浪费要记录到哪里断了下次接着传。但说实话BLE 传输效率本来就低一个几十几百 KB 的固件包完整重传一次十几分钟一般用户能接受。在某些业务场景里反而更省心。我推荐的做法是只在 Download 区做块级续传不做字节级续传。把固件包分成若干固定大小的块比如每块 4KB每成功接收一块就在 Flag 区记录块序号。断连重连后直接从最后一块未完成的块开始传或者干脆全部重传。全量重传的优点是逻辑简单缺点是多花几分钟时间。块级续传的优点是节省时间缺点是复杂度和出错概率上升。我的经验是如果固件小于 256KB全量重传就够用如果固件超过 512KB才值得做块级续传。3.3 断连检测与自动重连设备作为从机Peripheral时检测断连主要靠两个信号链路断连回调和数据超时。很多协议栈会在断连时触发连接参数更新或直接回调断连事件。如果在接收数据过程中一段时间内没有收到主机的任何包也要主动判定连接异常避免死等。实现自动重连时一个重要技巧是升级过程中断开连接后设备不应立刻恢复到正常的广播参数。正常广播可能间隔 100ms 甚至更久设备重新被发现的时间很长。OTA 失败后用户已经等了几分钟再让他干等十几秒搜索设备体验会很差。我的做法是检测到 OTA 连接断开后设备进入一个“快速重连模式”用 20ms 到 30ms 的广播间隔持续广播 30 秒。这段时间内只要手机重新扫描就能立即发现设备并重连。超过 30 秒没有重连再恢复到正常广播参数降低功耗。3.4 看门狗与超时兜底独立看门狗是恢复设计的最后一道防线。在 OTA 下载和写入过程中主循环必须定期喂狗。如果某个环节卡死比如 Flash 擦除异常、I2C 总线锁死、协议栈异常看门狗超时后强制复位Bootloader 会根据当前升级状态决定下一步动作。看门狗超时时间要校准。太短会导致写入 Flash 时擦写耗时较长误触发复位太长则失去了兜底意义。我一般设置为 Flash 最大单操作耗时的 2 到 3 倍同时在耗时操作前主动喂一次狗在耗时操作后立即补喂。这里有一个坑在擦除 Flash 的大扇区时有些芯片的擦除时间可能达到几百毫秒到一秒以上如果看门狗超时设置在 500ms就会误复位。务必先查清所用芯片擦除所需的最长时间。4. 实际实现从状态机到 Flash 写入的硬核细节4.1 OTA 升级状态机的完整定义整个 OTA 升级过程可以抽象成这样一个状态机IDLE空闲状态无升级任务。READY已建立连接已下发升级命令等待接收固件数据。DOWNLOADING正在接收固件数据并写入 Download 区。DOWNLOADED固件包下载完成校验通过。INSTALLING正在把固件从 Download 区搬入 App 区或擦写 App 区。VERIFIING启动新固件前的完整性校验。ROLLING_BACK校验失败回滚旧固件。每次状态切换时先把新状态写入 Flag 区再执行动作。这个顺序很重要。如果先执行动作再写状态中途断电会导致状态和数据不一致。举个例子从 DOWNLOADED 切换到 INSTALLING 时流程是将 Flag 区状态字段改为 INSTALLING。擦除 App 区。从 Download 区搬运数据到 App 区。校验 App 区数据。将 Flag 区状态改为 VERIFIING。复位并进入 Bootloader。如果在第 3 步中断电复位后 Bootloader 看到状态是 INSTALLING就知道 App 区可能是半成品此时立即回滚到备份区或者重新从 Download 区安装一次。4.2 BLE 升级包的发送与接收处理BLE OTA 传输可以基于自定义服务也可以基于扩展广播和分段数据。这里分享我在自定义服务上实现的高效传输处理逻辑。首先上层把固件包切成固定大小的数据块。块大小要根据 MTU 和传输效率综合定。BLE 默认 MTU 是 23 字节如果不去协商更大 MTU有效载荷只有 20 字节传一个大固件包会非常痛苦。一定要在连接建立或升级开始前通过交换 MTU 请求把 MTU 提升到 247 字节这样单包有效载荷可以达到 244 字节效率提升十几倍。块大小建议设置为 MTU 有效载荷的整数倍常规配置是 4KB 一块。这样每次写 Flash 就是一次完整的扇区写入操作减少 Flash 擦写次数。接收端的伪代码逻辑大致是这样void on_ota_data_received(uint8_t *data, uint16_t len) { uint16_t block_seq (data[0] 8) | data[1]; // 块序号 uint16_t offset_in_block (data[2] 8) | data[3]; // 块内偏移 if (block_seq ! current_block_seq) { // 说明收到的是重复包或乱序包直接丢弃 send_error_response(ERROR_SEQ_MISMATCH); return; } // 写入 Download 区这边要确保地址对齐 uint32_t flash_addr download_base_addr block_seq * BLOCK_SIZE offset_in_block; if (flash_write(flash_addr, data 4, len - 4) ! SUCCESS) { send_error_response(ERROR_FLASH_WRITE_FAIL); enter_recovery_flow(); return; } // 如果写完了最后一块做 CRC 校验 if (offset_in_block (len - 4) BLOCK_SIZE) { current_block_seq; send_block_ack(current_block_seq); } }块内偏移写进协议头里是让我在实际项目中少踩坑的一个设计。如果只记录块序号传输过程中一个块只发一次一旦中途断连重传只能整块重发。有了块内偏移支持断点续传时就能精确续上单个块内中断的位置。4.3 写入 Flash 的正确顺序先写标志再写数据Flash 操作是 OTA 最容易出错的地方。这里有几个经验法则第一先擦除再写。Flash 擦除是按扇区进行的写之前必须保证目标扇区已经是擦除状态全 0xFF。不要尝试边擦边写那样很容易出现地址错乱。第二先写状态标志再擦写应用数据。如果先擦除了应用数据、再写状态标志断电就会造成状态标志丢失无法判断 App 区是否完整。第三写标志时同样先擦除标志区扇区再写入。很多 Flash 芯片对已经写入过的地址再次写入会失败因此写标志前必须保证标志扇区为擦除状态。第四大固件搬运过程建议按块擦写。从 Download 区搬到 App 区时逐块擦写并把校验穿插进去避免一次性整区擦除后长时间暴露在脆弱窗口里。4.4 双区备份与回滚策略的具体落实如果采用双区备份方案Bootloader 的启动决策需要记录一个“当前活动分区”的索引。比如 Flag 区里有一个字节记录 A/B 哪个区是当前启动区。正常启动流程若当前活动区为 A尝试启动 A。启动后 App 内部自检若自检通过则在运行中上报“启动成功”。若 App 在启动超时时间内没有上报成功或者根本没有正常进入 App 的 main 函数Bootloader 收到超时信号后把当前活动区切换为另一个分区从 A 切到 B并复位重试。这种方案要求 Bootloader 在跳转 App 之前启动一个内部的通用定时器同时 App 侧在 main 函数最前面尽快通知 Bootloader“我已成功启动”并清除该定时器。这种两个分区互相兜底的结构可以在新固件崩溃时自动回滚旧固件效果非常稳。有一个细节跳过 Bootloader 的启动超时设计直接让 Bootloader 校验代码后再跳转看起来简单省事但很多固件 bug 是运行几分钟后才暴露的内存泄漏、外设初始化卡死等。我还是强烈建议加入 App 运行健康上报机制哪怕简单一点也行。5. 常见问题与排查技巧实录5.1 升级完成后设备反复重启是怎么回事这是最典型的回滚触发场景。Bootloader 切到新固件后新固件因为某些原因无法正常启动或者运行后崩溃被看门狗复位或触发异常。Bootloader 发现新固件连续 N 次启动失败后就会自动回滚。遇到这种情况排查思路是先看新固件是否能在单独烧录器烧录后稳定运行。如果单独烧录也崩溃那就是固件本身的代码问题与 OTA 流程无关。再看是不是新旧固件的分区地址不一致。比如新固件里改了链接脚本基址偏移了但 OTA 代码还是按旧地址写入就会导致 App 区内容错乱。还要检查是否开启了 RTC 或其他外设的保留 RAM。启动新固件时如果外设寄存器状态残留也可能导致崩溃。我踩过一个坑新固件在初始化外部 Flash 时因为没有处理“上一次 OTA 还没搬运完 Flag”这种情况直接在启动时读取了 Download 区的脏数据导致后续初始化流程异常。后来加了启动时的状态清理问题才解决。5.2 断连后广播一直不出来手机搜不到设备发生这种情况优先检查是不是广播参数没有恢复。很多协议栈在连接断开后会使用断开前最后一次广播参数。如果断开的瞬间广播间隔还被设置成很长的值比如 OTA 过程中为了省电故意调大广播间隔重新广播的发现时间会非常长。应对方法很直接在断连回调里将广播间隔重置为 20ms 到 40ms连续广播 30 秒到 60 秒确保用户能立刻搜到。这里要留意的是过短的广播间隔会增加瞬时功耗所以 30 秒后要切回正常广播间隔。还有一种是设备在升级过程中修改了蓝牙地址因为某些方案为了保证安全会重新生成地址导致原来配对过的手机无法通过白名单发现。建议 OTA 期间不要修改蓝牙地址保持原地址不变重连成功后再按新地址处理。5.3 Flash 擦写寿命到底要不要操心Flash 的擦写次数通常在数千次到数万次。OTA 升级每一次完整升级至少会擦写一次 Download 区、一次 App 区。按一天升级五次、连做十年算总共也就不到两万次擦写对绝大多数 Flash 来说余量还能接受。真正需要警惕的是“频繁失败触发反复擦写”。如果设备断连后反复下载、反复失败、反复擦除 Download 区几天下来就可能把 Download 区的寿命耗尽。解决办法是在 Download 区内做磨损均衡或者限制同一分区连续擦除次数。对大厂方案来说多花一点代码换 Flash 寿命很值。5.4 日志记录是恢复问题的唯一线索量产设备看不到串口所有调试信息只能靠日志。推荐把关键升级事件和错误码写入一个独立的日志分区至少记录以下内容每次升级的启动时间和结束时间。状态机每次切换的内容。失败原因码连接断开、Flash 写入失败、CRC 校验失败等。成功启动的是哪个分区启动失败几次。这样现场售后人员只需要读取日志读取指令就能知道设备经历了什么而不是让负责研发的同学远程拍脑袋“你恢复一下出厂设置看看”。6. 聊聊我踩过的一些坑先说一个印象深刻的现场事故。某个量产设备因为体积限制Flash 容量吃紧我采用单区 Download 临时区的方案。固件包很大下载阶段一切正常但在从 Download 区搬运到 App 区的过程中现场环境突然断电。重启后 Bootloader 检测到 App 区数据不完整进入回滚流程。可当时我“省脑子”没做备份区旧固件早被覆盖了设备直接变砖。那次之后我彻底改了设计方案宁可外挂 Flash 也坚决上双区备份。说白了省一块 Flash 芯片的钱换来现场大批退货风险这笔账怎么算都不划算。再说一个软件细节上的坑。我最初实现断点续传时块序号从 0 开始结果有一部分设备在重启后Flag 区读到的是上一次下载的末块序号而 Download 区已经被清空设备抓着一个无效的序号等下一块数据白白浪费了大量时间重传。后来我在块序号前加了一个“升级会话 ID”每次开始升级都重新生成一个随机 ID重连后必须会话 ID 匹配才继续传输否则重新开始下载。这个小小的防御式设计省了我无数个现场救火电话。最后分享一个小技巧OTA 升级期间设备要始终保持低功耗模式并尝试延长连接事件。发送完一包数据后立即进入休眠等待下一包触发唤醒。别看这个优化小在手机端走远、链路质量变差时少掉几包数据往往就能决定一次升级能不能完成。BLE 的低功耗特性用好了才是真的让 OTA 体验从“勉强能用”变成“相当稳定”的分水岭。