48、Bootloader设计:启动流程、分区管理、固件升级

Bootloader,说白了就是系统上电后第一个跑的程序。它负责把真正的应用程序从Flash里搬出来,然后跳过去执行。很多人觉得Bootloader就是个跳板,没啥技术含量。嗯,我刚开始也这么想,直到有一次产品量产时发现固件刷死了,整批货卡在产线……那滋味,真不好受。

今天咱们就聊聊Bootloader设计的三个核心:启动流程怎么走、分区怎么规划、升级怎么保证不掉坑。

启动流程:从复位向量到跳转

芯片上电后,CPU会从复位向量指向的地址开始执行。这个地址,通常就是Bootloader的入口。我个人习惯把Bootloader放在Flash的起始位置,比如0x08000000。

启动流程大致分这几步:

  1. 硬件初始化:关看门狗、设置时钟、初始化堆栈。这一步要快,别磨蹭。
  2. 外设初始化:串口、Flash控制器、可能还有I2C或SPI。我建议只初始化升级会用到的外设,别一股脑全开。
  3. 检查升级标志:看看有没有升级请求。比如某个GPIO被拉低,或者Flash里某个标志位被置位。
  4. 跳转或等待:有升级请求就进入升级模式,没有就直接跳转到应用程序。

核心要点:跳转前一定要关中断、关SysTick、复位外设状态。否则应用程序一启动就可能跑飞。

跳转的代码其实很简单,但容易出错。我贴一段我常用的跳转函数:

typedef void (*app_func_t)(void);

void jump_to_app(uint32_t app_addr)
{
    // 关全局中断
    __disable_irq();

    // 关SysTick
    SysTick->CTRL = 0;

    // 复位所有外设时钟(根据芯片调整)
    RCC->AHB1RSTR = 0xFFFFFFFF;
    RCC->AHB1RSTR = 0;
    RCC->APB1RSTR = 0xFFFFFFFF;
    RCC->APB1RSTR = 0;
    RCC->APB2RSTR = 0xFFFFFFFF;
    RCC->APB2RSTR = 0;

    // 设置主堆栈指针
    __set_MSP(*(uint32_t *)app_addr);

    // 跳转到复位向量+4(即Reset_Handler)
    app_func_t app = (app_func_t)(*(uint32_t *)(app_addr + 4));
    app();
}

这里有个坑:__set_MSP必须在跳转前执行,而且跳转函数要用__attribute__((noreturn))声明。我曾经因为没加这个属性,编译器优化后把堆栈搞乱了,查了两天才找到原因。

分区管理:Flash怎么分才合理

分区设计直接决定了升级的灵活性和可靠性。我见过不少产品,Bootloader和应用程序各占一块,中间留个空白区就算完事。这种设计,说实话,太粗糙了。

我个人推荐的分区方案是这样的:

分区名称 起始地址 大小 用途
Bootloader 0x08000000 64KB 启动、升级逻辑
参数区 0x08010000 16KB 升级标志、配置参数
App A(运行区) 0x08014000 256KB 当前运行的固件
App B(备份区) 0x08054000 256KB 升级接收的新固件
日志区 0x08094000 16KB 升级日志、错误记录

你想想看,为什么要有两个App区?这就是A/B升级(也叫双备份升级)。升级时新固件写到B区,校验通过后把标志位一改,下次启动就从B区启动。万一升级失败,还能回滚到A区。

小技巧:参数区最好单独划出来,别和代码混在一起。我习惯用结构体管理参数,末尾加CRC校验。每次读取时先校验,校验失败就用默认值。

分区大小怎么定?我的经验是:Bootloader给64KB足够了,除非你要支持复杂的加密或压缩。App区大小取决于你的固件大小,建议留出30%的余量。日志区16KB够用,记录最近几次升级结果就行。

固件升级:稳字当头

升级过程最容易出问题。掉电、传输错误、Flash写入失败……任何一个环节出岔子,设备就可能变砖。所以升级逻辑的核心就一个字:稳。

我设计的升级流程大致如下:

  1. 接收固件头:包含固件大小、CRC、版本号、目标分区等信息。先校验头部CRC,不对就直接拒绝。
  2. 分块接收数据:每块256字节或512字节,每块带序号和CRC。收到一块写一块,写完后读回校验。
  3. 整体校验:所有块收完后,对整个固件做一次CRC或SHA256校验。
  4. 切换标志:校验通过后,在参数区写入“下次从B区启动”的标志。
  5. 复位重启:Bootloader读到标志,从新分区启动。

警告:千万不要在写Flash的过程中断电!如果无法避免,至少要在写之前备份关键参数。我曾经遇到过客户在升级时拔电源,结果参数区被写坏,设备连串口都打不开了。

这里有个细节:分块写入时,每块写完要立即读回验证。别等到最后再校验,万一中间某块写坏了,你都不知道是哪块。我习惯在每块写入后加一句:

if (memcmp(buffer, (void *)flash_addr, block_size) != 0) {
    // 写入失败,记录错误并重试
    log_error("Block %d verify failed", block_num);
    return -1;
}

重试机制也很重要。我一般允许每块重试3次,3次都失败就终止升级,并回滚到旧固件。别无限重试,否则坏块会卡死整个流程。

SVG流程图:Bootloader核心逻辑

Bootloader 启动与升级流程 系统上电 / 复位 硬件初始化(时钟、堆栈) 有升级标志? (GPIO/Flash标志) 进入升级模式 接收固件 → 校验 → 写入 完成 跳转到应用程序 App 正常运行 注:升级完成后复位,再次进入Bootloader检查标志 若标志有效则从新分区启动,否则继续等待升级

避坑指南:我踩过的几个雷

做Bootloader这些年,我踩过的坑不少。挑几个典型的说说:

  • 中断向量表偏移:应用程序的中断向量表必须偏移到它的起始地址。我见过有人忘了设置SCB->VTOR,结果一按按键就触发HardFault。
  • 看门狗干扰:升级过程中如果看门狗没喂,写到一半就复位了。我建议在进入升级模式后,把看门狗超时设长一点,或者干脆关掉。
  • Flash擦除时间:擦除一个扇区可能要几百毫秒,这段时间如果串口有数据进来,缓冲区可能溢出。我习惯在擦除前先通知上位机暂停发送。
  • 版本兼容性:新固件可能改了分区大小或Bootloader接口。我建议在固件头里带上兼容性版本号,Bootloader先检查再决定要不要升级。

我的习惯:每次升级完成后,在日志区写一条记录,包含时间、版本号、校验结果。这样出了问题还能回溯,知道是哪次升级导致的。

最后说一句:Bootloader是系统的最后一道防线。设计时多花点心思,生产时就能少流点汗。别嫌麻烦,该加的校验加,该做的备份做。等你遇到一次批量变砖的事故,就知道这些功夫没白费。


公众号:蓝海资料掘金营,微信deep3321