7AA7 Blog

IoT 固件 OTA:差分升级与回滚

许既白 UID:107
2026-09-27 12:09
100 4 1

OTA 的风险不在下载过程,而在”写进去之后能不能跑起来”和”跑不起来怎么办”。所有 OTA 设计都围绕这两点:让失败可检测,让失败可回退。

分区方案的取舍

单分区 + 备份区:运行区只有一个,升级时把新固件下载到备份区,校验后由 bootloader 拷贝到运行区。地址固定、代码无需位置无关处理;但拷贝期间断电会留下半个固件。

A/B 双分区:flash 里放两个同样大小的应用分区,设备总是从其中一个启动。升级时把新固件写入当前未使用的那个分区,写完校验通过,只改一个标记位告诉 bootloader 下次从另一个分区启动。

A/B 的关键优势是运行区永远完整。写入发生在非活动分区,任何时刻断电下次仍跑旧固件,最多这次升级失败。代价是 flash 占用几乎翻倍——4 MB flash 扣掉 bootloader、分区表、NVS 后,每边可能只剩 1 MB 出头。

单分区 + 下载缓冲:介于两者之间,用外部 flash 做缓冲,校验后写入运行区,风险窗口仍在但可以做得很小。

差分减小占用:传差分包只需较小的暂存区,A/B 的空间压力会小很多。

选择时主要看 flash 够不够、能否接受变砖风险。能上 A/B 就上,不够再考虑其他方案。

bootloader 的跳转逻辑

bootloader 是上电后第一段执行的代码,不参与业务逻辑,职责明确:

  1. 读取分区表,找到两个应用分区
  2. 读取非易失存储里的启动标记,判断该从哪个分区启动
  3. 校验目标分区的完整性(CRC 或签名)
  4. 校验通过则跳转,失败则尝试另一个分区
  5. 两边都失败,进入恢复模式(比如等待串口下载)

跳转的关键操作是设置主栈指针和复位向量:

typedef struct {
    uint32_t msp;       /* 偏移 0:初始栈指针 */
    uint32_t reset;     /* 偏移 4:复位向量 */
} vector_t;

void jump_to_app(uint32_t app_addr) {
    vector_t *v = (vector_t *)app_addr;

    if ((v->msp & 0x2FFE0000) != 0x20000000) return;  /* 栈指针合理性检查 */

    __disable_irq();
    SysTick->CTRL = 0;              /* 关掉 bootloader 打开的 SysTick */
    HAL_DeInit();                   /* 复位外设,避免带着状态跳转 */
    for (int i = 0; i < 8; i++) NVIC->ICER[i] = 0xFFFFFFFF;  /* 清中断使能 */

    __set_MSP(v->msp);
    SCB->VTOR = app_addr;           /* 重定位中断向量表 */
    ((void (*)(void))v->reset)();
}

必须做的动作:清 NVIC 中断使能、关 SysTick、重设 VTOR。漏掉 VTOR 会让应用的中断仍跳到 bootloader 的向量表;漏掉关中断可能在向量表切换完成前触发中断。跳转前确认栈指针落在合法 RAM 范围。

分区标记通常存在独立的 NVS 分区或备份寄存器里,含当前活动分区、待启动分区、启动确认状态、失败计数。标记本身要有校验,写坏了 bootloader 就不知道该跳哪边。

断电保护

核心原则:任何时刻断电都要有一个可用固件。

写入顺序是设计的关键,以 A/B 方案为例:

1. 擦除非活动分区
2. 写入新固件到非活动分区
3. 计算并校验 CRC / 签名        ← 校验不过就停在这里,不动标记
4. 写标记:待启动 = 非活动分区,状态 = 待确认   ← 原子操作
5. 复位
6. bootloader 读到"待确认",跳转到新分区
7. 应用自检通过后,写标记:状态 = 已确认
8. 若自检失败或未在时限内确认,bootloader 计数 +1,回到旧分区

第 4 步的标记写入必须原子。标记跨多个扇区或需多次写入时,中途断电会留下不一致状态。常见做法:

  • 标记结构足够小,能对齐到 flash 编程单位一次写入
  • 两个交替存储槽带序号,取序号大的有效(序号与数据的写入顺序取决于断电时哪个先落盘)
  • 标记带 CRC,读出来先校验,坏了用默认值(默认从已知稳定的分区启动)

先校验再切换是硬要求。固件没写完整就改标记,断电后必然从半个固件启动。

另一个细节:擦除大块 flash 耗时较长,喂狗要放在擦写循环里。

差分升级的原理

差分升级的思路:服务端有旧版本 V1 和新版本 V2,算出 V1 到 V2 的补丁包下发给设备,设备用本地 V1 加补丁包重建出 V2。

