4. 终端升级代理设计:升级代理核心功能、状态机设计、下载管理、存储管理、升级执行与回滚

好,咱们进入第四章。这一章我打算聊聊终端上的升级代理——说白了,就是跑在设备里的那个负责OTA的小管家。很多同学觉得写个下载脚本就完事了,其实不然。我在项目里见过太多次,升级到一半设备变砖,就是因为代理设计得不够健壮。

升级代理不是简单的“下载→刷写”两步走。它要管的事情很多:什么时候该下载、下载坏了怎么办、存储空间够不够、刷进去能不能启动、启动失败怎么回滚。这些都得在代理里设计好。

4.1 升级代理的核心功能

一个合格的升级代理,至少得干这几件事:

  • 接收升级指令:从云端或本地触发升级流程
  • 下载管理:断点续传、校验完整性、处理网络异常
  • 存储管理:分配空间、管理分区、处理多版本共存
  • 状态机调度:控制升级流程的每一步,防止乱序执行
  • 升级执行:调用刷写接口,写入目标分区
  • 回滚机制:升级失败后能恢复到上一个可用版本

核心原则:升级代理必须做到“失败可恢复,断电不丢数据”。这是底线。

4.2 状态机设计

状态机是升级代理的大脑。我习惯用有限状态机来管理整个升级流程,每个状态对应一个明确的操作,状态之间的跳转由事件驱动。

为什么会用状态机?你想想看,如果没有状态机,下载到一半收到刷写指令怎么办?刷写到一半网络又恢复了怎么办?状态机能保证代理在任何时刻都知道自己该干什么。

4.2.1 状态定义

状态 含义 触发事件
IDLE 空闲状态,等待升级指令 系统启动、升级完成/回滚完成
CHECKING 检查升级条件(电量、空间、版本) 收到升级指令
DOWNLOADING 下载升级包 条件检查通过
VERIFYING 校验下载包的完整性 下载完成
READY 准备就绪,等待执行升级 校验通过
UPDATING 执行刷写操作 用户确认/自动触发
REBOOTING 重启进入新系统 刷写完成
ROLLBACK 回滚到旧版本 升级失败/新系统启动失败
ERROR 异常状态,需要人工干预 不可恢复的错误

4.2.2 状态跳转逻辑

状态跳转不是随便来的。我画了一张状态机图,你看一眼就明白了:

升级代理状态机 IDLE CHECKING DOWNLOADING VERIFYING READY UPDATING REBOOTING ROLLBACK ERROR 收到升级指令 条件通过 下载完成 校验通过 执行升级 刷写完成 启动成功 启动失败 回滚完成 下载失败 校验失败 刷写失败 条件不满足 说明: 每个状态都有明确的进入条件和退出动作。异常跳转(红色箭头)必须记录日志,方便事后排查。

我的经验:状态机里一定要加一个“超时跳转”。比如DOWNLOADING状态超过30分钟没完成,自动跳转到ERROR并尝试回滚。我在一个项目中没加这个,结果设备卡在下载状态整整两天。

4.3 下载管理

下载管理是升级代理里最容易出问题的环节。网络不稳定、服务器挂了、存储空间不够——任何一个都能让升级失败。

4.3.1 断点续传

我建议所有OTA升级都支持断点续传。实现方式很简单:

  • 把升级包分成固定大小的块(比如256KB一块)
  • 下载时记录已完成的块索引
  • 恢复下载时从第一个未完成的块开始
// 伪代码:断点续传逻辑
typedef struct {
    uint32_t total_blocks;
    uint32_t completed_blocks;
    uint8_t  block_bitmap[MAX_BLOCKS / 8];
} DownloadProgress;

int resume_download(DownloadProgress *progress) {
    for (int i = 0; i < progress->total_blocks; i++) {
        if (!is_block_completed(progress, i)) {
            int ret = download_block(i);
            if (ret != 0) {
                save_progress(progress);
                return -1;  // 下次继续
            }
            mark_block_completed(progress, i);
        }
    }
    return 0;  // 全部下载完成
}

4.3.2 完整性校验

