引言
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 侧同样适用。设备身份与影子收敛机制见 设备管理与设备影子 。
目录
- OTA 的三种形态
- OTA 端到端架构
- 固件制品与版本元数据
- ESP32 A/B 双分区与 OTA API
- MCUboot 与 U-Boot 的引导切换
- Linux 侧更新框架
- 差分更新的原理与收益
- 签名、加密与完整性校验
- 下载与传输优化
- 灰度与批次策略
- 失败场景与回滚
- 验证清单
- 权衡取舍
- 常见坑清单
- 小结
1. OTA 的三种形态
| 形态 | 原理 | 包大小 | 端上开销 | 适用面 |
|---|---|---|---|---|
| 整包更新 | 下载完整固件写入备用分区 | 与固件同大(数百 KB 到数十 MB) | 只需存储,无需还原算力 | 所有设备,最稳 |
| 差分更新 | 下载新旧版本差异,端上还原 | 整包的 3%~10% | 需还原算法与额外 RAM | 流量敏感、固件大 |
| 增量脚本 | 下发脚本执行打补丁 | 极小 | 不可控、易失败 | 仅调试,不建议生产 |
选择建议:MCU 设备固件小于 256KB 时,整包与差分差距不大,优先整包以降低复杂度;固件超过 512KB 或设备走蜂窝网络时,必须支持差分;增量脚本在生产环境一律不用,因为脚本执行到一半掉电会让设备处于未知状态,而整包与差分都有分区保护。
还有一个折中方案是「压缩整包」:把固件用 LZMA 或 zstd 压缩后再传,端上解压写入。压缩率通常 40%~60%,收益不如差分但实现简单、无需匹配旧版本,适合「设备版本分散、无法为每个旧版本准备差分包」的场景。
2. OTA 端到端架构
一条完整的 OTA 链路包含六个组件:
- 固件仓库 / 制品库:存放固件二进制与差分包,按
product/version/组织,具备不可变性与内容寻址(以 SHA-256 作为文件名或校验字段)。 - 版本元数据服务:记录版本号、适用硬件型号、依赖的最低版本、强制或可选、发布时间、变更说明。
- 灰度策略服务:决定哪些设备可以升级,输出目标设备集合与批次计划。
- 设备端 OTA Agent:接收任务、下载、校验、写入、切换、回滚、上报进度。
- 传输层:HTTPS 或 MQTT 下发任务,HTTP Range 分片下载固件,支持 CDN。
- 回执与观测:设备上报
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 的方案是经典的分区表加引导标记。分区表里通常包含:
| Name | Type | SubType | Offset | Size | 作用 |
|---|---|---|---|---|---|
| nvs | data | nvs | 0x9000 | 0x6000 | 键值存储,保存配置与计数器 |
| otadata | data | ota | 0xf000 | 0x2000 | 记录当前与待启动分区 |
| phy_init | data | phy | 0x11000 | 0x1000 | RF 校准数据 |
| factory | app | factory | 0x20000 | 0x180000 | 出厂固件(可省略) |
| ota_0 | app | ota_0 | 0x1A0000 | 0x180000 | 备用槽 A |
| ota_1 | app | ota_1 | 0x320000 | 0x180000 | 备用槽 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 artifact | A/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 B | 100% |
| 相邻版本差分 | 45 KB | 4.3% |
| 跨 5 个版本差分 | 128 KB | 12.2% |
| 压缩整包(LZMA) | 420 KB | 40.0% |
端上还原的约束是 RAM:detools 的 sequential 模式只需缓冲区大小级别的内存(如 4~16KB),而 in-place 模式可以完全不用额外 RAM,但要求补丁与目标区域布局配合。工程上的常见错误是选了内存开销大的模式导致还原时 OOM,或者补丁顺序搞错(补丁必须按 source → target 方向生成和应用,反向使用会生成损坏固件)。
差分还有一个隐性成本:需要在制品库里维护版本组合,且每个组合都要做一次「还原后哈希与目标固件哈希一致」的验证。建议把这项验证放进 CI,任何差分包发布前都自动跑一遍还原比对。
8. 签名、加密与完整性校验
安全链路的顺序是「先验签名、再校验哈希、最后写入」。只校验 SHA-256 是不够的,因为攻击者可以同时替换固件与哈希值;签名才能保证来源可信。
典型流程:
- 发布侧:对固件二进制计算 SHA-256,用私钥(RSA-2048 或 ECDSA P-256)签名摘要,把签名与哈希写入元数据。
- 设备侧:下载完成后用内置公钥验签,验签通过后计算实际哈希并与元数据比对,两者都通过才写入分区。
- 写入后:读回分区重新计算哈希(可选但推荐),确保存储介质没有写入错误。
- 启动时: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-2048 | ECDSA P-256 | MCU 用 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 的原子性能否真正成立;如何用影子把「目标版本」变成可观测的收敛指标,决定了灰度能否自动判断成败;而信任链、密钥管理与安全启动的整体设计,则决定了整个升级通道是否可信。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。