3. 升级包生成与签名:差分升级算法、全量升级包制作、数字签名与验签、升级包版本管理
升级包怎么来的?怎么保证它没被篡改?版本号怎么管?
这三个问题,是OTA项目里最容易被轻视、但出事最要命的环节。我见过不止一个团队,差分算法跑通了就欢呼,结果签名没做好,设备被刷了恶意固件——嗯,那场面相当惨烈。
3.1 全量升级包制作
全量包,说白了就是把整个固件镜像打包。简单,但最可靠。
制作流程大致分四步:
- 编译输出:从构建系统拿到最终的二进制镜像(比如
firmware.bin)。 - 添加元数据:在头部或尾部写入设备型号、硬件版本、固件版本、校验和等信息。
- 压缩:用 LZMA、gzip 或 zstd 压缩,减少传输体积。
- 打包:生成最终的分发文件,通常带
.swu或.ota后缀。
关键点:元数据必须包含 hardware_id 和 compat_level。否则刷错硬件变砖,你连哭的地方都没有。
我曾经接手过一个项目,全量包只写了版本号,没写硬件版本。结果同一套固件被刷到了两个不同硬件批次上,WiFi 模块直接烧了。从那以后,我要求元数据里必须有三项:product、hw_rev、sw_ver。
3.2 差分升级算法
全量包省心,但费流量。差分升级就是为了解决这个问题——只传输新旧固件之间的差异部分。
目前主流的差分算法有三种:
| 算法 | 原理 | 适用场景 | 补丁大小 |
|---|---|---|---|
| bsdiff | 基于后缀排序的 LCS 匹配 | 二进制固件、大文件 | 极小(通常 5%-15%) |
| hdiffpatch | 基于哈希的块匹配 | 嵌入式固件、内存受限设备 | 较小(10%-25%) |
| xdelta3 | VCDIFF 标准实现 | 通用文件、配置更新 | 中等(15%-30%) |
我个人习惯用 bsdiff。为什么?因为它生成的补丁最小。在 NB-IoT 这种按字节收费的网络上,每省 1KB 都是钱。但 bsdiff 有个缺点——内存占用高。生成补丁时,新旧固件都要加载到内存,32MB 以下的 MCU 基本跑不动。
避坑指南:如果你的设备 RAM 小于 64KB,别用 bsdiff。改用 hdiffpatch,它支持流式处理,内存占用能控制在 8KB 以内。
差分升级的完整流程是这样的:
# 服务端:生成差分补丁
bsdiff old_firmware.bin new_firmware.bin patch.bin
# 设备端:应用差分补丁
bspatch old_firmware.bin new_firmware.bin patch.bin
设备端拿到 patch.bin 后,读取当前固件,逐块应用补丁,生成新固件。整个过程不需要额外存储完整的新固件——这也是差分升级的核心优势。
注意:差分升级依赖旧固件的精确版本。如果设备当前版本和生成补丁时用的版本不一致,补丁会应用失败。所以,版本管理必须严格。
3.3 数字签名与验签
升级包在传输过程中可能被篡改。怎么防?数字签名。
我常用的方案是 ECDSA(椭圆曲线数字签名算法)。相比 RSA,ECDSA 的签名更短、验签更快,特别适合资源受限的嵌入式设备。
签名流程:
- 服务端用私钥对升级包的哈希值签名,生成签名文件。
- 将签名文件和升级包一起下发。
- 设备端用公钥验签:计算升级包的哈希,用公钥解密签名,比对两者是否一致。
核心原则:私钥永远留在服务端,公钥烧录在设备 Bootloader 中。设备端绝对不能存储私钥。
代码示例(使用 OpenSSL 和 mbedTLS):
// 服务端签名(OpenSSL)
openssl dgst -sha256 -sign private.pem -out signature.bin firmware.bin
// 设备端验签(mbedTLS)
int verify_signature(const unsigned char *firmware, size_t fw_len,
const unsigned char *signature, size_t sig_len) {
mbedtls_pk_context pk;
mbedtls_pk_init(&pk);
mbedtls_pk_parse_public_key(&pk, public_key, pub_key_len);
unsigned char hash[32];
mbedtls_sha256(firmware, fw_len, hash, 0);
int ret = mbedtls_pk_verify(&pk, MBEDTLS_MD_SHA256,
hash, 0, signature, sig_len);
mbedtls_pk_free(&pk);
return ret;
}
我曾经遇到过一个坑:公钥烧录时字节序搞反了,验签一直失败。排查了两天才发现是 大端/小端 的问题。嗯,嵌入式开发里这种低级错误最折磨人。
3.4 升级包版本管理
版本管理做不好,升级就是灾难。我见过一个团队,版本号用 v1.0、v1.1 这种字符串,结果设备升级时无法判断哪个版本更新——因为字符串比较 "v1.9" > "v1.10" 是错的。
我建议用 语义化版本号,格式为 MAJOR.MINOR.PATCH:
- MAJOR:不兼容的 API 或硬件变更
- MINOR:向下兼容的功能新增
- PATCH:向下兼容的 bug 修复
版本比较逻辑:
typedef struct {
uint16_t major;
uint16_t minor;
uint16_t patch;
} version_t;
int version_compare(version_t a, version_t b) {
if (a.major != b.major) return a.major - b.major;
if (a.minor != b.minor) return a.minor - b.minor;
return a.patch - b.patch;
}
除了版本号,还要维护一个 兼容性矩阵:
| 当前版本 | 可升级到的版本 | 升级方式 |
|---|---|---|
| v1.0.x | v1.1.x, v1.2.x | 差分 |
| v1.1.x | v1.2.x | 差分 |
| v1.0.x | v2.0.x | 全量 |
| v1.2.x | v2.0.x | 全量 |
个人经验:版本号最好用 uint32_t 编码,高 8 位 MAJOR、中 8 位 MINOR、低 16 位 PATCH。这样比较时直接做整数减法,省掉结构体比较的开销。
3.5 整体流程与架构
把上面几块串起来,就是完整的升级包生成与签名流程。我画了一张图,帮你理清关系:
从图上你能看到:旧固件和新固件经过差分算法生成补丁,补丁经过签名和版本元数据打包,最终输出可下发的升级包。设备端拿到包后,先验签、再解包、最后应用补丁。
最后提醒一句:差分升级虽然省流量,但容错性差。如果设备在应用补丁时断电,旧固件可能被破坏。所以,一定要保留一个 恢复分区 或 双备份分区,确保升级失败后还能回滚。
嗯,升级包生成这块,核心就是三件事:差分算法选对、签名验签做牢、版本号管好。这三件事做好了,OTA 升级就成功了一大半。