尧图精选

STM32H7双Bank Flash零停机OTA升级实战:原理、实现与避坑指南

🕒 发布时间:2026/9/28 1:32:00 📁 来源:尧图网络
1. 为什么STM32H7的双Bank Flash值得单独拿出来讲第一次在STM32H7上做OTA升级的人十有八九会踩同一个坑把新固件写进Flash的时候CPU直接从同一块Flash取指令结果写操作一启动总线就卡死程序跑飞。这不是代码写错了而是单Bank Flash的物理限制——同一时刻一块Flash要么在读要么在写没法既读又写。STM32H7的解法是双Bank Flash。以常见的STM32H743为例2MB Flash被平均切成两个1MB的BankBank1和Bank2各自有独立的读写控制逻辑。这意味着你可以在Bank1里安安稳稳跑着当前固件同时往Bank2里写新固件两个操作互不干扰。写完之后改一下启动地址复位新固件就跑起来了。整个过程设备不用停机业务逻辑可以一直在线。这个能力在工业网关、医疗设备、车载终端这类不能随便断电重启的场景里价值非常大。我做过一个项目设备装在产线控制柜里停机一次要停整条线损失按分钟算。用了双Bank方案之后固件升级对业务完全透明用户根本感知不到。这篇文章我会把整个方案拆开讲双Bank的地址映射怎么理解、链接脚本怎么改、跳转逻辑怎么写、状态机怎么设计、断电了怎么办。代码基于STM32H743 HAL库但思路对H750、H723这些同系列芯片同样适用。如果你正在做OTA或者被单Bank的写读冲突折磨过这篇应该能帮你省不少时间。2. 双Bank Flash的地址映射与启动机制2.1 两个Bank的物理地址到底怎么分STM32H743的2MB FlashBank1占0x08000000到0x080FFFFFBank2占0x08100000到0x081FFFFF。每个Bank 1MB各自独立擦写。注意这里有个容易搞混的点Bank2的起始地址是0x08100000不是0x08080000。我见过有人按1MB的一半去算结果地址算错写进去的数据全乱。除了主存储区还有几个关键地址要记住区域地址范围用途Bank1 主存储0x08000000 - 0x080FFFFF当前运行固件Bank2 主存储0x08100000 - 0x081FFFFF新固件暂存Option Bytes0x52002020配置启动Bank系统存储器0x1FF00000BootloaderOption Bytes里的BFB2位Bit 4决定从哪个Bank启动。BFB20从Bank1启动BFB21从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.ldMEMORY { 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是0x20000000AXI 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_BASEVECT_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正在接收新固件数据写入备用BankVERIFYING接收完成校验CRCREADY校验通过等待切换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取指令会触发总线错误。但因为我们在写Bank2CPU从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; // 0IDLE, 1RECEIVING, 2VERIFYING, 3READY 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秒。如果固件是1MB8个扇区光擦除就要十几秒。这个没法优化是硬件限制。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继续传。这样即使断了重连后也能继续不用重头来。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →