STM32双Bank切换实现OTA无感升级的坑与对策

一、双Bank切换原理:为什么能无感?

传统OTA升级通常需要停机擦写Flash,用户会感知到设备重启或卡顿。双Bank方案通过将Flash划分为两个独立的Bank(如Bank0和Bank1),每个Bank可独立存放一个完整固件。升级时,固件写入非活动Bank,完成后通过切换启动地址,让CPU从新Bank执行,实现“无感”升级。

以STM32H743为例,其Flash总容量2MB,默认双Bank模式(DBANK=1),每个Bank为1MB。系统复位后,CPU根据BOOT引脚或选项字节(BOOT_ADD0/BOOT_ADD1)决定从哪个Bank启动。关键点在于:切换Bank只需修改选项字节或调用特定系统函数,无需物理擦除当前运行Bank,因此升级过程对用户透明。

二、核心配置步骤:从零搭建双Bank框架

1. 使能双Bank模式

在STM32CubeMX中,选择MCU后,进入Option Bytes配置,将DBANK设置为Dual Bank。若使用代码配置,需操作Flash接口寄存器:

/* 使能双Bank模式(以H7为例) */
void flash_enable_dual_bank(void) {
    FLASH_OBProgramInitTypeDef ob_init;
    HAL_FLASHEx_OBGetConfig(&ob_init);
    ob_init.OptionType = OPTIONBYTE_BANK;
    ob_init.Banks = FLASH_BANK_BOTH;
    ob_init.DualBank = FLASH_DUALBANK_ENABLE;
    HAL_FLASHEx_OBProgram(&ob_init);
    HAL_FLASH_OB_Launch(); // 触发系统复位生效
}

2. 链接脚本分区

将Flash划分为两个区域,分别对应Bank0和Bank1。在链接脚本(.ld)中定义:

MEMORY
{
  FLASH_BANK0 (rx)  : ORIGIN = 0x08000000, LENGTH = 1M
  FLASH_BANK1 (rx)  : ORIGIN = 0x08100000, LENGTH = 1M
}

编译时,通过宏选择当前运行Bank,例如-DBANK_INDEX=0-DBANK_INDEX=1,并对应设置VECT_TAB_OFFSET

3. 启动地址切换

升级完成后,需要切换启动Bank。常见方法有两种:

  • 修改选项字节:设置BOOT_ADD0BOOT_ADD1指向目标Bank首地址。
  • 软件跳转:直接设置MSP和PC指针跳转,但需确保中断向量表正确。

推荐使用选项字节方式,因为硬件自动处理,更可靠。示例代码:

void switch_boot_bank(uint8_t bank_index) {
    FLASH_OBProgramInitTypeDef ob_init;
    HAL_FLASHEx_OBGetConfig(&ob_init);
    ob_init.OptionType = OPTIONBYTE_USER;
    ob_init.BOOT_ADD0 = (bank_index == 0) ? 0x08000000 : 0x08100000;
    ob_init.BOOT_ADD1 = (bank_index == 0) ? 0x08100000 : 0x08000000;
    HAL_FLASHEx_OBProgram(&ob_init);
    HAL_FLASH_OB_Launch(); // 复位后生效
}

三、实战中的坑与对策

坑1:Bank边界对齐错误

现象:固件写入Bank1后,启动时硬件异常或直接死机。

原因:STM32的Flash擦除操作以扇区为单位,但双Bank模式下,Bank0和Bank1的扇区大小可能不同(如H7的Bank0扇区128KB,Bank1扇区128KB,但地址偏移需对齐)。若固件大小超过Bank容量或未按扇区对齐,会导致写入越界。

对策

  • 确保固件大小不超过Bank容量,且编译时预留足够空间。
  • 擦写时,使用FLASH_EraseInitTypeDef指定正确的Bank和扇区编号。
  • 在链接脚本中,为每个Bank的末尾添加填充段,防止溢出。
/* 擦除目标Bank所有扇区 */
void erase_bank(uint32_t bank) {
    FLASH_EraseInitTypeDef erase_init;
    uint32_t sector_error = 0;
    erase_init.TypeErase = FLASH_TYPEERASE_SECTORS;
    erase_init.Banks = bank;
    erase_init.Sector = 0;
    erase_init.NbSectors = (bank == FLASH_BANK_1) ? 8 : 8; // 根据实际扇区数
    erase_init.VoltageRange = FLASH_VOLTAGE_RANGE_3;
    HAL_FLASHEx_Erase(&erase_init, &sector_error);
}

坑2:升级标志位失效

现象:升级完成后,重启却从旧Bank启动,标志位丢失。

