ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

WiFi OTA断点续传实战:从原理到嵌入式实现

WiFi OTA断点续传实战:从原理到嵌入式实现 1. 项目概述为什么 WiFi OTA 需要断点续传前一阵在做一款智能家居网关的固件升级功能设备本身没有 USB 口、没有调试串口外接升级只能走无线。最初版本很简单WiFi 连上服务器把整个固件包拉下来写到 Flash重启。听起来挺顺实际一跑全是问题——屋里 WiFi 信号满格但路由器承载了十几台设备传输过程动不动就卡住一次 1MB 的固件包能失败三四回而且每次失败都要从头再下。后来我花了一周时间把整条链路重构成“断点续传”升级成功率从 60% 左右直接拉到 99.7%这里把这套方案的完整思路和技术细节整理出来。WiFi OTA 断点续传简单说就是通过 Wi-Fi 网络完成设备固件的空中升级并且当传输中断时已下载的部分不会白费设备会记住下载到了哪里下次连接时直接从断点继续传输。这个功能解决的核心问题有三个一是 WiFi 信号在家庭、工厂等真实环境里并不稳定干扰和丢包很常见二是物联网设备资源受限重启一次下载进度就清零用户体验极差三是设备往往部署在难以物理接触的位置升级失败轻则返厂重则直接变砖。适合正在做智能家居、传感器节点、无人机、边缘网关等项目需要提升 OTA 可靠性的开发者参考。这套方案的难点不在于“下载文件”本身而在于如何设计一套在掉电、断网、服务器切换、Flash 磨损等极端情况下仍然可靠的状态机。全文我会按照“整体设计思路 → 关键技术点 → 核心代码 → 问题排查”四个维度展开所有内容都来自真实调试经历不是贴文档式翻译。2. 整体架构与核心技术选型2.1 断点续传的整体工作流程断点续传本质上就是“记录 校验 续传”三个动作不断循环。设备端发起升级请求时先从服务器读取一个状态接口拿到当前固件的版本号、文件大小、分块总数、每块的校验值然后在本地 Flash 中找上次下载的记录。如果存在有效的下载记录并且记录里的固件版本和服务器一致就从最后一个完成写入的块开始继续如果记录不存在或版本不一致则清空旧数据从第 0 块开始。这里有个容易被忽略的点记录本身也必须存放在掉电不丢失的介质上同时要保证“记录”和“数据块”的一致性。我见过不少方案把进度存到 RAM 里一旦掉电就丢失这等于白做。还有的方案把进度写在数据区附近结果进度写坏了数据区也被连带污染实际效果比不做断点续传还差。我的做法是画出一个独立的 OTA 信息区专门存放下载状态和元数据和数据区分开擦写次数也错开。在此基础上传输层我建议优先考虑 HTTP而不是 MQTT 或私有 TCP 协议。原因很直观物联网后端基础设施里 HTTP 的文件服务最成熟Nginx、MinIO、阿里云 OSS 原生支持 Range 请求固件包上传分发链路直接用现成方案不需要自己造协议。MQTT 适合小报文遥测固件包动辄几百 KB连续传输大文件时要分帧、重组且没有标准断点续传语义工程复杂度反而更高。2.2 为什么选择 HTTP Range 而不是自定义协议断点续传的底层能力HTTP/1.1 标准里早就写好了就是 Range 请求头。客户端发起GET /firmware.bin时带上Range: bytes1024-服务器就会从第 1024 字节开始返回后续内容并附带206 Partial Content状态码。有了这个机制设备端不需要知道文件总大小只需要记录“我已经拿到多少字节”然后让服务器接着给就行。在实际项目里我更喜欢两级粒度同时做HTTP 层面用 Range 控制“从哪里继续”业务层面把固件包切成固定大小的块比如 4KB 或 8KB每一块有独立的序号和 CRC 校验。这样做的好处有三点。第一如果某一块在传输中损坏不需要重新下载整块数据只需要记录这一块的状态重连后单独补这一块。第二Flash 擦写是按扇区进行的块大小对齐扇区后可以大幅减少擦写次数延长 Flash 寿命。第三元数据可以做得非常紧凑每块只占 2 个字节记载状态几百 KB 的固件也只需几十个字节的状态位图放在独立 OTA 信息区绰绰有余。服务端是否支持 Range 请求其实很容易验证。用 curl 发个测试请求就行curl -I -H Range: bytes100- http://your-server/firmware.bin如果响应头里看到HTTP/1.1 206 Partial Content和Content-Range: bytes 100-xxx/xxx说明服务器支持断点续传。如果返回的是200 OK和完整的Content-Length说明服务器忽略了 Range 头这时候要么换个服务端组件要么在业务层自行实现分块请求不能依赖 HTTP 层了。2.3 服务端存储和分发的选型参考做这套方案时服务端我只放了两个接口一个是元数据查询接口返回固件版本、文件大小、分块大小、各块 MD5另一个是静态文件下载接口。元数据用 JSON 返回格式大致是这样{ version: 1.2.3, file_size: 524288, block_size: 4096, block_count: 128, blocks_md5: [ a1b2c3d4e5f6..., 9f8e7d6c5b4a... ], force_erase: false }固件包本身放在对象存储里MinIO、OSS 或者简单的 Nginx static 目录都行。MinIO 默认支持 Range 请求这一点在官方文档里也有说明所以“MinIO 支持断点续传吗”这个问题的答案其实是分成两层的MinIO 作为服务端存储天然支持 HTTP Range苹果系统浏览器或下载器里看到的是“服务端断点续传能力”而如果你说的是客户端 SDK 层面的“断点续传”那属于业务层逻辑MinIO 本身不管需要你的设备端自己实现。有一点我特别想提醒开发环境里常常用本地文件目录模拟对象存储但应尽量避免直接用 Spring Boot 内置的静态资源处理大文件因为默认实现会在请求过程中加载过多的上下文信息嵌入式 WiFi 设备环境下的异常中断会让连接回收变得不可控实际测试中失败率会明显上升。生产环境请务必在 Nginx 层把大文件下载单独配置或者直接走对象存储。3. 设备端断点续传的详细实现3.1 下载任务状态机设计整个下载过程我把它抽象成一个状态机状态切换的清晰程度直接决定了后续出问题的排查难度。核心状态有四个空闲、下载中、续传中、已完成。每个状态对应一组允许的事件和动作我用一个枚举来定义typedef enum { OTA_IDLE 0, OTA_DOWNLOADING, OTA_RESUMING, OTA_COMPLETED } ota_state_t;“空闲”状态下设备收到升级指令后会先做版本比对和目标固件大小检查通过后进入“下载中”。需要特别注意的是从“下载中”不能直接进入“已完成”必须先经过“续传中”或者重传缺失块。这是因为 WiFi 环境下传输中断太常见了如果直接认为“所有块都写成功”就切到完成态很可能漏掉还没校验的尾巴。每次收到数据块并成功写入 Flash 后程序都会更新 OTA 信息区中的状态位图。位图中的每个 bit 对应一个块置 1 表示该块已在 Flash 中校验通过置 0 表示未下载或校验失败。位图本身就充当了断点续传的“记忆”下次启动时只需扫描位图找到第一个不为 1 的 bit就知道该从哪个块开始继续。状态机里特别容易出问题的是超时处理。WiFi 连接缓慢、TCP 半开连接、服务器响应延迟超过设备看门狗超时时间这些情况都会导致状态机误判。我的经验是网络操作的超时设置一定比看门狗短并且任何“重连后回退”的动作都要有一个退避策略比如第 1 次失败后等 5 秒重试第 2 次等 10 秒最多等 60 秒避免设备反复重连导致网络瘫痪。3.2 版本号与下载记录的一致性保障很多断点续传方案做到中途就出问题根源在于版本号管理混乱。设备端不仅要记录“当前正在下载哪个版本”还要记录“上次启动时运行的是哪个版本”。这两个版本必须分开存因为设备从 OTA 模式回落到旧版本时如果固件版本号覆盖了旧版本号整个回滚判定就不成立了。我在 OTA 信息区设计了这样一组结构体typedef struct { uint32_t magic; uint32_t version_downloading; uint32_t version_running; uint32_t block_size; uint32_t total_size; uint32_t downloaded_size; uint8_t bitmap[32]; uint32_t crc32; } ota_info_t;magic字段用来判断这组记录是否有效建议用固定的魔数比如0xAF55AA01。每次更新记录后程序会重新计算整个结构体的 CRC32 并写到尾部读取时先校验 CRCCRC 不对就认为记录无效清空重建。这个设计主要是防止写入过程中掉电导致记录处于半个状态。版本一致性检查的逻辑是启动 OTA 时如果magic无效、或version_downloading不等于服务器下发的新版本号、或block_size与服务器配置不一致一律清空信息区重新开始全量下载。只有当所有字段都匹配才读取位图进入续传逻辑。这个判断原则宁可保守也不能激进因为一旦错误续传了不同版本的固件块拼凑文件校验时一定失败而且很难排查。3.3 Flash 分区规划与双备份设计Flash 空间的规划决定了一个 OTA 方案的上限。我的建议是至少划分四个区域Bootloader、App A当前运行、App B备用、OTA 信息区。App A 和 App B 是双备份关系每次升级写入到不活跃的那个区域升级完成后通过 Bootloader 切换启动入口这样即使新固件起不来Bootloader 也能回滚到旧版本。具体分区表举例假设 Flash 总共 2MBBootloader64KB地址 0x000000 - 0x00FFFFApp A896KB地址 0x010000 - 0x0FFFFFApp B896KB地址 0x100000 - 0x1FFFFFOTA 信息区32KB地址 0x1F8000 - 0x1FFFFFOTA 信息区虽然标了 32KB但每次写入的数据其实只有几十字节因为 Flash 擦写寿命有限所以这个区域我会分成两个扇区交替使用。第一次写扇区 0第二次写扇区 1第三次擦掉扇区 0 再写这样可以把擦写次数分散到两个扇区延长寿命。对于频繁升级的设备来说这个细节很关键。双备份方案在实现上有一个注意点App A 和 App B 的烧录地址必须与链接脚本保持一致否则固件内跳转地址全都会错。比如工程里默认链接脚本在 ROM 的 0x010000 处如果你想把固件烧到 App B 的 0x100000就必须提供另一个链接脚本并重新编译一份固件。这是嵌入式开发里地址不对导致的诡异 bug 的最常见来源。4. 核心代码实现从 HTTP 拉取到 Flash 写入4.1 HTTP 客户端与 Range 请求拼接设备端如果是 ESP32我直接用的 ESP-IDF 里的 esp_http_client 组件它自带 Range 请求支持传 header 时加上Range字段即可。核心代码结构如下esp_http_client_config_t config { .url http://192.168.1.100:8080/firmware.bin, .method HTTP_METHOD_GET, .timeout_ms 10000, .buffer_size 4096, .event_handler http_event_handler, }; esp_http_client_handle_t client esp_http_client_init(config); char range_header[64]; snprintf(range_header, sizeof(range_header), bytes%d-, download_offset); esp_http_client_set_header(client, Range, range_header); esp_http_client_open(client, 0);download_offset是上一次成功写入的字节数直接从 OTA 信息区读出来。用esp_http_client_open之后就可以通过esp_http_client_fetch_headers判断服务器返回的是不是206 Partial Content并读取Content-Length计算本次实际接收的数据总量。这里有一个特别容易踩的坑服务器返回的Content-Length可能不是整个固件的长度而是从断点位置开始到文件结尾的长度。比如固件总长 512KB断点在第 100KB那么Content-Length应该是 412KB。如果设备端忽略了这个细节继续按照“总长度 - 已下载长度”去计算剩余量就会得到错误结果。所以每次续传时都要同时读取并记录两个量请求的 Range 起始位置以及响应中的Content-Length。如果你用的不是 ESP-IDF而是 AT 指令或标准 socket那也完全可行只是需要自己处理 HTTP 报文解析。我建议不要手写解析完整 HTTP 头而是用一个轻量级的 HTTP 解析库比如 http-parser否则分块传输、chunked 编码、keep-alive 这些细节会让你调试到崩溃。4.2 固件块的校验与写入数据流从网络收到后不能直接盲写 Flash。我采用的做法是在内存中攒满一个块4KB 或 8KB先累加 CRC 或计算 MD5与元数据接口里的blocks_md5比对一致才执行擦写。校验失败则丢弃这一块让 TCP 层自动重传也就是让 HTTP 的请求继续拉后续数据但不对失败块做任何写入。这是一个看似简单但实际效果非常好的优化。因为 WiFi 链路的误码率远高于有线网络TCP 校验失败会重传但应用层的 HTTP 解析并不会感知比特翻转只有靠应用层的块校验才能兜底。实测下来加了块校验后OTA 升级后首次启动的失败率降了一个数量级。写入 Flash 时最核心的要求是“擦写分离”不要每收到一个小数据块就擦一次扇区。我的写入逻辑是这样的if (block_index block_first_in_sector || (block_index 0 (block_index * block_size) % sector_size 0)) { esp_partition_erase_range(partition, sector_start, sector_size); } esp_partition_write(partition, offset, block_buffer, block_size);每次block_index跨入一个新的扇区时先擦除整个扇区再将块数据连续写入。这样每个扇区只擦一次而不是每块擦一次。这个优化对 Flash 寿命影响巨大我的测试板上 Flash 擦写次数原本能到 10 万次如果每 4KB 就擦一次 4KB 扇区升级 1MB 固件要擦 256 次长期高频升级很快会磨损完而优化后同样的固件只需擦 128 次整整省了一半寿命损耗。4.3 断点记录更新时机与掉电安全什么时候更新断点记录我见过两种做法。一种是在每收到一块并成功写入后立即更新位图另一种是每收到 N 块才更新一次。前者的优点是掉电损失最小缺点是频繁写 Flash 会加速磨损。后者的优点是对 Flash 友好缺点是一旦掉电会重复下载最近 N 块数据。我最终采用的是“每成功写入一块就更新一次位图但记录结构体只在每 16 块或扇区切换时才写一次”。为什么可以这样因为位图存在 OTA 信息区结构体里存的是已下载总字节数这两个数据可以互相推导。实际掉电恢复时即使位图显示的块数和downloaded_size有偏差程序也会优先信任更保守的值多下几块数据再校验多传的数据量有限但安全性更高。掉电安全的另一个关键是写记录时的顺序。我必须强调先写数据块后写记录。如果顺序反了记录先更新了但数据还没落盘掉电后设备会认为那一块已经下载完成而实际数据是旧的最后校验失败。数据块先落盘、记录后更新即使掉电导致记录丢失也只会多做一次全量或增量下载不会出现“虚假完成”的状态。4.4 版本回滚与 Bootloader 切换策略下载完成后设备会写入一个“固件就绪”标记然后重启进入 Bootloader。Bootloader 的职责有两个校验新固件的完整性以及在启动超时或崩溃时回滚到上一个版本。我实现的方式是App 启动后立即上报当前版本到中心端正常运行时定期更新看门狗。Bootloader 维护一个启动计数器每次启动时先读计数器值如果 App 没有在 30 秒内“汇报成功”计数器加 1当计数器连续 3 次失败时Bootloader 切换到另一分区启动同时把计数器清零。这个机制简单但够用避免了设备升级后反复重启的尴尬。另外千万不要把“版本回滚”做成“重新拷贝”操作。双备份分区模式下回滚只需要修改启动指针指向旧版本即可千万不要做批量拷贝。拷贝大文件的时间足够看门狗咬合还可能出现拷贝到一半停电导致双分区全部损坏那就只能返厂了。5. 实操过程中的性能优化与参数调优5.1 传输块大小与并发窗口的权衡传输块大小的选择直接影响着下载速度和 Flash 磨损但很多人不重视。我测试了几组数值块大小 512 字节时校验频繁每块都要算一次 CRCCPU 占用率高速度只有 10KB/s块大小 16KB 时整块校验的数据缓冲较大如果解码到一半出错就要重新请求WiFi 恢复后重新传输的损失也高。最终在 4KB 到 8KB 这个区间取得了收益平衡既能提升传输效率又不会因为块太大导致单次校验失败需要重新拉取的数据量过大。如果你用 HTTP还可以开启 Keep-Alive 来减少 TLS/HTTP 握手带来的延迟开销。每次断点续传重新发起请求时如果 TCP 连接还保活着就可以省掉一次完整握手时间。我在 ESP32 上测试开启 Keep-Alive 后续传时整体耗时大约降低 20%效果相当可观。另一个容易被忽略的参数是 TCP 接收窗口。ESP32 的 lwIP 默认接收缓冲区可能只有 4KB如果服务器下发速率超过这个值数据会被内核丢包重传吞吐量上不去。我把CONFIG_LWIP_TCP_WND调到 32KBCONFIG_LWIP_TCP_RECVMBOX_SIZE调到 16实测下载速度从 60KB/s 提升到了 180KB/s提升非常明显。5.2 失败重试策略与退避算法WiFi 环境下一次完整的固件下载过程中出现多次中断是常态。所以重试策略必须设计得足够有弹性不能一失败就无脑重连、无脑重新请求。我整理出一套分级重试策略简单说明如下第 1 次中断立即重试但是从头重发当前块的 HTTP 请求不重新建立 TCP。第 2~4 次中断每次等待 2 秒、4 秒、8 秒递增退避重新建立 TCP 和 HTTP 请求但 Range 继续从断点开始。第 5 次以上中断等待 30 秒主动断开 WiFi重新扫描并连接网络后再继续。这套策略在实际部署中效果很好。当 WiFi 信号不佳时前 4 次重试能解决大部分瞬时干扰信号长期不稳定时断网重连往往能找到一个更好的 AP 或者信道成功率明显提升。退避算法的实现有个细节重连后不要急于立刻发送 HTTP Range 请求先做一次网络连通性探测比如 ping 一下服务器或读取一个极小的状态接口。因为 TCP 重连成功后网络栈可能还在做 DHCP 或 ARP 解析立刻发大请求很容易再次超时。等待 500ms 再发请求成功率会高很多。5.3 下载完成后的固件整体校验逐块校验通过并不代表整体固件没有问题。因为固件内部也可能有分段校验甚至在链接时加入了某种填充。OTA 完成后设备端还需要对整个固件文件再计算一次 SHA-256与服务器下发的全局哈希比对一致后才会真正写入启动标志。这样即使某块搬运时有错位、调整、填充等小概率事件发生最终也能被检查出来。双重校验的代价是会多花一点时间。1MB 固件计算 SHA-256在 ESP32 上大约耗时 1~2 秒这个成本可以接受。但如果你的设备主频很低比如只有几十 MHz 的 MCU可以考虑用 CRC32 做全局校验虽然碰撞概率比 SHA-256 高一些但对于消费类产品已经够用。全局校验完成后把固件就绪标志写到信息区执行系统复位。注意复位前要调用 WiFi 断连和 flash 缓存刷出的 API否则复位瞬间 WiFi 模块可能还有未处理的 DMA 数据写入 Flash 的最后一部分可能没落盘。6. 常见问题与排查技巧实录6.1 续传后写入错位导致校验失败现象断点续传后下载进度显示正常但最终整体校验失败而且失败位置总是在断点处附近。排查思路先看download_offset是不是正确写入到了 OTA 信息区再看服务器返回的Content-Range起始位置是否等于download_offset。我遇到过一种情况客户端用Range: bytes4096-请求但服务器实现错误返回了从 0 开始的数据导致新下载的数据覆盖了旧数据破坏了固件完整性。这种情况在 Nginx 上很少见但自建 HTTP 服务时经常出现尤其当服务端把 Range 头解析成了start0。解决办法有两个层面。第一层是协议层客户端必须检查 HTTP 响应状态码是否为 206以及Content-Range头里的起始字节是否等于请求的 Range 起始值不相等直接终止并报错。第二层是业务层每一块下载后做 MD5 比对如果收到的是从错误位置开始的数据块校验会直接失败不会误写入 Flash。6.2 WiFi 休眠导致下载中断现象下载进行到一半时连接断开而且重连后继续下载也没有恢复速度反而反复超时。排查原因很多 WiFi 模组默认开启了省电模式在低功耗策略下WiFi 模块会在无数据活动时进入 sleep但这个休眠周期和 HTTP 下载的长时间空闲期重叠后部分模组状态未能正确唤醒导致 TCP 连接被重置。解决方法是下载期间主动关闭省电esp_wifi_set_ps(WIFI_PS_NONE);下载完成后恢复esp_wifi_set_ps(WIFI_PS_MIN_MODEM);这不是唯一的坑。还有一个相关问题是 DHCP 租约到期。如果下载耗时很久且租期内没有续租成功网络会断掉。解决方式是在 OTA 期间主动禁用租约刷新或者确保网关的租约时间足够长。我一般把网关租约设为 12 小时完全覆盖一次升级窗口。6.3 Flash 写入失败或擦除失败现象下载过程中 Flash 写入返回错误查看日志发现esp_partition_write返回ESP_ERR_FLASH_OP_FAIL而且复现概率不稳定。原因通常有两个。一个是软件原因在写入 Flash 期间系统触发了中断可能是在 flash 操作期间访问了 flash 里的常量数据这在 MMU 缓存一致性较弱的芯片上非常致命。另一个是硬件原因Flash 供电电压偏低或温度过高。这方面我的建议是下载固件时尽量关掉不必要的系统日志输出尤其是带时间戳的日志机制它在写 Flash 时会打断大块擦写操作。另外不要在 Flash 写过程中去读取调试输出缓冲优先把日志发送到串口之外的通道避免在 Flash 写期间触发对 flash 内容的读取。6.4 断点续传记录丢失导致重新全量下载现象升级已经开始中途断电重新上电后发现进度归零设备又从头开始下载。我最开始也遇到这个现象排查后发现问题在“记录擦除时机”。有些代码在写入固件前会把 OTA 信息区整个擦除或者在每次启动 OTA 时不清除旧记录而是直接读取但如果上次下载过程中信息区写的记录因为断电损坏读取时 CRC 校验失败程序就默认全量重来。这里可以通过一个“双记录区 序列号”方案解决。我在 OTA 信息区存两个结构体每次写入时带上递增序列号读取时优先选择序列号大且 CRC 有效的那份。如果只有一份有效就用有效的那份如果两份都无效才做全量下载。这样单次掉电把两份记录都写坏的概率极低整体可靠性大幅提升。6.5 常见问题速查表我把自己踩过以及帮朋友排查的典型问题整理成一张表可以快速对照定位现象可能原因排查方法解决方向续传后固件整体校验失败断点记录与服务器 Range 不一致检查 206 和 Content-Range 响应头校验 Range 起始字节等于 offset下载中途 TCP 断开WiFi 省电模式进入休眠确认 esp_wifi_get_ps 返回值下载期间设置 WIFI_PS_NONE进度一直在 0%信息区记录 CRC 校验失败查看日志中 OTA info 的错误码增加双记录区 序列号机制下载速度慢到不可用TCP 接收窗口过小抓包看窗口字段调大 LWIP TCP_WND 和 mailbox下载完启动死机全局校验未做或跳过看 Bootloader 启动日志增加 SHA-256 整体校验写 Flash 报 ESP_ERR_FLASH_OP_FAIL写期间日志输出触发 flash 读确认日志输出通道关闭日志或改到 DMA 通道多次升级后 Flash 损坏擦写次数太多统计擦除次数扇区切换 连续块写入策略这张表基本覆盖了我在实际项目中遇到的 90% 问题。剩下 10% 大多和具体芯片、具体服务器配置有关处理思路也差不多先做分层定位网络层看 TCP 连接和重传应用层看 HTTP 状态码和块校验存储层看 Flash API 返回值没有一次快速定位不到的。7. 最后分享一个实用技巧说实话WiFi OTA 断点续传这个功能最大的价值不是省了那几次重新下载的时间而是让远程升级这件事变得“敢做”。过去设备部署之后我每次远程升级都提心吊胆生怕升级失败又联系不上现场有了断点续传、双备份、回滚机制之后我可以放心地在半夜自动推送固件第二天早上起来看统计报表就行。如果上面任意一个问题你之前踩过坑应该能明白这套方案里每个设计都不是多余的。尤其是“块校验 断点记录分离 双备份”这一套组合几乎是目前资源受限设备上最稳妥的 OTA 底座。后续如果你打算做多设备批量升级还可以在服务器端加一个升级任务管理系统把这里的设备端状态上报和任务调度对接起来顺理成章。
返回列表