☰
STM32H7双Bank Flash零停机OTA升级实战:原理、实现与避坑指南
2026/9/28 1:31:55 网站建设 项目流程

1. 为什么STM32H7的双Bank Flash值得单独拿出来讲

第一次在STM32H7上做OTA升级的人,十有八九会踩同一个坑:把新固件写进Flash的时候,CPU直接从同一块Flash取指令,结果写操作一启动,总线就卡死,程序跑飞。这不是代码写错了,而是单Bank Flash的物理限制——同一时刻,一块Flash要么在读,要么在写,没法既读又写。

STM32H7的解法是双Bank Flash。以常见的STM32H743为例,2MB Flash被平均切成两个1MB的Bank,Bank1和Bank2各自有独立的读写控制逻辑。这意味着你可以在Bank1里安安稳稳跑着当前固件,同时往Bank2里写新固件,两个操作互不干扰。写完之后改一下启动地址,复位,新固件就跑起来了。整个过程设备不用停机,业务逻辑可以一直在线。

这个能力在工业网关、医疗设备、车载终端这类不能随便断电重启的场景里,价值非常大。我做过一个项目,设备装在产线控制柜里,停机一次要停整条线,损失按分钟算。用了双Bank方案之后,固件升级对业务完全透明,用户根本感知不到。

这篇文章我会把整个方案拆开讲:双Bank的地址映射怎么理解、链接脚本怎么改、跳转逻辑怎么写、状态机怎么设计、断电了怎么办。代码基于STM32H743 + HAL库,但思路对H750、H723这些同系列芯片同样适用。如果你正在做OTA,或者被单Bank的写读冲突折磨过,这篇应该能帮你省不少时间。

2. 双Bank Flash的地址映射与启动机制

2.1 两个Bank的物理地址到底怎么分

STM32H743的2MB Flash,Bank1占0x08000000到0x080FFFFF,Bank2占0x08100000到0x081FFFFF。每个Bank 1MB,各自独立擦写。注意这里有个容易搞混的点:Bank2的起始地址是0x08100000,不是0x08080000。我见过有人按1MB的一半去算,结果地址算错,写进去的数据全乱。

除了主存储区,还有几个关键地址要记住:

区域地址范围用途
Bank1 主存储0x08000000 - 0x080FFFFF当前运行固件
Bank2 主存储0x08100000 - 0x081FFFFF新固件暂存
Option Bytes0x52002020配置启动Bank
系统存储器0x1FF00000Bootloader

Option Bytes里的BFB2位(Bit 4)决定从哪个Bank启动。BFB2=0从Bank1启动,BFB2=1从Bank2启动。这个位改完之后需要复位才生效,不是立即切换的。

2.2 启动流程和向量表偏移

Cortex-M7的启动流程是:复位后从0x00000000取MSP初值,从0x00000004取Reset_Handler地址。STM32H7通过地址重映射,把0x00000000映射到当前启动Bank的起始地址。所以你不需要手动改向量表基址,硬件帮你做了。

但有个细节要注意:如果你的固件用了RTOS或者中断,向量表偏移寄存器SCB->VTOR必须指向当前Bank的起始地址。在system_stm32h7xx.c里,VECT_TAB_OFFSET默认是0x00,如果你把固件放在Bank2运行,这个值要改成0x00100000。我一般直接在链接脚本里处理,让VTOR自动跟着Bank走。

2.3 为什么不用外部Flash做OTA

有人会问,既然要双份存储,为什么不外挂一颗SPI Flash,把新固件放外面?这个方案我也用过,但有几个现实问题:

  • 外部Flash读写速度慢,1MB固件通过QSPI写进去要十几秒,期间如果断电,恢复逻辑更复杂
  • 外部Flash需要额外的驱动和文件系统,代码量上去了
  • 最关键的是,外部Flash里的固件不能直接执行,还得先搬到内部RAM或者内部Flash,多一道搬运

双Bank方案的优势在于,新固件直接写在内部Flash里,写完就能跳转执行,不需要搬运。而且内部Flash的擦写寿命和可靠性比大多数外部Flash好。代价就是Flash容量要够大,2MB的H743刚好能放下两份1MB的固件,如果固件超过1MB,这个方案就得调整。

3. 链接脚本与工程配置的实操细节

3.1 两个工程的链接脚本怎么改

双Bank方案需要两个独立的工程:一个跑在Bank1的App,一个跑在Bank2的App。它们的代码可以完全一样,但链接脚本必须不同。

Bank1的链接脚本(STM32H743VIHx_FLASH_Bank1.ld):

MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1024K RAM (xrw) : ORIGIN = 0x24000000, LENGTH = 512K } SECTIONS { .isr_vector : { . = ALIGN(4); KEEP(*(.isr_vector)) . = ALIGN(4); } >FLASH .text : { . = ALIGN(4); *(.text) *(.text*) . = ALIGN(4); } >FLASH _sidata = LOADADDR(.data); .data : { . = ALIGN(4); _sdata = .; *(.data) *(.data*) . = ALIGN(4); _edata = .; } >RAM AT> FLASH .bss : { . = ALIGN(4); _sbss = .; *(.bss) *(.bss*) . = ALIGN(4); _ebss = .; } >RAM }

Bank2的链接脚本只需要改一行:ORIGIN = 0x08100000。其他完全一样。

这里有个坑:如果你用的是STM32CubeIDE,它自动生成的链接脚本里RAM的ORIGIN可能是0x20000000。H743的DTCM是0x20000000,AXI SRAM是0x24000000。我建议把主RAM放在AXI SRAM,因为DTCM只有128KB,跑大一点的协议栈不够用。改的时候注意_sidata这些符号,它们决定了.data段从Flash哪里加载。

3.2 中断向量表的处理

前面提到VTOR要跟着Bank走。在system_stm32h7xx.c里:

#define VECT_TAB_BASE_ADDRESS FLASH_BANK1_BASE #define VECT_TAB_OFFSET 0x00000000

如果你编译Bank2的固件,把VECT_TAB_BASE_ADDRESS改成FLASH_BANK2_BASE,VECT_TAB_OFFSET改成0x00100000。或者更省事的办法,在main()开头手动设置:

SCB->VTOR = FLASH_BANK2_BASE;

我一般用后者,因为这样两个工程可以用同一份system文件,只靠宏定义区分。

3.3 编译产物的处理

两个工程编译出来是两个bin文件:app_bank1.bin和app_bank2.bin。OTA的时候,设备当前跑在Bank1,就把app_bank2.bin写进Bank2;当前跑在Bank2,就把app_bank1.bin写进Bank1。

这里有个版本管理的问题:两个bin的版本号要能区分。我在固件头部加了一个结构体:

