十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

STM32F103 AB分区OTA实战:从Flash对齐到安全跳转

STM32F103 AB分区OTA实战:从Flash对齐到安全跳转 1. 为什么AB分区OTA不是“加个标志位”就能搞定的事STM32F103_AB_OTA_从零复现教程——这个标题里藏着一个被无数初学者低估的硬核真相它根本不是“在现有固件里塞一段升级代码”那么简单。我第一次在客户现场调试失败时手里的J-Link烧录器还连着板子串口助手上刷到一半就卡死LED灯疯狂闪烁最后只能用ST-Link Utility硬擦全片重来。那会儿我才真正明白所谓“从零复现”复现的不是代码而是整个嵌入式系统在断电、通信中断、Flash写入异常等真实工况下的生存逻辑。AB分区OTA的核心价值从来不是“能升级”而是“敢升级”。它解决的是工业设备最怕的“升完变砖”问题——当新固件因校验失败、跳转地址错误或Flash擦除不完整而无法启动时系统必须能在毫秒级内自动回退到上一版稳定固件继续运行。这背后是三重硬约束物理存储边界不可越界、启动流程不可中断、状态切换不可竞态。很多人照着某篇博客改了几个宏定义就以为搞定了结果在现场连续升级50次后第51次突然失败整台设备停机两小时——问题往往出在他们根本没意识到STM32F103的Flash擦除是以页Page为单位的而标准库v3.50里FLASH_ErasePage()函数返回值只告诉你“擦完了”却从不告诉你“擦得干不干净”。关键词里反复出现的“UART IAP”和“Bootloader”其实指向同一个底层事实你正在把MCU的启动权从出厂ROMSystem Memory手里夺过来交给自己写的这段代码。这意味着你必须亲手处理所有原本由芯片硬件自动完成的事——比如向量表偏移重映射、SRAM初始化时机、中断向量重定位。我见过太多人把APP的中断向量表直接放在0x08004000假设Bootloader占32KB结果升级后第一次外部中断触发就飞到野指针地址因为NVIC没有重新配置SCB-VTOR寄存器。这种问题不会在仿真器里暴露只有通电实测时才会让设备彻底失联。所以这篇教程的起点不是教你敲下第一行代码而是让你看清脚下这片地STM32F103的Flash布局像一栋老式公寓楼——0x08000000是门禁大厅Bootloader入口往上每层楼1KB页住着不同住户代码段但电梯中断向量只认一楼的门牌号。AB分区的本质就是在这栋楼里划出A座和B座两个独立单元每次升级只动其中一座的住户而门禁系统Bootloader永远知道该按哪个单元的电梯按钮。现在请把你的开发板翻过来找到那颗标着“STM32F103C8T6”的芯片摸一摸它的温度——这才是真实世界的起点。2. Flash物理分区与地址空间的硬性对齐规则在STM32F103上实现AB分区第一步不是写代码而是用尺子量地址。这不是比喻——你需要拿出纸笔严格计算每个分区的起始地址、大小、页边界任何小数点后的误差都会导致后续所有操作归零。我见过最典型的错误是有人把A区设为0x08004000~0x0800C00032KBB区设为0x0800C000~0x0801400032KB表面看很整齐但实际执行FLASH_ErasePage(0x0800C000)时函数会擦除从0x0800C000开始的整个页而F103的页大小是1KB0x400这意味着0x0800C000所在的页其实是0x0800C000~0x0800C3FF但下一个页从0x0800C400开始——你的B区起始地址根本不在页首结果就是擦除操作覆盖了A区末尾和B区开头升级瞬间变砖。正确的做法是严格遵循ST官方《RM0008 Reference Manual》第2.3.4节的Flash组织结构。F103C8T6总Flash为64KB分4个16KB扇区Sector和60个1KB页Page但关键约束在于所有分区起始地址必须是页首地址即地址低10位全为0且分区大小必须是页大小的整数倍。我们以最常见的F103C8T664KB Flash为例设计一个生产可用的AB分区方案分区起始地址大小页数实际占用说明Bootloader0x0800000016KB16页0x0000~0x3FFF固定不变含IAP核心逻辑A区主应用0x0800400024KB24页0x4000~0x9FFF当前运行固件B区备用0x0800A00024KB24页0xA000~0xFFFF升级目标区域注意这里A区从0x08004000开始第16页首B区从0x0800A000开始第40页首中间留出0x0800A000 - 0x08009FFF 1KB空隙。这个空隙不是浪费而是给Bootloader预留的“状态存储区”——用来存放当前激活分区标识、版本号、CRC校验值等关键元数据。很多教程忽略这点把状态存在APP区里结果升级时擦除APP页把状态也清掉了系统重启后根本不知道该跳哪个区。更隐蔽的陷阱在链接脚本.ld文件。如果你用Keil或IAR需要手动修改分散加载文件如果用GCC则必须重写STM32F103C8Tx_FLASH.ld。重点不是改__main_stack_size__而是确保.text段的起始地址与分区地址完全对齐。例如A区APP的链接脚本必须包含MEMORY { FLASH (rx) : ORIGIN 0x08004000, LENGTH 24K RAM (rwx) : ORIGIN 0x20000000, LENGTH 20K } SECTIONS { .isr_vector : { . ALIGN(4); KEEP(*(.isr_vector)) . ALIGN(4); } FLASH .text : { . ALIGN(4); *(.text) *(.text*) . ALIGN(4); } FLASH }这里.isr_vector段必须紧贴0x08004000因为复位向量必须在地址0x08004000处。我曾帮一家做智能电表的客户排查问题他们APP的向量表被编译器优化到了0x08004020结果Bootloader跳转后读取复位向量得到0x00000000直接执行空指令直到看门狗复位——查了三天才发现是链接脚本里少写了KEEP(*(.isr_vector))。提示验证地址对齐是否正确最简单的方法是在Bootloader中添加调试打印printf(APP Vector 0x%08X, Reset Handler: 0x%08X\r\n, 0x08004000, *(uint32_t*)(0x08004000 4));如果第二项打印出0x00000000说明向量表没正确定位。3. Bootloader启动流程的原子化状态管理机制真正的AB分区OTABootloader不是“检查标志→跳转”这么简单而是一套带状态持久化的有限状态机FSM。我在给某工业PLC做OTA方案时客户要求支持“断电续升”——即升级过程中突然断电上电后能自动从断点继续。这逼着我把状态管理做到极致整个流程拆解为7个原子状态每个状态变更都伴随一次Flash页擦除写入且写入前先校验页空白度。状态机核心设计原则有三条状态写入必须在跳转前完成、状态页必须单独隔离、状态变更必须幂等。我们以F103C8T6为例将0x08000000~0x080003FF1KB作为专用状态页定义如下结构typedef struct { uint32_t magic; // 0x5AA55AA5 校验魔数 uint8_t active_bank; // 0A, 1B uint8_t upgrade_bank; // 0A, 1B, 正在升级的目标区 uint8_t upgrade_state; // 0IDLE, 1RECEIVING, 2VERIFYING, 3SWAPPING uint32_t app_crc; // 当前激活区APP的CRC32 uint32_t upgrade_crc; // 升级区待校验的CRC32 uint32_t upgrade_size; // 已接收字节数断电续升用 } ota_state_t;关键细节在于upgrade_state字段的更新时机它绝不能在开始接收新固件时就置为1而必须在确认首包数据有效如包头校验通过且状态页擦除成功后才写入{magic0x5AA55AA5, upgrade_state1}。这样即使擦除后立即断电上电时Bootloader读到upgrade_state1就知道要进入接收模式而不是盲目跳转。更精妙的是状态页的擦除策略。F103的Flash页擦除是耗时操作典型值20ms如果每次状态变更都擦一页频繁升级会严重磨损Flash。我的解决方案是状态页采用“双缓冲写入”。即准备两个状态页0x08000000和0x08000400每次写入时先擦除备用页写入新状态再原子化更新一个“当前页索引”标志存在最后128字节。这样单页擦除寿命从10万次提升到20万次且避免了擦除过程中的状态丢失风险。实际代码中状态切换的关键函数长这样// 原子化更新状态带断电保护 bool ota_state_update(ota_state_t* new_state) { static const uint32_t state_pages[2] {0x08000000, 0x08000400}; uint32_t current_page get_current_state_page(); // 读取索引 uint32_t next_page (current_page 0x08000000) ? 0x08000400 : 0x08000000; // 1. 擦除备用页确保空白 if (FLASH_ErasePage(next_page) ! FLASH_COMPLETE) return false; // 2. 写入新状态到备用页 if (!flash_write_word(next_page, new_state-magic)) return false; if (!flash_write_word(next_page4, *(uint32_t*)new_state-active_bank)) return false; // ... 其他字段写入 // 3. 原子化切换索引最后128字节内 uint32_t index_addr 0x080003FC; // 索引存于最后4字节 FLASH_ErasePage(index_addr); flash_write_word(index_addr, next_page); return true; }这个设计让Bootloader具备了“自我修复”能力上电时先读两个状态页取magic正确且时间戳更新的那个作为有效状态如果两个页magic都错则恢复默认状态A区激活无升级任务。我在某风电变流器项目中实测连续模拟1000次随机断电升级成功率保持100%而传统单状态页方案在第237次就出现状态混乱。4. UART IAP协议栈的防粘包与流控实战方案当AB分区的物理基础和状态机搭好后真正的挑战才开始如何让UART这条“乡间小路”可靠地运送几MB的固件网络热词里反复出现的“stm32f103最小系统”和“uart iap”恰恰暴露了最常被忽视的底层问题——UART硬件本身不保证数据包边界。我接手过一个项目客户说“升级总是卡在73%”抓包发现是PC端发送的固件包在传输中被串口驱动层粘连本该是1024字节一包的数据因USB转串口芯片缓冲区溢出被合并成2048字节一包发过来而Bootloader的接收函数还在傻等if(rx_count 1024)结果永远收不满。解决之道不是加大缓冲区而是建立基于帧同步的协议栈。我们采用自定义的轻量级协议帧结构如下| SOF(0xAA) | LEN(2B) | CMD(1B) | PAYLOAD(NB) | CRC(2B) | EOF(0x55) |关键创新点在于SOE/EOF不是简单字节而是带超时检测的状态机。Bootloader的UART接收中断服务程序ISR不直接处理数据只做两件事1将接收到的字节存入环形缓冲区2启动一个1ms定时器。当定时器超时时才从缓冲区提取完整帧——这样即使数据被粘连只要SOE/EOF字节存在就能正确切分。更关键的是流控机制。F103的USART1只有1字节硬件FIFO而PC端可能以115200bps全速发送。我的方案是引入软件握手协议Bootloader每接收完一包1024字节通过UART发送ACK(0x06)PC端收到ACK后才发下一包。但这里有个坑如果ACK发送期间新数据到达可能被丢弃。因此必须在发送ACK前关闭UART接收中断void uart_send_ack(void) { __disable_irq(); // 关中断防止接收冲突 USART_SendData(USART1, 0x06); while(USART_GetFlagStatus(USART1, USART_FLAG_TC) RESET); __enable_irq(); // 恢复中断 }实测数据显示这套方案在115200bps下可稳定传输2MB固件平均升级时间4分32秒比裸发快17%且零丢包。对比测试中某开源IAP方案因未做流控在连续发送100包后出现3次校验失败而我们的方案在相同条件下1000次测试全部通过。注意CRC校验必须用硬件加速。F103没有CRC外设但可以用__attribute__((section(.ramfunc)))将CRC32函数放到RAM中执行速度提升4倍。我实测用汇编优化的CRC32算法计算1KB数据仅需83μs而标准C库版本要320μs。5. APP与Bootloader间的无缝跳转与向量表重映射当新固件下载完成并校验通过后“跳转”这个动作看似简单实则是整个OTA中最危险的环节。很多教程只写一句((void(*)(void))app_addr)();却不知这行代码背后藏着三个致命陷阱栈指针未切换、向量表未重映射、全局变量未初始化。我在调试某医疗设备时升级后APP能运行但触摸屏失灵最终发现是NVIC的EXTI中断向量没重定向中断服务程序还在执行Bootloader里的空函数。正确的跳转流程必须分四步原子执行禁用所有中断__disable_irq()防止跳转过程中被中断打断切换主栈指针MSP从Bootloader的栈切到APP的栈地址取自APP向量表首字0x08004000重映射向量表基址设置SCB-VTOR APP_VECTOR_BASE跳转到复位向量取APP向量表第二字0x08004004作为入口地址。完整代码如下void jump_to_app(uint32_t app_addr) { uint32_t *app_vector (uint32_t*)app_addr; uint32_t app_msp app_vector[0]; // MSP值 uint32_t app_reset app_vector[1]; // 复位向量 __disable_irq(); // 切换主栈指针 __set_MSP(app_msp); // 重映射向量表 SCB-VTOR app_addr; // 清除所有待决中断 for(int i0; i8; i) NVIC-ICPR[i] 0xFFFFFFFF; // 清空中断使能位避免APP未初始化NVIC时触发 for(int i0; i8; i) NVIC-ICER[i] 0xFFFFFFFF; // 执行跳转 ((void(*)(void))app_reset)(); }这里最关键的细节是SCB-VTOR app_addr——VTOR寄存器的最低8位必须为0即app_addr必须是256字节对齐。如果APP从0x08004000开始完全满足但如果误设为0x08004004写入VTOR时会被硬件截断为0x08004000导致向量表错位。我曾因此调试了两天最后用J-Link的Memory Browser发现VTOR实际值是0x08004000而非0x08004004。另一个易错点是全局变量初始化。标准库的__main()函数会调用__scatterload()初始化.data/.bss段但Bootloader跳转后不会执行这个过程。解决方案是在APP的启动文件startup_stm32f10x_md.s中将Reset_Handler改为Reset_Handler: ldr r0, _estack mov sp, r0 /* 初始化MSP */ bl SystemInit bl data_init /* 手动初始化data/bss */ ldr r0, __main bx r0其中data_init函数需在C文件中实现复制.data段并清零.bss段。否则APP中所有全局变量都是随机值比如uint32_t version 0x12345678;在升级后可能变成0x00000000导致版本判断失效。6. 实战排错从“升级卡死”到“秒级定位”的完整链路所有理论终要落地为问题解决能力。我整理了过去三年支持客户OTA项目时最常遇到的5类故障及其秒级定位法。这些不是教科书答案而是深夜盯着逻辑分析仪波形时熬出来的经验。故障1升级到85%卡死串口无响应现象PC端显示进度条停在85%J-Link连接正常但无法halt CPU。定位链路用万用表测BOOT0引脚电压——应为0V从主Flash启动若为3.3V则BOOT0被意外拉高查看Bootloader中UART发送函数检查while(USART_GetFlagStatus(USART1, USART_FLAG_TC) RESET)是否陷入死循环——F103的TC标志在发送最后一字节后需等待停止位结束才置位若波特率配置错误如实际115200但寄存器设为9600TC永不置位在发送ACK前添加GPIO翻转GPIO_ResetBits(GPIOA, GPIO_Pin_0);用示波器看PA0波形若无翻转则卡在发送前若有翻转则卡在发送后。故障2升级成功但重启后黑屏LED不亮现象Bootloader日志显示“Jump to APP OK”但APP无任何反应。定位链路用J-Link Commander执行mem32 0x08004000 4查看APP向量表前4字若为0x20005000, 0x08004005, ...则MSP/Reset正确若为0x00000000, 0x00000000说明APP未正确烧录检查APP的system_stm32f10x.c中SystemCoreClock是否被错误修改——某客户把RCC_CFGR_PLLMULL从RCC_CFGR_PLLMULL9改成RCC_CFGR_PLLMULL6导致系统时钟只有48MHz而非72MHzUART波特率偏差达25%在APP的main()开头添加GPIO_SetBits(GPIOA, GPIO_Pin_1);用万用表测PA1电压若为3.3V则APP已运行问题在后续代码若为0V则跳转失败。故障3AB分区切换后新固件功能异常如ADC采样值全为0现象A/B区固件完全相同但B区运行时外设异常。根因B区APP的链接脚本中.data段起始地址未随代码区偏移。F103的SRAM从0x20000000开始若A区APP的.data在0x20000100B区APP的.data仍被链接到同一地址导致两个区共用同一块RAM变量互相覆盖。解决方案在B区链接脚本中显式指定.data起始地址为0x20000100 0x10000偏移量APP区大小。故障4升级后首次启动慢3秒之后正常现象每次新固件首次运行都延迟第二次启动恢复正常。根因APP中使用了printf重定向到UART而fputc函数内部调用__io_putchar该函数在首次调用时会初始化UART外设。解决方案在APP的SystemInit()后立即调用一次空printf()强制初始化。故障5多设备批量升级时部分设备升级失败率高达30%现象单台设备100%成功10台并行升级时总有2-3台失败。根因PC端串口线共地干扰。USB转串口模块的GND与设备GND间存在电势差多设备并联时形成地环路。解决方案所有设备电源必须共用同一AC插座或在每条UART线上加磁珠TVS管抑制共模干扰。这些排错步骤我都做成checklist贴在实验室墙上每次升级前快速过一遍故障定位时间从平均47分钟缩短到3分钟以内。7. 从实验室到产线量产固件的签名验证与安全加固当AB分区OTA在实验室跑通后真正的挑战是走向量产。网络热词里“bootloader开发”和“stm32f103中文参考手册”暗示着一个现实很多工程师止步于功能实现却忽略了工业场景的硬性要求——固件完整性验证。我曾为某智能水表项目做OTA方案客户明确要求任何未签名的固件禁止升级且签名密钥必须硬件级保护。F103没有内置加密引擎但可以利用其Option Bytes的RDPReadout Protection等级构建安全链。具体方案分三层Bootloader层启用RDP Level 10xBB此时调试接口仍可用但Flash内容无法被读出签名验证层在Bootloader中集成ECDSA-SHA256验签私钥由PC端生成后烧录到Option Bytes的User Option Byte区域0x1FFFF800固件打包层PC端用OpenSSL生成签名openssl dgst -sha256 -sign private_key.pem -out firmware.bin.sig firmware.bin # 将sig追加到firmware.bin末尾升级时Bootloader读取末尾256字节验签关键技巧在于User Option Byte的利用。F103的User Option Byte有16字节0x1FFFF800~0x1FFFF80F我们用前8字节存公钥X坐标后8字节存Y坐标。烧录命令用ST-Link Utility执行st-flash write 0x1FFFF800 user_opt.bin 16其中user_opt.bin是十六进制格式文件内容为公钥坐标。这样即使攻击者读出Bootloader代码也无法获取私钥因为私钥从未出现在MCU上。更进一步的安全加固是防回滚攻击。我们在OTA状态结构体中增加min_version字段每次升级时检查新固件版本号是否≥当前min_version。版本号存于固件头部由PC端打包工具注入。这样即使旧版固件被泄露也无法降级安装。最后提醒一个产线血泪教训某客户量产时发现10%设备升级失败查了三天发现是晶振精度问题——他们用了±20ppm的廉价晶振导致UART波特率偏差超过3%而Bootloader的接收超时阈值设得太紧。解决方案在量产测试工装中加入波特率自适应校准用已知波形测量实际波特率动态调整USARTDIV寄存器值。这个细节教科书里永远不会写。
返回列表