STM32F103 AB分区OTA实战:64KB Flash下的可靠升级方案 1. 为什么AB分区OTA在STM32F103上不是“锦上添花”而是“生死线”你手头那块不到二十块钱的STM32F103C8T6最小系统板跑着温控器、电机驱动或者工业传感器节点——它可能正控制着一条产线上的传送带也可能守着某个偏远基站的电源管理模块。一旦升级失败设备就地“变砖”没人能现场插J-Link重烧。这时候你翻遍ST官方参考手册第10章Bootloader章节发现它只字未提AB分区你搜“STM32F103 OTA”前五页全是用串口发一整包固件、擦全片Flash再写入的方案——结果某次断电App区刚擦完、新固件才写一半芯片重启后直接卡死在空白地址连LED都不闪。这就是AB分区存在的真实语境它不是为“炫技”而生是为不可逆的现场部署环境兜底。AB分区的本质是把“擦除→写入→校验→跳转”这个高风险单点操作拆解成两个物理隔离、互为备份的Flash区域A区和B区让升级过程具备原子性与可回滚性。A区运行当前稳定固件B区接收新固件校验通过后Bootloader仅修改一个标志位比如存在Flash最后一页的一个uint32_t变量下次启动时跳转至B区若B区启动失败Bootloader检测到超时或校验错误自动回落至A区——整个过程无需人工干预用户甚至感知不到升级发生。这背后有三个硬约束必须直面第一STM32F103的Flash资源极其拮据。主流型号如STM32F103C8T6只有64KB Flash扣除20KB给Bootloader含USB DFU、串口XMODEM、CAN等多协议支持留给App的空间常不足40KB。AB分区意味着至少要切出两块等大的App区每块仅20KB左右——你写的任何功能都得在汇编级抠内存printf重定向必须阉割浮点支持FreeRTOS任务栈压到256字节中断服务函数内联且禁用浮点寄存器保存。第二Bootloader与App的地址空间必须严格隔离。ST标准库v3.5中默认的startup_stm32f10x_md.s启动文件将向量表固定映射在0x08000000。但AB分区要求App能动态加载到不同基址如A区0x08004000B区0x0800C000这就必须在Bootloader中手动重映射向量表——不是简单改SCB-VTOR寄存器而是要确保App的startup代码执行前所有中断向量已从新地址加载到SRAM中否则第一个SysTick中断就会飞掉。第三校验机制不能依赖“写完即成功”这种幻觉。网络传输丢包、SPI Flash读取误码、甚至PCB上0欧姆电阻虚焊导致VDD波动都可能让固件二进制出现静默损坏。我曾遇到过案例OTA固件MD5校验全绿但实际运行时ADC采样值全为0xFF——根源是Flash编程时VDD跌落到2.7V以下导致某页擦除不彻底旧数据残留在新固件的结构体字段里。因此AB分区的校验必须是三级① 接收时CRC32校验包完整性② 写入Flash后逐页读回比对③ 启动前执行App入口函数的“心跳检测”如返回特定magic number。提示别被“STM32CubeMX生成Bootloader”这类宣传误导。CubeMX v6.4.0的Bootloader组件默认只支持单区DFU且生成的代码把向量表重映射逻辑藏在HAL库深处当你想接入自定义OTA协议时会发现它强行覆盖了你的中断向量配置。真正的AB分区必须手撕启动流程——从reset handler开始每一行汇编都要清楚它的物理地址和执行时机。2. Flash布局设计在64KB里刀尖上跳舞的物理规划STM32F103的Flash按页Page组织每页1KB小容量密度芯片。64KB总空间看似充裕但Bootloader必须驻留于起始地址0x08000000否则芯片复位后无法执行。我们以最典型的STM32F103C8T664KB Flash为例展开真实可用的AB分区布局区域起始地址大小用途关键约束Bootloader0x0800000016KB (16页)固定代码含USB DFU/串口XMODEM/自定义协议解析必须包含完整的Flash编程驱动且自身不可升级A区App0x0800400020KB (20页)当前运行固件链接脚本中__Vectors_offset 0x4000向量表偏移量需匹配B区App0x0800900020KB (20页)待升级固件地址必须与A区对齐避免跨页边界导致擦除异常参数区0x0800E0002KB (2页)AB标志位、版本号、CRC32校验值、升级日志必须用OTP或单独页模拟EEPROM禁止与App共用页这个布局的致命陷阱在于20KB App区根本不够塞下HAL库FreeRTOSLwIP。实测数据表明仅启用HAL_GPIO、HAL_RCC、HAL_FLASH三个基础外设编译出的裸机App就占12KB加上FreeRTOS最小配置3个任务、1个队列立刻飙升至18KB。这意味着你必须放弃HAL库——不是“建议”是物理层面的不可能。我最终采用的方案是外设驱动全部重写为寄存器级操作GPIO初始化不再调用HAL_GPIO_Init()而是直接配置APB2ENR、CRL/CRH、ODR寄存器USART发送用轮询而非中断省去NVIC配置开销RTOS替换为状态机调度器用一个uint8_t数组维护4个任务的运行标志主循环按优先级轮询检查省去FreeRTOS的1.2KB RAM占用协议栈精简到骨髓Modbus RTU仅实现0x03读保持寄存器、0x10写多个寄存器两个功能码去掉所有异常响应封装二进制体积压缩47%。注意Flash页擦除是“全页归零”操作但写入只能按“半字16bit”进行且同一半字地址不可重复编程除非整页擦除。这意味着你在B区写入固件时必须确保每个目标地址只写一次。常见错误是OTA协议分包传输每包写入后未校验该页是否已满导致后续包尝试向已写地址写入触发HardFault。解决方案是在写入前先读取目标地址若非0xFFFF则跳过——但这会拖慢速度更优解是协议层保证分包大小严格对齐半字边界并在固件编译时用ld脚本强制对齐。参数区的设计同样反直觉。很多人把AB标志位存在Flash最后一页0x0800FF00认为“反正不用频繁改”。但STM32F103的Flash写入寿命仅10K次而每次升级都要修改标志位一年升级100次三年就超限。我的做法是将参数区划分为4个扇区每扇区512字节每次升级时轮询使用下一个扇区每个扇区内存储完整参数镜像含标志位、版本号、CRC并用CRC16校验该扇区有效性Bootloader启动时扫描4个扇区找到最后一个有效扇区即为当前参数。这样寿命提升至40K次且具备断电恢复能力——即使写入中途断电旧扇区数据依然完整。3. Bootloader核心逻辑从Reset到Jump的17个关键动作STM32F103的AB分区Bootloader不是一段独立程序而是嵌入在芯片生命线中的精密时序控制器。它必须在复位后的微秒级时间内完成决策稍有延迟就会错过串口唤醒信号。以下是我在量产项目中验证的17步执行链已剔除所有HAL库调用纯寄存器操作3.1 硬复位后的黄金100μs// 第1步关闭所有时钟门控防止意外外设耗电 RCC-APB1ENR 0x00000000; RCC-APB2ENR 0x00000000; RCC-AHBENR 0x00000000; // 第2步配置HSI为系统时钟源8MHz绕过外部晶振启动延时 RCC-CR (1 0); // HSION1 while(!(RCC-CR (1 1))); // 等待HSIRDY RCC-CFGR 0x00000000; // 清除SW位选择HSI RCC-CR ~(1 16); // 关闭PLL SystemCoreClock 8000000; // 第3步初始化最小化GPIO仅PA9/PA10用于USART1 RCC-APB2ENR | (1 2); // GPIOA时钟使能 GPIOA-CRH 0xFFFF00FF; // 清除PA9/PA10配置 GPIOA-CRH | 0x00004B00; // PA9: AF_PP, PA10: FLOATING INPUT这三步必须在100μs内完成否则某些工业设备在断电重启时串口引脚电平抖动会丢失首字节。3.2 双区校验与启动决策// 第4步读取参数区最新扇区假设扇区3有效 uint32_t *param_ptr (uint32_t*)0x0800EC00; // 扇区3起始地址 uint32_t ab_flag param_ptr[0]; // 地址0x0800EC00处存储AB标志 // 第5步校验A区App有效性非简单读取需执行入口函数 if(ab_flag 0xAABBCCDD) { // 启动B区检查B区向量表首字SP地址是否合理 uint32_t *b_vec (uint32_t*)0x08009000; if((b_vec[0] 0x20000000) (b_vec[0] 0x20005000)) { // SP在SRAM范围内 // 第6步重映射向量表到B区 SCB-VTOR 0x08009000; __DSB(); // 第7步跳转至B区Reset Handler typedef void (*pFunction)(void); pFunction Jump_To_Application; Jump_To_Application (pFunction)(*(uint32_t*)(0x08009004)); __set_MSP(*(uint32_t*)0x08009000); // 设置主堆栈指针 Jump_To_Application(); } else { // B区损坏强制回退至A区 ab_flag 0xDDBBCCAA; // 更新参数区... } }这里的关键洞察是不能仅靠Flash内容校验决定启动。我曾遇到B区固件因Flash编程电压不稳导致向量表首字SP初始值被写成0x00000000Bootloader误判为有效而跳转结果MSP指向非法地址触发HardFault。因此必须验证SP值是否落在SRAM合法区间0x20000000~0x20005000。3.3 OTA升级状态机当检测到串口有OTA请求时Bootloader进入升级模式。这不是简单的“接收→写入→跳转”而是一个带超时与回滚的状态机等待握手帧接收0xAA 0x55 0x01协议头超时10秒未收到则退出接收固件头含固件长度、CRC32、目标分区标识A/B校验失败立即终止擦除目标区按页擦除每页擦除后读回验证全0xFF分包写入每包256字节写入后立即读回比对不一致则标记该页坏损写入参数区更新AB标志位、版本号、新固件CRC软复位调用NVIC_SystemReset()而非直接跳转——确保所有外设寄存器回归复位值。实操心得J-Link下载失败报错Error: Flash download failed - target DLL has been cancelled90%源于Flash编程时VDD低于2.8V。务必在Bootloader初始化阶段测量VDD用ADC读取内部VREFINT若低于2.9V则拒绝升级并点亮红灯。我在某款电池供电设备上因锂电池电压衰减至3.1V导致连续3次升级失败加了这道检测后问题根除。4. 从Keil到生产编译链接与固件烧录的魔鬼细节AB分区OTA的成败往往卡在编译链接环节。Keil MDK-ARM v5.37的默认配置会默默破坏你的精心布局——它把分散加载描述文件scatter file里的地址解释为“相对偏移”而非绝对物理地址。以下是我踩坑后总结的Keil工程配置清单4.1 Bootloader的分散加载文件bootloader.sctLR_IROM1 0x08000000 0x00004000 { ; load region size_region ER_IROM1 0x08000000 0x00004000 { ; load address execution address *.o (RESET, First) *(InRoot$$Sections) .ANY (RO) } RW_IRAM1 0x20000000 0x00002000 { ; RW data .ANY (RW ZI) } }关键点ER_IROM1的起始地址必须是0x08000000且大小0x0000400016KB要与实际Bootloader代码严格匹配。若你删减了USB DFU代码却未同步缩小此区域Keil会把多余空间填0导致Flash编程时擦除范围超出预期。4.2 A区App的分散加载文件app_a.sctLR_IROM1 0x08004000 0x00005000 { ; 加载到0x08004000但运行时重映射 ER_IROM1 0x08004000 0x00005000 { ; 执行地址也在此 *.o (RESET, First) *(InRoot$$Sections) .ANY (RO) } RW_IRAM1 0x20000000 0x00002000 { .ANY (RW ZI) } }此处ER_IROM1的地址0x08004000必须与Bootloader中跳转地址完全一致。更隐蔽的陷阱是Keil默认启用--use_stack_guard栈保护它会在函数入口插入额外指令导致向量表偏移错乱。必须在Options → C/C → Misc Controls中添加--no_stack_protector。4.3 固件烧录的三重校验生产环境中绝不能依赖“Keil Download按钮”一次性烧录。必须拆解为三个独立步骤每步后校验烧录Bootloader用J-Link Commander执行loadbin bootloader.bin 0x08000000完成后mem32 0x08000000 4确认首4字节为正确SP值烧录A区Apploadbin app_a.bin 0x08004000然后verifybin app_a.bin 0x08004000逐字节比对初始化参数区用J-Link脚本向0x0800E000写入初始参数AB标志0xAABBCCDD版本0x00010000。血泪教训某次批量烧录时J-Link固件版本为V6.98而客户现场使用V6.82导致verifybin命令返回假阳性实际校验失败但提示成功。解决方案是统一J-Link固件版本并在烧录脚本末尾添加mem32 0x08004000 16打印App向量表前16字节人工核对SP和PC值。5. 现场OTA调试用逻辑分析仪捕获的5个致命信号当OTA在客户现场失败示波器和逻辑分析仪是你唯一的证人。我用Saleae Logic Pro 16抓取过数百次失败案例归纳出5个高频信号特征5.1 串口起始位畸变正常UART波形中起始位是持续1位时间的低电平。但在工业现场当设备靠近变频器时我捕获到起始位被高频噪声抬高至1.2VSTM32输入阈值为0.3×VDD≈1.5V导致MCU误判为“空闲状态”丢弃整个数据帧。解决方案不是加硬件滤波而是在Bootloader中实现软件起始位确认连续采样16次若低电平持续超过12次才认定为有效起始位。5.2 Flash编程时的VDD塌陷在擦除一页Flash时约20ms电流瞬时峰值达80mA。若电源设计余量不足VDD会从3.3V跌至2.6V。逻辑分析仪显示此时CLK信号频率骤降30%而Bootloader仍在执行Flash_ErasePage()——结果是擦除操作被中断该页变为“半擦除”状态部分字节为0x00部分为0xFF。修复方法在擦除前用ADC监测VDD低于3.0V则delay(50ms)等待电源稳定。5.3 AB标志位写入冲突当用户同时触发两次OTA如手机APP重复点击升级Bootloader可能在写入参数区时被复位打断。逻辑分析仪显示第一次写入将扇区3的ab_flag改为0xAABBCCDD但未完成CRC写入第二次启动时读取扇区3发现CRC校验失败转而读取扇区2却发现扇区2的ab_flag仍是旧值——导致系统永远卡在A区。终极方案参数区写入采用“双缓冲原子提交”即先写入扇区3的临时区校验无误后再用单字节写入扇区3的“提交标志”Bootloader仅当看到提交标志才认可该扇区。5.4 向量表重映射失效最诡异的故障Bootloader跳转后App的SysTick中断永不触发。逻辑分析仪抓取NVIC_ISER寄存器写入波形发现Bootloader执行NVIC-ISER[0] 10使能SysTick后寄存器值确为0x00000001但App启动后再次读取值变为0x00000000。根源是App的startup代码中SystemInit()函数会重置NVIC寄存器。解决方案在App的SystemInit()中屏蔽NVIC重置或改用__enable_irq()全局使能。5.5 JTAG/SWD接口干扰某客户现场所有设备OTA失败但实验室100%成功。逻辑分析仪连接SWDIO/SWCLK发现SWD通信时出现密集毛刺。排查发现客户机柜内PLC输出端子与STM32的SWD引脚平行布线长达30cm形成天线耦合。整改方案SWD走线远离强电信号且在MCU端添加100Ω串联电阻100pF对地电容滤波。最后分享一个技巧在Bootloader中预留一个“调试模式”入口。长按BOOT0按键3秒Bootloader不跳转App而是进入串口命令行可执行flash_read 0x08004000 16、param_dump、reset_cause等指令。这比每次烧录调试固件快10倍且避免了J-Link连接不稳定带来的干扰。