3. OTA系统概述

各位工程师,欢迎来到第三章。前两章我们聊了OTA的整体架构和通信协议,今天要落地了——升级包到底怎么做?怎么保证它不被篡改?差分算法又是怎么省流量的?

这一章,我打算从升级包的结构设计讲起,然后深入差分算法,再聊签名验签,最后说说版本管理。嗯,都是实战中每天要面对的东西。

升级包结构设计:一个包,五脏俱全

先问个问题:一个升级包,里面应该装什么?

很多人第一反应是“镜像文件”。没错,但远不止这些。我见过不少项目,升级包结构设计得乱七八糟,结果升级到一半发现缺少校验信息,或者版本号对不上,直接导致升级失败。

一个规范的升级包,通常包含以下几个部分:

组成部分 说明 我的建议
元数据头 包类型、版本号、目标硬件ID、包大小、校验算法标识 固定128字节,不要偷懒
签名块 对整个包内容的数字签名 放在头部之后,便于验签
镜像清单 每个镜像的路径、大小、哈希值、分区目标 JSON格式,可读性好
镜像数据 差分后的二进制数据或全量镜像 按清单顺序排列
尾部校验 整个包的CRC32或SHA256 双重校验,更安全

核心原则:升级包必须自描述、自校验、自签名。任何依赖外部信息的包结构,都是定时炸弹。

我个人习惯把元数据头设计成固定长度,128字节。为什么是128?对齐方便,解析简单。里面包含魔数(Magic Number)、版本号、包类型、目标硬件ID、镜像数量、签名算法标识、哈希算法标识。这些字段缺一不可。

举个例子,一个典型的元数据头结构:

typedef struct {
    uint32_t magic;          // 0xOTA1
    uint32_t version;        // 包格式版本
    uint32_t pkg_type;       // 0=全量, 1=差分
    uint32_t hw_id;          // 目标硬件ID
    uint32_t img_count;      // 镜像数量
    uint32_t sig_alg;        // 签名算法: 0=RSA2048, 1=ECDSA
    uint32_t hash_alg;       // 哈希算法: 0=SHA256
    uint32_t reserved[24];   // 保留字段
} ota_pkg_header_t;

这里有个坑:保留字段一定要留够。我在项目中吃过亏,一开始只留了8个字节,后来要加功能,不得不改协议版本,兼容性搞得一团糟。

差分算法原理:省流量,但别省质量

差分升级,说白了就是只传输变化的部分。车机升级动辄几百MB,如果每次都全量下载,用户流量扛不住,服务器带宽也扛不住。

目前主流的差分算法有三种:

  • bsdiff:基于后缀排序,压缩率高,但内存消耗大。适合大文件。
  • hdiffpatch:基于哈希匹配,速度更快,适合嵌入式环境。
  • xdelta:基于VCDIFF标准,通用性好,但压缩率一般。

我在智能座舱项目里,主要用bsdiff。为什么?因为座舱的MCU和SoC镜像都比较大,bsdiff的压缩率能到5%-15%,效果很理想。但要注意,bsdiff在生成差分包时,内存占用大约是源文件大小的5倍。如果你的编译服务器内存不够,可能会直接OOM。

实战技巧:差分前,先对镜像做“对齐处理”。把相同功能的代码段对齐到相同地址,能显著提高差分率。我曾经把一个200MB的镜像,差分后只有12MB,压缩率6%。

差分算法的核心流程:

  1. 读取旧镜像和新镜像
  2. 扫描新旧镜像的差异块
  3. 生成差异指令(copy/add/run)
  4. 压缩差异数据
  5. 打包成差分包

在车端,差分还原的过程正好相反:解压差异数据,根据指令从旧镜像中复制未变部分,插入新增部分,最终合成新镜像。

注意:差分升级对旧镜像的完整性要求极高。如果旧镜像已经被破坏,差分还原出来的新镜像也是坏的。所以,差分前一定要校验旧镜像的哈希值。

签名与验签机制:信任的基石

