OTA 固件升级与差分更新

本文讲解物联网 OTA 固件升级的端到端工程实现,回答整包与差分怎么选、双分区如何回滚、签名校验放在哪一层等问题。覆盖固件制品与版本元数据、ESP32 A/B 分区与 MCUboot 及 U-Boot 引导切换、RAUC 与 SWUpdate、bsdiff 与 detools 差分收益、SHA-256 加 ECDSA 签名链、HTTP Range 断点续传、灰度批次与失败自动回滚。

引言

OTA 是物联网项目里「不做不行、做了又容易翻车」的典型功能。不做 OTA,一次固件 bug 就要派人到现场,两万台设备的召回成本足以吃掉整个项目的利润;做了 OTA 但设计不周,一次错误的灰度可能让设备集体变砖,现场恢复成本比不升级更高。区别在于是否把「可回滚」当成第一原则而不是事后补丁。

第二个现实约束是网络与电量。NB-IoT 设备每月流量配额可能只有 10MB,一个 800KB 的整包就吃掉 8%;电池供电的传感器满电只够跑两年,一次固件写入可能消耗掉 1% 的电量。因此差分包与断点续传不是优化项,而是能否上线的前提。工程上典型的收益是:1MB 固件做差分后压到 30~80KB,流量降到 5% 以内。

第三个约束是设备形态的多样性。MCU 侧有 ESP32 的 A/B 分区、MCUboot 的 slot 交换;Linux 侧有 RAUC、SWUpdate、mender 三套成熟的更新框架。它们对「原子性」「回滚」「签名」的实现方式完全不同,选错方案会导致后期无法支持差分或无法满足安全合规。

本文按「形态 → 架构 → 制品 → 分区与引导 → 差分 → 签名 → 传输 → 灰度 → 失败处理 → 验证」的顺序展开,代码以 ESP32 与 shell 为例,概念对 Linux 侧同样适用。设备身份与影子收敛机制见 设备管理与设备影子 。

目录

  1. OTA 的三种形态
  2. OTA 端到端架构
  3. 固件制品与版本元数据
  4. ESP32 A/B 双分区与 OTA API
  5. MCUboot 与 U-Boot 的引导切换
  6. Linux 侧更新框架
  7. 差分更新的原理与收益
  8. 签名、加密与完整性校验
  9. 下载与传输优化
  10. 灰度与批次策略
  11. 失败场景与回滚
  12. 验证清单
  13. 权衡取舍
  14. 常见坑清单
  15. 小结

1. OTA 的三种形态

形态原理包大小端上开销适用面
整包更新下载完整固件写入备用分区与固件同大(数百 KB 到数十 MB)只需存储,无需还原算力所有设备,最稳
差分更新下载新旧版本差异,端上还原整包的 3%~10%需还原算法与额外 RAM流量敏感、固件大
增量脚本下发脚本执行打补丁极小不可控、易失败仅调试,不建议生产

选择建议:MCU 设备固件小于 256KB 时,整包与差分差距不大,优先整包以降低复杂度;固件超过 512KB 或设备走蜂窝网络时,必须支持差分;增量脚本在生产环境一律不用,因为脚本执行到一半掉电会让设备处于未知状态,而整包与差分都有分区保护。

还有一个折中方案是「压缩整包」:把固件用 LZMA 或 zstd 压缩后再传,端上解压写入。压缩率通常 40%~60%,收益不如差分但实现简单、无需匹配旧版本,适合「设备版本分散、无法为每个旧版本准备差分包」的场景。

2. OTA 端到端架构

一条完整的 OTA 链路包含六个组件:

  1. 固件仓库 / 制品库:存放固件二进制与差分包,按 product/version/ 组织,具备不可变性与内容寻址(以 SHA-256 作为文件名或校验字段)。
  2. 版本元数据服务:记录版本号、适用硬件型号、依赖的最低版本、强制或可选、发布时间、变更说明。
  3. 灰度策略服务:决定哪些设备可以升级,输出目标设备集合与批次计划。
  4. 设备端 OTA Agent:接收任务、下载、校验、写入、切换、回滚、上报进度。
  5. 传输层:HTTPS 或 MQTT 下发任务,HTTP Range 分片下载固件,支持 CDN。
  6. 回执与观测:设备上报 downloading / verifying / applying / success / failed 状态与进度百分比,平台聚合出收敛率。

任务下发与状态上报建议走轻量的控制通道(MQTT),固件本体走 HTTPS。原因是固件是大块二进制,MQTT 的 topic 与 payload 模型不适合,且 HTTPS 能直接复用 CDN、Range 请求与缓存;控制通道则复用已有的长连接,实时性好。

