4. OTA系统总体架构

终端升级这件事,说白了就是一套「检测→下载→校验→备份→激活」的组合拳。每个环节都可能翻车,我这些年踩过的坑,基本都集中在这几个节点上。

核心要点:升级流程不是线性的,而是一个带状态机的闭环。每个步骤都要考虑异常恢复,否则一次升级失败,设备可能变砖。

1. 升级检测:什么时候该升级?

终端不会无缘无故去检查升级。触发方式通常有三种:

  • 定时轮询:设备每隔一段时间(比如24小时)向服务器询问是否有新版本。我见过有些产品设成1小时轮询一次,结果服务器被请求打爆了。
  • 被动推送:服务器主动下发升级通知,终端收到后开始检测。这种方式实时性好,但需要终端保持长连接。
  • 用户触发:用户在设置页面点击「检查更新」。嗯,这个最直接,但用户不点你就没办法。

检测时,终端会带上当前版本号、硬件型号、区域信息等参数。服务器返回的升级包信息通常包含:

字段 说明 我的建议
version 目标版本号 用语义化版本,别搞什么v1.2.3_beta_final
url 升级包下载地址 用CDN地址,别直接挂内网IP
md5/sha256 校验值 必须用SHA256,MD5已经不够安全了
size 包体大小 用于进度条和存储空间预检
force 是否强制升级 关键安全更新建议强制,功能更新可选
经验之谈:我在做智能门锁项目时,发现有些设备网络不稳定,轮询经常超时。后来改成「指数退避」策略——第一次失败等5分钟,第二次等10分钟,最多等2小时。效果不错。

2. 下载策略:断点续传与静默下载

下载是整个流程中最耗时的环节,也是最容易出问题的。我遇到过下载到90%突然断网,然后从头开始下的情况——用户差点把设备砸了。

断点续传

断点续传的原理很简单:把大文件切成小块,记录已经下载的块偏移量。断网后重新连接,从断点继续下载,而不是重头开始。

实现时需要注意:

  • 服务端必须支持 Range 请求头
  • 本地要持久化下载进度(写到Flash或文件系统)
  • 下载完成后做完整性校验,防止拼接错误
// 伪代码:断点续传核心逻辑
typedef struct {
    uint32_t total_size;
    uint32_t downloaded_size;
    uint8_t  chunk_hash[32];  // 每块的SHA256
} DownloadProgress;

// 恢复下载时
DownloadProgress progress = read_progress_from_flash();
http_set_header("Range", "bytes=%d-", progress.downloaded_size);
// 继续接收剩余数据

静默下载

静默下载就是在用户无感知的情况下把升级包拉下来。听起来很美好,但坑不少:

  • 带宽控制:不能把用户正在用的网络占满。我一般限制在总带宽的30%以内。
  • 时机选择:设备空闲时下载,比如深夜、充电时、WiFi环境下。
  • 电量考虑:电池供电的设备,电量低于20%时暂停下载。
避坑指南:我曾经在一个车载项目里用了静默下载,结果用户正在导航,下载占满了4G带宽,地图加载不出来。后来加了策略:前台有高优先级应用时,静默下载自动降速或暂停。

3. 校验与解密:信任是从代码到硬件的

下载完成后,不能直接拿来用。你得先确认这包是完整的、没被篡改的、而且是合法的。

完整性校验

用SHA256对比本地计算值和服务器下发的值。如果对不上,说明下载过程中数据损坏了,需要重新下载。

// 校验示例
uint8_t expected_hash[32];
uint8_t actual_hash[32];

sha256_compute(download_buffer, buffer_size, actual_hash);
if (memcmp(expected_hash, actual_hash, 32) != 0) {
    // 校验失败,重新下载
    goto retry_download;
}

签名验证

完整性校验只能防传输错误,防不了恶意篡改。签名验证才是安全的核心:

  • 服务器用私钥对升级包签名
  • 终端用预置的公钥验证签名
  • 签名算法推荐ECDSA或RSA-2048以上
关键点:公钥必须写在终端的只读区域(比如OTP或eFuse),不能存在可擦写的Flash里。否则攻击者替换了公钥,你的签名验证就形同虚设。

解密

如果升级包是加密的,终端需要用密钥解密。密钥管理是个大话题,这里只说几个原则:

  • 每个设备的密钥不同(基于设备唯一ID派生)
  • 密钥不在Flash中明文存储
  • 解密过程在安全环境(如TEE或SE)中执行

4. 备份与回滚:给自己留条后路

升级失败怎么办?没有备份机制,设备就变砖了。我见过太多产品因为没做回滚,售后成本直接翻倍。

备份策略

常见的备份方式有两种:

方案 原理 优缺点
A/B分区 两个系统分区轮流使用 切换快,但Flash占用翻倍
备份分区 单独一个备份分区存旧版本 节省空间,但恢复时需拷贝

我个人更推荐A/B分区方案。虽然多占一倍空间,但切换是原子操作,几乎不会失败。

回滚触发条件

  • 新版本启动后,看门狗超时(系统卡死)
  • 关键服务注册失败(比如网络连不上)
  • 用户手动选择回滚
  • 连续重启超过N次(我一般设3次)
血的教训:有一次我忘了在回滚前擦除新版本的标记位,结果设备在A/B分区之间来回跳,永远起不来。后来加了「回滚次数计数器」,最多回滚3次,超过就停在当前分区并上报错误。

5. 激活与切换:最后一哆嗦

所有准备工作就绪,终于到了激活新版本的环节。这一步要保证「要么成功,要么回滚」。

激活流程

  1. 设置新分区为「待激活」状态
  2. 写入启动标记(比如Bootloader读取的flag)
  3. 执行系统复位
  4. Bootloader检查标记,加载新分区
  5. 新系统启动,执行自检
  6. 自检通过后,标记为「已激活」

切换的原子性

写入启动标记这一步必须是原子的。如果写了一半掉电,Bootloader可能读到脏数据,不知道该启动哪个分区。

解决办法:

  • 使用双备份标记(写两次,比较一致才生效)
  • 使用Flash的原子写操作(比如STM32的选项字节)
  • 标记写入后立即读回验证
// 原子切换示例
void switch_to_new_partition(void) {
    // 先写主标记
    write_boot_flag(BOOT_FLAG_NEW);
    // 读回验证
    if (read_boot_flag() != BOOT_FLAG_NEW) {
        // 写入失败,保持当前分区
        return;
    }
    // 写备份标记
    write_boot_flag_backup(BOOT_FLAG_NEW);
    // 执行复位
    system_reset();
}

整体流程一览

下面这张图把整个升级流程串起来了。你可以看到,每个环节都有分支,不是一条直线走到底。

终端升级完整流程图 1. 升级检测 2. 下载升级包 3. 校验与解密 4. 备份旧版本 5. 激活新版本 6. 启动自检 回滚旧版本 标记已激活 说明 自检失败 → 回滚 自检成功 → 标记激活

你看,整个流程中「回滚」这条线是保命的。没有它,升级失败就是灾难。我参与过的项目,凡是认真做了回滚机制的,售后问题都少了一大半。

最后说一句:升级流程的设计,本质上是在「成功率」和「安全性」之间找平衡。别追求100%成功,那不现实。但一定要保证失败后能安全恢复——这才是OTA架构师的真功夫。