3. Bootloader 架构设计:内存规划、工作模式与状态机

好,咱们进入第三章。这一章很关键,说白了就是给 Bootloader 和 App 画地盘、定规矩。你想想看,一个系统里 Bootloader 和 App 要共存,谁住哪、谁先跑、怎么交接,这些不规划清楚,后面全是坑。

我个人习惯,在写第一行代码之前,先把内存分区图画出来。别急着敲键盘,画图花不了十分钟,但能省你后面几天调试时间。

3.1 Bootloader 与 App 的内存分区规划

STM32 的 Flash 是从 0x08000000 开始的。Bootloader 和 App 各占一段,互不干扰。我见过不少新手把 App 直接放在 0x08000000,结果 Bootloader 没地方放,或者一升级就把自己给擦了——嗯,这属于“自杀式设计”。

下面是一个典型的分区方案,以 STM32F103C8T6(64KB Flash)为例:

分区 起始地址 大小 内容
Bootloader 0x08000000 16 KB 启动代码、升级逻辑、Flash 驱动
App 向量表 0x08004000 — App 中断向量表起始
App 代码区 0x08004000 40 KB 用户应用程序
参数存储区 0x0800E000 4 KB 升级标志、版本号、校验值
备份/暂存区 0x0800F000 4 KB 升级过程中暂存数据

关键点:Bootloader 的大小必须预留足够。我建议至少留 16KB,哪怕你现在的 Bootloader 只有 8KB。为什么?因为后面要加安全校验、加密解密、双备份,代码量会翻倍。我曾经在一个项目里只留了 12KB,结果加了个 SHA256 校验就爆了,最后不得不重新分区,所有固件都得重刷——血的教训。

App 的起始地址不是随便定的。它必须是 0x200 的倍数,因为 STM32 的向量表对齐要求是 2^N,最小 256 字节对齐。实际项目中我一般按 1KB 对齐,省心。

还有一个容易被忽略的点:参数存储区。这个区域用来存升级标志、版本号、CRC 校验值。我习惯把它放在 Flash 的最后几页,这样即使 Bootloader 或 App 的大小变了,参数区地址也不受影响。

3.2 Bootloader 的两种工作模式

Bootloader 就干两件事:要么直接跳转到 App,要么等着接收新固件。这就是所谓的直通模式和升级模式。

3.2.1 直通模式(Pass-Through Mode)

直通模式最简单。上电后 Bootloader 检查升级标志,如果没有升级请求,直接跳转到 App 入口。整个过程几十毫秒,用户几乎感觉不到 Bootloader 的存在。

跳转代码长这样:

/* 跳转到 App 入口 */
void jump_to_app(uint32_t app_addr)
{
    uint32_t stack_ptr = *(volatile uint32_t*)app_addr;
    uint32_t entry     = *(volatile uint32_t*)(app_addr + 4);
    
    /* 关闭全局中断 */
    __disable_irq();
    
    /* 重置所有外设(可选,但推荐) */
    deinit_all_peripherals();
    
    /* 设置主堆栈指针 */
    __set_MSP(stack_ptr);
    
    /* 跳转 */
    void (*app_entry)(void) = (void (*)(void))entry;
    app_entry();
}

我的习惯:跳转前一定要关闭所有中断,并且把用过的外设反初始化。否则 App 启动时可能会触发意外中断,或者外设状态混乱。我踩过这个坑——跳转后串口一直在发乱码,查了两天才发现是 DMA 没关干净。

3.2.2 升级模式(Update Mode)

升级模式就复杂多了。Bootloader 需要初始化通信接口(UART、SPI、USB 或 CAN),接收固件数据,写入 Flash,最后校验。这个过程中 Bootloader 是主角,App 完全不参与。

升级模式的触发方式有几种:

  • GPIO 检测:上电时检测某个引脚电平,拉低就进升级模式。我最常用的方式,简单可靠。
  • 标志位检测:检查参数区的升级标志。App 收到升级指令后,在复位前写一个标志,Bootloader 看到标志就进升级模式。
  • 超时检测:上电后等待几秒,如果收到特定命令就进升级模式,否则跳 App。适合没有额外引脚的场景。
  • 强制模式:如果 App 校验失败或不存在,自动进入升级模式。这是保底方案。

