升级包结构设计:升级包的组成与分片策略

好,咱们进入第二章。上一章聊了断点续传的整体架构,这一章咱们得把升级包本身拆开看看。

说白了,升级包就是你要发给车机的那坨数据。但怎么组织这坨数据,直接决定了断点续传能不能续得上、续得快。我在项目里见过不少团队,上来就写业务逻辑,结果升级包结构一团糟,断点续传根本没法做——嗯,咱们今天就把这事聊透。

升级包的三大组成部分

一个标准的车载OTA升级包,我习惯把它分成三块:元数据、分片数据、校验信息。缺一不可。

组成部分 核心内容 作用
元数据 包版本、目标ECU列表、分片总数、分片大小、哈希算法、签名 描述升级包“是什么、给谁用、怎么拼”
分片数据 按策略切割后的二进制块,每个分片有独立序号 实际传输和存储的最小单元
校验信息 每个分片的哈希值、整体包的哈希值、数字签名 保证数据完整性,防止篡改

关键点:元数据必须在升级包的最前面,而且不能被分片。车机拿到升级包的第一件事,就是解析元数据。元数据坏了,整个包就废了。

元数据设计:升级包的“身份证”

元数据我一般用JSON或Protocol Buffers来定义。JSON可读性好,调试方便;Protobuf体积小,解析快。车载场景我倾向于Protobuf,毕竟带宽和存储都金贵。

一个典型的元数据长这样:

message OtaPackageMeta {
    string package_version = 1;        // 升级包版本号
    string target_ecu_id = 2;          // 目标ECU标识
    uint32 total_chunks = 3;           // 总分片数
    uint32 chunk_size = 4;             // 每个分片的大小(字节)
    string hash_algorithm = 5;         // 哈希算法,如"sha256"
    bytes package_hash = 6;            // 整个包的哈希值
    bytes signature = 7;               // 数字签名
    uint64 package_size = 8;           // 原始包总大小
    repeated EcuTarget targets = 9;    // 多ECU场景下的目标列表
}

你可能会问:为什么要把total_chunks和chunk_size放在元数据里?

原因很简单——车机拿到元数据后,就能算出自己需要收多少个分片。如果之前已经收了30个分片,发现总分片是100个,那它就知道还要收70个。这就是断点续传的起点。

我的习惯:元数据里再加一个min_supported_version字段。有一次我在项目中遇到旧版车机不兼容新版升级包格式,差点导致远程升级大面积失败。从那以后,我都在元数据里带上兼容性信息。

分片策略:切多大才合适?

分片粒度是升级包设计的核心问题。切得太小,元数据膨胀,网络开销大;切得太大,断点续传的粒度太粗,重传代价高。

我经历过一个项目,一开始用了1MB的分片,觉得挺合理。结果车机网络环境差,经常传一半断掉,每次重传都要丢将近1MB的数据。后来改成256KB,重传效率提升了4倍。

分片大小的选择,主要看这几个因素:

  • 网络稳定性:网络越差,分片应该越小。4G环境下我常用256KB~512KB;Wi-Fi环境下可以用1MB~2MB。
  • 存储限制:车机MCU的RAM通常很小,分片太大可能放不下。我曾经在某个MCU上只能用64KB的分片,因为它的RAM只有128KB。
  • 校验开销:每个分片都要算哈希,分片太多会增加CPU负担。但现代车机芯片算个SHA256也就毫秒级,这个不是瓶颈。
  • 传输协议限制:有些HTTP服务器对单个请求的大小有限制,比如Nginx默认的client_max_body_size是1MB。
场景 推荐分片大小 理由
4G蜂窝网络(信号一般) 256KB ~ 512KB 断线重传代价小,适应波动
Wi-Fi / 以太网 1MB ~ 4MB 带宽充足,减少分片数量
MCU(RAM < 256KB) 64KB ~ 128KB 内存限制,必须小分片
卫星/窄带物联网 16KB ~ 64KB 极低带宽,极小分片

注意:分片大小一旦确定,整个升级过程中不能改变。否则车机算不清自己到底收了哪些分片,断点续传的逻辑会乱掉。我曾经见过一个团队在升级中途动态调整分片大小,结果车机端的分片索引全对不上了——那叫一个惨。

校验和设计:每一片都不能错

校验和是升级包的“安全锁”。我见过有些方案只在包级别做校验,分片级别不做。这其实有风险——如果一个分片传坏了,你得等到所有分片收完、合并成完整包之后才能发现。那时候再重传,代价太大了。

我的做法是:每个分片独立校验,整体包再做一次校验。

分片校验用SHA256就够了。整体包校验除了哈希,还要加数字签名,防止升级包被篡改。

// 分片校验信息结构
struct ChunkChecksum {
    uint32_t chunk_index;       // 分片序号
    uint8_t  hash[32];          // SHA256哈希值
    uint64_t chunk_offset;      // 在原始包中的偏移
    uint32_t chunk_size;        // 实际大小(最后一个分片可能不满)
};

// 整体包校验信息
struct PackageChecksum {
    uint8_t  package_hash[32];  // 整个包的SHA256
    uint8_t  signature[256];    // RSA-2048签名
};

车机收到每个分片后,先算哈希,跟元数据里的比对。对不上就直接请求重传这个分片,不用等全部收完。这就是断点续传的“精准打击”。

升级包整体结构图

下面这张图把升级包的组成和分片流程串起来了。你看一眼就能明白整体逻辑。

升级包结构组成与分片流程 OTA升级包 元数据 分片数据 校验信息 片1 片2 片3 片N 每个分片独立SHA256校验 → 全部收齐后整体SHA256校验 → RSA签名验证 断点续传:车机记录已收分片索引(如片1~片50),下次从片51开始请求 已收分片写入本地存储,元数据中的 total_chunks 决定何时收齐 分片粒度决定断点续传的精度:256KB分片 vs 1MB分片,重传代价差4倍

分片索引与存储策略

分片收下来之后,怎么存?我推荐两种方式:

  1. 文件系统方式:每个分片存成一个独立文件,文件名包含分片序号。优点是简单直观,缺点是文件数太多时inode可能不够用。
  2. 稀疏文件方式:预先分配一个跟原始包等大的文件,每个分片写入对应的偏移位置。优点是只有一个文件,管理方便;缺点是需要文件系统支持稀疏文件(ext4、btrfs都支持)。

我个人偏好稀疏文件方式。在项目中用过一次文件系统方式,结果升级包分了2000多个小文件,车机的flash文件系统差点撑爆。换成稀疏文件后,一个文件搞定,清爽多了。

小技巧:用稀疏文件时,记得在元数据里记录每个分片的chunk_offset。这样车机收到分片后,直接seek到对应位置写入,不需要额外维护映射表。

避坑指南:我踩过的三个坑

  • 坑一:分片大小跟文件系统块大小不对齐。我曾经用511KB的分片大小,结果每个分片跨了两个文件系统块,写入性能直接腰斩。后来改成512KB(对齐到1MB边界),问题解决。
  • 坑二:元数据里没留扩展字段。有一次需要增加一个“升级后需要重启”的标记,结果元数据格式已经冻结了,只能打补丁。从那以后,我都在元数据里留一个map<string, string> extra字段。
  • 坑三:分片序号从0开始还是从1开始?看似小事,但团队里有人用0有人用1,对接时出了bug。我统一规定从0开始,跟数组下标一致,减少心智负担。