2、固件基础:MCU固件结构(Bootloader + App)、固件镜像格式(Bin/Hex)、固件版本管理
好,咱们正式开始聊固件。很多刚入行的朋友,拿到一个MCU项目,第一件事就是写业务代码,然后直接烧录进去就跑。嗯,这其实是个坑。我见过不少产品,因为固件结构没规划好,后期升级时搞得焦头烂额。今天这一节,咱们就把固件的基础打扎实。
2.1 MCU固件结构:Bootloader + App
一个成熟的MCU固件,通常分成两个部分:Bootloader 和 App。说白了,就是「引导程序」和「应用程序」。
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
固件编译出来,最终要烧录到芯片里。常见的格式有两种:Bin 和 Hex。很多新手搞不清它们的区别,其实很简单。
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 | 重构通信协议,不兼容旧版 | 不兼容 |
版本检查流程:
- Bootloader 启动,读取App区域的版本号
- 如果版本号低于服务器上的最新版本,进入升级模式
- 如果版本号相同或更高,直接启动App
- 升级完成后,更新版本号
避坑指南:我曾经遇到一个情况,版本号用字符串存储,结果比较时忘了处理字符串长度,导致「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升级的实战流程,到时候这些基础都会用上。