下载完成后必须校验。我一般用两步校验:

  1. 逐块校验:每下载一块,计算SHA256,跟清单里的哈希对比
  2. 整体校验:全部下载完成后,对整个包计算一次哈希

注意:不要只做整体校验。如果最后一块坏了,你都不知道是哪一块的问题,只能全部重下。逐块校验可以精确定位到坏块。

4.4 存储管理

存储管理是升级代理的“后勤部”。空间不够、分区写保护、坏块——这些都得提前考虑。

4.4.1 分区规划

我推荐使用A/B分区方案。说白了就是准备两套系统分区:

分区 用途 大小建议
boot_a 当前启动的系统 32MB
rootfs_a 当前启动的根文件系统 256MB
boot_b 备用系统 32MB
rootfs_b 备用根文件系统 256MB
data 用户数据、升级包缓存 128MB
misc 元数据(启动次数、版本号) 16MB

升级时,把新系统写入备用分区,然后切换启动标志。这样即使升级失败,当前系统还能正常启动。

4.4.2 空间管理策略

存储空间有限,得精打细算:

  • 下载前检查:确保有足够空间存放升级包 + 解压临时文件
  • 下载后清理:升级完成后立即删除下载包,释放空间
  • 预留空间:至少保留10%的存储空间作为安全余量

避坑指南:我曾经遇到一个设备,升级包下载完成后空间刚好用完,结果解压时直接失败。后来我加了一条规则:下载前预留的空间必须是升级包大小的1.5倍。

4.5 升级执行与回滚

这是最后一步,也是最危险的一步。刷写过程中断电、写错分区、校验失败——任何一个都能让设备变砖。

4.5.1 升级执行流程

  1. 进入UPDATING状态:停止所有非关键服务,防止干扰
  2. 备份关键数据:把当前系统的启动参数、配置信息备份到misc分区
  3. 写入备用分区:调用刷写接口,逐块写入
  4. 写入校验:写入完成后,读回并对比哈希
  5. 切换启动标志:更新misc分区中的启动计数器
  6. 重启:进入REBOOTING状态

4.5.2 回滚机制

回滚是最后的救命稻草。我设计回滚时遵循三个原则:

  • 自动回滚:新系统启动失败(比如连续重启3次),自动切回旧系统
  • 手动回滚:用户可以通过物理按键或远程指令触发回滚
  • 安全回滚:回滚过程中断电,重启后继续回滚,不会卡在半路
// 伪代码:回滚逻辑
void rollback() {
    // 1. 读取当前启动计数器
    uint8_t boot_count = read_misc(BOOT_COUNT);
    
    // 2. 如果启动次数超过阈值,触发回滚
    if (boot_count >= MAX_BOOT_RETRIES) {
        // 3. 切换启动标志到旧分区
        switch_boot_partition(OLD_PARTITION);
        
        // 4. 重置启动计数器
        write_misc(BOOT_COUNT, 0);
        
        // 5. 重启
        system_reboot();
    }
}

重要:回滚操作本身也要做幂等设计。也就是说,回滚执行到一半断电了,重启后还能继续回滚,不会出现“回滚到一半,两个分区都坏了”的情况。

4.5.3 升级执行中的异常处理

我整理了一份常见的异常场景和处理策略:

异常场景 处理策略 恢复方式
刷写时断电 记录写入进度到misc分区 重启后从断点继续写入
写入校验失败 标记该块为坏块,重新写入 重试3次,失败则回滚
新系统启动失败 启动计数器+1,超过阈值自动回滚 自动切回旧系统
存储空间不足 删除临时文件,释放空间 释放后重试,失败则报错
升级包损坏 重新下载 最多重试3次

个人习惯:我会在misc分区里维护一个“升级日志区”,每次状态跳转都写一条日志。这样即使设备变砖,通过串口读misc分区就能知道升级卡在哪一步。这个习惯救过我很多次。

嗯,升级代理的设计就聊到这儿。核心就三句话:状态机管流程,下载管数据,存储管空间。回滚是最后的保险,但最好别用到它。