以 bsdiff 类算法为例,它扫一遍新旧文件,找出两部分:可从旧文件复制的相同区块(偏移 + 长度表示)和必须新增的字节。补丁包就是一串复制指令加少量新增数据。

固件这种”改几行代码、重编译后大部分字节不变”的场景,补丁包通常显著小于完整固件——收益取决于改动范围和编译确定性。若编译产物因时间戳、构建路径大量变化,差分效果会大打折扣。提高差分效率的前提是构建可复现:固定编译选项、去掉时间戳、固定链接顺序。

收益边界:

  • 省的是流量,代价是端侧要跑重建算法,需要额外的 RAM 和计算时间
  • 版本管理复杂度上升:服务端要为每个源版本维护一份补丁,M 个历史版本 × N 个目标版本就是 M×N 个产物。通常只对最近几个版本提供补丁
  • 端侧重建本身可能失败,要能回退到下载完整包
  • 差分要求设备当前版本与补丁源版本严格一致。若设备固件因异常与服务器记录不符,打补丁会得到错误结果,所以重建后必须校验固件的哈希

回滚触发条件

回滚不能靠人工判断,要写成设备可判定的规则:

启动失败计数:bootloader 维护连续启动失败计数,超过阈值(比如 3 次)就切回另一分区并清零。这是最后一道防线。

启动确认超时:新固件启动后必须在规定时间内(比如几十秒)显式确认,否则判失败。确认点应在关键功能自检之后——连不上网、读不到传感器都不算成功。

心跳未达标:升级后若干分钟内无心跳上报,服务端判定失败并下发回滚指令,需要设备支持服务端触发的分区切换。

业务指标恶化:升级后重启次数、通信失败率明显上升,这类判据适合灰度阶段统计。

回滚后要把失败版本号记录并上报,避免服务端反复推送同一问题版本,应有”坏版本黑名单”机制。

灰度与分批推送

全量推送有风险,分批的基本做法:

  • 先推内部测试设备,观察一段时间
  • 再按 1%、5%、20% 逐步放大,每档之间留观察窗口
  • 观察指标预先定好:在线率、崩溃率、业务成功率、功耗
  • 任一档异常即暂停,定位后再继续

分组维度有讲究:按设备 ID 哈希分组简单,但可能把同一批次硬件全放进一组;按地域、硬件版本、固件源版本分层抽样更能暴露兼容性问题。

推送侧还要控制并发。大量设备同时下载会打满带宽和 CDN,也造成基站侧拥塞,下发时给设备一个随机延迟窗口错开下载。

签名校验

OTA 通道是攻击面,固件若可被替换,设备就完全受控于攻击者。签名校验防的是固件被篡改或替换,不是防”看到”内容——那是加密解决的问题。

基本流程:

服务端:hash = SHA256(firmware)
        sig  = RSA_Sign(hash, private_key)
        下发 firmware + sig

设备端:hash' = SHA256(下载的固件)
        用烧录在设备里的 public_key 验签 sig(hash')
        通过才写入 / 才允许启动

要点:

公钥必须烧在设备里且不可改写。放在可写 flash 区域,攻击者改掉公钥就能用自己签的固件。

验签在写入前和启动时都要做。写入前防下载到坏包,启动时防绕过 OTA 直接刷入。攻击者不一定走 OTA 通道,两者都要有。

哈希要覆盖完整固件,包括版本头、向量表等所有字节。

版本防降级:签名里带版本号,bootloader 拒绝启动低于当前记录的版本,否则攻击者可刷入”合法签名但有已知漏洞”的旧版本。防降级计数器要放在不可回滚的存储里(如 eFuse 单调计数器),放 flash 会被擦掉。

密钥管理:生产时烧入公钥,私钥离线保存,签名在受控构建环境完成,不要让签名密钥出现在开发机上。

还要考虑 debug 口:量产后禁用调试接口或加读保护,否则签名校验可被调试器绕过,要留受控的恢复路径。

🔁 1
2.1kViews
100Likes
4Comments
4Participants
10 minread time

Comments (4)

  • 逻辑熵 Logic · 2026-09-27 14:19

    有个细节可能需要注意:不同版本默认值不一样。

    Reply
  • 雾梨 Mist · 2026-09-27 23:10

    代码能跑,但这段我第一眼看成了 bug,仔细看才反应过来。

    Reply
  • Schema 李 · 2026-09-28 08:53

    同事看完这篇开始改我们的架构图,我有点慌。

    Reply
  • 温言 · 2026-09-28 17:32

    这篇要是早半年出来,我们能少加一周班。

    Reply