3. 固件制品与版本元数据

版本号建议用语义化版本 major.minor.patch 并附加构建号,同时记录源码 commit。元数据示例:

{
  "product_key": "sensor-t100",
  "version": "1.9.0",
  "build": "20261005-1",
  "hw_models": ["T100-A", "T100-B"],
  "min_version": "1.6.0",
  "size": 892416,
  "sha256": "3f2a...c9d1",
  "signature": "MEUCIQ...",
  "url": "https://fw.example.com/sensor-t100/1.9.0/app.bin",
  "mandatory": false,
  "released_at": "2026-10-05T12:00:00+08:00"
}

四个字段值得单独说明:hw_models 防止把 B 版硬件刷成 A 版固件(这是变砖的头号原因);min_version 声明最低可升级版本,低于该版本必须先升到中间版本(常见于分区表或引导程序变更);sha256 与 signature 是校验依据;mandatory 决定设备是否可推迟。

差分包还要额外记录 source_version,即它是从哪个版本差到哪个版本。这意味着制品库要保存 N × (N-1) 量级的组合,实践中只保留最近 3~5 个版本之间的差分,更早的版本走整包升级。

4. ESP32 A/B 双分区与 OTA API

ESP32 的方案是经典的分区表加引导标记。分区表里通常包含:

NameTypeSubTypeOffsetSize作用
nvsdatanvs0x90000x6000键值存储,保存配置与计数器
otadatadataota0xf0000x2000记录当前与待启动分区
phy_initdataphy0x110000x1000RF 校准数据
factoryappfactory0x200000x180000出厂固件(可省略)
ota_0appota_00x1A00000x180000备用槽 A
ota_1appota_10x3200000x180000备用槽 B

两个 app 槽各 1.5MB,意味着新固件不能超过约 1.4MB(留 10% 余量)。如果固件接近这个上限,应改用「factory 槽复用为 ota 槽」的布局,把 flash 从 4MB 换到 8MB,而不是压缩功能。

otadata 记录当前从哪个分区启动,以及下一个待启动分区。OTA 的写入流程是「取下一个分区 → 写入 → 校验 → 设置启动分区 → 重启」:

#include "esp_ota_ops.h"
#include "esp_partition.h"

static esp_err_t do_ota(const void *data, size_t len, size_t total)
{
    const esp_partition_t *update = esp_ota_get_next_update_partition(NULL);
    esp_ota_handle_t handle = 0;
    esp_err_t err = esp_ota_begin(update, total, &handle);
    if (err != ESP_OK) return err;

    size_t written = 0;
    while (written < len) {
        size_t chunk = (len - written) > 4096 ? 4096 : (len - written);
        err = esp_ota_write(handle, (const uint8_t *)data + written, chunk);
        if (err != ESP_OK) { esp_ota_abort(handle); return err; }
        written += chunk;
    }

    err = esp_ota_end(handle);            /* 校验镜像 magic 与 SHA-256 */
    if (err != ESP_OK) return err;

    return esp_ota_set_boot_partition(update);
}

重启后新固件运行,必须在应用里调用 esp_ota_mark_app_valid_cancel_rollback() 确认;如果不调用,bootloader 会认为新固件未验证,下一次重启自动回滚到旧分区。这个「自确认 + 回滚」机制是 ESP32 方案的核心价值,务必在业务初始化成功后再调用,而不是上电立刻调用。

5. MCUboot 与 U-Boot 的引导切换

MCUboot 面向 Cortex-M 类 MCU,概念上更严谨。它把固件放在 slot0(primary)与 slot1(secondary),每个镜像末尾附加 TLV 结构的 image trailer,包含哈希、签名、版本号与 magic(0x96f3b83d)。升级时把新固件写入 slot1,重启后由 bootloader 按 swap 算法交换两个槽位。

swap 算法有三种:swap-scratch 使用一个额外的 scratch 分区做中转,最通用;swap-move 不用 scratch 但要求分区布局满足特定约束,速度更快;direct-xip 不搬移数据,直接指定从哪个槽启动,需要 MCU 支持从任意地址执行。掉电安全是重点:swap 过程中任意时刻断电,重启后 bootloader 都能根据 trailer 中的状态位判断并继续或回退,不会出现两个槽都不可用的情况。

U-Boot 侧则用「启动计数」实现回滚。升级完成、准备重启进入新系统前,写入三个环境变量:

fw_setenv upgrade_available 1    # 标记本次是升级后的首次启动
fw_setenv bootcount 0            # 启动计数归零
fw_setenv bootlimit 3            # 连续启动失败 3 次即回滚
if test "${upgrade_available}" = "1"; then
  if test ${bootcount} -gt ${bootlimit}; then
    run altbootcmd
  fi
fi

即:新固件启动后必须主动清掉 upgrade_available(表示自检通过),否则 bootcount 每重启一次加一,超过 bootlimit(如 3 次)就执行 altbootcmd 回滚到旧系统。这套机制的坑在于很多 BSP 默认没把 bootcount 存进可持久化的环境分区,掉电后会归零,回滚永远不触发。

6. Linux 侧更新框架

Linux 设备有三套成熟框架,格式与适用场景不同:

框架包格式原子性机制特点
RAUC.raucb(squashfs + manifest)A/B 槽 + 引导选择元数据用 INI 描述,支持 bundle 签名与兼容性检查
SWUpdate.swu(cpio 归档)A/B 槽 + 自定义 handler灵活,可更新内核、根文件系统、单独文件
mender.mender artifactA/B 根分区 + 数据分区自带服务端与客户端,适合整机 OTA

RAUC 的 bundle 由 manifest.raucm 描述:

[update]
compatible=sensor-gw-rk3588
version=1.9.0
build=20261005-1

[bundle]
format=verity

[image.rootfs]
filename=rootfs.ext4
sha256=3f2a...c9d1

compatible 字段是与 /etc/rauc/system.conf 中声明的机型比对,不匹配直接拒绝安装,这是防止跨机型刷机最有效的一道闸。format=verity 启用 dm-verity 校验,能在读取时检测块级篡改。

这三套框架与桌面发行版的包管理思路不同:桌面包管理做的是「增量替换文件」,OTA 框架做的是「整槽切换 + 原子生效」。设备侧若还需要管理应用层依赖,可以在槽内使用只读包管理方案,思路可参考 Linux 包管理与依赖治理 。

7. 差分更新的原理与收益

差分更新基于「新旧固件高度相似」这一事实。三种主流算法:

  • bsdiff:基于后缀排序(suffix sorting)找最长公共子串,压缩率高但内存开销大(约为文件大小的 17 倍),不适合 MCU 端还原。
  • detools(heatshrink 作者出品):专为嵌入式设计,支持 sequential、in-place、patch 等多种还原模式,可在 RAM 受限环境下工作。
  • hdiffpatch:支持流式与原地还原,压缩率与速度均衡,常用于 Android 生态。

实测收益(以 STM32 + 1MB 应用固件为例):

场景包大小相对整包
整包1048576 B100%
相邻版本差分45 KB4.3%
跨 5 个版本差分128 KB12.2%
压缩整包(LZMA)420 KB40.0%

端上还原的约束是 RAM:detools 的 sequential 模式只需缓冲区大小级别的内存(如 4~16KB),而 in-place 模式可以完全不用额外 RAM,但要求补丁与目标区域布局配合。工程上的常见错误是选了内存开销大的模式导致还原时 OOM,或者补丁顺序搞错(补丁必须按 source → target 方向生成和应用,反向使用会生成损坏固件)。

差分还有一个隐性成本:需要在制品库里维护版本组合,且每个组合都要做一次「还原后哈希与目标固件哈希一致」的验证。建议把这项验证放进 CI,任何差分包发布前都自动跑一遍还原比对。

8. 签名、加密与完整性校验

安全链路的顺序是「先验签名、再校验哈希、最后写入」。只校验 SHA-256 是不够的,因为攻击者可以同时替换固件与哈希值;签名才能保证来源可信。

典型流程:

  1. 发布侧:对固件二进制计算 SHA-256,用私钥(RSA-2048 或 ECDSA P-256)签名摘要,把签名与哈希写入元数据。
  2. 设备侧:下载完成后用内置公钥验签,验签通过后计算实际哈希并与元数据比对,两者都通过才写入分区。
  3. 写入后:读回分区重新计算哈希(可选但推荐),确保存储介质没有写入错误。
  4. 启动时:bootloader 再次校验镜像签名(安全启动),形成从 ROM 到应用的信任链。

MCUboot 把签名与元信息放在 image trailer 的 TLV 中,结构如下:

