第四节:断点续传机制
断点续传,说白了就是让升级包下载可以「接着上次的进度继续」。
我刚开始做OTA时,觉得这功能可有可无。直到有一次,一个设备在升级到95%时突然断电,重启后又要从头下载——那是个200MB的固件包,在2G网络下跑了将近40分钟。用户直接投诉了。嗯,从那以后,我再也不敢轻视断点续传了。
断点续传的原理
核心思路其实很简单:把大文件切成小块,记录已经下载了哪些块。
具体来说,分三步走:
- 分块:将固件包按固定大小(比如1MB)切分成多个块
- 记录:每下载完一块,就把进度记到本地存储里
- 续传:重新连接时,先查本地记录,从断掉的那块开始请求
你想想看,这和你看视频时拖动进度条是一个道理。只不过我们更精细,要精确到字节级别。
关键点:断点续传不是简单地「从断掉的位置继续」,而是要确保断掉之前的数据是完整且正确的。否则,续传回来的数据拼在一起,固件就坏了。
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块。用偏移量只能记录「最大连续位置」,会漏掉中间缺失的块。位图可以精确到每一块。
校验方面,我建议做两层:
- 记录本身校验:每次读写进度记录时,用CRC32检查数据是否被篡改或损坏
- 已下载数据校验:每下载完一块,立即计算该块的哈希值,和服务器下发的哈希对比
我曾经犯过的错:只做了记录校验,没做数据校验。结果有一次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校验失败,你都不知道该不该续传。双备份的意思是:写记录时先写备份区,再写主区;读记录时主区坏了就读备份区。这样就算写一半断电,至少还有一份完整的记录可用。
嗯,断点续传就这些。说白了就是「记好账,算好数,防好错」。把这三点做到位,用户就再也不用忍受从头下载的痛苦了。