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;
}
⚠️ 重要提醒: 记录文件的写入频率要控制好。每下载一个块就写一次,如果块很小(比如256KB),写入次数会非常频繁。Flash的擦写寿命是有限的(一般10万次)。我建议每下载完5~10个块,或者每下载完一定数据量(比如1MB),才写一次记录文件。这样既保证了断点精度,又延长了Flash寿命。
💡 个人经验: 我在一个智能家居项目里,客户要求断点精度到「字节级」。一开始我每下载一个字节就写一次记录,结果Flash一周就报废了。后来改成每下载完一个块(256KB)写一次,精度完全够用,Flash用了三年都没问题。所以,别追求极致的断点精度,够用就行。

最后说一句,断点记录文件在下载完成后要记得清除。不清除的话,下次下载时会误以为有断点,从错误的位置开始。我一般在VERIFYING状态通过后,就把记录文件所在的扇区擦除掉。

好了,客户端这边的断点续传实现,核心就是这三块:状态机管流程、分块下载器管数据、记录文件管现场。三者配合好了,断点续传就稳了。下一章咱们聊聊升级包的校验和安全性,那又是另一番天地了。