[ image header (32B) ][ firmware payload ][ TLV area ]
TLV 项:
  0x01 KEYHASH    (32B)  公钥哈希,用于选择验证公钥
  0x10 SHA256     (32B)  镜像哈希
  0x20 RSA2048    (256B) 或 0x22 ECDSA-P256 (64B) 签名
  0x03 IMAGE_TLV_RSA2048_PSS 等
  0xFF TRAILER_MAGIC 0x96f3b83d  + flags(含 image_ok / copy_done 状态位)

ECDSA P-256 的优势是签名只有 64 字节、验签速度快,适合 MCU;RSA-2048 签名 256 字节、验签较慢但兼容性好。选型上 MCU 优先 ECDSA,Linux 设备可用 RSA 或 Ed25519。

固件加密(如 ESP32 的 Flash Encryption)是另一个维度,解决的是「物理提取 Flash 读出固件」的问题,与签名互补:签名防篡改,加密防泄露。启用后必须注意密钥烧录与烧录模式不可逆,误操作会导致整批设备报废,建议先在样机验证。安全启动与密钥管理的完整体系见 物联网安全加固 。

9. 下载与传输优化

固件下载要处理四件事:分片、续传、限速、分发。

total=$(curl -sI "$URL" | awk '/[Cc]ontent-[Ll]ength/{print $2}' | tr -d '\r')
offset=$(stat -c %s "$PART" 2>/dev/null || echo 0)

while [ "$offset" -lt "$total" ]; do
  end=$(( offset + 16384 - 1 ))
  [ "$end" -ge "$total" ] && end=$(( total - 1 ))
  curl -s -f -H "Range: bytes=${offset}-${end}" "$URL" >> "$PART" || exit 1
  offset=$(( end + 1 ))
  report_progress "$offset" "$total"
done

关键参数取值:分片大小 416KB,太小则请求数过多、TLS 开销占比高,太大则在弱网下重传代价高;并发下载不要开太多(12 个连接),蜂窝网络下多连接反而降低总吞吐;每片失败重试 3 次并带退避,整体超时设为 10~30 分钟。

限速与错峰同样重要:几十万台设备同时升级会打爆源站,必须走 CDN 并给设备侧加随机抖动(如任务下发后随机延迟 0~30 分钟再开始下载)。对于跨运营商或跨境场景,还要考虑分区域配置不同的下载域名。

流量成本估算公式是「设备数 × 包大小 × 重试系数 1.2」:10 万台 × 50KB 差分包 × 1.2 = 6GB,走 CDN 成本可忽略;若是 10 万台 × 900KB 整包 = 108GB,成本与耗时都显著上升,这也是大固件必须做差分的直接原因。

10. 灰度与批次策略

灰度是控制爆炸半径的唯一手段。一个可用的批次计划:

批次 0(内部验证):10 台实验室设备,人工确认 24 小时
批次 1(canary)  :1% 设备,观察 24 小时,失败率阈值 1%
批次 2(扩大)    :10% 设备,观察 12 小时,失败率阈值 1%
批次 3(全量)    :100%,失败率阈值 2%,超阈值自动暂停

暂停与回滚条件要事先定义清楚:失败率(failed / attempted)超过阈值、或「上报成功数长时间不增长」、或关键指标(重启率、离线率)异常。自动回滚的实现是「把 desired.fw_version 改回旧版本」,设备下次拉取 delta 时会自行降级——前提是设备支持降级,很多安全设计默认禁止降级,需要显式允许。

批次划分维度建议按「地域 → 型号 → 站点」逐层细分,因为固件问题往往与硬件批次强相关。同时要避开业务高峰:工业设备避开生产时段,消费设备避开晚间。

11. 失败场景与回滚

OTA 的失败场景需要逐一设计对策:

场景后果对策
下载中断电备用分区半写分区带校验标记,下次启动丢弃并重下
校验失败固件损坏拒绝切换启动分区,保持旧固件运行
切换后启动失败设备不响应看门狗 + 启动计数触发自动回滚
双分区空间不足无法写入分区表预留足够空间(新固件不超过槽位 90%)
版本降级安全策略冲突显式声明允许降级并校验签名
电池电量不足写入中途掉电电量低于 50% 时拒绝升级,提示插电
反复升级失败设备被锁死限制重试次数(如 3 次),超限上报并停止

电池设备要特别处理:升级前检查电量,低于阈值则把任务延后;升级过程中如果检测到掉电趋势,应中止在写入前的阶段(下载可以随时中止,写入阶段必须完成或丢弃)。这也是「先下载到临时区、校验通过后再写入」这一顺序的价值所在。

12. 验证清单

