三、固件镜像管理:版本号管理、差分升级包生成、镜像签名与校验

好,咱们接着聊固件镜像管理。说实话,这部分是OTA升级的“心脏”。你想想看,升级包要是出了问题,单车锁可就打不开了——那麻烦就大了。我个人习惯把镜像管理分成三个核心模块:版本号怎么管、差分包怎么做、签名校验怎么搞。一个一个来。

3.1 版本号管理——别小看这个数字

版本号看起来简单,不就是1.0、2.0嘛?我在项目里见过太多因为版本号混乱导致的升级事故了。有一次,运维那边把旧版本当成新版本推送了,结果全城几百辆单车锁集体“降级”,后台一看版本号还变大了——因为他们的版本号是纯数字递增,没做语义化。

我建议用语义化版本号,格式是:主版本.次版本.修订号。比如 2.3.1

  • 主版本:不兼容的API变更,比如通信协议改了
  • 次版本:向下兼容的功能新增,比如加了蓝牙唤醒功能
  • 修订号:向下兼容的问题修复,比如修了个内存泄漏

嗯,这里要注意:版本号要固化在固件头部,编译时自动生成。我习惯在Makefile里加个脚本,从git tag里提取版本号,这样就不会出现手写错误。

版本号在固件中的存储结构(示例)

typedef struct {
    uint8_t  major;      // 主版本
    uint8_t  minor;      // 次版本
    uint16_t patch;      // 修订号
    uint32_t build_time; // 编译时间戳
    char     git_sha[8]; // Git提交哈希前8位
} firmware_version_t;

// 实际使用时,这个结构体放在固件的固定偏移位置
const firmware_version_t __attribute__((section(".version"))) fw_ver = {
    .major = 2,
    .minor = 3,
    .patch = 1,
    .build_time = 1712345678,
    .git_sha = "a1b2c3d4"
};

为什么要加git_sha?说白了就是溯源。万一版本号写错了,还能通过哈希找到对应的代码提交。我在排查线上问题时,这招救过我好几回。

3.2 差分升级包生成——省流量就是省钱

单车锁用的是NB-IoT或者蓝牙,带宽有限,流量还贵。全量升级包动不动几百KB,差分升级包可能就几十KB。你想想看,几千台设备同时升级,省下来的流量费够买好几台服务器了。

差分升级的核心思路:只传输新旧固件之间的差异部分。设备端用旧固件加上差异数据,拼出新固件。

常用的差分算法有bsdiff、hdiffpatch。我个人偏爱bsdiff,虽然生成差分包时计算量大,但压缩率好。不过要注意,bsdiff对内存要求高——生成差分包时,新旧固件都要加载到内存里。我在服务器上跑过,2MB的固件大概需要64MB内存,还行。

生成差分包的命令行示例

# 安装bsdiff工具
sudo apt-get install bsdiff

# 生成差分包:old_fw.bin -> new_fw.bin,输出patch.bin
bsdiff old_fw.bin new_fw.bin patch.bin

# 查看差分包大小
ls -lh patch.bin
# 输出示例:-rw-r--r-- 1 user user 32K Mar 15 10:00 patch.bin
# 而全量包是512KB,差分包只有32KB,省了93%

设备端怎么用?需要集成bspatch。我建议在Bootloader里实现差分还原逻辑,这样即使升级过程中断电,重启后还能从Bootloader继续还原。

注意:差分升级的边界情况

  • 如果新旧固件差异太大(比如超过50%),差分包可能比全量包还大。这时候我建议直接发全量包。
  • 差分还原需要额外内存做临时缓冲区。我曾经遇到过设备内存不够,还原到一半崩溃了。后来我加了个内存检查,不够就回退到全量升级。

3.3 镜像签名与校验——防篡改的最后一道防线

这个我多说两句。OTA升级最怕什么?怕中间人攻击,怕固件被篡改。你想想,如果有人把恶意固件推送到单车锁上,那后果不堪设想。所以,签名和校验是必须的,不是可选项

