3. 固件升级的软件基础:固件镜像格式、版本号管理与签名校验

做固件升级,说白了就是跟“文件”打交道。你想想看,不管你的升级流程设计得多花哨,最终烧进芯片里的,总归是一堆二进制数据。但这堆数据怎么组织、怎么识别、怎么保证它没被人动过手脚,这就是我们今天要聊的核心。

我个人习惯把固件升级的软件基础拆成三块:镜像格式、版本号管理、签名与校验。这三块搞不明白,后面的升级流程全是空中楼阁。

3.1 固件镜像格式:BIN、HEX、ELF

先说说最常见的三种格式。很多新手工程师上来就问:“我该用哪种格式?”其实答案很简单——看你的目标是什么。

3.1.1 BIN 格式

BIN 文件,就是纯粹的二进制数据。没有头、没有尾、没有地址信息。它是什么?它就是芯片 Flash 里内容的“裸拷贝”。

  • 优点:体积最小,解析最简单。烧录器直接按地址顺序往里写就行。
  • 缺点:没有地址映射。如果你想把一段代码放到 0x08001000,你得自己告诉烧录工具。
  • 适用场景:量产烧录、OTA 升级包(因为体积小,传输快)。
我的经验:我在做 STM32 的 OTA 升级时,升级包就用的 BIN 格式。因为 MCU 的 Bootloader 只需要把收到的数据按固定偏移写入 Flash,BIN 格式最省事。但要注意,BIN 文件没有校验,你得自己在协议层加 CRC。

3.1.2 HEX 格式(Intel HEX)

HEX 文件是文本格式的二进制数据。每一行都包含地址、数据类型和校验和。说白了,它比 BIN 多了一层“地址标签”。

  • 结构::LLAAAATTDDDD...CC
  • 优点:可以指定每段数据存放的地址,支持分段加载。
  • 缺点:体积比 BIN 大(因为 ASCII 编码和校验开销)。
  • 适用场景:调试阶段、通过串口或 J-Link 下载时。
// 一个典型的 HEX 记录示例
:020000040800F2   // 扩展线性地址记录,设置基地址为 0x0800
:1000000000BE00A0E301F000F800F000F800F000F8A7  // 数据记录
:00000001FF       // 文件结束记录
注意:我曾经遇到过一个问题——用串口 Ymodem 协议传输 HEX 文件时,Bootloader 解析地址记录出错,导致数据写到了错误的位置。后来我强制要求所有升级包统一转成 BIN 格式,才彻底解决。所以,Bootloader 里尽量别解析 HEX,除非你有十足的把握。

3.1.3 ELF 格式

ELF 是 Linux 和嵌入式高级调试中最常用的格式。它不光包含代码和数据,还包含符号表、调试信息、重定位表。

  • 优点:信息最全,支持调试、支持动态链接。
  • 缺点:体积巨大,结构复杂。
  • 适用场景:开发调试、Linux 内核/驱动加载。

嗯,这里要注意:ELF 几乎不会直接用于固件升级。因为它的结构太复杂了,Bootloader 解析起来成本太高。通常的做法是:开发时用 ELF 调试,发布时用 objcopy 转成 BIN 或 HEX。

3.1.4 三种格式对比

特性 BIN HEX ELF
体积 最小 中等 最大
地址信息 无 有 有(含符号)
校验 无 行内校验和 无(依赖工具)
解析复杂度 极低 低 高
升级场景 OTA、量产 串口下载 调试阶段

3.2 固件版本号管理

版本号这东西,看着简单,但坑特别多。我见过不少产品,因为版本号管理混乱,导致升级后设备变砖。

3.2.1 版本号的组成

我个人习惯用三段式版本号:主版本号.次版本号.修订号。

  • 主版本号:重大架构变更,不兼容升级。
  • 次版本号:新增功能,向下兼容。
  • 修订号:Bug 修复,功能不变。

但光有这三段还不够。在嵌入式里,我建议再加一个内部构建号或Git Commit ID。为什么?因为有时候你改了代码但忘了改版本号,结果两个不同版本的固件版本号一模一样,升级逻辑就懵了。

核心原则:版本号必须能唯一标识一次构建。我建议在编译脚本里自动将 Git 的短哈希写入固件头。

3.2.2 版本号存储在哪里?

版本号不能只写在代码里。它必须放在固件镜像的固定偏移位置,这样 Bootloader 不需要解析整个固件就能读到版本信息。

