4. 断点续传实现(客户端):下载状态机设计、分块下载器实现、断点记录文件管理
好,咱们接着聊客户端这边的事。上一章讲了服务端怎么配合,这一章轮到客户端上场了。说白了,断点续传的核心逻辑,其实都在客户端这边。你想想看,设备断电了、网络断了、升级包下到一半停了,客户端得自己知道「我下到哪了」、「接下来该干嘛」。这就是状态机和断点记录要解决的问题。
4.1 下载状态机设计
我个人习惯,做任何带状态流转的功能,第一件事就是画状态机。别急着写代码,先把状态理清楚。我见过太多人上来就写下载逻辑,写到后面发现状态乱了,各种if-else嵌套,改都改不动。
对于OTA断点续传,我一般把客户端下载状态分成这几个:
| 状态 | 含义 | 触发条件 |
|---|---|---|
| IDLE | 空闲状态,未开始下载 | 系统初始化、下载完成、下载失败重置 |
| CHECKING | 检查本地是否有断点记录 | 收到下载指令后进入 |
| DOWNLOADING | 正在下载数据块 | 检查完断点记录,确定起始位置后 |
| PAUSED | 下载暂停(用户主动或网络异常) | 收到暂停指令、网络断开 |
| VERIFYING | 校验已下载的数据完整性 | 所有分块下载完成 |
| COMPLETED | 下载完成,等待安装 | 校验通过 |
| FAILED | 下载失败,不可恢复 | 校验失败、致命错误 |
嗯,这里要注意一点:PAUSED和FAILED的区别。PAUSED是可以恢复的,比如网络闪断,设备自动重连后继续下。FAILED是没救了,比如固件包签名校验失败,这时候必须重新下载。
状态机的代码实现,我建议用查表法,别用switch-case硬写。为什么呢?因为状态多了以后,switch-case会变得又臭又长,而且状态迁移的逻辑散落在各个case里,维护起来很痛苦。
看一个简单的状态表实现:
// 状态迁移表结构
typedef struct {
DownloadState currentState;
DownloadEvent event;
DownloadState nextState;
void (*action)(void *param);
} StateTransition;
// 定义状态表
static StateTransition g_stateTable[] = {
{IDLE, EVT_START_DOWNLOAD, CHECKING, doCheckResume},
{CHECKING, EVT_RESUME_FOUND, DOWNLOADING, doStartDownload},
{CHECKING, EVT_NO_RESUME, DOWNLOADING, doStartFromBegin},
{DOWNLOADING, EVT_BLOCK_OK, DOWNLOADING, doDownloadNext},
{DOWNLOADING, EVT_NET_ERROR, PAUSED, doSaveCheckpoint},
{DOWNLOADING, EVT_ALL_DONE, VERIFYING, doVerifyIntegrity},
{PAUSED, EVT_NET_RECOVER, DOWNLOADING, doResumeDownload},
{PAUSED, EVT_TIMEOUT, FAILED, doReportFailure},
{VERIFYING, EVT_VERIFY_OK, COMPLETED, doNotifyComplete},
{VERIFYING, EVT_VERIFY_FAIL, FAILED, doReportFailure},
};
// 状态机执行函数
DownloadState stateMachineRun(DownloadEvent event, void *param) {
for (int i = 0; i < sizeof(g_stateTable)/sizeof(g_stateTable[0]); i++) {
if (g_stateTable[i].currentState == g_currentState &&
g_stateTable[i].event == event) {
g_currentState = g_stateTable[i].nextState;
if (g_stateTable[i].action) {
g_stateTable[i].action(param);
}
break;
}
}
return g_currentState;
}
你看,这样写的好处是:状态迁移一目了然,新增一个状态或事件,只需要在表里加一行。我曾经在一个项目里,用这种表驱动的方式,把原来300多行的状态机代码压缩到了80行,而且bug率直线下降。
4.2 分块下载器实现
分块下载,说白了就是把一个大固件包切成很多小块,一块一块地下。为什么要分块?两个原因:一是网络不稳定,小块失败了重传成本低;二是方便断点续传,记录到哪个块了就行。
我建议的分块策略是这样的:
- 块大小:一般取256KB或512KB。太小了,HTTP请求次数太多,握手开销大;太大了,失败重传代价高。我在实际项目中,针对不同网络环境做过测试,Wi-Fi环境下512KB比较均衡,4G/5G环境下256KB更稳妥。
- 并发数:嵌入式设备资源有限,我一般控制在2~4个并发。别贪多,并发太多会把带宽占满,影响其他业务。
- 重试机制:每个块最多重试3次,每次重试间隔递增(1s、2s、4s)。超过3次,整个下载进入PAUSED状态。
来看一个简化版的分块下载器实现:
typedef struct {
uint32_t blockIndex; // 块索引
uint32_t offset; // 在文件中的偏移
uint32_t size; // 块大小
uint8_t status; // 0:待下载, 1:下载中, 2:已完成, 3:失败
uint8_t retryCount; // 重试次数
uint32_t crc32; // 块校验值
} BlockInfo;
typedef struct {
uint32_t totalBlocks; // 总块数
uint32_t blockSize; // 每块大小
uint32_t completedBlocks; // 已完成块数
BlockInfo *blocks; // 块信息数组
uint8_t concurrentMax; // 最大并发数
} Downloader;
// 下载一个块
static int downloadBlock(Downloader *dl, uint32_t index) {
BlockInfo *block = &dl->blocks[index];
char url[256];
snprintf(url, sizeof(url), "%s?offset=%u&size=%u",
g_downloadUrl, block->offset, block->size);
// 发起HTTP Range请求
int ret = httpGetRange(url, block->offset, block->size,
g_buffer + block->offset);
if (ret == 0) {
// 校验CRC
uint32_t calcCrc = crc32_calc(g_buffer + block->offset, block->size);
if (calcCrc == block->crc32) {
block->status = 2; // 完成
dl->completedBlocks++;
return 0;
}
}
// 失败处理
block->retryCount++;
if (block->retryCount >= 3) {
block->status = 3; // 失败
return -1;
}
return -2; // 需要重试
}
// 调度器:管理并发下载
void downloaderSchedule(Downloader *dl) {
int activeCount = 0;
for (uint32_t i = 0; i < dl->totalBlocks; i++) {
if (dl->blocks[i].status == 1) {
activeCount++; // 统计正在下载的块数
}
}
for (uint32_t i = 0; i < dl->totalBlocks && activeCount < dl->concurrentMax; i++) {
if (dl->blocks[i].status == 0) { // 找到待下载的块
dl->blocks[i].status = 1; // 标记为下载中
downloadBlock(dl, i);
activeCount++;
}
}
}
这里有个细节:下载完成后要立即写Flash。别等到所有块都下完了再写,万一中间断电,内存里的数据全丢了。我一般每下载完一个块,就调用Flash写入接口,把数据持久化到存储分区。
4.3 断点记录文件管理
断点记录文件,是断点续传的「命根子」。设备断电重启后,全靠它来恢复现场。这个文件的设计,我踩过不少坑,分享几个关键点。
第一,记录文件要放在独立的存储区域。 别和固件包放在同一个分区。为什么?因为固件包分区可能在升级过程中被擦除或覆盖,记录文件要是跟着没了,那就真断了。我一般单独划一个4KB的小分区,专门存断点记录。
第二,记录文件要有校验机制。 我曾经遇到过一个情况:设备写到一半断电了,记录文件只写了一半,重启后读出来全是乱码,结果从错误的位置开始下载,整个固件包都坏了。后来我加了个CRC校验,每次读写都校验,发现不对就丢弃,从头开始下。
记录文件的结构,我推荐这样设计:
typedef struct {
uint32_t magic; // 魔数,用于识别文件有效性,固定为0xOTA1
uint32_t version; // 记录格式版本号
uint32_t firmwareSize; // 固件包总大小
uint32_t blockSize; // 分块大小
uint32_t totalBlocks; // 总块数
uint32_t completedBlocks; // 已完成块数
uint8_t blockBitmap[128];// 块完成位图,最大支持1024个块
uint32_t crc32; // 整个记录文件的CRC校验
} ResumeRecord;
为什么用位图而不是数组?因为位图省空间。1024个块,用位图只需要128字节,用数组要4096字节(每个块4字节)。嵌入式设备存储资源宝贵,能省则省。
读写记录文件的代码:
// 保存断点记录
int saveResumeRecord(Downloader *dl) {
ResumeRecord record;
record.magic = 0xOTA1;
record.version = 1;
record.firmwareSize = dl->totalBlocks * dl->blockSize;
record.blockSize = dl->blockSize;
record.totalBlocks = dl->totalBlocks;
record.completedBlocks = dl->completedBlocks;
// 构建位图
memset(record.blockBitmap, 0, sizeof(record.blockBitmap));
for (uint32_t i = 0; i < dl->totalBlocks; i++) {
if (dl->blocks[i].status == 2) { // 已完成
record.blockBitmap[i / 8] |= (1 << (i % 8));
}
}
// 计算CRC
record.crc32 = crc32_calc((uint8_t *)&record,
sizeof(ResumeRecord) - 4);
// 写入Flash(先擦除再写)
flashErase(RESUME_RECORD_SECTOR);
flashWrite(RESUME_RECORD_ADDR, (uint8_t *)&record, sizeof(ResumeRecord));
return 0;
}
// 读取断点记录
int loadResumeRecord(Downloader *dl) {
ResumeRecord record;
flashRead(RESUME_RECORD_ADDR, (uint8_t *)&record, sizeof(ResumeRecord));
// 校验魔数和CRC
if (record.magic != 0xOTA1) {
return -1; // 无效记录
}
uint32_t calcCrc = crc32_calc((uint8_t *)&record, sizeof(ResumeRecord) - 4);
if (calcCrc != record.crc32) {
return -2; // 记录损坏
}
// 恢复下载器状态
dl->blockSize = record.blockSize;
dl->totalBlocks = record.totalBlocks;
dl->completedBlocks = record.completedBlocks;
// 根据位图恢复每个块的状态
for (uint32_t i = 0; i < dl->totalBlocks; i++) {
int isCompleted = (record.blockBitmap[i / 8] >> (i % 8)) & 1;
dl->blocks[i].status = isCompleted ? 2 : 0;
}
return 0;
}
最后说一句,断点记录文件在下载完成后要记得清除。不清除的话,下次下载时会误以为有断点,从错误的位置开始。我一般在VERIFYING状态通过后,就把记录文件所在的扇区擦除掉。
好了,客户端这边的断点续传实现,核心就是这三块:状态机管流程、分块下载器管数据、记录文件管现场。三者配合好了,断点续传就稳了。下一章咱们聊聊升级包的校验和安全性,那又是另一番天地了。