注意:升级过程中不能断电!我建议在硬件上做掉电检测,或者在软件里做断点续传。否则写到一半断电,Flash 里的数据就毁了,设备变砖。我在一个工业项目里吃过这个亏,后来加了双备份区才解决。

3.3 状态机设计

Bootloader 本质上是一个有限状态机。把状态理清楚,代码结构就清晰了。我习惯用枚举定义状态,用 switch-case 实现状态流转。

下面是一个典型的状态机定义:

typedef enum {
    BOOT_STATE_INIT,        // 初始化
    BOOT_STATE_CHECK,       // 检查升级标志
    BOOT_STATE_JUMP_APP,    // 跳转到 App
    BOOT_STATE_WAIT_CMD,    // 等待升级命令
    BOOT_STATE_RECV_DATA,   // 接收固件数据
    BOOT_STATE_VERIFY,      // 校验固件
    BOOT_STATE_UPDATE_DONE, // 升级完成
    BOOT_STATE_ERROR        // 错误处理
} boot_state_t;

状态流转图如下:

Bootloader 状态机流转图 BOOT_STATE_INIT BOOT_STATE_CHECK BOOT_STATE_JUMP_APP BOOT_STATE_WAIT_CMD BOOT_STATE_RECV_DATA BOOT_STATE_VERIFY BOOT_STATE_ERROR 说明: • 绿色:直通模式路径 • 橙色/紫色:升级模式路径 • 红色虚线:异常回退路径

状态机的核心逻辑在 BOOT_STATE_CHECK 里做决策:

void boot_state_machine(void)
{
    boot_state_t state = BOOT_STATE_INIT;
    
    while(1)
    {
        switch(state)
        {
            case BOOT_STATE_INIT:
                system_init();
                state = BOOT_STATE_CHECK;
                break;
                
            case BOOT_STATE_CHECK:
                if(check_update_flag())
                {
                    state = BOOT_STATE_WAIT_CMD;
                }
                else
                {
                    state = BOOT_STATE_JUMP_APP;
                }
                break;
                
            case BOOT_STATE_JUMP_APP:
                jump_to_app(APP_ADDR);
                // 不会返回
                break;
                
            case BOOT_STATE_WAIT_CMD:
                if(receive_command() == CMD_START_UPDATE)
                {
                    state = BOOT_STATE_RECV_DATA;
                }
                else if(check_timeout())
                {
                    state = BOOT_STATE_JUMP_APP;
                }
                break;
                
            case BOOT_STATE_RECV_DATA:
                if(receive_firmware() == RECV_COMPLETE)
                {
                    state = BOOT_STATE_VERIFY;
                }
                else if(receive_error())
                {
                    state = BOOT_STATE_ERROR;
                }
                break;
                
            case BOOT_STATE_VERIFY:
                if(verify_firmware() == VERIFY_PASS)
                {
                    state = BOOT_STATE_UPDATE_DONE;
                }
                else
                {
                    state = BOOT_STATE_ERROR;
                }
                break;
                
            case BOOT_STATE_UPDATE_DONE:
                clear_update_flag();
                system_reset();
                break;
                
            case BOOT_STATE_ERROR:
                error_handler();
                // 等待看门狗复位或手动复位
                break;
        }
    }
}

设计建议:每个状态都要有超时处理。比如 BOOT_STATE_WAIT_CMD 如果等不到命令,超时后自动跳 App,而不是死等。我在一个产品里忘了加超时,结果产线测试时有一批板子 Bootloader 卡在等待状态,全部返工——丢人。

3.4 分区规划与状态机的联动

分区规划和状态机不是孤立的。举个例子,参数存储区里存的升级标志,就是状态机在 BOOT_STATE_CHECK 阶段读取的。而暂存区的大小,决定了 BOOT_STATE_RECV_DATA 阶段是一次性接收还是分块接收。

我常用的参数区结构体:

typedef struct {
    uint32_t magic;          // 魔数,0xA5A5A5A5 表示有效
    uint32_t update_flag;    // 升级标志
    uint32_t app_version;    // App 版本号
    uint32_t boot_version;   // Bootloader 版本号
    uint32_t crc32;          // 整个参数区的 CRC 校验
} boot_param_t;

魔数的作用是判断参数区是否被初始化过。如果读出来不是 0xA5A5A5A5,说明这是第一次上电,或者 Flash 被擦除了,Bootloader 应该走默认流程。