尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

物联网设备OTA架构设计:双分区防变砖与差分升级实战

物联网设备OTA架构设计:双分区防变砖与差分升级实战 1. 从“升级变砖”说起为什么每个物联网设备都需要一套靠谱的OTA架构做物联网嵌入式开发这几年我最大的感受是设备联网之后真正让人夜不能寐的不是业务功能写不出来而是产品已经铺出去几百上千台了固件突然发现一个低级的临界bug。这时候你不可能挨个去拆机烧录唯一的路就是把OTAOver-The-Air空中升级架构设计好。标题里提到的“双分区防变砖”和“差分升级”是所有联网设备固件架构里绕不开的两个核心话题。先给刚入行的朋友一个朴素的定义OTA升级就是通过网络把新的固件包下发到设备端设备自己完成固件写入和切换实现远程修复、功能迭代。听起来很简单但真正落地的时候你会发现它牵扯到Bootloader设计、分区表规划、网络传输策略、数据校验、异常恢复机制甚至还要考虑低功耗设备的电量边界。这篇文章不聊那些只会出现在PPT里的高深理论只讲我实际项目中用过的方案、踩过的坑以及为什么最终选择了双分区加差分升级这套组合。这篇文章比较适合三类人一是正在做物联网网关、智能家居设备、工业采集终端准备接OTA但不知道从何下手的嵌入式工程师二是已经做了基础OTA但遭遇过“升级失败设备变砖”然后被售后折磨的朋友三是毕业设计或开源项目选了ESP32、STM32做联网设备、想把OTA这块做得漂漂亮亮的学生。不论你属于哪一种下面这套从分区规划到差分烧录的完整落地思路都能直接抄作业。2. 整体架构设计双分区为什么能救设备一命2.1 没有回滚能力的OTA都是耍流氓先聊一个最扎心的问题设备升级到一半断电了怎么办固件写入Flash的过程中写坏了Bootloader区怎么办新固件本身有bug一启动就崩溃死循环怎么办这三个“怎么办”归结起来就是一个核心需求——升级必须可回滚。而“可回滚”这件事在嵌入式设备上最可靠的实现方式不是去云端拉一个旧固件重新刷而是在本地留着上一次能正常运行的完整固件副本主固件挂了随时能切回去。这就是双分区也叫A/B分区、冗余分区存在的意义。我见过不少小团队的第一版OTA方案是“覆盖式升级”固件只有一份放在APP分区升级时先把新固件下载到临时区域校验通过后擦除APP分区再写入新固件。这个方案最大的风险在于——擦除和写入之间的时间窗口。只要在擦掉旧固件之后、写完新固件之前发生任何意外断电、Flash写入错误、看门狗超时复位设备就只剩一个空白的APP分区Bootloader不知道跳到哪里直接变砖。要救回来只能返厂或者靠人工去按恢复按钮这种事故在真实项目里不是小概率事件。2.2 A/B双分区的核心机制槽位切换而不是覆盖双分区方案的核心思路是放弃“覆盖”的思维改用“切换”的思维。Flash里划分两个大小相同且完全独立的固件分区习惯上叫Slot A和Slot B也叫slot 0和slot 1。当前运行在Slot A那么升级时新固件写入Slot B写入完成后修改引导标志位然后重启Bootloader根据标志位跳到Slot B启动。如果Slot B启动成功、业务自检通过就确认这个槽位有效下次正常从这个槽位启动如果启动失败Bootloader自动回退到Slot A设备继续运行旧固件升级就像没有发生过一样。这里有一个关键的工程设计点设备怎么判断“新固件启动成功”你不能指望设备自己说“我觉得自己挺好的”。工程上通行的做法是引入“尝试启动次数”和“成功标志”两个状态。具体来说Bootloader每次启动时检查当前槽位的“尝试启动计数”如果大于设定的最大次数一般设3次就不再用这个槽位启动自动切到另一个槽位同时把“该槽位不可用”的状态记录下来。新固件启动后业务层需要完成一系列自检包括模块初始化、传感器读取、网络注册、核心业务逻辑跑通等全部通过之后主动向某个专用的Flash地址写入“固件运行正常”标志同时把尝试计数清零。如果新固件本身有bug会在自检阶段卡死或重启每次尝试都会累加计数三次之后Bootloader自动回滚把控制权还给旧固件。这个机制说白了一句话Bootloader永远不信任应用层一切以应用层主动上报的“我活着”信号为准。信任链条断在哪里哪里就要回滚。2.3 分区表怎么规划才算合理双分区不是简单地在Flash里划两块一样大小的区域就行工程上还需要考虑Bootloader区、参数存储区、日志区、升级缓存区之间的关系。我以ESP32和STM32两个最常用的平台为例说明一套实际可用的分区规划逻辑。ESP32平台典型的4MB Flash分区表可以这么排分区名称分区类型偏移地址/大小作用nvs数据0x9000 / 24KB保存WiFi配置、校准参数、升级计数等关键数据otadata数据0xF000 / 8KBA/B槽位切换的元数据记录当前激活槽位、尝试次数bootloader程序0x1000 / 32KB二级引导程序负责槽位选择与跳转app_a程序0x10000 / 1.5MBA槽位固件app_b程序0x190000 / 1.5MBB槽位固件coredump/log数据剩余空间崩溃转储和设备日志ESP32官方提供的idf.py和esptool都原生支持A/B分区需要启用CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE等选项硬件上因为有两级Bootloader实现起来比较省心。STM32平台则更依赖你自己写Bootloader。比较稳妥的Flash划分方式是0x08000000Bootloader区大小32KB负责接收新固件、校验签名、管理槽位切换0x08008000Slot A区存放当前主固件0x08040000Slot B区存放备用/新升级固件0x08078000参数区记录当前激活槽位、升级状态、尝试次数最后留一小块做日志区便于排查升级失败原因。选择哪个平台不重要重要的是分区思路是统一的Bootloader、应用、元数据三者分离A/B应用区对称且足够大参数区具备掉电不丢失能力日志区存疑点信息。2.4 双分区方案的代价Flash容量和复杂度换安全感双分区当然不是没有代价最直接的代价就是Flash占用翻倍。原来一个固件2MB双分区就要留出4MB甚至更多的Flash空间给应用区加上Bootloader、参数区、日志区一颗8MB的Flash芯片分分钟显得局促。这对于成本敏感、Flash资源紧张的产品来说是一个必须提前跟硬件团队battle清楚的重点。另外一个容易被忽略的代价是逻辑复杂度。单分区方案只需要“下载—擦除—写入—重启”四步双分区方案还要处理槽位切换、尝试计数、成功上报、回滚判定等一整套状态机。代码量、测试量、出bug的概率都会上升。但从工程价值来说双分区省掉的是“批量变砖之后返厂刷机”这种灾难级售后成本这个交换非常划算。我实际踩过的经验如果产品交付量超过100台且不具备人工现场刷机能力双分区几乎是无脑选型只有那些固件极小几十KB、Flash资源极度紧张、且升级失败最多人工按住恢复键重新刷的设备才值得考虑单分区加临时备份的折中方案。3. 差分升级省流量的另一种工程智慧3.1 全量包升级的三个隐藏痛点双分区解决了设备变砖的问题但没过多久你就会发现新的问题——流量成本。全量升级包每个版本都打包完整固件比如一个固件1.5MB设备有5000台在线每次升级就要下发大约7.5GB的数据。如果走4G网络按照模组流量资费计算这就是白花花的成本如果设备还是锂电池供电、上行带宽又窄的NB-IoT全量包下载时间可能长达三四十分钟中途一个信号波动就断掉用户体验直接崩。还有一个不太容易注意到的坑边缘节点的带宽和存储都有限。一个工业网关可能同时挂了几百个传感器节点升级主网关时全量包会把带宽占满导致业务数据上报超时。我遇到过一个项目设备通过3G/4G网络每天上报环境数据每次OTA全量升级时后台就出现大量数据断档排查到最后发现是升级流量把运营商分配给设备的限速额度吃干净了。全量升级第三个痛点是版本迭代频繁时的资源浪费。很多产品的版本更新改的可能只是某个通信协议字段、某个算法的阈值、或者一条UI提示文案二进制差异也许只有几十KB但全量升级包永远都是一两兆。这种资源浪费在规模化设备上会被放大得非常明显所以差分升级几乎是必须考虑的事情。3.2 差分升级的原理只传变化的部分差分升级的核心思想很简单你手上有设备当前的旧固件云端的服务器上有新固件两边一对比只把有差异的字节部分打包下发设备在本地把旧固件和差分补丁合成为新固件再写入另一个槽位。传统上最常用的差分算法是bsdiff和hdiffpatch它们基于“最长公共子序列”以及后缀排序的思想在二进制文件里找出最长匹配块剩下的就是新增、删除、修改的增量数据。类似技术还有xdelta应用在VCDIFF格式、CourgetteChrome浏览器用的、以及针对嵌入式场景优化过的Arms delta update方案。但这里想提醒你一个容易踩的坑差分算法在PC上跑得风生水起不代表能直接套到嵌入式设备上。因为bsdiff在生成差分补丁时非常吃内存需要将两个文件都读入内存并排序但更重要的是设备端应用差分补丁合成新固件的算法bspatch同样吃内存和时间。一颗几十MHz主频、几百KB RAM的MCU想让它完整跑一遍bspatch合成1MB固件很可能内存直接爆了。嵌入式环境下常见的做法是分块差分或者分页差分把固件按4KB或8KB的块大小切开对每个块单独做差分设备端按块接收差分数据逐块校验、逐块写入新槽位。这个过程不需要把整个旧固件和新固件都放在内存里内存占用可以降到最低同时还能配合断点续传机制网络断了大不了重传没有完成的那几个块。这块在实际工程中非常有用。3.3 差分策略不是所有场景都划算虽然差分升级能节省流量但它并不是所有情况下的最优解我们需要针对不同场景做策略判断场景特征推荐策略原因固件体积大1MB、增量小、设备网络带宽有限差分升级流量成本节约明显固件体积小100KB、增量基本等于全量、设备内存极小全量升级差分合成复杂度和风险大于省下的流量版本跨度大比如从v1.0直接升到v5.0全量升级或重新生成差分包旧版本差异过大差分包可能比全量包还大设备Flash充足、但网络资费敏感差分升级节省流量优先级高于Flash利用率实际项目里我一般不会只做一种策略而是做成动态选择云端根据设备当前版本号和目标版本号自动计算差分包大小如果差分包小于全量包的70%就走差分否则走全量。这个阈值可以根据实际流量资费和设备处理能力调整比如资源紧张的设备可以放宽到50%。3.4 差分升级与双分区如何协同双分区和差分升级不是二选一的关系而是分层配合的关系。差分升级负责解决“传输层面省流量”的问题双分区负责解决“写入后系统安全”的问题。我的实际落地做法是差分补丁下载完成后设备不是先把旧固件合成好了再去覆盖自己而是利用双分区的空闲槽位在空闲槽位上完成合成。具体流程是读取旧槽位当前运行槽位的固件数据加上已下载的差分补丁在内存中分块合成出新固件数据直接写入空闲槽位中写入一块校验一块。全部写完后对空闲槽位的整个固件做校验校验通过后切换激活槽位。整个过程当前运行槽位的数据完全没有被碰过天然具备回滚能力。4. 完整OTA升级链路实操从云端下发到设备切换4.1 升级任务的全流程拆解把双分区和差分升级放到一条完整的升级链路里看整个流程可以拆成下面几个核心阶段第一步版本探测与升级触发。设备开机后或定时向云端上报当前固件版本号、硬件型号、分区使用情况等元信息。云端比对版本如果有新版本就生成一个升级任务ID唯一标识返回给设备升级包的下载地址、包大小、差分包校验值以及差分的基准版本号说明这个包是从哪个旧版本升上来的版本不匹配直接拒绝。第二步差分补丁下载与存储。设备拿到下载地址后开始下载差分包。这一步需要重点考虑断点续传和限速。断点续传是记录已下载字节数每收到一块数据就写入Flash缓存区或者分片文件网络断了下次从断点继续。限速是为了避免升级流量抢占正常的业务通信带宽我一般控制在总带宽的30%以下低功耗设备还要注意单次下载不能超时过长。第三步差分包完整性校验。下载完成后先对差分包做MD5或SHA256校验确保文件在传输过程中没有损坏。如果校验失败直接删除差分包并上报错误码下次重新下载绝不执行后续步骤。第四步空闲槽位固件合成。这是差分升级最关键的一步。设备从当前运行槽位读取旧固件数据从已下载的差分包中读取增量数据按块合成新固件并写入空闲槽位。每一步都要做Flash写入校验。如果合成过程中断电进度会存放在参数区里下次开机可以继续或者重来但当前运行槽位不受影响。第五步切换与启动验证。全部合成完成后更新otadata/参数区的激活槽位指向新槽位然后软复位。Bootloader启动时发现当前槽位是“待验证”状态进入尝试计数流程。新固件启动并完成业务自检后上报“成功”标志升级正式结束。第六步失败回滚与上报。如果新槽位三次尝试启动都失败Bootloader自动切回旧槽位设备正常工作同时把失败的原因记录到日志区通过业务通道上报给云端。云端标记该设备升级失败进入重试或人工处理队列。4.2 一个可直接参考的双分区状态机设计双分区最复杂的部分在于状态切换这里给出一套我项目中实际使用过的状态机定义。整个升级模块按以下状态流转状态状态值说明OTA_IDLE0x00空闲态设备正常运行OTA_DOWNLOADING0x01正在下载差分包OTA_DOWNLOAD_FINISHED0x02差分包下载完成待校验OTA_VERIFY_OK0x03校验通过准备合成OTA_SYNTHESIZING0x04正在合成新固件到空闲槽位OTA_SYNTHESIZE_OK0x05合成完成待切换OTA_PENDING_REBOOT0x06已切换槽位等待重启验证OTA_VERIFY_APP0x07新固件启动中待业务自检确认OTA_SUCCESS0x08升级成功OTA_FAILED0x09升级失败做回滚或清理状态迁移过程中任何一个状态如果发生超时、CRC错误、Flash读写失败都要有对应的错误码记录同时跳转到老状态或回滚逻辑。这里有一个容易被忽略的细节状态持久化必须放在独立于槽位区的参数Flash区域并且在写入状态时要做“写前备份”和“写入后回读校验”。如果参数区自身损坏Bootloader会进入安全模式两个槽位都不信任优先启动上次运行正常的槽位并上报参数区异常。4.3 固件签名与安全校验不能省说到校验就不得不提安全问题。OTA通道如果被中间人篡改攻击者完全可以往设备里塞恶意固件。物联网设备的安全基线不需要做到银行级但几个基础动作必须有HTTPS/WSS传输通道至少保证传输过程不被窃听和篡改固件包签名使用RSA或ECDSA签名算法云端用私钥签名设备端内置公钥验证。私钥放在云端安全环境设备公钥固化在Bootloader里不可被应用固件修改版本号单调递增检查防止攻击者伪造低版本固件实现降级规避已修复的安全漏洞签名验证必须放在Bootloader里不能放在应用层因为应用层本身可能已经被篡改过了。签名的验证流程大致是下载完成后先用SHA-256计算整个固件的哈希值再用内置公钥对这个哈希值做签名验证。验证通过才允许写入/切换。签名验证在合成新固件的时候最好做两次一次是合成前检查差分包本身的签名另一次是合成完成后对整个新固件做签名校验。这样可以把攻击者的篡改拦截在Flash写入之前。4.4 代码示例STM32差分合成到空闲槽位的核心逻辑下面给一段我在STM32平台上实现过的伪代码核心逻辑主要是差分合成和槽位切换的骨架直接照搬到真实项目里还需要针对平台做适配。// 差分固件合成核心流程 // 假设已在RAM中缓存了差分包区块数据 int ota_synthesize_and_write(uint32_t dst_slot_addr, uint32_t src_slot_addr, uint32_t patch_size, const uint8_t *patch_buf) { uint32_t offset 0; uint8_t block[BLOCK_SIZE]; uint8_t new_block[BLOCK_SIZE]; while (offset patch_size) { // 1. 从差分包解析当前block的操作类型 diff_block_header_t *hdr (diff_block_header_t *)patch_buf[offset]; if (hdr-magic ! DIFF_MAGIC) { log_error(diff block header magic mismatch at offset %d, offset); return OTA_ERR_VERIFY_FAIL; } // 2. 根据操作类型读取旧固件数据并合成新数据 switch (hdr-op_type) { case OP_COPY: flash_read(src_slot_addr hdr-src_offset, (uint8_t *)block, hdr-block_len); memcpy(new_block, block, hdr-block_len); break; case OP_INSERT: // 新增数据 memcpy(new_block, patch_buf[offset sizeof(diff_block_header_t)], hdr-block_len); break; case OP_MODIFY: // 修改数据同旧数据做XOR运算 flash_read(src_slot_addr hdr-src_offset, (uint8_t *)block, hdr-block_len); for (int i 0; i hdr-block_len; i) { new_block[i] block[i] ^ patch_buf[offset sizeof(diff_block_header_t) i]; } break; default: log_error(unknown diff op type %d, hdr-op_type); return OTA_ERR_UNKNOWN_OP; } // 3. 分块写入新槽位并回读校验 flash_erase(dst_slot_addr hdr-dst_offset, hdr-block_len); flash_write(dst_slot_addr hdr-dst_offset, new_block, hdr-block_len); flash_read(dst_slot_addr hdr-dst_offset, (uint8_t *)block, hdr-block_len); if (memcmp(block, new_block, hdr-block_len) ! 0) { log_error(flash readback verify failed at offset %d, hdr-dst_offset); return OTA_ERR_FLASH_READBACK; } offset sizeof(diff_block_header_t) hdr-block_len; } // 4. 整个新固件写完后校验最终哈希和签名 if (ota_verify_firmware(dst_slot_addr) ! OTA_OK) { log_error(firmware verification failed); return OTA_ERR_SIGNATURE; } return OTA_OK; }这段代码只展示了基本框架实际工程里还需要处理Flash擦除粒度对齐、差分包头部的跨区块处理、写入时的掉电保护以及把合成进度记录到参数区以便断电后继续。4.5 创建差分包的后台流水线设备端搞定之后云端和CI/CD侧也需要建立起配套能力。我的做法是在版本发布流水线里增加一个“差分生成”步骤每次发布的固件会同时保留上一版本和上上版本的release包存到制品仓库当新固件编译通过且通过测试后发布流水线自动运行bsdiff将当前版本与历史版本逐一对比生成对应的差分升级包将差分包与目标版本号、基准版本号一起打包上传到OTA服务器测试环境先用一组模拟设备验证差分包可正常合成和启动验证通过后才允许灰度下发。建议在流水线里同时生成全量包和几个常用历史版本的差分包至少包含最近一两个版本这样大多数设备可以直接升级只有极少数长期离线、版本跨度大的设备走全量。5. 升级过程常见问题与排查技巧5.1 问题一升级后设备反复重启Bootloader不切换新槽位现象设备升级完成后进入Bootloader尝试启动新固件失败回退旧固件后系统正常但过一段时间又自动尝试升级反复循环。排查思路检查otadata/参数区中的尝试计数是否被正确写入。很多情况下是Bootloader读取参数区的偏移地址和应用层写入的偏移地址不一致或者参数区的擦写粒度没有对齐导致计数写了一半就失败。检查新固件是否真的完成了业务自检。如果新固件刚启动1秒就被看门狗复位尝试计数没有来得及清零三次之后就会回滚。建议调试阶段把“尝试启动最大次数”临时调大到10次观察是否偶发成功。如果新固件本身就是有问题的比如引用了一个不存在的Flash地址回滚行为是对的此时要去修固件而不是调参。5.2 问题二差分合成过程中设备断电重新上电后固件状态不确定现象断电后重新上电Bootloader报告两个槽位都校验失败不知道怎么选。发生原因这种现象一般发生在合成中途断电空闲槽位写了一半同时参数区的状态位刚好处于“切换槽位”的半写状态导致Bootloader无法确认哪个槽位可信。解决方案参数区状态写入必须使用“双备份交叉校验”机制即每个状态存两份一个主一个备读取时对比两份是否一致不一致则说明掉电发生在写入过程中需要回退到上一个稳定状态空闲槽位在写入前先做整块擦除标记比如在槽位头部写一个“合成中”魔数合成完成后再擦掉这个魔数。Bootloader如果发现当前激活槽位带“合成中”标记就直接回退到另一个槽位这个方案简单可靠如果Flash资源允许最好再单独留一小块区域记合成进度已合成到哪个block断电后可以断点续合成而不是整个重来。5.3 问题三差分补丁合成之后新固件无法启动但回滚后旧固件也出现异常现象这个比较诡异回滚之后旧固件虽然能启动但某些外设初始化失败或者WiFi/网络连不上。发生原因绝大多数情况不是新固件破坏了旧固件而是新旧固件共享了非易失性参数区比如NVS或者EEPROM新固件启动时把参数区的结构体重新初始化了旧固件读到的参数已经不符合预期。解决方案在设计分区时业务参数区必须带版本号和兼容性。新固件写入参数时先备份旧版本格式如果回滚后检测到版本不匹配自动恢复备份。最关键的是新旧固件之间不要直接复用一套参数区的结构定义建议每个版本定义一个字段变更记录做向前兼容。5.4 问题四云端下发了差分包但设备拒绝升级日志提示版本不匹配现象设备当前运行版本是v2.1云端下发的是基于v2.0生成的差分包导致设备校验基准版本号失败。发生原因这是版本管理没有做好的典型问题。设备上报的版本号、云端记录的版本号、差分包内的基准版本号三者不一致。解决方案建立严格的版本管理规范固件版本号必须体现在三处——编译时嵌入固件头部的宏定义、云端ORM中设备注册的版本字段、差分包的元数据。任何一处不一致都不能放行升级。同时建议云端升级策略做链式升级比如设备在v2.0不能直接跳v2.2必须先升v2.1再升v2.2或者云端检测到跨度较大时自动切换到全量包。6. 工程落地过程中的三点重要体会6.1 宁可多花时间设计状态机也不要在线上试错OTA模块是我做嵌入式以来少有的、不建议在小规模测试之后就大面积上线的功能。因为升级失败不像业务bug那样能看到报错它往往是静默的——设备联网状态正常但一重启就再也起不来了。我在一个项目里就是因为状态机少定义一个“合成中断”状态导致一批设备在断电后一直卡在Bootloader的异常分支里最后只能安排人去现场强制恢复。从那以后OTA状态机的每个分支我都会做一次模拟掉电测试甚至在Flash写入的每条指令中间插入调度器让出目的是故意制造各种时序错乱看看状态机能不能兜住。6.2 差分算法不是越新越好稳定性和资源占用才是第一优先级嵌入式场景下差分算法的选择要考虑MCU算力、RAM大小、Flash写入速度之间的平衡。如果你的主控是一颗Cortex-M3或者M4运行bsdiff原版bspatch非常吃力建议使用针对MCU优化的差分实现比如开源的Heatshrink、DiffPatchEmbedded、或者基于xdelta的轻量化实现。实际项目中我也遇到过为了省流量选了压缩率高但内存占用大的差分方案结果设备端一合成就内存溢出重启的情况。给新人的建议很简单下载差分包之前的策略算法跑在云端随便压、随便优化合成差分包时的算法跑在设备端必须精确评估CPU、RAM、Flash写入的耗时和占用后再选型。6.3 升级成功之后别忘了把“升级成功”本身当成一种业务很多团队把OTA当成一个纯技术功能升级成功就算完事。但当你有一批设备分批升级的时候你会慢慢发现升级之后还有一堆事情要做——新固件对旧固件的存量数据进行迁移、云端统计设备分布版本号、灰度升级的比例控制、升级失败设备的自动补发重试。这些都是围绕OTA展开的业务闭环。我建议在产品设计中就把OTA当成一个独立的服务模块来建设而不是某个固件版本的附属品这样才能在百万设备的大规模运维中不手忙脚乱。双分区加差分升级这套组合拳我陆续在WiFi插座、工业数据采集器、智能网关几种不同类型的设备上都验证过一旦跑顺了后续每次版本发布都非常丝滑云端生成差分包、灰度下发、设备自动合成切换、出错自动回滚几乎不用人盯着。希望这篇文章能帮你在设计设备OTA的时候少走几步弯路把“升级变砖”的风险从你的字典里彻底删掉。
返回列表