第4章 Bootloader开发:从零编写一个支持双备份的Bootloader,实现跳转与校验
Bootloader这东西,说白了就是MCU上电后跑的第一段程序。它的任务很简单——检查有没有新固件,有就更新,没有就跳去跑应用。但简单归简单,一旦做不好,设备就变砖了。
我早年做一款智能插座产品时,就吃过这个亏。OTA升级到一半断电了,重启后Bootloader发现固件校验失败,直接卡死。用户只能返厂维修。那次之后,我彻底重构了Bootloader方案,加入了双备份机制。今天就把这套方案拆开来讲。
4.1 双备份Bootloader的核心架构
双备份,说白了就是Flash里存两份固件:一份是运行区(Active Slot),一份是备份区(Backup Slot)。Bootloader启动时,先校验运行区,如果坏了就回滚到备份区。
核心原则:任何时候,Flash里至少有一份可用的固件。
来看一下典型的Flash分区布局:
| 分区名称 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| Bootloader区 | 0x08000000 | 32 KB | 存放Bootloader代码 |
| 参数区 | 0x08008000 | 4 KB | 存储升级标志、版本号、校验值 |
| 运行区(Slot A) | 0x08009000 | 256 KB | 当前运行的应用固件 |
| 备份区(Slot B) | 0x08049000 | 256 KB | 备份的应用固件 |
嗯,这里要注意:参数区非常关键。Bootloader每次启动都要读它,才知道该跳转到哪个区。我习惯把参数区放在独立的Flash页里,方便擦写,不会误伤固件。
4.2 启动流程:三步走
Bootloader的启动逻辑其实不复杂,我把它拆成三步:
- 校验运行区——计算CRC32,跟参数区里存的值比对。
- 判断升级标志——如果参数区标记了“有待升级固件”,就去备份区拿新固件覆盖运行区。
- 跳转执行——校验通过后,修改向量表偏移,跳转到应用入口。
为什么会这样设计?因为升级过程中最怕断电。如果新固件写到一半断电了,运行区可能损坏。但备份区是完整的,下次启动时Bootloader发现运行区校验失败,就会从备份区恢复。
我的经验:校验算法别用简单的累加和。CRC32虽然慢一点,但可靠性高很多。我在项目里遇到过两次累加和碰撞导致误判的情况,换成CRC32后再没出过问题。
4.3 核心代码实现
下面这段代码是Bootloader的主循环逻辑。我精简了硬件相关部分,保留了核心跳转和校验流程。
// bootloader_main.c - 双备份Bootloader核心逻辑
#include "flash_if.h"
#include "crc32.h"
#include "boot_params.h"
#define APP_SLOT_A_ADDR 0x08009000
#define APP_SLOT_B_ADDR 0x08049000
#define APP_MAX_SIZE (256 * 1024)
typedef void (*pFunction)(void);
int bootloader_main(void)
{
BootParams_t params;
uint32_t crc_slot_a, crc_slot_b;
int slot_a_valid = 0, slot_b_valid = 0;
// 1. 读取参数区
flash_read_params(¶ms);
// 2. 校验两个槽位的固件完整性
crc_slot_a = crc32_calculate((uint8_t*)APP_SLOT_A_ADDR, APP_MAX_SIZE);
crc_slot_b = crc32_calculate((uint8_t*)APP_SLOT_B_ADDR, APP_MAX_SIZE);
if (crc_slot_a == params.crc_slot_a) slot_a_valid = 1;
if (crc_slot_b == params.crc_slot_b) slot_b_valid = 1;
// 3. 判断升级标志
if (params.upgrade_pending) {
// 有待升级固件,从备份区拷贝到运行区
if (slot_b_valid) {
flash_erase_slot_a();
flash_copy(APP_SLOT_A_ADDR, APP_SLOT_B_ADDR, APP_MAX_SIZE);
params.crc_slot_a = crc_slot_b;
params.upgrade_pending = 0;
flash_write_params(¶ms);
slot_a_valid = 1;
} else {
// 备份区也坏了,标记升级失败
params.upgrade_pending = 0;
params.boot_status = BOOT_FAILED;
flash_write_params(¶ms);
}
}
// 4. 选择启动槽位
if (slot_a_valid) {
jump_to_app(APP_SLOT_A_ADDR);
} else if (slot_b_valid) {
// 运行区坏了,从备份区启动
flash_erase_slot_a();
flash_copy(APP_SLOT_A_ADDR, APP_SLOT_B_ADDR, APP_MAX_SIZE);
params.crc_slot_a = params.crc_slot_b;
flash_write_params(¶ms);
jump_to_app(APP_SLOT_A_ADDR);
} else {
// 两个槽位都坏了,进入恢复模式
enter_recovery_mode();
}
return 0;
}
static void jump_to_app(uint32_t app_addr)
{
pFunction app_entry;
uint32_t stack_ptr;
// 读取应用的中断向量表
stack_ptr = *(volatile uint32_t*)app_addr;
app_entry = (pFunction)(*(volatile uint32_t*)(app_addr + 4));
// 关闭全局中断
__disable_irq();
// 修改向量表偏移(Cortex-M系列)
SCB->VTOR = app_addr;
// 设置主堆栈指针
__set_MSP(stack_ptr);
// 跳转到应用入口
app_entry();
}
注意:跳转前一定要关闭全局中断,否则中断服务函数可能跑飞。我见过有人忘了关中断,跳转后应用还没初始化,一个定时器中断进来直接HardFault。
4.4 双备份升级流程
升级时,新固件先写入备份区。写入完成后,设置升级标志。下次重启时,Bootloader负责把备份区拷贝到运行区。
这样做的好处很明显:
- 升级中断不砖——即使拷贝过程中断电,备份区仍然是完整的。
- 回滚方便——如果新固件跑不起来,Bootloader可以自动回滚到旧版本。
- 校验双保险——每次启动都校验,防止Flash比特翻转导致的问题。
我曾经在一个户外设备项目里,遇到过Flash因为温度变化产生比特翻转的情况。双备份加CRC校验,帮我自动恢复了三次,客户完全没感知。
4.5 双备份Bootloader状态机
下面这张图是我设计的双备份Bootloader状态机,你可以直接拿去用。
4.6 避坑指南
写Bootloader这些年,我踩过的坑不少。挑几个典型的说说:
我曾经犯过的错:
- 向量表偏移没对齐——Cortex-M要求VTOR必须按128字节对齐。我一开始没注意,跳转后中断全乱套了。
- Flash擦写时间过长——擦除一个扇区可能要几十毫秒,如果此时有外部看门狗,直接复位。记得在擦写前喂狗。
- 参数区磨损均衡——每次升级都写参数区,Flash有擦写寿命。我后来加了磨损均衡算法,把参数区循环使用多个页。
我的建议:Bootloader里不要放复杂功能。它越简单越可靠。我见过有人在Bootloader里塞了TCP/IP协议栈,结果升级过程中协议栈崩了,设备直接变砖。Bootloader只做三件事:校验、拷贝、跳转。其他功能交给应用层。
4.7 双备份方案的取舍
双备份不是银弹。它需要双倍的Flash空间。对于Flash紧张的MCU,可以考虑单备份加外部Flash的方案。
| 方案 | Flash占用 | 可靠性 | 适用场景 |
|---|---|---|---|
| 双备份(内部Flash) | 2倍固件大小 | 最高 | 对可靠性要求极高的设备 |
| 单备份 + 外部Flash | 1倍固件 + 外部存储 | 较高 | 内部Flash紧张,但需要OTA |
| 单区直写 | 1倍固件 | 低 | 开发调试阶段,量产不建议 |
我个人习惯,只要Flash够用,一定上双备份。多花一倍空间,换来的是设备在恶劣环境下的自愈能力。你想想看,一个部署在野外的传感器,出了问题没人去修,双备份可能就是它唯一的救命稻草。