STM32双Bank切换实现OTA零停机:基于Flash双区映射的实战要点

一、为什么需要双Bank?OTA的停机痛点

传统OTA方案通常采用“单Bank + 外部备份”或“A/B分区”策略。前者需在升级期间暂停业务,将新固件写入备份区,再通过Bootloader搬运至主区,耗时且存在掉电风险;后者虽支持双区,但切换时需跳转至Bootloader,重新初始化时钟与外设,造成毫秒级甚至百毫秒级的中断,对实时控制或通信链路(如CAN、EtherCAT)而言,这已是“停机”。

STM32的双Bank Flash(如F4系列在1MB以上容量时支持)允许将Flash划分为两个独立的Bank(Bank0/Bank1),每个Bank可独立擦写。通过修改选项字节(Option Bytes)中的BFB2位或使用硬件交换机制,CPU可瞬间将代码执行空间从当前Bank映射到另一Bank,无需复位,实现真正的零停机切换。

二、双Bank映射原理:硬件如何“偷梁换柱”

2.1 Flash地址映射

以STM32F427(2MB Flash)为例,默认地址映射:

  • Bank0:0x08000000 ~ 0x080FFFFF(1MB)
  • Bank1:0x08100000 ~ 0x081FFFFF(1MB)

当设置选项字节BFB2=1时,系统启动时从Bank1执行(若Bank1有效),否则从Bank0执行。但更关键的是运行时的Bank切换:通过设置FLASH_CR寄存器的SWAP_BANK位(或使用FLASH_OPTCRSWAP位),硬件会将Bank0和Bank1的地址空间互换。即:

  • 切换后,Bank1的物理内容映射到0x08000000,而Bank0映射到0x08100000。

这意味着,CPU正在执行的代码(位于0x08000000)在切换瞬间,其物理来源变为Bank1,而程序计数器(PC)无需改变,因为地址空间不变!这实现了“原地跳转”。

2.2 切换的硬件时序

切换操作并非瞬时完成,需要等待Flash控制器完成内部操作。典型步骤:

  1. 设置FLASH_CRSWAP_BANK位(或通过FLASH_OPTCR编程)。
  2. 等待FLASH_SRBSY位清零。
  3. 执行一条DSB(数据同步屏障)指令确保后续取指使用新映射。

注意:切换期间,CPU流水线中的预取指令可能来自旧Bank,因此需确保切换代码位于RAM中执行(或使用__RAM_FUNC),避免取指冲突。

三、工程配置:从零搭建双Bank OTA框架

3.1 内存布局与链接脚本

假设使用STM32F427,Flash共2MB,分为Bank0和Bank1各1MB。设计如下:

  • Bank0:运行区(App0),存放当前固件。
  • Bank1:待更新区(App1),存放新固件。

链接脚本(.ld)需为两个Bank分别生成镜像,但通常我们只编译一个工程,通过宏控制起始地址。例如,定义APP_BASE=0x08000000(Bank0)或0x08100000(Bank1)。

// 链接脚本片段(stm32f427.ld)
FLASH_ORIGIN = 0x08000000;  /* 可通过宏覆盖 */
FLASH_LENGTH = 1M;

编译时使用-DAPP_BASE=0x08100000生成Bank1镜像。

3.2 选项字节设置

使用STM32CubeProgrammer或代码方式设置选项字节:

  • BFB2=1:使能双Bank启动,若Bank1有效则从Bank1启动(用于回滚)。
  • SWAP_BANK:运行时切换(通过软件触发)。

代码中编程选项字节需解锁Flash,并注意选项字节本身位于独立区域,修改后需复位生效(但运行时切换不依赖此)。

3.3 中断向量表重定向

每个Bank的固件必须有自己的中断向量表,且位于该Bank起始地址。在运行时切换后,CPU仍使用旧的向量表(位于0x08000000),因此必须在切换前设置SCB->VTOR为新Bank的向量表地址。

#define APP0_BASE 0x08000000
#define APP1_BASE 0x08100000

