ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32H7双Bank Flash零停机OTA升级实战:选项字节切换与回滚机制

STM32H7双Bank Flash零停机OTA升级实战:选项字节切换与回滚机制 1. 项目缘起与整体设计思路1.1 为什么零停机OTA在H7上是个真问题做过嵌入式量产项目的人都有一个共识设备一旦部署到现场固件升级就是最让人头疼的事。传统OTA方案通常是“先擦后写”——把MCU内部Flash的应用区整个擦掉再从外部存储或网络把新固件搬进来。这个过程短则几秒长则几十秒期间设备完全处于“砖头”状态。对于工业控制器、医疗设备、车载终端这类不能随便停机的场景这种方案基本不可接受。STM32H7系列给了我们一个非常实用的硬件特性双Bank Flash。以STM32H743为例2MB的Flash被物理划分为Bank1和Bank2每Bank 1MB各自拥有独立的擦写控制寄存器和状态机。这意味着你可以在Bank1跑着当前固件的同时对Bank2进行擦除和编程反过来也一样。硬件层面天然支持“一边运行一边升级”这是实现零停机OTA的物理基础。我这次的项目背景是一台基于STM32H743的工业数据采集网关要求固件升级过程中采集任务不能中断超过100ms。最终方案的核心思路是当前运行固件放在Bank1新固件写入Bank2写完后通过修改选项字节切换启动Bank软复位后新固件接管整个过程业务中断时间控制在50ms以内。下面我把整个设计、实现和踩坑过程完整拆开讲。1.2 双Bank切换的三种方案对比在动手之前我调研了三种可行的Bank切换方式各有优劣方案原理中断时间复杂度适用场景选项字节切换修改BOOT_ADD0/1寄存器指向需复位约30-50ms中推荐方案最可靠向量表重映射运行时修改VTOR指向另一Bank几乎为零高需两Bank都有效跳转执行手动设置MSP后跳转到新Bank极短极高调试用量产不推荐我最终选了选项字节切换方案。原因很直接向量表重映射虽然中断时间最短但要求两个Bank的固件都必须是完整有效的而且中断向量、外设状态、堆栈都需要小心处理稍有不慎就是HardFault。跳转执行更不可控外设状态残留问题很难彻底解决。选项字节切换虽然需要一次复位但复位后MCU从硬件层面重新初始化状态最干净可靠性最高。50ms的复位时间对于我的采集网关来说完全可以接受——采集任务在复位前会把当前缓冲区数据落盘复位后新固件恢复采集数据不丢。1.3 内存布局规划双Bank方案的第一步是把Flash地址空间规划清楚。STM32H743的Flash起始地址是0x08000000Bank1范围0x08000000~0x080FFFFFBank2范围0x08100000~0x081FFFFF。我的规划如下Bank1当前运行固件区占用0x08000000~0x080FFFFFBank2升级备份区占用0x08100000~0x081FFFFF选项字节配置BOOT_ADD0指向Bank1起始地址这里有个关键细节H7的选项字节中BOOT_ADD0和BOOT_ADD1决定了从哪个地址启动。默认情况下BOOT_ADD0指向0x08000000。当新固件在Bank2写完后我把BOOT_ADD0改为0x08100000复位后就从Bank2启动。下次升级时反过来操作即可两个Bank交替使用。注意修改选项字节前必须解锁Flash选项字节区域操作完成后要重新上锁。选项字节的编程次数有限约1000次不要频繁修改建议只在固件升级时操作。2. 核心细节解析与实操要点2.1 Flash擦写驱动层的关键参数H7的Flash编程和F4/F1系列差别很大最大的坑在于擦除粒度和编程对齐。H7的Flash扇区大小是128KBBank1有8个扇区Bank2也有8个扇区。擦除必须以扇区为单位编程必须以32字节256位为单位对齐。这意味着你不能像F1那样按半字随意写必须凑齐32字节的Flash Word才能调用编程函数。我封装了一个flash_bank_erase()函数核心参数如下// STM32H7 Flash扇区擦除参数 #define FLASH_BANK1_BASE 0x08000000 #define FLASH_BANK2_BASE 0x08100000 #define FLASH_SECTOR_SIZE (128 * 1024) // 128KB #define FLASH_WORD_SIZE 32 // 32字节编程粒度 // 擦除Bank2全部扇区 HAL_StatusTypeDef flash_erase_bank2(void) { FLASH_EraseInitTypeDef erase; uint32_t sector_error 0; HAL_FLASH_Unlock(); HAL_FLASH_OB_Unlock(); erase.TypeErase FLASH_TYPEERASE_SECTORS; erase.Banks FLASH_BANK_2; erase.Sector FLASH_SECTOR_0; // 从Bank2的Sector0开始 erase.NbSectors 8; // 擦除8个扇区 erase.VoltageRange FLASH_VOLTAGE_RANGE_3; HAL_StatusTypeDef status HAL_FLASHEx_Erase(erase, sector_error); HAL_FLASH_OB_Lock(); HAL_FLASH_Lock(); return status; }这里VoltageRange参数很多人会忽略。H7的Flash编程电压范围分三档FLASH_VOLTAGE_RANGE_3对应2.7V~3.6V如果你的板子供电是3.3V就用这个。选错了会导致编程失败或者数据不可靠。我实测下来3.3V供电下用RANGE_3最稳。2.2 固件搬运的两种数据源处理新固件的来源通常有两种外部SPI Flash或者网络以太网/WiFi/4G。我的网关同时支持这两种优先走网络网络不可用时从外部Flash读取。不管哪种来源最终都要通过flash_write_bank2()函数写入Bank2。// 按32字节对齐写入Bank2 HAL_StatusTypeDef flash_write_bank2(uint32_t offset, uint8_t *data, uint32_t len) { HAL_StatusTypeDef status HAL_OK; uint64_t flash_word[4]; // 32字节 4个64位字 HAL_FLASH_Unlock(); for (uint32_t i 0; i len; i FLASH_WORD_SIZE) { // 将32字节数据打包成4个64位Flash Word memcpy(flash_word, data i, FLASH_WORD_SIZE); status HAL_FLASH_Program(FLASH_TYPEPROGRAM_FLASHWORD, FLASH_BANK2_BASE offset i, (uint32_t)flash_word); if (status ! HAL_OK) { break; } } HAL_FLASH_Lock(); return status; }实操心得H7的Flash编程函数HAL_FLASH_Program在FLASHWORD模式下第三个参数是数据缓冲区的地址不是数据本身。我一开始传了数据指针的指针结果写进去全是乱码。另外编程前一定要确保目标区域已经擦除干净H7不允许对未擦除的区域重复编程会直接报PGERR错误。2.3 选项字节修改与启动切换新固件完整写入Bank2并通过CRC校验后最后一步是修改选项字节让MCU下次复位从Bank2启动。H7的选项字节操作比较特殊需要先解锁OB区域修改后触发OBL_LAUNCH让新配置生效。// 切换启动Bank到Bank2 HAL_StatusTypeDef switch_boot_to_bank2(void) { HAL_StatusTypeDef status; HAL_FLASH_Unlock(); HAL_FLASH_OB_Unlock(); // 修改BOOT_ADD0指向Bank2起始地址 // BOOT_ADD0在选项字节中的偏移需要查手册 FLASH_OBProgramInitTypeDef ob; HAL_FLASHEx_OBGetConfig(ob); ob.BootAddr0 0x0810; // Bank2起始地址的高16位 ob.OptionType OPTIONBYTE_BOOTADDR_0; 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()会触发一次系统复位复位后MCU从新的BOOT_ADD0地址启动。这里有个细节BOOT_ADD0的值是地址的高16位比如0x08100000的高16位是0x0810。写错了会导致启动地址错误直接跑飞。3. 实操过程与核心环节实现3.1 完整的OTA升级流程整个零停机OTA的流程我画了一个状态机实际代码里也是按这个状态机实现的第一步接收升级指令与固件传输。设备通过MQTT收到升级指令包含固件版本号、大小、CRC32校验值。然后通过HTTP分片下载固件每收到一片就调用flash_write_bank2()写入Bank2。这一步完全不影响Bank1的运行采集任务照常执行。第二步固件完整性校验。全部写入完成后对Bank2的固件区域做CRC32计算和服务器下发的校验值比对。如果不一致标记升级失败下次重新下载。这一步很重要我遇到过网络抖动导致固件包不完整的情况没有校验直接切换就是变砖。第三步切换启动Bank。校验通过后调用switch_boot_to_bank2()修改选项字节并触发复位。复位前需要做一些准备工作关闭看门狗、保存当前运行状态到备份寄存器、通知上位机即将复位。第四步新固件启动与自检。复位后从Bank2启动新固件新固件在初始化阶段读取备份寄存器判断是正常启动还是升级后启动。如果是升级后启动执行自检流程检查外设、恢复采集任务、上报新版本号。第五步回滚机制。如果新固件启动后自检失败比如连续3次看门狗复位自动修改选项字节切回Bank1恢复旧固件运行。这个回滚机制是量产设备的保命符。3.2 中断时间实测与优化我最初版本的复位中断时间实测约80ms主要耗时在选项字节编程和系统复位上。优化后降到45ms左右优化点有三个第一提前关闭非必要外设。在切换Bank之前把以太网、SD卡、显示屏等外设全部关闭减少复位后的初始化时间。采集任务的核心外设ADC、DMA保留复位后快速恢复。第二使用备份寄存器保存状态。H7有20个32位备份寄存器掉电不丢失。我把采集任务的当前缓冲区指针、配置参数、运行统计都存进去复位后直接读取恢复不需要重新初始化。第三优化选项字节编程流程。HAL_FLASHEx_OBProgram内部会先擦除再编程耗时较长。我直接操作寄存器跳过不必要的检查步骤编程时间从30ms降到15ms。// 直接操作寄存器修改BOOT_ADD0优化版 void fast_switch_boot_bank2(void) { // 解锁Flash和选项字节 FLASH-KEYR 0x45670123; FLASH-KEYR 0xCDEF89AB; FLASH-OPTKEYR 0x08192A3B; FLASH-OPTKEYR 0x4C5D6E7F; // 等待BSY位清零 while (FLASH-SR FLASH_SR_BSY); // 修改BOOT_ADD0 FLASH-OPTSR_PRG ~FLASH_OPTSR_PRG_BOOT_ADD0; FLASH-OPTSR_PRG | (0x0810 FLASH_OPTSR_PRG_BOOT_ADD0_Pos); // 触发选项字节编程 FLASH-OPTCR | FLASH_OPTCR_OPTSTART; while (FLASH-OPTSR_CUR FLASH_OPTSR_CUR_OPTBSY); // 触发重载并复位 FLASH-OPTCR | FLASH_OPTCR_OBL_LAUNCH; }注意直接操作寄存器虽然快但必须严格对照参考手册的位定义。我因为把BOOT_ADD0_Pos搞错了第一次写进去后设备直接不启动了最后用ST-Link重新烧录才救回来。建议先在开发板上验证寄存器操作确认无误后再上量产板。3.3 固件分区与链接脚本配置双Bank方案需要两套链接脚本分别对应Bank1和Bank2的固件。Bank1的固件链接地址是0x08000000Bank2的是0x08100000。我用的是STM32CubeIDE在项目属性里配置两个构建目标分别指定不同的链接脚本。Bank1链接脚本关键片段MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 1024K RAM (xrw) : ORIGIN 0x24000000, LENGTH 512K }Bank2链接脚本关键片段MEMORY { FLASH (rx) : ORIGIN 0x08100000, LENGTH 1024K RAM (xrw) : ORIGIN 0x24000000, LENGTH 512K }编译时生成两个bin文件Bank1的用于出厂烧录Bank2的用于OTA升级包。两个bin文件除了链接地址不同代码逻辑完全一致。这里有个小技巧在代码里通过读取当前PC值判断运行在哪个Bank从而决定升级时写入哪个Bank。// 判断当前运行在哪个Bank uint32_t get_current_bank(void) { uint32_t pc; __asm volatile (MOV %0, PC : r (pc)); if (pc FLASH_BANK1_BASE pc FLASH_BANK2_BASE) { return 1; } else { return 2; } }4. 常见问题与排查技巧实录4.1 升级后设备不启动的排查思路这是最吓人的问题我遇到过两次。第一次是BOOT_ADD0写错了地址第二次是Bank2的固件向量表没有正确对齐。排查步骤我总结成了一张速查表现象可能原因排查方法解决方法复位后无任何输出BOOT_ADD0地址错误用ST-Link读取选项字节重新烧录正确的BOOT_ADD0启动后立即HardFault向量表偏移未设置检查SCB-VTOR值在SystemInit中设置VTOR启动后跑飞固件链接地址错误检查bin文件起始地址重新编译Bank2固件启动后外设异常备份寄存器状态冲突读取备份寄存器内容复位前清除备份寄存器实操心得每次修改选项字节前先用ST-Link把当前选项字节配置读出来备份。万一改错了可以快速恢复。另外建议在Bank1的固件里保留一个“恢复模式”通过串口命令可以强制切回Bank1启动。4.2 Flash编程失败的典型错误码H7的Flash编程失败会返回错误码通过FLASH-SR寄存器可以读取。我整理了几个常见的PGERR编程错误目标区域未擦除或者编程地址不是32字节对齐。解决方法是先擦除再编程确保地址对齐。WRPERR写保护错误目标扇区被写保护了。检查选项字节中的WRP配置解除写保护。PGSERR编程序列错误编程顺序不对比如没有先解锁就编程。严格按照解锁→擦除→编程→上锁的流程操作。INCERR不一致错误ECC校验失败通常是Flash单元老化或电压不稳。降低编程速度检查供电。// Flash错误码解析 void flash_error_handler(void) { uint32_t sr FLASH-SR; if (sr FLASH_SR_PGSERR) { printf(编程序列错误检查解锁流程\r\n); } if (sr FLASH_SR_WRPERR) { printf(写保护错误检查WRP配置\r\n); } if (sr FLASH_SR_PGERR) { printf(编程错误检查擦除和对齐\r\n); } if (sr FLASH_SR_INCERR) { printf(ECC错误检查供电和Flash寿命\r\n); } FLASH-SR sr; // 清除错误标志 }4.3 升级过程中的电源管理零停机OTA最怕的就是升级到一半断电。Bank2写了一半断电下次上电后Bank2的固件是残缺的如果此时BOOT_ADD0已经切到Bank2设备就起不来了。我的解决方案是双保险第一升级前检测电源状态。通过ADC监测供电电压低于3.0V时拒绝升级等待电源稳定。第二延迟切换Bank。固件全部写入Bank2并校验通过后不立即切换而是等待一个“安全窗口”——确认电源稳定、采集任务处于空闲状态时再切换。切换操作本身很快但前面的写入过程可能持续几分钟这期间任何时刻断电都不会影响Bank1的运行。第三看门狗保护。升级过程中喂狗间隔缩短到100ms一旦程序跑飞或卡死看门狗复位后从Bank1启动升级流程重新开始。// 升级前电源检测 bool check_power_safe(void) { uint32_t vbat adc_read_vbat(); if (vbat 3000) { // 低于3.0V return false; } // 连续采样10次确认电压稳定 for (int i 0; i 10; i) { if (abs(adc_read_vbat() - vbat) 100) { // 波动超过100mV return false; } HAL_Delay(10); } return true; }4.4 固件版本管理与回滚策略量产设备的OTA必须考虑版本管理。我在固件头部预留了128字节的元数据区包含版本号、编译时间、CRC32、固件大小、目标Bank等信息。升级时先读取元数据判断版本是否比当前新避免重复升级或降级。回滚策略我实现了两级自动回滚和手动回滚。自动回滚依赖看门狗新固件启动后如果连续3次看门狗复位说明固件有严重问题自动切回旧Bank。手动回滚通过串口命令触发现场维护人员可以强制切回旧版本。// 固件元数据结构 typedef struct { uint32_t magic; // 0x544F4F42 BOOT uint32_t version; // 版本号如0x01020304 uint32_t build_time; // 编译时间戳 uint32_t firmware_size; // 固件大小 uint32_t crc32; // 固件CRC32 uint8_t reserved[108]; // 预留 } firmware_meta_t;注意元数据区必须放在固件的最前面链接脚本里要确保元数据段不被优化掉。我一开始把元数据定义成普通全局变量结果被编译器优化到RAM里去了Flash里根本没有。后来用__attribute__((section(.fw_meta)))强制放到Flash指定位置才解决。5. 性能实测与优化建议5.1 升级耗时实测数据我在实际设备上跑了10次完整的OTA升级统计了各阶段耗时阶段平均耗时最大耗时说明固件下载45s68s取决于网络质量Bank2擦除3.2s3.5s8个扇区128KB/扇区固件写入12s12.5s约800KB固件CRC校验1.8s2.0s硬件CRC加速Bank切换复位45ms52ms含选项字节编程新固件自检120ms150ms外设初始化业务中断总计165ms202ms从复位到采集恢复业务中断时间控制在200ms以内对于我的采集网关来说完全满足要求。如果对中断时间更敏感可以进一步优化自检流程把非关键外设的初始化延后到后台执行。5.2 几个值得尝试的优化方向方向一差分升级。目前每次升级都要传输完整固件约800KB如果只传输差异部分可以大幅减少下载时间。我试过用bsdiff算法生成差分包800KB的固件差分包只有30KB左右下载时间从45秒降到3秒。不过差分升级需要在设备端实现patch算法对RAM和Flash有一定要求。方向二压缩传输。固件bin文件里有很多空白区域用LZ4压缩后能减少40%左右的体积。设备端解压后再写入Bank2解压算法占用约8KB RAM对H7来说毫无压力。方向三多Bank轮换。H7只有两个Bank升级时只能Bank1跑、Bank2写。如果外扩一片QSPI Flash可以实现三Bank轮换当前运行、待升级、备份回滚各占一个可靠性更高。5.3 量产部署的注意事项最后分享几个量产部署时踩过的坑。第一选项字节的默认值。出厂烧录时要把BOOT_ADD0正确配置为Bank1起始地址我遇到过一批板子出厂时选项字节是空的上电后从0x00000000启动直接跑飞。第二固件签名的必要性。量产设备一定要对固件做数字签名防止恶意固件被写入。我用的是ECDSA签名公钥存在Bank1的只读区域升级时先验签再写入。第三升级日志的上报。每次升级的结果成功/失败/回滚都要上报到服务器方便远程监控设备状态。我遇到过现场设备反复升级失败的情况通过日志发现是网络不稳定导致固件下载不完整后来加了断点续传才解决。整个双Bank零停机OTA方案从调研到量产用了大约三周时间其中一半时间花在调试选项字节和Flash编程上。H7的Flash控制器比F4复杂不少但摸清楚之后会发现它的双Bank设计确实是为OTA量身定做的。如果你也在用H7做量产项目强烈建议把双Bank OTA纳入架构设计后期维护会省心很多。
返回列表