第四节:断点续传机制

断点续传,说白了就是让升级包下载可以「接着上次的进度继续」。

我刚开始做OTA时,觉得这功能可有可无。直到有一次,一个设备在升级到95%时突然断电,重启后又要从头下载——那是个200MB的固件包,在2G网络下跑了将近40分钟。用户直接投诉了。嗯,从那以后,我再也不敢轻视断点续传了。

断点续传的原理

核心思路其实很简单:把大文件切成小块,记录已经下载了哪些块

具体来说,分三步走:

  1. 分块:将固件包按固定大小(比如1MB)切分成多个块
  2. 记录:每下载完一块,就把进度记到本地存储里
  3. 续传:重新连接时,先查本地记录,从断掉的那块开始请求

你想想看,这和你看视频时拖动进度条是一个道理。只不过我们更精细,要精确到字节级别。

关键点:断点续传不是简单地「从断掉的位置继续」,而是要确保断掉之前的数据是完整且正确的。否则,续传回来的数据拼在一起,固件就坏了。

HTTP Range请求实现

HTTP协议里有个好东西叫Range头。它就是为断点续传量身定做的。

客户端发请求时,带上这么个头:

GET /firmware/update.bin HTTP/1.1
Host: ota.example.com
Range: bytes=1048576-2097151

服务器收到后,会返回206 Partial Content状态码,只发送你请求的那段数据。

我给你们看个实际项目里的代码片段:

/* 构建Range请求头 */
static int build_range_header(char *buf, int buf_len, 
                               uint32_t offset, uint32_t length)
{
    /* 格式:Range: bytes=start-end */
    return snprintf(buf, buf_len, 
                    "Range: bytes=%u-%u\r\n",
                    offset, offset + length - 1);
}

/* 解析206响应 */
static int parse_206_response(const char *response)
{
    /* 检查状态码 */
    if (strstr(response, "206 Partial Content") == NULL) {
        return -1;  /* 不是206,说明服务器不支持断点续传 */
    }
    
    /* 提取Content-Range,确认返回的数据范围 */
    const char *cr = strstr(response, "Content-Range: bytes ");
    if (cr == NULL) {
        return -2;
    }
    
    return 0;
}

我的经验:有些老旧服务器不支持Range请求,会直接返回200和完整文件。代码里一定要做兼容处理——检测到200时,就丢弃已下载的数据重新开始。我在一个工业项目里就踩过这个坑,那台服务器是2012年部署的,压根不认识Range头。

Range请求有几个要点要注意:

  • 范围是闭区间bytes=0-1023表示第0到第1023字节,共1024字节
  • 可以请求多个范围bytes=0-1023,2048-3071,但实际项目中很少这么用
  • 服务器可能返回不同大小:比如你请求1024字节,服务器可能只返回512字节。别假设,要检查实际返回长度

本地进度记录与校验

进度记录这事,看着简单,做起来全是细节。

我习惯用结构体来管理下载状态:

typedef struct {
    uint32_t magic;          /* 魔数,用于校验记录有效性 */
    uint32_t version;        /* 记录格式版本 */
    uint32_t total_size;     /* 固件包总大小 */
    uint32_t downloaded;     /* 已下载字节数 */
    uint32_t block_size;     /* 块大小 */
    uint8_t  block_bitmap[32]; /* 位图,标记哪些块已下载 */
    uint32_t crc32;          /* 整个记录的校验值 */
} download_record_t;

这里有个设计细节——为什么用位图而不是直接记一个偏移量?

因为网络环境复杂。你可能先下载了第5块,然后第3块,最后才下载第1块。用偏移量只能记录「最大连续位置」,会漏掉中间缺失的块。位图可以精确到每一块。

校验方面,我建议做两层:

  1. 记录本身校验:每次读写进度记录时,用CRC32检查数据是否被篡改或损坏
  2. 已下载数据校验:每下载完一块,立即计算该块的哈希值,和服务器下发的哈希对比

我曾经犯过的错:只做了记录校验,没做数据校验。结果有一次Flash写入时发生了位翻转,下载记录显示「第10块已完成」,但实际数据是错的。固件刷进去,设备直接变砖。从那以后,我每块数据都算哈希,绝不偷懒。

具体的校验流程是这样的:

/* 每块数据下载完成后的处理 */
static int on_block_completed(uint32_t block_idx, 
                               const uint8_t *data, uint32_t len)
{
    uint32_t hash = calculate_block_hash(data, len);
    
    /* 从服务器响应头中获取该块的预期哈希 */
    uint32_t expected_hash = get_expected_hash(block_idx);
    
    if (hash != expected_hash) {
        /* 数据损坏,标记该块为未下载 */
        clear_block_bitmap(block_idx);
        return -1;
    }
    
    /* 标记该块已完成 */
    set_block_bitmap(block_idx);
    
    /* 更新进度记录并写入Flash */
    save_download_record();
    
    return 0;
}

进度记录存哪里?我一般这么选:

存储介质 优点 缺点 适用场景
Flash专用分区 速度快,寿命长 需要预留空间 主流方案,推荐
外部EEPROM 独立存储,不占Flash 成本高,速度慢 高端设备
文件系统 管理方便 依赖文件系统,有磨损问题 Linux/Android设备

我个人偏好Flash专用分区方案。原因很简单——不依赖文件系统,启动就能读写,而且可以精确控制擦写次数。

最后说个容易被忽略的点:进度记录要写双备份

为什么?因为写Flash过程中如果断电,记录可能只写了一半。下次启动时读出来,CRC校验失败,你都不知道该不该续传。双备份的意思是:写记录时先写备份区,再写主区;读记录时主区坏了就读备份区。这样就算写一半断电,至少还有一份完整的记录可用。

嗯,断点续传就这些。说白了就是「记好账,算好数,防好错」。把这三点做到位,用户就再也不用忍受从头下载的痛苦了。