升级包制作与签名:从原始固件到安全交付

好,咱们进入第二章。上一章聊了日志系统怎么搭,这一章我们聚焦一个更核心的问题——你辛辛苦苦写完固件,怎么把它做成一个可靠的、安全的升级包?

我见过太多团队,代码写得漂亮,结果升级包格式搞错了,或者没做校验,现场升级直接变砖。说白了,升级包就是固件的「最后一公里」,这步出问题,前面全白干。

bin 与 hex:两种最常见的固件格式

先搞清楚我们要处理什么。嵌入式世界里,bin 和 hex 是两大主流格式。

特性 bin 格式 hex 格式
内容 纯二进制数据,无地址信息 ASCII 文本,包含地址、长度、校验
体积 紧凑,与 Flash 大小一致 比 bin 大约 2-3 倍
可读性 不可读 文本编辑器可打开
烧录方式 需指定起始地址 自带地址,分段烧录
典型场景 OTA 升级包、量产烧录 调试器下载、分段烧录

我个人习惯,量产和 OTA 用 bin,调试阶段用 hex。为什么?bin 干净利落,没有冗余信息,传输效率高。hex 虽然大,但每行都有地址和校验,烧录器可以逐行验证,出问题能精确定位到哪一行数据坏了。

小技巧: 很多工具链(比如 ARMCC、GCC)都能直接输出两种格式。我常用 objcopy 做转换:arm-none-eabi-objcopy -O binary firmware.elf firmware.bin。一行命令,搞定。

CRC 校验:快速检测数据完整性

升级包做好了,怎么保证它传输过程中没被篡改或损坏?最简单的办法就是加个校验。

CRC(循环冗余校验)是嵌入式里最常用的校验方式。它不像哈希那么重,但足够检测出常见的传输错误。

我常用的 CRC 参数是 CRC-32(IEEE 802.3 标准):

// CRC-32 查表法实现(简化版)
uint32_t crc32(const uint8_t *data, uint32_t len) {
    uint32_t crc = 0xFFFFFFFF;
    for (uint32_t i = 0; i < len; i++) {
        crc ^= data[i];
        for (int j = 0; j < 8; j++) {
            if (crc & 1) {
                crc = (crc >> 1) ^ 0xEDB88320;
            } else {
                crc >>= 1;
            }
        }
    }
    return ~crc;
}

嗯,这里要注意:CRC 只能检测随机错误,不能防恶意篡改。如果有人故意修改了固件,重新算个 CRC 附上去,你根本发现不了。所以 CRC 适合做「完整性校验」,不适合做「安全校验」。

避坑指南: 我曾经在一个项目里只用了 CRC 做升级包校验,结果现场被人截获了升级包,改了里面的配置参数,重新算了 CRC 就刷进去了。从那以后,凡是涉及安全的产品,我至少会用哈希 + 签名。

哈希算法:更可靠的完整性保证

哈希算法比 CRC 强在哪?它有两个关键特性:

  • 单向性: 从哈希值无法反推出原始数据
  • 抗碰撞性: 几乎不可能找到两个不同数据有相同哈希值

嵌入式里常用的哈希算法有 SHA-256 和 MD5。MD5 已经被证明不安全了,我建议直接用 SHA-256。

你想想看,SHA-256 输出 32 字节,CRC-32 输出 4 字节。这 28 字节的差距,就是安全性的差距。

// 伪代码:计算固件包的 SHA-256
uint8_t firmware_hash[32];
SHA256_CTX ctx;
sha256_init(&ctx);
sha256_update(&ctx, firmware_bin, firmware_len);
sha256_final(&ctx, firmware_hash);

// 将哈希值附加到升级包末尾
memcpy(package + package_len, firmware_hash, 32);
package_len += 32;

但哈希也有局限——它只能保证数据没变,不能证明数据是谁发的。这就引出了数字签名。

数字签名:让升级包「验明正身」

数字签名是解决「信任」问题的。它用非对称加密:私钥签名,公钥验证。

流程是这样的:

  1. 服务器用私钥对固件哈希值加密,生成签名
  2. 设备收到升级包后,用公钥解密签名,得到哈希值
  3. 设备自己算一遍固件的哈希值
  4. 两个哈希值一致,说明固件是官方发布的,且未被篡改

我常用的签名算法是 ECDSA(椭圆曲线数字签名算法)。它比 RSA 密钥更短,计算更快,特别适合资源受限的 MCU。

核心要点: 签名验证必须在设备端完成,且公钥要安全存储在设备内部(比如烧死在 Flash 的只读区域,或者使用安全元件)。如果公钥能被替换,签名就形同虚设。

升级包结构设计

说了这么多,一个完整的升级包到底长什么样?我画个图给你看。

升级包结构示意图 升级包(.bin / .swu / .ota) 包头(Header) 魔数 | 版本号 | 固件长度 | 目标设备ID | 时间戳 固件数据(Firmware Data) bin 或 hex 格式的原始固件 通常包含:Bootloader + Application + 配置参数 校验与签名区(Checksum & Signature) CRC-32(4字节) | SHA-256(32字节) | ECDSA签名(64字节) 可选:公钥哈希、证书链、升级策略标志

这个结构我用了好多年,基本没出过问题。注意几点:

  • 魔数: 一个固定值(比如 0xAA55),用来快速判断是不是合法的升级包
  • 版本号: 必须包含主版本、次版本、修订号,防止降级攻击
  • 签名区: 放在最后,方便设备边接收边计算哈希,最后再验证签名

签名验证的完整流程

最后,我把整个流程串起来,给你一个可落地的参考。

// 设备端:升级包验证流程(伪代码)
int verify_firmware_package(const uint8_t *package, uint32_t pkg_len) {
    // 1. 解析包头
    PackageHeader *hdr = (PackageHeader *)package;
    if (hdr->magic != PACKAGE_MAGIC) return ERR_INVALID_MAGIC;
    if (hdr->version <= current_version) return ERR_VERSION_ROLLBACK;
    
    // 2. 提取固件数据和签名
    uint32_t fw_len = hdr->fw_length;
    const uint8_t *fw_data = package + sizeof(PackageHeader);
    const uint8_t *signature = fw_data + fw_len;
    
    // 3. 计算固件哈希
    uint8_t hash[32];
    sha256(fw_data, fw_len, hash);
    
    // 4. 用公钥验证签名
    if (ecdsa_verify(public_key, hash, signature) != 0) {
        return ERR_SIGNATURE_MISMATCH;
    }
    
    // 5. 可选:再算一次 CRC 做快速校验
    uint32_t crc = crc32(fw_data, fw_len);
    if (crc != hdr->crc32) return ERR_CRC_MISMATCH;
    
    return ERR_OK;
}
我的经验: 签名验证放在设备启动阶段做一次就够了,不用每次重启都验。但 CRC 校验建议每次读取 Flash 都做,因为 Flash 本身有比特翻转的风险。我在一个工业项目里就遇到过,Flash 老化导致某位翻转,CRC 第一时间发现了,避免了设备跑飞。

好了,这一章的内容就到这。升级包制作和签名,说白了就是三件事:选对格式、算对校验、签对名。每一步都有坑,但只要你按这个流程走,基本稳了。