第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的启动逻辑其实不复杂,我把它拆成三步:

  1. 校验运行区——计算CRC32,跟参数区里存的值比对。
  2. 判断升级标志——如果参数区标记了“有待升级固件”,就去备份区拿新固件覆盖运行区。
  3. 跳转执行——校验通过后,修改向量表偏移,跳转到应用入口。

为什么会这样设计?因为升级过程中最怕断电。如果新固件写到一半断电了,运行区可能损坏。但备份区是完整的,下次启动时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状态机,你可以直接拿去用。

双备份Bootloader状态机 上电启动 读取参数区 校验Slot A 校验Slot B 升级标志判断 回滚恢复 跳转到应用 关键决策点 Slot A校验失败 → 回滚Slot B 升级标志置位 → 拷贝备份区 双区都坏 → 进入恢复模式

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够用,一定上双备份。多花一倍空间,换来的是设备在恶劣环境下的自愈能力。你想想看,一个部署在野外的传感器,出了问题没人去修,双备份可能就是它唯一的救命稻草。