4. 终端升级代理设计:升级代理核心功能、状态机设计、下载管理、存储管理、升级执行与回滚
好,咱们进入第四章。这一章我打算聊聊终端上的升级代理——说白了,就是跑在设备里的那个负责OTA的小管家。很多同学觉得写个下载脚本就完事了,其实不然。我在项目里见过太多次,升级到一半设备变砖,就是因为代理设计得不够健壮。
升级代理不是简单的“下载→刷写”两步走。它要管的事情很多:什么时候该下载、下载坏了怎么办、存储空间够不够、刷进去能不能启动、启动失败怎么回滚。这些都得在代理里设计好。
4.1 升级代理的核心功能
一个合格的升级代理,至少得干这几件事:
- 接收升级指令:从云端或本地触发升级流程
- 下载管理:断点续传、校验完整性、处理网络异常
- 存储管理:分配空间、管理分区、处理多版本共存
- 状态机调度:控制升级流程的每一步,防止乱序执行
- 升级执行:调用刷写接口,写入目标分区
- 回滚机制:升级失败后能恢复到上一个可用版本
核心原则:升级代理必须做到“失败可恢复,断电不丢数据”。这是底线。
4.2 状态机设计
状态机是升级代理的大脑。我习惯用有限状态机来管理整个升级流程,每个状态对应一个明确的操作,状态之间的跳转由事件驱动。
为什么会用状态机?你想想看,如果没有状态机,下载到一半收到刷写指令怎么办?刷写到一半网络又恢复了怎么办?状态机能保证代理在任何时刻都知道自己该干什么。
4.2.1 状态定义
| 状态 | 含义 | 触发事件 |
|---|---|---|
| IDLE | 空闲状态,等待升级指令 | 系统启动、升级完成/回滚完成 |
| CHECKING | 检查升级条件(电量、空间、版本) | 收到升级指令 |
| DOWNLOADING | 下载升级包 | 条件检查通过 |
| VERIFYING | 校验下载包的完整性 | 下载完成 |
| READY | 准备就绪,等待执行升级 | 校验通过 |
| UPDATING | 执行刷写操作 | 用户确认/自动触发 |
| REBOOTING | 重启进入新系统 | 刷写完成 |
| ROLLBACK | 回滚到旧版本 | 升级失败/新系统启动失败 |
| ERROR | 异常状态,需要人工干预 | 不可恢复的错误 |
4.2.2 状态跳转逻辑
状态跳转不是随便来的。我画了一张状态机图,你看一眼就明白了:
我的经验:状态机里一定要加一个“超时跳转”。比如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 完整性校验
下载完成后必须校验。我一般用两步校验:
- 逐块校验:每下载一块,计算SHA256,跟清单里的哈希对比
- 整体校验:全部下载完成后,对整个包计算一次哈希
注意:不要只做整体校验。如果最后一块坏了,你都不知道是哪一块的问题,只能全部重下。逐块校验可以精确定位到坏块。
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 升级执行流程
- 进入UPDATING状态:停止所有非关键服务,防止干扰
- 备份关键数据:把当前系统的启动参数、配置信息备份到misc分区
- 写入备用分区:调用刷写接口,逐块写入
- 写入校验:写入完成后,读回并对比哈希
- 切换启动标志:更新misc分区中的启动计数器
- 重启:进入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分区就能知道升级卡在哪一步。这个习惯救过我很多次。
嗯,升级代理的设计就聊到这儿。核心就三句话:状态机管流程,下载管数据,存储管空间。回滚是最后的保险,但最好别用到它。