void switch_to_bank(uint32_t new_bank_base) {
    // 1. 设置向量表偏移
    SCB->VTOR = new_bank_base;
    __DSB();
    __ISB();
    
    // 2. 执行Bank切换(假设使用SWAP位)
    FLASH->CR |= FLASH_CR_SWAP_BANK;  // 或通过选项字节编程
    while (FLASH->SR & FLASH_SR_BSY);
    __DSB();
    
    // 3. 跳转到新固件的复位处理函数(可选,若代码地址不变则无需)
    // 实际上,切换后PC仍指向旧代码位置,但物理来源已变,因此需确保新旧固件在相同偏移处有相同指令?
    // 更安全做法:切换后执行软复位,但会停机。零停机需保证新旧固件在切换点后的指令流兼容。
}

关键点:零停机切换要求新旧固件在切换点处的执行上下文完全一致。通常做法是:在切换前,将新固件的关键状态(如寄存器、外设配置)保存,切换后恢复。但更简单的是,设计一个“切换服务”函数,该函数在RAM中执行,切换后返回新固件的主循环。

四、完整代码示例:RAM中执行切换函数

以下示例展示如何在RAM中执行切换,避免Flash取指冲突。

// 定义在RAM中的切换函数
__attribute__((section(".ramfunc"))) void bank_switch_ram(void) {
    // 1. 设置新向量表(新固件基址)
    SCB->VTOR = APP1_BASE;
    __DSB();
    __ISB();
    
    // 2. 触发Bank交换(以F4为例)
    FLASH->CR |= FLASH_CR_SWAP_BANK;
    while (FLASH->SR & FLASH_SR_BSY);
    
    // 3. 数据同步,确保后续取指来自新Bank
    __DSB();
    
    // 4. 跳转到新固件的main函数(可选)
    // 注意:如果新旧固件在相同地址有相同布局,可直接返回,但通常需重新初始化
    void (*new_main)(void) = (void (*)(void))(APP1_BASE + 0x1C0); // 假设main在固定偏移
    new_main();
    
    while(1);
}

// 调用切换
void start_ota_switch(void) {
    // 确保新固件已完整写入Bank1并校验
    if (verify_firmware(APP1_BASE)) {
        // 关闭全局中断,避免切换期间中断干扰
        __disable_irq();
        bank_switch_ram();
    }
}

链接脚本需将.ramfunc段放置在RAM中,例如:

.ramfunc : {
    . = ALIGN(4);
    *(.ramfunc)
} > RAM

五、注意事项与陷阱

  1. Flash擦写期间CPU停顿:在Bank1写入新固件时,若Bank0正在执行,由于两个Bank独立,可并行操作,但Flash控制器可能共享总线,需合理调度。建议使用DMA或中断进行写入,避免阻塞主循环。

  2. 缓存与预取:切换Bank后,Flash预取缓冲区可能包含旧数据。务必执行FLASH->CR |= FLASH_CR_ICEN(指令缓存使能)和DCEN,并在切换前无效化缓存(FLASH->CR |= FLASH_CR_ICRST)。

  3. 中断处理:切换期间必须关闭所有中断,否则中断向量表可能指向旧Bank,导致跳转错误。切换完成后重新使能,并确保新固件的中断处理函数已就绪。

  4. 状态保存:零停机意味着业务状态不能丢失。建议在切换前将关键变量存入备份寄存器或外部RAM,新固件启动时恢复。

  5. 回滚机制:若新固件启动失败(如看门狗复位),需利用BFB2位从Bank1启动,但若Bank1无效,则回退到Bank0。可在Bootloader中检查标志位。

  6. 编译优化:切换函数必须编译为位置无关代码(PIC)或确保链接地址正确。使用-fPIC或手动指定地址。

六、总结

STM32双Bank切换为OTA提供了硬件级原子操作,避免了传统Bootloader跳转的停机时间。但实现零停机并非仅靠硬件,还需精心设计软件架构:RAM中执行切换、向量表重定向、缓存处理、状态保存。本文给出的框架可作为起点,实际产品中还需结合通信协议(如HTTP、MQTT)和固件校验机制。掌握这些要点,你的嵌入式设备将拥有近乎完美的在线升级体验。