STM32 Bootloader 与 IAP 远程升级实战:让设备在现场”越用越好”

2026年9月15日 0 By admin

设备一旦发货到现场,再想改固件就只能靠拆壳接 SWD 了——几十台机器分布在各地,差旅成本比开发成本还高。所以我给最近这批设备做了 IAP(In-Application Programming)远程升级:Bootloader 常驻 Flash 头部,App 通过串口/4G 拉取新固件自更新。这篇文章把整个方案从分区规划到跳转代码、再到踩过的坑完整梳理一遍。

一、Flash 分区规划:一切的前提

Bootloader 方案第一步是划地盘。以 STM32F103VE(512KB Flash)为例,我的典型分区:


0x08000000 - 0x08007FFF   Bootloader    32KB(够放 CRC、Flash 驱动、通信协议)
0x08008000 - 0x0803FFFF   App A(运行区)224KB
0x08040000 - 0x08077FFF   App B(下载区)224KB
0x08078000 - 0x0807FFFF   参数区          32KB(版本号、升级标志、CRC)

几个原则:

  • Bootloader 宁可留大也别抠。后期想加差分升级、加密校验,Bootloader 改一次就要全线重新烧录一次——它没法远程自升级(除非上双 Bootloader,那是另一个故事)。我第一版只给了 16KB,后来加 AES 校验时差点爆,被迫用 -Os + 精简 printf 才塞下。
  • App 至少两块区(A/B 方案):新固件先写 B 区,CRC 通过后再切启动区。这样升级中途断电,Bootloader 发现 A 区完好就正常启动,B 区作废重来即可。
  • 参数区单独划,用最后一两个扇区,避免 App 固件膨胀把参数区挤掉。

二、App 侧的改动:中断向量表偏移

这是每个做 Bootloader 的人必踩的第一个坑:App 烧到 0x08008000 后一上电就 HardFault,或者中断一开就飞。

原因:Cortex-M 核上电后从 0x08000000(或 BOOT 引脚决定的地址)取向量表,跳进的是 Bootloader。App 自己的中断向量表在 0x08008000,必须显式告诉内核去那里取:


// SystemInit 或 main 最前面
#define APP_BASE  0x08008000UL
SCB->VTOR = APP_BASE;   // F1 无 Cache,一行即可

如果是 F4/H7 带 I-Cache 或用了某些启动文件,还需要 SCB_EnableICache() 前设置 VTOR,并且把 VECT_TAB_OFFSET 宏改对。此外,Keil/IAR 里要把中断向量表的 ENTRY 地址改到新位置,或修改分散加载文件(scatter)让 __main 落在 0x08008000。

对应的,Bootloader 跳转前要先做三件事:反初始化外设、关中断、把向量表指回 App


typedef void (*pFunc)(void);

void jump_to_app(uint32_t app_base)
{
    uint32_t sp = *(volatile uint32_t *)app_base;        // 栈顶指针
    uint32_t pc = *(volatile uint32_t *)(app_base + 4);  // Reset_Handler

    // 合法性粗检:栈顶应落在 RAM 内,PC 应在 Flash 内
    if ((sp & 0xFF000000UL) != 0x20000000UL || (pc & 0xFF000000UL) != 0x08000000UL)
        return;  // App 不存在,留在 Bootloader 等升级

    __disable_irq();
    // 反初始化 Bootloader 用过的外设和时钟
    HAL_RCC_DeInit();
    HAL_DeInit();
    SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0;

    SCB->VTOR = app_base;
    __set_MSP(sp);          // 重设主栈指针
    __set_CONTROL(0);       // 确保 Privileged / MSP 模式
    __ISB(); __DSB();
    ((pFunc)pc)();
}

我最初漏了 HAL_RCC_DeInit(),跳转后 App 里 SystemCoreClock 和实际时钟对不上,串口波特率乱码查了一下午。Bootloader 用过的所有外设都要恢复复位状态,这条要写进 checklist。

三、固件包格式与 CRC 校验

裸发 bin 文件最省事,但生产环境强烈建议加个简单包头。我的固件包结构:


[8B  魔数 "MIUAFW01"]      防止误把别的文件灌进去
[4B  固件版本  u32]
[4B  固件长度  u32]        纯 App bin 长度
[4B  整包 CRC32]
[4B  头部 CRC32]           头部自身校验
[App bin 数据]

传输流程:上位机/服务器下发包头 → 设备校验魔数和头部 CRC → 回复当前版本号(可用于服务端判断要不要发差分包)→ 分块传输(每块 512B + 块内 CRC16)→ 写 B 区 → 全部完成后整包 CRC32 校验 → 设参数区标志 → 复位。