原因:很多开发者将升级标志存放在Flash的某个固定地址,但该地址可能位于被擦除的Bank内,导致标志被清除。

对策

  • 将标志存放在独立于两个Bank的区域,如备份寄存器(RTC Backup Register)或Flash的Option Bytes区域。
  • 使用备份寄存器示例:
void set_update_flag(uint8_t flag) {
    HAL_RTC_BackupWrite(&hrtc, RTC_BKP_DR0, flag);
}
uint8_t get_update_flag(void) {
    return HAL_RTC_BackupRead(&hrtc, RTC_BKP_DR0);
}

坑3:跳转时序错乱

现象:软件跳转后,中断不响应或程序跑飞。

原因:直接跳转时,未正确设置中断向量表偏移,或未关闭全局中断,导致跳转瞬间中断冲突。

对策

  • 跳转前,关闭所有中断,并确保系统时钟稳定。
  • 设置SCB->VTOR为新Bank的向量表地址。
  • 使用内联汇编或函数指针跳转,并确保栈指针正确。
void jump_to_bank(uint32_t app_addr) {
    __disable_irq();
    SCB->VTOR = app_addr;
    uint32_t msp = *(volatile uint32_t *)app_addr;
    uint32_t reset_handler = *(volatile uint32_t *)(app_addr + 4);
    __set_MSP(msp);
    void (*jump)(void) = (void (*)(void))reset_handler;
    jump();
    while(1);
}

坑4:Flash写入期间系统卡顿

现象:升级过程中,系统响应变慢或看门狗复位。

原因:Flash擦写操作会阻塞CPU,且耗时较长(如擦除一个扇区可能数百毫秒),若未喂狗或未处理实时任务,系统会崩溃。

对策

  • 在擦写前暂停看门狗,或使用窗口看门狗并适时喂狗。
  • 将升级过程拆分为多个小任务,利用空闲时间分块写入。
  • 使用DMA或中断方式,但注意Flash操作期间总线占用。
/* 分块写入示例 */
void write_firmware_chunk(uint32_t dest, uint8_t *data, uint32_t len) {
    HAL_FLASH_Unlock();
    for (uint32_t i = 0; i < len; i += 8) {
        uint64_t val = 0;
        memcpy(&val, data + i, 8);
        HAL_FLASH_Program(FLASH_TYPEPROGRAM_QUADWORD, dest + i, val);
        // 每写一块喂一次狗
        HAL_IWDG_Refresh();
    }
    HAL_FLASH_Lock();
}

四、完整代码示例:双Bank升级流程

以下是一个简化的双Bank升级流程,包含接收固件、写入Bank1、切换启动:

// 假设通过UART接收固件,存储到buffer
void ota_update_process(uint8_t *firmware, uint32_t size) {
    // 1. 检查固件大小
    if (size > BANK_SIZE) { error_handler(); }
    
    // 2. 擦除Bank1
    erase_bank(FLASH_BANK_1);
    
    // 3. 写入固件到Bank1起始地址
    HAL_FLASH_Unlock();
    write_firmware_chunk(0x08100000, firmware, size);
    HAL_FLASH_Lock();
    
    // 4. 设置升级标志(备份寄存器)
    set_update_flag(0x5A5A);
    
    // 5. 切换启动Bank并复位
    switch_boot_bank(1);
}

// 启动时检查标志
void check_update_flag(void) {
    if (get_update_flag() == 0x5A5A) {
        clear_update_flag();
        // 确认新固件运行正常,否则回滚
        if (verify_firmware()) {
            // 正常,无需操作
        } else {
            switch_boot_bank(0); // 回滚到旧Bank
        }
    }
}

五、注意事项与最佳实践

  • 固件签名验证:升级前务必验证固件完整性(如CRC或SHA256),防止写入损坏数据。
  • 回滚机制:设计双保险,新固件启动后若在指定时间内未上报“运行正常”,自动回滚到旧Bank。
  • 中断向量表:每个Bank的固件必须独立设置VECT_TAB_OFFSET,否则中断会错乱。
  • Flash操作电压:确保供电稳定,避免在低电压下擦写Flash导致数据损坏。
  • 测试覆盖:在硬件上测试所有边界条件,如升级中途断电、固件大小恰好等于Bank容量等。

六、总结

双Bank切换是嵌入式OTA无感升级的利器,但细节决定成败。本文剖析了四个典型坑点,并给出了针对性对策。实际项目中,还需结合具体MCU型号的参考手册,仔细核对Flash扇区布局和选项字节配置。希望本文能帮你少走弯路,打造出稳定可靠的OTA方案。