typedef struct { uint32_t magic; // 0x5A5A5A5A uint32_t version; // 版本号 uint32_t size; // 固件大小 uint32_t crc32; // 固件CRC uint8_t reserved[16]; } firmware_header_t;

这个头放在bin文件最前面,OTA的时候先读头,校验magic和CRC,通过了再写。这样能防止写进去一个损坏的固件。

4. 零停机OTA的状态机设计与代码实现

4.1 状态机的整体设计

零停机的核心是:当前固件一直在跑,新固件在后台写。整个流程分几个状态:

  • IDLE:没有升级任务,正常运行
  • RECEIVING:正在接收新固件数据,写入备用Bank
  • VERIFYING:接收完成,校验CRC
  • READY:校验通过,等待切换
  • SWITCHING:修改Option Bytes,准备复位

状态机跑在后台任务里,不阻塞主业务。我用的是FreeRTOS,单独开了一个低优先级任务处理OTA,优先级比通信任务低,保证业务不受影响。

4.2 擦写备用Bank的代码

擦除和写入用HAL库的HAL_FLASH_Unlock()和HAL_FLASHEx_Erase()。注意H7的Flash编程粒度是256位(32字节),不是字节。写之前要保证数据按32字节对齐。

#define BANK2_START_ADDR 0x08100000 #define FLASH_SECTOR_SIZE 0x20000 // 128KB per sector static uint32_t ota_write_addr = BANK2_START_ADDR; HAL_StatusTypeDef ota_erase_bank2(void) { FLASH_EraseInitTypeDef erase; uint32_t sector_error = 0; HAL_FLASH_Unlock(); erase.TypeErase = FLASH_TYPEERASE_SECTORS; erase.Banks = FLASH_BANK_2; erase.Sector = FLASH_SECTOR_0; erase.NbSectors = 8; // Bank2有8个128KB扇区 erase.VoltageRange = FLASH_VOLTAGE_RANGE_3; HAL_StatusTypeDef status = HAL_FLASHEx_Erase(&erase, &sector_error); HAL_FLASH_Lock(); return status; } HAL_StatusTypeDef ota_write_data(uint32_t offset, uint8_t *data, uint32_t len) { HAL_StatusTypeDef status; uint32_t addr = BANK2_START_ADDR + offset; HAL_FLASH_Unlock(); for (uint32_t i = 0; i < len; i += 32) { uint64_t data64[4]; memcpy(data64, data + i, 32); status = HAL_FLASH_Program(FLASH_TYPEPROGRAM_FLASHWORD, addr + i, (uint32_t)data64); if (status != HAL_OK) { HAL_FLASH_Lock(); return status; } } HAL_FLASH_Lock(); return HAL_OK; }

这里有个实测经验:H7的Flash写操作期间,如果CPU从同一个Bank取指令,会触发总线错误。但因为我们在写Bank2,CPU从Bank1取指令,所以没问题。但如果你在写Bank2的时候,中断向量表或者某些代码在Bank2里,那就麻烦了。所以务必确认当前运行的固件完全在Bank1。

4.3 修改Option Bytes切换Bank

写完之后,改BFB2位。HAL库提供了HAL_FLASHEx_OBProgram():

HAL_StatusTypeDef ota_switch_to_bank2(void) { FLASH_OBProgramInitTypeDef ob; HAL_StatusTypeDef status; HAL_FLASH_Unlock(); HAL_FLASH_OB_Unlock(); HAL_FLASHEx_OBGetConfig(&ob); ob.OptionType = OPTIONBYTE_USER; ob.USERType = OB_USER_BFB2; ob.USERConfig = OB_BFB2_ENABLE; // 从Bank2启动 status = HAL_FLASHEx_OBProgram(&ob); if (status == HAL_OK) { HAL_FLASH_OB_Launch(); // 触发复位 } HAL_FLASH_OB_Lock(); HAL_FLASH_Lock(); return status; }

HAL_FLASH_OB_Launch()会触发系统复位,复位后从Bank2启动。注意这个函数不会返回,复位是立即发生的。所以在调用之前,要确保所有该保存的状态都保存了。

4.4 跳转前的完整性校验

在切换之前,必须校验Bank2里的固件是完整的。我用CRC32校验:

uint32_t ota_calc_crc32(uint32_t addr, uint32_t len) { uint32_t crc = 0xFFFFFFFF; uint8_t *p = (uint8_t *)addr; for (uint32_t i = 0; i < len; i++) { crc ^= p[i]; for (int j = 0; j < 8; j++) { crc = (crc >> 1) ^ (0xEDB88320 & -(crc & 1)); } } return ~crc; }

校验的时候,从Bank2起始地址读firmware_header_t,拿到size和crc32,然后算实际数据的CRC,对比。不一致就回到IDLE,重新接收。

5. 断电恢复与回滚机制

5.1 断电发生在不同阶段的处理

OTA最怕的就是写到一半断电。双Bank方案的好处是,无论什么时候断电,当前运行的Bank1固件是完好的,设备还能正常启动。关键是启动之后怎么判断上次OTA没完成。

我在Flash的最后一个扇区(Bank1的Sector7)放了一个OTA状态记录区:

typedef struct { uint32_t magic; uint32_t state; // 0=IDLE, 1=RECEIVING, 2=VERIFYING, 3=READY uint32_t target_bank; // 1 or 2 uint32_t firmware_size; uint32_t firmware_crc; uint32_t retry_count; } ota_status_t;

每次状态变化都更新这个结构。启动的时候读这个结构,如果state不是IDLE,说明上次OTA没完成,根据state决定是继续还是放弃。

5.2 回滚逻辑

如果新固件启动失败怎么办?比如Bank2的固件有bug,一启动就HardFault。这时候需要能回滚到Bank1。

我的做法是在Bank2固件的开头加一个"启动确认"机制:新固件启动后,先跑一段自检,自检通过了,把Option Bytes改回Bank1,同时标记Bank1为"已确认"。如果自检没通过,或者看门狗超时,硬件会自动复位,复位后还是从Bank2启动(因为BFB2还是1),但如果连续几次都失败,就强制切回Bank1。

具体实现是在状态记录区加一个boot_attempt计数。每次从Bank2启动,计数加1。如果计数超过3,说明Bank2固件有问题,自动切回Bank1。

void ota_check_boot_status(void) { ota_status_t *status = (ota_status_t *)OTA_STATUS_ADDR; if (status->magic != OTA_MAGIC) { return; // 没有OTA记录 } if (status->state == OTA_STATE_READY) { // 上次OTA完成但没切换,检查是否要切换 if (status->target_bank == 2) { ota_switch_to_bank2(); } } else if (status->state == OTA_STATE_RECEIVING) { // 上次写到一半断电,重新开始 status->state = OTA_STATE_IDLE; status->retry_count++; if (status->retry_count > 3) { // 重试太多次,放弃 status->magic = 0; } } }

5.3 看门狗配合

零停机OTA期间,看门狗不能停。我在OTA任务里定期喂狗,保证写Flash的时候不会因为超时复位。但要注意,Flash擦除一个128KB扇区大概需要1-2秒,这段时间如果看门狗超时时间设得太短,会误复位。我一般把IWDG超时设成5秒,然后在擦除前喂一次狗,擦除后立即再喂一次。

6. 常见问题与排查实录

6.1 写Bank2的时候程序跑飞

这是最常见的问题。原因通常是:当前固件的某些代码或中断向量表被链接到了Bank2的地址范围。检查链接脚本,确保Bank1固件的所有段都在0x08000000-0x080FFFFF之间。用arm-none-eabi-objdump -h app_bank1.elf看一下各段的地址。

另一个可能的原因是中断。如果在写Flash的时候来了中断,而中断服务程序在Bank2里,就会触发总线错误。解决办法是在写Flash期间关中断,或者确保所有中断服务程序都在Bank1。

6.2 切换Bank后不启动

改完Option Bytes复位后,如果设备没反应,先检查BFB2位是否真的写进去了。用ST-Link Utility或者STM32CubeProgrammer读一下Option Bytes。有时候HAL_FLASHEx_OBProgram()返回OK,但实际没写进去,因为OB的写保护没解除。

还有一个可能是Bank2的固件向量表没设置对。复位后硬件从Bank2的0x08100000取MSP和Reset_Handler,如果Bank2的固件链接脚本还是按0x08000000链接的,那取到的就是错误的值。确认Bank2工程的链接脚本ORIGIN是0x08100000。

6.3 CRC校验总是失败

先确认写入的数据和源数据一致。可以在写入后立即读回来对比。如果读回来不一致,检查Flash编程的地址对齐。H7要求32字节对齐,如果offset不是32的倍数,HAL_FLASH_Program会返回错误。

另外,CRC计算的范围要包含firmware_header_t之后的所有数据,不包括header本身。我见过有人把header也算进去,结果CRC永远对不上。

6.4 OTA速度太慢

1MB固件通过串口115200波特率传输,理论最快也要90秒。实际加上协议开销和Flash写入时间,可能要2-3分钟。如果嫌慢,可以改用USB或者以太网。USB Full Speed理论12Mbps,实际能到1MB/s左右,1MB固件几秒钟就传完了。

Flash写入本身也是瓶颈。H7的Flash写速度大概1MB/s左右,擦除一个128KB扇区要1-2秒。如果固件是1MB,8个扇区,光擦除就要十几秒。这个没法优化,是硬件限制。

6.5 常见问题速查表

现象可能原因排查方法
写Flash时HardFault代码/中断在Bank2检查链接脚本和VTOR
切换后不启动BFB2没写进去读Option Bytes确认
切换后不启动Bank2向量表错误检查Bank2链接脚本
CRC校验失败地址未对齐确认offset是32的倍数
CRC校验失败计算范围错误排除header本身
OTA速度慢串口波特率低改用USB/以太网
断电后无法恢复状态记录未更新检查状态写入时机

7. 几个我踩过的坑和实操建议

第一个坑是Flash的写保护。H7的Flash默认有写保护,HAL_FLASH_Unlock()只是解锁了控制寄存器,如果Option Bytes里设置了WRP(写保护),擦除会失败。我一开始没注意,擦除一直返回错误,查了半天才发现是WRP的问题。用STM32CubeProgrammer把WRP全解除就好了。

第二个坑是中断优先级。OTA任务在写Flash的时候,如果来了高优先级中断,而中断处理时间较长,可能导致Flash编程超时。我的做法是把OTA任务的中断优先级设成最低,同时在写Flash的临界区关中断。但关中断时间不能太长,否则影响实时性。折中方案是每次只写32字节,写完立即开中断,然后再关。

第三个建议是版本号管理。两个Bank的固件版本号一定要能区分,否则设备不知道当前跑的是哪个版本,也不知道该升级到哪个版本。我在firmware_header_t里放了version字段,OTA服务器下发固件的时候带上版本号,设备对比当前版本,只有新版本才升级。

第四个建议是测试要充分。我见过有人只测试了正常流程,没测试断电恢复。结果现场断电,设备变砖。测试的时候要模拟各种断电时机:擦除中断电、写入中断电、校验中断电、切换中断电。每种情况都要能恢复。

最后说一个实际部署的经验:OTA服务器最好支持断点续传。1MB固件传到一半断了,如果要从头传,用户体验很差。我在协议里加了offset字段,设备上报已接收的offset,服务器从offset继续传。这样即使断了,重连后也能继续,不用重头来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询