升级包如果不签名,就等于把车门敞开让黑客进来。签名机制的核心是:谁签的?谁验的?怎么保证没被改过?

我习惯用ECDSA(椭圆曲线数字签名算法)而不是RSA。原因很简单:ECDSA的密钥更短,签名速度更快,在车端MCU上验签性能更好。256位的ECDSA,安全性相当于3072位的RSA。

签名流程:

  • 服务端:计算升级包的SHA256哈希 → 用私钥对哈希签名 → 将签名附加到包中
  • 车端:计算升级包的SHA256哈希 → 用公钥验签 → 匹配则通过,否则拒绝

这里有个关键点:公钥怎么安全地存储在车端?

我见过最蠢的做法是把公钥明文写在代码里。稍微好一点的是写在安全存储区。最好的做法是:公钥由硬件安全模块(HSM)管理,验签操作也在HSM内部完成,应用层根本拿不到公钥。

避坑指南:我曾经遇到一个项目,公钥存储在外部Flash里,结果被攻击者替换了公钥,直接签了一个恶意包。从那以后,我坚持公钥必须烧录在芯片的一次性可编程(OTP)区域,不可更改。

验签的代码示例(伪代码):

int verify_ota_package(const uint8_t *pkg, uint32_t pkg_size) {
    // 1. 提取签名块
    ota_pkg_header_t *hdr = (ota_pkg_header_t *)pkg;
    uint8_t *signature = pkg + sizeof(ota_pkg_header_t);
    
    // 2. 计算包体哈希
    uint8_t hash[32];
    sha256(pkg + SIGNATURE_SIZE, pkg_size - SIGNATURE_SIZE, hash);
    
    // 3. 从HSM获取公钥并验签
    if (hsm_ecdsa_verify(hash, signature, PUBLIC_KEY_ID) != 0) {
        return -1; // 验签失败
    }
    
    return 0;
}

升级包版本管理:别让版本号成为灾难

版本管理看似简单,但做不好会出大问题。我见过一个案例:因为版本号比较逻辑写反了,导致车机反复升级同一个包,用户投诉不断。

版本号的设计原则:

  • 语义化版本:主版本.次版本.修订号(如2.1.3)
  • 兼容性标识:标明是否向前兼容
  • 回滚策略:允许回滚到哪些版本

在OTA系统中,版本管理不仅仅是记录一个数字,还要管理版本之间的依赖关系。比如:升级A版本到B版本,必须保证当前版本是A,不能从C直接跳到B。

版本号 兼容性 可升级目标 备注
1.0.0 基础版 1.1.0, 2.0.0 初始版本
1.1.0 向前兼容 1.2.0, 2.0.0 修复Bug
2.0.0 不兼容 2.1.0 架构变更

我个人习惯在升级包的元数据里,同时记录“源版本号”和“目标版本号”。这样车端在升级前就能判断:当前版本是否匹配源版本?如果不匹配,直接拒绝,避免升级到错误的状态。

小技巧:版本号比较不要用字符串比较,要拆成整数逐段比较。字符串比较会出问题,比如"2.1.10"会被认为小于"2.1.9",因为'1' < '9'。

知识结构总览

下面这张图,把本章的核心逻辑串起来了。从原始镜像到最终的安全升级包,每一步都有讲究。

升级包制作与签名流程 升级包制作与签名流程 旧镜像 新镜像 私钥 差分算法(bsdiff) 元数据组装 计算SHA256哈希 ECDSA签名 打包:元数据+签名+差分数据 安全升级包(.ota) 关键:差分减少流量,签名保证安全,版本管理防止误升级

这张图展示了从旧镜像和新镜像开始,经过差分、哈希计算、签名,最终打包成安全升级包的完整链路。每一步都不可或缺。

总结一下:升级包制作不是简单的文件打包,而是涉及结构设计、差分算法、签名验签、版本管理的系统工程。任何一个环节出问题,都可能导致升级失败,甚至让车机变砖。