2、固件基础:MCU固件结构(Bootloader + App)、固件镜像格式(Bin/Hex)、固件版本管理

好,咱们正式开始聊固件。很多刚入行的朋友,拿到一个MCU项目,第一件事就是写业务代码,然后直接烧录进去就跑。嗯,这其实是个坑。我见过不少产品,因为固件结构没规划好,后期升级时搞得焦头烂额。今天这一节,咱们就把固件的基础打扎实。

2.1 MCU固件结构:Bootloader + App

一个成熟的MCU固件,通常分成两个部分:BootloaderApp。说白了,就是「引导程序」和「应用程序」。

Bootloader 是什么?

Bootloader 是一段独立的程序,它负责在MCU上电后,先于App运行。它的核心任务就两个:

  • 检查App是否合法、完整
  • 决定是跳转到App,还是等待新固件

我个人习惯把Bootloader做得尽量小,功能单一。它不需要处理复杂的业务逻辑,只需要做好「看门人」的角色。

核心原则:Bootloader 只做一件事——决定「启动谁」。它不参与业务,也不依赖App的任何代码。

App 是什么?

App 就是你的业务逻辑。传感器采集、通信协议、控制算法,全在这里。App 不知道 Bootloader 的存在,它只知道自己被放在某个固定的Flash地址上。

为什么一定要分开?

你想想看,如果只有一个固件,升级时万一写到一半断电了,整个芯片就变砖了。有了Bootloader,它可以在启动时检查App的完整性,发现坏了就进入「等待升级」模式。我在项目中遇到过,一个客户的产品在野外升级时断电,全靠Bootloader保住了设备,不然得派人去现场拆机烧录,那成本就大了。

Flash 分区示例(以STM32为例):

Flash 起始地址: 0x08000000

0x08000000 - 0x08003FFF  (16KB)  → Bootloader
0x08004000 - 0x08007FFF  (16KB)  → 参数存储区 (可选)
0x08008000 - 0x080FFFFF  (约1MB) → App 区域

注意,Bootloader 和 App 的地址不能重叠。而且,Bootloader 的向量表要单独设置,App 也要有自己的向量表偏移。这个细节很多人会忘,我刚开始做的时候也踩过这个坑。

避坑指南:我曾经在切换App时,忘记重新设置向量表偏移,结果一进中断就死机。排查了整整两天才发现。记住:跳转到App之前,一定要用 SCB->VTOR = APP_ADDRESS 重新定位向量表。

2.2 固件镜像格式:Bin 与 Hex

固件编译出来,最终要烧录到芯片里。常见的格式有两种:BinHex。很多新手搞不清它们的区别,其实很简单。

Bin 文件:

  • 纯二进制数据,没有地址信息
  • 体积最小,直接就是Flash里的内容
  • 烧录时必须指定起始地址
  • 适合OTA传输,因为数据量小

Hex 文件:

  • Intel Hex 格式,包含地址、校验和等信息
  • 体积比Bin大(大约多30%-50%)
  • 可以包含多个段的地址信息
  • 烧录器可以直接解析,不需要手动指定地址

什么时候用哪个?

我个人习惯:

  • 开发调试阶段:用Hex。因为烧录器直接认,省事。
  • OTA升级:用Bin。因为要传输的数据越少越好,而且Bootloader知道App的固定地址,不需要Hex里的地址信息。

Hex 文件结构示例:

:020000040800F2
:1000000000C046FEFF7F0000000000000000000000F0
:1000100000000000000000000000000000000000E0
:00000001FF

每一行都包含:长度、地址、记录类型、数据、校验和。烧录器就是靠这些信息,知道把数据放到哪个地址。

小技巧:如果你需要把Hex转成Bin,可以用 arm-none-eabi-objcopy -O binary firmware.hex firmware.bin。这个命令我几乎每个项目都会用到。

2.3 固件版本管理

版本管理,听起来简单,但做不好会出大乱子。我见过一个团队,因为版本号混乱,把旧固件当成新固件推送到了生产环境,结果批量返工。

版本号怎么定义?

我推荐使用 语义化版本,格式为:主版本.次版本.修订号

  • 主版本:不兼容的API修改,或者重大功能变更
  • 次版本:向下兼容的功能新增
  • 修订号:向下兼容的问题修正

版本号存在哪里?

版本号不能写在代码里,因为每次编译都会变。我建议:

  • 在编译脚本中自动生成版本号
  • 将版本号写入固件的固定偏移位置(比如Flash末尾的某个地址)
  • Bootloader 启动时读取这个版本号,决定是否需要升级

版本管理表(示例):

版本号 发布日期 变更内容 兼容性
1.0.0 2024-01-15 初始版本 -
1.1.0 2024-03-20 新增低功耗模式 向下兼容
1.1.1 2024-04-10 修复I2C通信超时问题 向下兼容
2.0.0 2024-06-01 重构通信协议,不兼容旧版 不兼容

版本检查流程:

  1. Bootloader 启动,读取App区域的版本号
  2. 如果版本号低于服务器上的最新版本,进入升级模式
  3. 如果版本号相同或更高,直接启动App
  4. 升级完成后,更新版本号

避坑指南:我曾经遇到一个情况,版本号用字符串存储,结果比较时忘了处理字符串长度,导致「1.10.0」被判定为小于「1.9.0」。后来我改用整数存储(比如 1.10.0 存为 0x010A00),再也没出过问题。

版本号在固件中的存储方式:

// 在链接脚本中定义固定地址
// 例如:放在Flash的0x0800FFF0位置

#define VERSION_ADDR  0x0800FFF0

typedef struct {
    uint8_t major;
    uint8_t minor;
    uint8_t patch;
    uint8_t reserved;  // 用于校验或标志位
} firmware_version_t;

// 读取版本号
firmware_version_t* ver = (firmware_version_t*)VERSION_ADDR;
printf("Current firmware: v%d.%d.%d\n", ver->major, ver->minor, ver->patch);

嗯,到这里,固件的基础结构、镜像格式和版本管理就讲完了。这些东西看似简单,但每一个细节都关系到产品的稳定性和可维护性。下一节,咱们会深入OTA升级的实战流程,到时候这些基础都会用上。