STM32 Bootloader 与 IAP 远程升级实战:让设备在现场”越用越好”
设备一旦发货到现场,再想改固件就只能靠拆壳接 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 执行回滚。
五、踩坑清单
- 擦写 Flash 期间取指死机:STM32 同一 Bank 擦写时无法取指。App 自己擦自己所在的扇区会直接挂掉——要么把写 Flash 的函数搬到 RAM 执行,要么(更推荐)所有 Flash 操作只在 B 区/参数区进行,A 区只由 Bootloader 改写。
- 中断在跳转瞬间触发:Bootloader 里开了某个外设中断但没关就跳转,向量表切换瞬间来了中断,取到的是 App 的处理函数和错误的上下文。跳转前务必
__disable_irq(),让 App 自己的SystemInit里再__enable_irq()。
- 读保护(RDP)把 IAP 锁死:现场调试发现升级总失败,最后发现样机开了 RDP Level 2,运行时连 B 区都写不进去。量产前务必确认读保护等级与 IAP 方案兼容。
- 看门狗在升级中”帮倒忙”:4G 模组下载大固件,一次 TCP 重传超过 5 秒,IWDG 复位,升级中断。解法是在 Flash 写入循环和协议收包两个位置都喂狗,而不是只在 main 循环喂。
- Bootloader 里的 printf 用了中断:Bootloader 的调试 printf 走 UART 中断发送,跳转前没等发送完成,日志丢一半是小事,如果 DMA 还在跑就会干扰 App。调试打印要么阻塞发送,要么跳转前彻底等待完成。
六、小结
IAP/OTA 方案的复杂度不在代码量,而在异常路径的完备性:
- 分区有冗余,Bootloader 一次做够;
- 传输有校验(块级 + 整包 CRC);
- 状态在参数区持久化,任意时刻断电都能恢复到一致状态;
- 有试运行 + 回滚机制,新固件”坏了”也不会变砖;
- 跳转前的反初始化和关中断形成固定套路。
把这套东西打磨稳定后,设备就具备了”现场自愈”能力——版本迭代、bug 修复、功能增强都只需服务器上传一个包。对于出货量大的产品,这一两周的投入回报极高。
下一篇计划聊聊差分升级(bsdiff)在 MCU 上的落地和 Bootloader 资源受限下的压缩解压取舍。