我常用的方案是:ECDSA签名 + SHA256哈希。为什么选ECDSA?因为签名短(64字节),适合物联网设备。RSA签名动不动256字节,在NB-IoT这种窄带网络里传输太浪费了。

流程是这样的:

  1. 服务端:用私钥对固件哈希签名,生成签名文件
  2. 设备端:用公钥验证签名,确保固件未被篡改
  3. 设备端:验证通过后,再计算固件哈希,确保完整性

签名与校验的代码示例(伪代码)

// 服务端签名(Python示例)
import ecdsa
import hashlib

def sign_firmware(fw_data, private_key_path):
    # 1. 计算固件哈希
    fw_hash = hashlib.sha256(fw_data).digest()
    
    # 2. 加载私钥并签名
    with open(private_key_path, 'rb') as f:
        sk = ecdsa.SigningKey.from_pem(f.read())
    signature = sk.sign(fw_hash, hashfunc=hashlib.sha256)
    
    # 3. 返回签名(64字节)
    return signature

// 设备端校验(C语言示例)
#include "ecdsa.h"
#include "sha256.h"

bool verify_firmware(const uint8_t* fw_data, uint32_t fw_len,
                     const uint8_t* signature, const uint8_t* public_key) {
    // 1. 计算固件哈希
    uint8_t hash[32];
    sha256_calculate(fw_data, fw_len, hash);
    
    // 2. 验证签名
    if (!ecdsa_verify(public_key, hash, signature)) {
        return false;  // 签名验证失败,固件可能被篡改
    }
    
    return true;
}

嗯,这里有个坑:公钥怎么安全地存到设备上? 我建议在产线烧录时就把公钥写进安全存储区(比如STM32的OTP区域),或者用芯片自带的唯一ID绑定公钥。我曾经见过有人把公钥明文写在固件里——那跟没签名有什么区别?攻击者反编译一下就能替换公钥。

我的签名校验流程建议

  1. 设备收到升级包后,先校验签名(ECDSA)
  2. 签名通过后,再校验哈希(SHA256)
  3. 哈希通过后,才把固件写入Flash
  4. 写入完成后,再读出来校验一次哈希(防止Flash写入错误)

说白了就是“双重校验”:签名防篡改,哈希防传输错误。我在项目中一直这么用,从来没出过问题。

3.4 版本回退策略——给自己留条后路

升级失败了怎么办?比如新固件有bug,单车锁连不上网了。这时候需要版本回退机制。

我建议在Bootloader里保留两个固件槽位:

槽位 用途 说明
Slot A 当前运行固件 正常启动时从这里加载
Slot B 备份固件 升级失败时回退到这里

升级流程是这样的:

  1. 新固件下载到Slot B(不覆盖Slot A)
  2. 校验签名和哈希,通过后标记Slot B为“待激活”
  3. 重启设备,Bootloader从Slot B启动
  4. 如果启动成功,设备上报“升级成功”,Bootloader把Slot B标记为“当前”
  5. 如果启动失败(比如设备没上报),Bootloader自动回退到Slot A

这个机制我称之为“先试后买”。设备先在新固件上跑一段时间,确认没问题了再正式切换。万一有问题,重启就回到旧版本,用户完全无感知。

回退时要注意版本号兼容性

我曾经遇到过一个问题:新固件改了Flash存储结构,回退到旧固件后,旧固件读不懂新格式的数据,直接死机了。后来我加了个版本号兼容性检查——如果回退后的版本号低于某个阈值,就格式化存储区,用默认配置重新初始化。虽然丢了一些用户数据,但总比设备变砖强。

3.5 总结一下

固件镜像管理这块,说白了就是三件事:

  • 版本号:用语义化版本,自动生成,别手写
  • 差分包:省流量,但要注意边界情况,内存不够就回退到全量
  • 签名校验:ECDSA + SHA256,双重保险,公钥要安全存储

再加上版本回退机制,整个OTA升级系统才算完整。我在多个项目里验证过这套方案,稳定运行了好几年,没出过重大事故。你照着这个思路做,基本不会踩坑。