接收侧伪代码骨架:


void ota_task(void)
{
    fw_info_t info;
    if (recv_fw_header(&info) != OK) return;
    if (info.version <= current_version) { reply("已是最新"); return; }

    flash_erase(APP_B_BASE, align_to_sector(info.length));
    uint32_t addr = APP_B_BASE;
    while (addr < APP_B_BASE + info.length) {
        recv_block(buf, &len);                  // 512B + CRC16
        if (crc16(buf, len) != blk_crc) { retry_or_abort(); }
        flash_write(addr, buf, len);
        addr += len;
        feed_watchdog();                        // 大固件烧写耗时,必须喂狗
    }

    if (crc32(APP_B_BASE, info.length) == info.crc32) {
        param_set_boot_flag(BOOT_TRY_NEW);      // 写"尝试新固件"标志
        NVIC_SystemReset();
    } else {
        param_inc_fail_count();                 // 允许重传,别直接锁死
    }
}

四、失败回滚:给升级上一道保险

任何传输都可能中途断电、断网,所以 Bootloader 启动逻辑必须是无状态、可重入的:


void bootloader_main(void)
{
    boot_flag_t flag = param_get_boot_flag();

    if (flag == BOOT_TRY_NEW) {
        if (app_crc_ok(APP_A_BASE)) {           // 上次切换成功
            param_set_boot_flag(BOOT_NORMAL);
        } else {
            // 不会走到这:切换时才改 A 区
        }
    }

    if (app_crc_ok(APP_A_BASE)) {
        jump_to_app(APP_A_BASE);
    }
    // A 区损坏:进入恢复模式(等服务器推完整包重写 A 区)
    recovery_mode();
}

关键设计是“试运行确认”机制:新固件 CRC 通过后只是把标志改成 BOOT_TRY_NEW,App 启动后主动上报”我活下来了”,Bootloader 收到确认才把新固件标记为正式版。如果新固件本身跑飞(CRC 对但逻辑有 bug),App 根本没机会确认——下次复位 Bootloader 发现还是 TRY_NEW 且无确认,就回滚到 B 区的旧版本。看门狗在这里也是回滚链路的一环:App 里正常喂狗,若升级后固件起不来,狗超时复位,Bootloader 执行回滚。

五、踩坑清单

  1. 擦写 Flash 期间取指死机:STM32 同一 Bank 擦写时无法取指。App 自己擦自己所在的扇区会直接挂掉——要么把写 Flash 的函数搬到 RAM 执行,要么(更推荐)所有 Flash 操作只在 B 区/参数区进行,A 区只由 Bootloader 改写。
  1. 中断在跳转瞬间触发:Bootloader 里开了某个外设中断但没关就跳转,向量表切换瞬间来了中断,取到的是 App 的处理函数和错误的上下文。跳转前务必 __disable_irq(),让 App 自己的 SystemInit 里再 __enable_irq()
  1. 读保护(RDP)把 IAP 锁死:现场调试发现升级总失败,最后发现样机开了 RDP Level 2,运行时连 B 区都写不进去。量产前务必确认读保护等级与 IAP 方案兼容。
  1. 看门狗在升级中”帮倒忙”:4G 模组下载大固件,一次 TCP 重传超过 5 秒,IWDG 复位,升级中断。解法是在 Flash 写入循环和协议收包两个位置都喂狗,而不是只在 main 循环喂。
  1. Bootloader 里的 printf 用了中断:Bootloader 的调试 printf 走 UART 中断发送,跳转前没等发送完成,日志丢一半是小事,如果 DMA 还在跑就会干扰 App。调试打印要么阻塞发送,要么跳转前彻底等待完成。

六、小结

IAP/OTA 方案的复杂度不在代码量,而在异常路径的完备性

  • 分区有冗余,Bootloader 一次做够;
  • 传输有校验(块级 + 整包 CRC);
  • 状态在参数区持久化,任意时刻断电都能恢复到一致状态;
  • 有试运行 + 回滚机制,新固件”坏了”也不会变砖;
  • 跳转前的反初始化和关中断形成固定套路。

把这套东西打磨稳定后,设备就具备了”现场自愈”能力——版本迭代、bug 修复、功能增强都只需服务器上传一个包。对于出货量大的产品,这一两周的投入回报极高。

下一篇计划聊聊差分升级(bsdiff)在 MCU 上的落地和 Bootloader 资源受限下的压缩解压取舍。