发布前建议逐项过一遍:

  • 升级过程中拔电(在写入的不同阶段各试一次),设备能回到可用状态。
  • 回滚路径验证:人为让新固件启动失败,确认自动回滚生效。
  • 版本兼容:从最老的支持版本升到最新,以及跨 min_version 边界的行为。
  • 差分包还原验证:还原结果哈希与整包一致(放进 CI 自动跑)。
  • 签名校验:用错误签名、被篡改的固件、过期元数据各测一次,均被拒绝。
  • 回执可观测:平台能看到每台设备的进度百分比与失败原因码。
  • 限速与错峰:模拟 1000 台并发下载,源站与 CDN 无明显异常。
  • 电量与网络条件:在 2G 弱网与低电量下各跑一次。

设备侧的固件实现细节(分区表配置、Flash 操作、看门狗)与 MCU 平台强相关,可参考 嵌入式 MCU 与 ESP32 开发 。

权衡取舍

决策点方案 A方案 B建议
包形态整包差分固件 <256KB 用整包;蜂窝网络或大固件用差分
分区方案单分区 + 原地写A/B 双分区生产一律 A/B,单分区无法原子回滚
签名算法RSA-2048ECDSA P-256MCU 用 ECDSA,Linux 可用 RSA 或 Ed25519
校验时机只校验哈希验签 + 哈希 + 启动校验必须全链路,只校验哈希等于没有安全
传输通道MQTT 传固件HTTPS + CDN固件走 HTTPS,控制走 MQTT
灰度粒度按设备 ID 全量按批次百分比必须有批次与阈值,禁止一键全量
降级策略允许自由降级默认禁止降级默认禁止,仅回滚场景显式放行
固件加密不加密Flash 加密有物理攻击风险时开启,注意不可逆

常见坑清单

  • 跨机型刷机变砖:现象是设备重启后无响应,原因是未校验 hw_models 或 compatible,规避方法是元数据与设备侧双重机型校验。
  • 未调用自确认导致回滚:现象是新固件每次重启都退回旧版本,原因是未执行 esp_ota_mark_app_valid_cancel_rollback,规避方法是在业务初始化成功后立即调用。
  • bootcount 未持久化:现象是回滚永不触发,原因是环境变量存在易失分区,规避方法是把 bootcount 存到可持久化存储并验证掉电保持。
  • 分区空间不足:现象是写入到 90% 后失败,原因是新固件超过槽位容量,规避方法是分区表预留 10% 余量并在发布前校验。
  • 只校验哈希不验签:现象是固件可被替换,原因是元数据与固件同源可篡改,规避方法是引入非对称签名与内置公钥。
  • 差分方向搞反:现象是还原后设备无法启动,原因是把 target → source 补丁用于升级,规避方法是在包名与元数据中显式标注方向并在 CI 验证哈希。
  • 并发下载打爆源站:现象是下载大面积超时,原因是无 CDN 与限速,规避方法是走 CDN、加随机抖动并限制并发连接数。
  • 低电量升级掉电:现象是设备变砖且无法远程恢复,原因是写入阶段掉电,规避方法是电量阈值前置检查与写入前完整性校验。
  • 重试无上限:现象是设备反复下载安装失败,原因是失败后无限重试,规避方法是限制 3 次并上报失败原因码。
  • 回执只报成功:现象是平台显示 100% 但实际版本未变,原因是缺少中间状态上报,规避方法是上报 downloading/verifying/applying/success/failed 全状态。

小结

OTA 的工程本质是「用不可靠的传输与电源,完成一次必须原子生效的替换」。所有设计都围绕这个矛盾展开:A/B 双分区提供原子性,签名校验提供可信性,差分与分片提供可行性,灰度与回滚提供可恢复性。四者缺一,都会在规模上去之后暴露成事故。

实践中最容易被跳过的两步是「回滚路径的主动验证」和「差分包还原的自动化校验」。前者往往等到真的需要回滚时才发现不生效,后者则会在现场表现为「升级后设备无法启动」。把这两项放进发布流水线,能挡掉绝大多数严重故障。

下一步建议结合设备侧的实现细节与平台侧的收敛机制一起看:固件如何操作 Flash、如何配置分区与看门狗,决定了 OTA 的原子性能否真正成立;如何用影子把「目标版本」变成可观测的收敛指标,决定了灰度能否自动判断成败;而信任链、密钥管理与安全启动的整体设计,则决定了整个升级通道是否可信。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「物联网」更多文章

  1. 工业物联网协议与网关
  2. 边缘 AI 推理
  3. 设备配网与批量运维