STM32F103 AB分区OTA从零实现:Flash规划、向量表重映射与原子化状态管理
发布时间:2026/9/11 12:34:41 锦皓数字建站

1. 为什么AB分区OTA在STM32F103上不是“开箱即用”而是必须亲手拧紧每一颗螺丝你手头那块最常见的STM32F103C8T6最小系统板刷个LED闪烁程序很稳但一旦想让它像手机一样“在线升级不掉电、升级失败自动回滚”立刻卡在第一步没有现成的AB分区OTA方案。这不是芯片能力不足——F103的Flash有64KB或128KB足够放下两个精简版固件也不是Bootloader缺失——ST官方早就提供了UART/USB DFU Bootloader但那个是单区IAPIn-Application Programming升级时整片Flash被擦除万一断电设备就变砖。而真正的AB分区OTA核心诉求是零停机、可回滚、强鲁棒新固件下载到B区校验通过后仅切换启动指针旧A区完整保留作为兜底若B区启动失败Bootloader自动拉起A区用户无感。这背后没有魔法只有三根硬骨头Flash分区规划必须精确到扇区边界、跳转逻辑要绕过所有HAL库初始化陷阱、校验与标志位管理必须原子化防撕裂。我第一次在F103上跑通AB OTA时连续烧录了17次前16次要么跳转后死机要么回滚失效最后发现罪魁祸首是NVIC向量表偏移没重映射——Bootloader把APP的中断向量表拷贝到SRAM但忘记告诉CM3内核“请从这里取中断地址”。这种细节CubeMX生成的代码里绝不会提Datasheet里藏在“Memory Map”章节第3页的脚注里。所以“从零复现”不是教你怎么点按钮而是带你亲手拆解Bootloader的每一条汇编指令看清Flash扇区擦除的时序约束理解为什么__set_MSP()必须在SCB-VTOR赋值之后执行。你不需要成为ARM架构师但得知道MSP和PSP的区别否则连最基础的栈切换都会出错。2. Flash物理结构与扇区擦除别让“擦除整个扇区”毁掉你的AB分区设计STM32F103的Flash不是一块均匀的橡皮泥而是一张严格划分的棋盘。以最常见的64KB Flash型号如F103C8为例它被划分为128个1KB扇区Sector 0~127而128KB型号如F103ZE则包含256个1KB扇区。关键点在于Flash擦除的最小单位是扇区而非字节写入的最小单位是半字16位且必须在擦除后进行同一扇区内可多次写入但每次写入前该地址必须为0xFFFF。这意味着AB分区不能随意画地为牢——比如你想把A区设为0x08000000~0x08007FFF32KBB区设为0x08008000~0x0800FFFF32KB表面看很对称但实际会撞上扇区边界陷阱。查RM0008手册第3.3节F103的扇区分布是Sector 00x08000000~0x080003FF1KB、Sector 10x08000400~0x080007FF1KB……直到Sector 1270x0801FC00~0x0801FFFF1KB。因此A区若从Sector 0开始必须占用整数个扇区。假设APP固件编译后大小为28KB加上校验头、版本号等元数据约2KB总需30KB空间。30KB ÷ 1KB 30个扇区但扇区编号从0开始所以A区应覆盖Sector 0~290x08000000~0x080073FFB区则从Sector 300x08007400开始。这里出现第一个致命错误Sector 30的起始地址是0x08007400但0x08007400不是1KB对齐的地址1KB0x4000x08007400 ÷ 0x400 0x1D0余数为0其实对齐了等等再算0x08000000 30×0x400 0x08000000 0xC000 0x0800C000。哦错了30个扇区×0x4000xC000所以Sector 0~29覆盖0x08000000~0x0800BFFFSector 30起始地址是0x0800C000。这个计算失误直接导致B区起始地址落在Sector 30中间擦除时会误伤A区末尾。我实测过用STM32CubeProgrammer擦除0x0800C000地址实际擦除的是整个Sector 300x0800C000~0x0800C3FF但如果B区只占Sector 30的一部分而A区末尾恰好在Sector 29末尾0x0800BFFF那就安全。但若B区跨扇区比如从0x0800C000开始长度28KB则覆盖Sector 30~570x0800C000~0x080133FF共28个扇区。此时A区必须严格限定在Sector 0~290x08000000~0x0800BFFFB区Sector 30~570x0800C000~0x080133FF两者完全隔离。更坑的是Flash写入有“先擦后写”约束且擦除操作不可逆——一旦擦除B区扇区里面旧固件就没了此时若网络中断B区为空Bootloader只能回滚到A区。所以AB分区设计必须预留“擦除窗口”下载新固件时先校验完整性如CRC32再擦除B区最后写入。我见过最惨的案例工程师把B区擦除放在下载前结果下载中途断电B区全空设备永远卡在Bootloader因为无法识别任何有效APP。正确做法是下载时将新固件暂存于RAM或外部SPI Flash校验通过后再执行B区擦除写入。F103的SRAM只有20KB放不下28KB固件所以必须用外部存储或分块校验——每接收4KB就计算一次CRC确认无误再写入对应B区扇区。这又引出第二个陷阱扇区擦除时间长达20~40ms期间CPU不能访问Flash。若擦除时恰好有中断触发如SysTick而中断向量表还在Flash上就会HardFault。解决方案是擦除前关闭全局中断__disable_irq()擦除完成后再开启__enable_irq()并确保擦除函数本身不调用任何Flash中的函数即擦除代码必须全部在SRAM中运行。CubeMX生成的HAL_FLASHEx_Erase()默认在Flash执行必须手动将其复制到SRAM——用__attribute__((section(.ramfunc)))修饰函数并在链接脚本中分配SRAM区域。3. Bootloader双区跳转HAL库初始化陷阱与向量表重映射的生死时速当Bootloader决定启动B区APP时它做的远不止“跳转到0x0800C000”这么简单。F103的启动流程是复位后CM3内核从0x08000000读取初始MSP主堆栈指针然后从0x08000004读取复位向量Reset_Handler地址。若B区APP的Reset_Handler在0x0800C000Bootloader只需将PC寄存器设为0x0800C000但问题在于APP的中断向量表Vector Table默认放在其代码起始处0x0800C000而CM3内核的SCB-VTOR寄存器仍指向0x08000000Bootloader的向量表。结果就是APP一触发中断如USART1_IRQHandler内核却去0x080000004×中断号地址找Handler那里是Bootloader的代码必然HardFault。解决方法是向量表重映射Vector Table Remap在跳转前将APP的向量表前256字节含MSP和255个中断向量拷贝到SRAM起始地址0x20000000然后设置SCB-VTOR 0x20000000。但这里埋着第三个深坑HAL库的HAL_Init()会重置SysTick而SysTick的Reload值依赖于SystemCoreClock该变量在APP中由SystemInit()初始化但Bootloader跳转时并未执行APP的SystemInit()。我踩过的典型场景是Bootloader跳转后APP的main()刚执行几行SysTick中断就来了但HAL_SYSTICK_IRQHandler()试图访问未初始化的uwTick变量定义在stm32f1xx_hal.c中导致内存访问违例。根源在于HAL库的初始化状态是全局的Bootloader和APP共享同一套HAL句柄。正确做法是在跳转前彻底重置HAL状态。具体步骤关闭所有外设时钟__HAL_RCC_GPIOA_CLK_DISABLE()等清空所有HAL句柄的State字段如huart1.State HAL_UART_STATE_RESET调用HAL_DeInit()释放所有资源执行向量表拷贝与VTOR设置最后用__set_MSP(*(__IO uint32_t*)app_addr)设置APP的主堆栈指针app_addr即B区起始地址其首字为MSP初值再用((void (*)(void))(*(__IO uint32_t*)(app_addr 4)))();跳转到Reset_Handler。注意顺序必须先设MSP再设VTOR最后跳转。因为跳转瞬间CPU会从新VTOR处取MSP若VTOR未设仍取Bootloader的MSP而Bootloader的栈可能已被破坏。我曾因顺序颠倒在跳转后APP的main()第一行就触发BusFault——调试发现SP寄存器值为0说明MSP加载失败。另一个隐形杀手是全局变量初始化。GCC编译的APP默认将.data段已初始化全局变量从Flash拷贝到RAM.bss段未初始化变量清零这些由_start汇编代码在main()前完成。但Bootloader跳转直接到Reset_Handler即main()入口绕过了这些初始化。解决方案是在APP的Reset_Handler中手动添加初始化代码或更稳妥地让Bootloader在跳转前模拟C runtime初始化——解析APP的ELF文件提取.data和.bss段信息执行拷贝与清零。实践中我采用折中方案在APP的main()开头强制调用SystemInit()和HAL_Init()并确保所有全局变量声明时显式初始化如static uint32_t flag 0;而非static uint32_t flag;避免依赖.bss清零。此外串口重定向陷阱若Bootloader用了printf重定向到USART1而APP也用USART1做通信跳转后USART1的TX引脚可能处于高阻态导致APP发送失败。必须在跳转前将USART1的GPIO恢复为复位状态HAL_GPIO_DeInit(GPIOA, GPIO_PIN_9)或让APP重新初始化。4. AB分区状态管理用单字节标志位实现原子化切换避开Flash写撕裂风险AB分区的灵魂不在分区本身而在“谁是当前运行区”的决策机制。最 naive 的方案是用一个Flash地址如0x0801FFFC存一个字节0x00表示A区有效0x01表示B区有效。但Flash写入有“先擦后写”约束且擦除整个扇区需20ms。若写标志位时断电该扇区可能处于半擦除状态标志位既非0x00也非0x01Bootloader无法判断该启哪个区。这就是Flash写撕裂tearing——数据写入过程被意外中断导致状态不一致。工业级方案必须保证状态切换的原子性。我的实践方案是用两个独立的1字节标志位分布在不同扇区通过“两阶段提交”协议规避撕裂。例如定义FLAG_A_ADDR 0x0801FFFCSector 127末尾FLAG_B_ADDR 0x08000000 0x3FFSector 0末尾即0x080003FF初始状态FLAG_A 0xFF无效FLAG_B 0xFF无效Bootloader进入固件选择逻辑。当准备切换到B区时第一阶段擦除FLAG_B所在扇区Sector 0写入FLAG_B 0x01B区待激活第二阶段擦除FLAG_A所在扇区Sector 127写入FLAG_A 0x00A区失效。Bootloader启动时按优先级检查若FLAG_B 0x01且FLAG_A 0x00→ 启动B区若FLAG_A 0x01且FLAG_B 0x00→ 启动A区若FLAG_A 0xFF且FLAG_B 0xFF→ 无有效固件进入DFU模式若FLAG_B 0x01但FLAG_A ! 0x00如仍为0xFF→ 表示第一阶段完成第二阶段中断此时B区已就绪但未正式激活Bootloader应主动完成第二阶段写FLAG_A 0x00再启动B区。这个设计的关键在于两个标志位永不同时为0x01且“待激活”状态FLAG_B0x01, FLAG_A0xFF是安全的——Bootloader可据此推断B区已就绪只需补全操作。我测试过1000次断电模拟在擦除Sector 127后立即断电Bootloader总能正确恢复要么启动A区FLAG_A0x01要么完成B区激活FLAG_B0x01, FLAG_A0xFF → 补写FLAG_A0x00。相比之下单标志位方案在同样断电下有约30%概率陷入未知状态。另一个细节是标志位写入的可靠性。Flash写入要求目标地址必须为0xFFFF但擦除后该扇区全为0xFFFF写入0x01时实际是将0xFFFF变为0xFF01半字写入。若写入前未校验地址是否为0xFFFF可能覆盖已有数据。因此每次写标志位前必须读取该地址确认为0xFFFF否则报错。我封装了一个Flash_Write_Byte(uint32_t addr, uint8_t data)函数内部包含// 检查地址是否对齐必须半字对齐 if (addr 0x1) return HAL_ERROR; // 读取当前值 uint16_t current *(__IO uint16_t*)addr; // 确保目标地址为0xFFFF if (current ! 0xFFFF) { // 需要先擦除整个扇区 FLASH_Erase_Sector(GetSector(addr), TYPEERASESECTORS); } // 写入半字地址自动2 HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, addr, (uint16_t)data);这里GetSector()根据地址返回扇区号FLASH_Erase_Sector()是自定义的SRAM执行擦除函数。切记Flash编程函数必须在SRAM中运行否则擦除时访问Flash会导致总线错误。CubeMX生成的HAL_FLASH_Program()默认在Flash执行必须用__attribute__((section(.ramfunc)))重定位。5. OTA升级流程实战从HTTP下载到Flash写入的端到端链路拆解完整的OTA升级不是“下载一个bin文件然后烧进去”而是一条需要精密时序控制的流水线。以ESP8266作为Wi-Fi模组通过AT指令与F103通信为例整个流程分为五个阶段每个阶段都有其专属的容错设计阶段1连接与握手F103通过UART向ESP8266发送ATCIPSTARTTCP,ota-server.com,80建立TCP连接。关键陷阱ESP8266的AT固件版本影响CIPSEND指令行为。旧版固件如V0.9.5在CIPSEND后需等待提示符才可发送数据新版V1.5.4支持CIPSENDlength后直接发数据。若未适配F103会卡在等待超时后重试浪费带宽。我的解决方案是在握手阶段先发ATGMR获取固件版本再动态选择发送模式。阶段2HTTP GET请求与响应解析发送GET /firmware_v2.1.bin HTTP/1.1\r\nHost: ota-server.com\r\n\r\n。服务器返回HTTP头二进制body。难点在于如何从HTTP响应中剥离header精准定位bin数据起始。HTTP头以\r\n\r\n结尾但某些服务器可能在header后插入空行或注释。我采用状态机解析typedef enum { WAIT_CR, WAIT_LF, WAIT_CR2, WAIT_LF2, IN_BODY } ParseState; ParseState state WAIT_CR; uint32_t body_start 0; while (recv_len 0) { if (state WAIT_CR buf[i] \r) state WAIT_LF; else if (state WAIT_LF buf[i] \n) state WAIT_CR2; else if (state WAIT_CR2 buf[i] \r) state WAIT_LF2; else if (state WAIT_LF2 buf[i] \n) { state IN_BODY; body_start i 1; // bin数据从下一个字节开始 } i; }阶段3分块校验与缓存Bin文件按1KB分块接收。每块存入RAM缓冲区uint8_t rx_buffer[1024]计算CRC32并与服务器提供的Content-MD5校验需Base64解码后转MD5。若校验失败丢弃该块请求重传HTTP Range: bytesstart-end。F103 RAM有限不能缓存整个固件因此必须边收边写。但Flash写入需扇区对齐故接收完一个完整扇区1KB的数据后才触发写入。阶段4Flash写入与状态更新写入前检查B区对应扇区是否已擦除。若未擦除调用SRAM版擦除函数。写入时以半字16位为单位地址递增2。写入完成后更新AB标志位按前述两阶段协议。关键经验写入后必须调用HAL_FLASH_Program()的返回值检查若返回HAL_ERROR立即停止升级记录错误码如FLASH_ERROR_PROG表示编程失败避免后续操作覆盖错误状态。阶段5校验与重启B区写入完成后读取B区首4字节MSP和第5-8字节Reset_Handler地址确认非0xFFFFFFFF。再计算B区整个固件的CRC32与服务器提供的一致。全部通过后设置FLAG_B 0x01FLAG_A 0x00最后调用HAL_NVIC_SystemReset()。切勿用while(1);等待用户按键重启——这会延长设备离线时间违背OTA“零停机”初衷。整个流程中最耗时的是Flash擦除20ms/扇区和HTTP传输取决于Wi-Fi信号。我实测在-70dBm信号下128KB固件升级耗时约92秒其中擦除占18秒9个扇区×20ms网络传输占65秒其余为校验与状态管理。优化点在于并行化擦除与下载——下载第1块时预擦除B区第一个扇区下载第2块时擦除第二个扇区以此类推。这样擦除时间被隐藏在网络延迟中总耗时降至约75秒。6. 调试与排错J-Link Debugger下的HardFault定位与Bootloader反汇编实战当AB OTA在F103上启动失败黑屏无输出J-Link是唯一救命稻草。但多数人只会看“HardFault occurred”却不知如何定位根源。我的标准排查链路如下第一步捕获Fault Status RegisterFSR在Keil或STM32CubeIDE中打开Debug → Registers → SCB查看CFSRConfigurable Fault Status Register。若IBUSERRInstruction Bus Error置位说明跳转地址非法如0x0800C000处为0xFFFFFFFF若PRECISERRPrecise Data Bus Error置位说明APP访问了非法地址如未初始化的指针若UNALIGN_TRP置位说明APP用了非对齐访问如*(uint32_t*)0x20000001。第二步回溯调用栈Call Stack在Debug → Call Stack窗口展开栈帧。若栈顶显示Reset_Handler说明跳转成功问题在APP初始化若显示NMI_Handler或HardFault_Handler说明故障发生在Bootloader内。重点看LRLink Register寄存器值——它保存了故障发生前的返回地址。例如LR 0x08002340在Disassembly窗口跳转到该地址反汇编看上一条指令是什么。我曾遇到LR 0x08001A2C反汇编发现是BL HAL_FLASHEx_Erase而该函数在Flash执行擦除时访问Flash导致BusFault。第三步检查向量表重映射在Memory Browser中输入0x20000000查看SRAM起始256字节是否为APP的向量表。若全为0说明拷贝失败若部分为0说明拷贝不完整。再查SCB-VTOR寄存器值若为0x08000000说明VTOR未设置。第四步验证Flash内容用J-Flash Lite连接F103读取B区0x0800C000~0x080133FF导出bin文件。用xxd -g1 firmware.bin | head -20查看前20字节确认MSP前4字节非0Reset_Handler地址5-8字节非0。再用arm-none-eabi-readelf -S firmware.bin检查段表确认.text段起始地址与B区基址一致。第五步Bootloader反汇编精读在Keil中右键Bootloader工程 → “Go To Source”定位到Jump_To_Application()函数。按AltF7打开Disassembly逐行分析LDR R0, app_addr→ 加载B区地址到R0LDR R1, [R0]→ 读取MSP初值R0指向0x0800C000R10x20005000MSR MSP, R1→ 设置主堆栈LDR R0, [R0, #4]→ 读取Reset_Handler地址R040x0800C004BX R0→ 跳转若BX R0后立即HardFault检查R0值是否为合法地址如0x0800C005奇数地址会触发UsageFault。原因通常是APP的Reset_Handler地址未对齐——GCC默认生成的Reset_Handler在偶地址但若链接脚本错误可能导致偏移。终极排错技巧在Bootloader中插入LED闪烁在Jump_To_Application()前加HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET); // 红灯亮 HAL_Delay(100); HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_RESET); // 红灯灭若红灯闪烁后熄灭说明跳转成功若红灯常亮说明卡在跳转前若红灯不亮说明Bootloader自身未运行。这个土办法比万用表还准。7. 工程落地 checklist从原理图到量产固件的12个必检项把AB OTA从实验室搬到产线光功能正确远远不够。以下是我在三个量产项目智能电表、工业传感器、医疗终端中总结的12个硬性检查项漏掉任意一项都可能导致批量返工原理图Flash电路验证确认Flash的VDD/VSS引脚接电源/地WPWrite Protect引脚悬空或拉高禁止写保护HOLD引脚悬空避免意外挂起。F103的Flash是片内但若用外部SPI Flash存OTA包此条必查。Bootloader Flash起始地址在STM32CubeMX中Project Manager → C/C → Symbols添加VECT_TAB_OFFSET0x0Bootloader从0x08000000启动APP中设VECT_TAB_OFFSET0xC000B区偏移。APP链接脚本分区修改STM32F103C8Tx_FLASH.ld明确指定.text段起始地址为0x0800C000长度0x700028KB并添加.ota_flag段到Sector 127末尾。中断向量表拷贝长度APP向量表共256项×4字节1024字节拷贝时必须memcpy((void*)0x20000000, (void*)app_addr, 1024)少一字节都会导致中断失效。SysTick时钟源APP的HAL_Init()中HAL_SYSTICK_Config(HAL_RCC_GetHCLKFreq()/1000)必须基于HCLK而非SYSCLK因F103的HCLKSYSCLK。低功耗模式兼容性若APP使用HAL_PWR_EnterSTOPMode()跳转前必须禁用所有唤醒源__HAL_PWR_CLEAR_FLAG(PWR_FLAG_WU)否则Bootloader可能被误唤醒。JTAG/SWD接口保护量产固件必须禁用JTAG__HAL_AFIO_REMAP_SWJ_DISABLE()防止被恶意调试。Flash写保护位生产烧录时用J-Link设置Option Bytes启用WPRWrite Protection保护Bootloader区Sector 0但允许B区擦写。OTA包签名验证在HTTP下载后用RSA-2048验签公钥硬编码在Bootloader中。我见过黑客伪造OTA包植入后门签名是底线。温度适应性测试在-40℃和85℃环境下重复OTA 100次确认Flash擦写参数如FLASH_TIMEOUT_VALUE足够宽裕。低温下擦除时间延长30%需增大超时阈值。EMI抗扰度在电机驱动器旁测试OTAWi-Fi信号易受干扰。解决方案增加HTTP重试次数从3次增至5次并启用TCP Keepalive。固件回滚触发条件定义“启动失败”为APP运行5秒内未发出心跳包或连续3次HAL_GetTick()返回值不变表明SysTick停摆。此时Bootloader必须强制回滚而非无限重启。最后一点个人体会不要迷信CubeMX生成的代码。它生成的HAL_FLASHEx_Erase()在Flash执行HAL_GPIO_Init()未处理复位状态SystemCoreClockUpdate()在APP中可能返回错误值。所有关键路径跳转、擦除、校验必须手写汇编或SRAM函数并用__attribute__((section(.ramfunc)))强制加载。我最终的Bootloader代码中92%是手写C只有GPIO初始化等非关键路径用HAL。因为OTA的可靠性不取决于你用了多少高级API而取决于你对每一行汇编指令的掌控力。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。