3、断点续传核心原理:HTTP Range请求、分块下载、本地断点记录、续传逻辑
断点续传,说白了就是让OTA升级能「从哪里跌倒,从哪里爬起来」。
我最早接触这个功能是在一个车载T-Box项目上。客户反馈说,每次升级到80%左右就失败,然后从头再来。你想想看,一个200MB的固件包,在农村2G网络下要下载半小时。重来一次?用户直接骂娘了。
所以,断点续传不是锦上添花,是刚需。
3.1 HTTP Range请求:服务器端的「切蛋糕」能力
要实现断点续传,首先得让服务器支持「只发一部分数据」。HTTP协议里有个好东西——Range请求头。
客户端发请求时,带上这么一行:
GET /firmware_v2.bin HTTP/1.1
Host: ota.example.com
Range: bytes=1048576-2097151
服务器如果支持,会返回206 Partial Content状态码,并在响应头里告诉你:
HTTP/1.1 206 Partial Content
Content-Range: bytes 1048576-2097151/8388608
Content-Length: 1048576
这里1048576-2097151就是字节范围,8388608是文件总大小。服务器只返回你请求的那1MB数据。
关键点:Range的单位是字节,不是块。所以你的分块策略必须和字节偏移量对齐。
我在项目中遇到过一个问题:有些老旧服务器返回的Content-Range格式不规范,少了个bytes 前缀。解析时一定要做容错处理,否则续传直接崩掉。
3.2 分块下载:把大文件切成小任务
有了Range能力,我们就可以把固件包切成若干块,逐块下载。我个人习惯每块大小设为256KB或512KB。为什么是这个值?
- 太小(比如4KB):HTTP请求开销太大,100MB文件要发2万多次请求,效率极低
- 太大(比如10MB):一旦某块下载失败,重传代价太高
- 256KB~1MB:在嵌入式设备上,内存占用和网络效率的平衡点
分块下载的伪代码大概是这样的:
# 伪代码:分块下载逻辑
BLOCK_SIZE = 256 * 1024 # 256KB
file_size = get_remote_file_size(url)
total_blocks = ceil(file_size / BLOCK_SIZE)
for block_id in range(total_blocks):
start = block_id * BLOCK_SIZE
end = min(start + BLOCK_SIZE - 1, file_size - 1)
# 检查本地是否已有该块
if not is_block_downloaded(block_id):
data = http_range_request(url, start, end)
save_block_to_storage(block_id, data)
update_local_record(block_id, status="done")
嗯,这里要注意:end的计算一定要用min,最后一块通常不满256KB,别算错了。
3.3 本地断点记录:你的「小本本」
服务器支持Range了,分块策略也定了。但设备断电重启后,怎么知道哪些块已经下载了?
你需要一个本地断点记录。说白了就是一个持久化的小数据库,记录每个块的下载状态。
我常用的结构体是这样的:
// C语言示例:断点记录结构体
typedef struct {
uint32_t block_id; // 块编号
uint32_t start_offset; // 起始字节偏移
uint32_t end_offset; // 结束字节偏移
uint8_t status; // 0=未下载, 1=下载中, 2=已完成
uint32_t crc32; // 块数据的CRC校验
uint64_t timestamp; // 下载完成时间戳
} BlockRecord_t;
存储位置我建议放在两个地方:
- 主存储:外部Flash的专用分区,比如叫
ota_resume分区 - 备份存储:内部Flash的最后几个扇区。万一外部Flash坏了,还能从内部恢复
我的经验:断点记录文件要定期刷写到Flash,但别每下载一个块就刷一次。Flash有擦写寿命限制。我一般每下载10个块,或者每30秒,批量刷写一次。
3.4 续传逻辑:重启后的「认亲」过程
设备重启后,OTA升级任务怎么恢复?我把它叫做「三问」流程:
- 问本地:断点记录文件还在不在?CRC对不对?
- 问服务器:当前固件版本和远程版本是否匹配?
- 问自己:已下载的块数据是否完整?有没有被意外篡改?
续传逻辑的核心代码:
// 续传入口函数
int ota_resume_download(void) {
// 第一步:加载本地断点记录
ResumeRecord_t record;
if (load_resume_record(&record) != 0) {
// 没有断点记录,从头开始
return start_fresh_download();
}
// 第二步:校验已下载的块
for (int i = 0; i < record.total_blocks; i++) {
if (record.blocks[i].status == 2) {
// 重新计算CRC,防止存储错误
uint32_t calc_crc = calculate_block_crc(i);
if (calc_crc != record.blocks[i].crc32) {
// CRC不匹配,标记为未下载
record.blocks[i].status = 0;
log_warning("Block %d CRC mismatch, will re-download", i);
}
}
}
// 第三步:从第一个未完成的块开始续传
for (int i = 0; i < record.total_blocks; i++) {
if (record.blocks[i].status != 2) {
download_block(i);
}
}
return 0;
}
曾经踩过的坑:有一次我发现设备重启后,断点记录文件还在,但实际存储块数据的Flash区域已经被其他程序覆盖了。原因是分区表配置有误,OTA分区和日志分区重叠了。所以续传前一定要做数据完整性校验,不能只看记录文件。
3.5 续传中的边界情况处理
续传不是简单的「接着下」,有几个边界情况必须处理:
| 场景 | 问题 | 解决方案 |
|---|---|---|
| 服务器端固件已更新 | 续传的数据和新固件不匹配 | 比较文件大小和全局CRC,不一致则全量重下 |
| 存储空间不足 | 续传过程中写不进去 | 预留10%的冗余空间,下载前检查剩余容量 |
| 网络切换(WiFi→4G) | IP地址变化,TCP连接断开 | HTTP Range请求是无状态的,重新建立连接即可 |
| 时间戳回退 | RTC电池没电,时间回到1970年 | 不要依赖时间戳判断续传有效性,用版本号和CRC |
我个人最头疼的是第一种情况。用户下载到一半,后台运维偷偷把固件包替换了。续传时新旧数据混在一起,刷进去直接变砖。所以我的做法是:续传前先请求服务器返回固件包的ETag或Content-MD5,和本地记录比对。不一致?对不起,全部清空重来。
3.6 性能优化:别让续传变成「续命」
断点续传虽然可靠,但也不能太慢。我分享几个优化技巧:
- 并发下载:对于多核MCU或Linux设备,可以同时下载2~4个块。但注意控制并发数,嵌入式设备的网络栈通常比较脆弱
- 块大小自适应:网络好的时候用512KB块,网络差的时候自动降为128KB。我在项目中用RTT(往返时延)作为判断依据
- 预读机制:当前块下载完成后,提前发起下一个块的HTTP连接。减少TCP握手等待时间
举个例子,我做过一个测试:4G网络下,256KB块大小,单线程下载100MB文件,耗时约45秒。开启2个并发线程后,降到28秒。但开到4个线程时,反而因为设备CPU瓶颈,只降到25秒。所以不是越多越好。
总结一句话:断点续传的核心就三件事——服务器支持Range、本地记好小本本、重启后先校验再续传。把这三点做扎实了,OTA升级的可靠性至少提升一个数量级。