// 固件头结构体示例(放在镜像开头)
typedef struct {
    uint32_t magic;          // 魔数,用于识别固件合法性
    uint32_t version_major;  // 主版本号
    uint32_t version_minor;  // 次版本号
    uint32_t version_patch;  // 修订号
    uint32_t build_number;   // 构建号
    uint32_t firmware_size;  // 固件总大小
    uint32_t crc32;          // 固件数据 CRC32 校验
    uint8_t  reserved[16];   // 保留字段
} firmware_header_t;
避坑指南:我曾经遇到过一个问题——版本号用字符串存储,比如 "v1.2.3"。结果 Bootloader 比较版本号时,字符串比较 "v1.9.0" 比 "v1.10.0" 大,导致升级逻辑出错。所以,版本号一定要用数值存储和比较,别偷懒用字符串。

3.2.3 版本号比较逻辑

Bootloader 在决定是否升级时,需要比较当前固件版本和新固件版本。逻辑很简单:

  1. 先比较主版本号,大的优先。
  2. 主版本相同,比较次版本号。
  3. 主次都相同,比较修订号。
  4. 全部相同,比较构建号(或 Git Commit)。

但这里有个细节:是否允许降级? 我建议默认禁止降级,除非有特殊开关。因为降级可能导致配置不兼容,或者引入已知 Bug。

3.3 固件签名与校验

这是固件升级的安全基石。没有签名校验,你的设备就是裸奔的。

3.3.1 为什么要签名?

你想想看,如果攻击者伪造了一个恶意固件,通过 OTA 推送给了你的设备,会发生什么?轻则设备变砖,重则被植入后门。签名就是为了解决这个问题——确保固件来源可信、内容完整。

3.3.2 签名流程

  1. 生成密钥对:私钥保存在安全的构建服务器上,公钥烧录在设备中。
  2. 计算哈希:对固件数据(通常是 BIN 文件)计算 SHA256 哈希。
  3. 签名:用私钥对哈希值进行 RSA 或 ECDSA 签名。
  4. 打包:将签名附加到固件头或固件尾部。

3.3.3 校验流程

  1. Bootloader 读取固件头和签名。
  2. 计算固件数据的 SHA256 哈希。
  3. 用公钥解密签名,得到原始哈希。
  4. 比较两个哈希值。一致则通过,否则拒绝升级。
关键点:公钥必须硬编码在 Bootloader 中,并且 Bootloader 本身不能通过 OTA 更新(或者更新 Bootloader 需要更严格的签名验证)。否则攻击者可以替换公钥,绕过签名校验。

3.3.4 签名算法选择

算法 签名长度 计算开销 安全性
RSA-2048 256 字节 较高 高
ECDSA-P256 64 字节 中等 高
HMAC-SHA256 32 字节 低 中(对称密钥)

我个人更推荐 ECDSA。为什么?签名短、计算快,对资源受限的 MCU 更友好。RSA 虽然成熟,但签名太长,传输和存储都有开销。

我的经验:在 Cortex-M4 上,ECDSA-P256 的验签时间大约在 50-100ms 左右,而 RSA-2048 需要 200-500ms。对于用户来说,升级等待时间越短,体验越好。

3.3.5 校验的另一个维度:CRC 或 SHA

签名校验保证了固件的来源可信,但传输过程中可能发生比特翻转。所以,我建议在签名之外,再加一层完整性校验——比如 CRC32 或 SHA256。

  • CRC32:速度快,适合校验传输错误。
  • SHA256:更安全,但计算慢。

通常的做法是:签名校验只在升级开始时做一次,而 CRC 校验可以在每收到一个数据包时做,也可以在写入 Flash 后做。

3.4 知识体系总览

下面这张图,把今天讲的内容串起来了。你可以看到,从固件构建到最终烧录,每一步都有对应的软件基础支撑。

固件升级软件基础体系 固件升级软件基础体系 镜像格式 版本号管理 签名与校验 BIN HEX ELF 三段式 + 构建号 RSA / ECDSA / HMAC objcopy 转换 版本比较逻辑 验签 + CRC 安全可靠的固件升级 镜像格式决定传输方式 版本号决定升级策略 签名校验决定安全性

好了,以上就是固件升级软件基础的三个核心支柱。搞懂了这些,你就能理解为什么有的升级方案稳定可靠,有的却漏洞百出。下一层,我们会深入到具体的升级流程设计中去。