尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32F103 AB分区OTA从零复现:Bootloader设计与固件升级实战

STM32F103 AB分区OTA从零复现:Bootloader设计与固件升级实战 很多做嵌入式开发的兄弟手里都有一两块STM32F103开发板。这种经典的MCU跑裸机或者简单的RTOS都没问题但一旦产品做到量产阶段就会面临一个实际问题设备固件怎么升级拆壳烧录显然不现实于是OTA升级就被提上了日程。标题里提到了“STM32F103_AB_OTA_从零复现”这个组合很有意思。STM32F103代表了非常经典的Cortex-M3内核MCUAB代表当前主流的双分区OTA方案。整套方案在高端Linux设备上已经很成熟了但放到F103这种Flash只有64KB、128KB甚至256KB的芯片上就需要仔细做资源规划。这篇文章我不打算讲太虚的理论直接按照我在实际项目里复现这套方案的完整思路来梳理从分区规划、Bootloader设计、App端适配到升级协议、断电恢复、实测踩坑一步到位讲清楚。这篇内容适合正在做小家电、传感器节点、便携设备固件远程升级的工程师也适合刚学完单片机、想搞懂“Air升级”到底怎么玩的学生朋友。看完之后你能直接照着一套可落地的流程在自己的F103板子上把AB分区OTA跑起来。1. 分区方案与整体设计思路1.1 为什么最终选了AB双分区做OTA升级方案其实不少。我在早期做过一个单分区方案Bootloader直接擦掉App区然后接收固件写入全部完成后再跳转。这个方案逻辑简单、占用Flash少但有一个致命问题——升级过程中断电或通信中断设备就变砖了。解决变砖的办法一般有两个一个是Bootloader里做恢复固件功能配合上位机重新烧录就是所谓的Recovery模式另一个就是标题里提到的AB双分区方案。AB双分区的核心思想很简单把用户程序区划分为两个独立的槽位分区A和分区B。当前运行在一个分区升级时把新固件写入另外一个分区。写入完成后通过标记切换启动分区下次重启后Bootloader引导进入新分区。如果新固件跑不起来看门狗触发重启后Bootloader发现新分区状态异常自动回退到旧分区。整个过程不用人工干预设备永远有一个已知能跑的固件打底。这个方案在F103上的优势非常明显第一抗断电能力极强哪怕升级过程掉电最多只是把新分区写了一半旧分区毫发无损第二回滚机制天然存在不需要额外维护一个完整的备份镜像只是把存储区域一分为二通过不同的偏移地址访问而已。1.2 F103资源评估与分区表规划做AB分区前先要评估知道F103不同型号的Flash容量。以最常见的STM32F103C8T6为例它的Flash是64KBRAM是20KB。做一个Bootloader大约占4KB到8KB A分区约24KB B分区约24KB 参数区约2KB基本上是极限了。如果应用代码超过24KB建议换更大容量的芯片比如STM32F103RCT6它的Flash是256KB分区的弹性就大得多A和B各分96KB都没问题。我以128KB Flash的STM32F103ZET6或者RBT6为例给出一套典型分区表区域名称起始地址大小说明Bootloader0x0800000016KB引导程序、升级逻辑A分区App A0x0800400040KB默认运行区出厂固件B分区App B0x0800E00040KB备用运行区OTA目标区参数区0x080180004KB存放分区状态、版本信息、升级标志末尾保留0x08019000剩余预留这里为什么Bootloader要预留16KB因为除了最基础的跳转逻辑Bootloader里还要实现Flash擦写驱动、升级通信协议解析、CRC32校验算法这几块代码加起来很容易就超过8KB了。如果链路层走的是IAP串口协议还要处理分包和握手逻辑所以空间预留宽裕一些是明智的。参数区是关键设计。这里的4KB空间用于记录当前分区状态、新固件版本号和升级次数。参数区之所以单独划分是因为它需要频繁擦写不能放在代码段里。Flash擦写的寿命是有限度的F103的Flash擦写次数标称是1万次把参数区独立出来可以避免频繁擦写整个分区。1.3 为什么Bootloader需要独立工程我在网上看到过很多教程把Bootloader和App写在一个工程里用条件编译区分。这种做法在调试阶段省事但到了真正的AB双分区场景很难维护。原因在于两个程序的编译地址不同Bootloader从0x08000000开始App从0x08004000开始。如果共用一个工程链接脚本就得来回切换稍微不留神就把中断向量表放错了位置启动后系统死机都找不到原因。我的习惯是务必将Bootloader和App拆成两个独立的Keil或IAR工程编译产物各自独立。这样能更清晰地管控每一端的Flash占用和依赖关系。Bootloader工程里只放引导逻辑、Flash驱动和最小化的通信协议App工程则完全专注于业务代码只需要在项目配置里把IROM1的起始地址改为App分区的起始地址偏移量设置为同样数值即可。2. 核心原理拆解Booter到底做了什么2.1 从启动到跳转的完整链路无论是冷启动还是看门狗复位重启MCU都会从0x08000000地址取栈顶指针然后从复位向量开始执行。AB分区的引导流程就建立在这个基本机制之上。Bootloader启动后第一步是检查参数区里的升级状态标志第二步是校验A和B两个分区的固件有效性CRC32或App有效标志第三步是跳转到有效且优先级更高的分区。判断优先级的时候有一个场景需要重点考虑设备当前在A分区正常运行通过网络收到了新固件。此时Bootloader不会立刻跳转而是由App把新固件写入B区。在写入完成、校验通过后App才在参数区里把启动标志切换为“下次从B启动”。这个过程叫“延迟切换”确保机器重启后Bootloader才会引导新固件。如果升级后的B区固件在运行时崩溃或死机内置看门狗会超时复位设备再次进入Bootloader。Bootloader检查参数区域时发现B区还没被标记为“运行成功”于是自动把启动目标切回A区。这样用户感知到的可能只是设备重启了一下不会出现彻底变砖的情况。2.2 中断向量表重定向的关键细节跳转前有一道必须跨过的门槛中断向量表重定向。F103的Cortex-M3架构在程序启动时默认从Flash起始地址读取中断向量表。如果App运行在0x08004000而中断向量表还在0x08000000那么一旦产生中断比如SysTick、串口接收中断MCU就会跳转到Bootloader的中断向量表完全执行错误的处理函数。解决方案有两个写法。早期标准库时代常用的是在App初始化最前面把向量表基址写入SCB-VTOR寄存器#define APP_ADDR 0x08004000 void app_entry(void) { // 关键功能设置中断向量表偏移 SCB-VTOR APP_ADDR; // ... 后续初始化 }如果是IAR或Cortex-M3老版本固件库不支持VTOR偏移的还需要通过内存映射重映射Flash。这个办法比较绕现代开发直接操作VTOR寄存器最直接、最可靠。但这里有一个细节在App函数入口的最最开始编译器生成的启动代码会先执行堆栈初始化、拷贝.data段、清.bss段这些过程不依赖中断所以VTOR的设置在main函数前面不会出问题。实际操作中常见的做法是不修改启动汇编直接用SystemInit函数或者在main函数首行设置VTOR后再初始化外设和使能全局中断。2.3 跳转前的现场清理与地址检查Bootloader在执行跳转前必须把自身用到的外设全部复位。比如串口、DMA、定时器如果在跳转前不关闭跳转后这些外设的中断请求可能处于悬空状态App一旦开启全局中断立刻就会进入异常。正确做法是在跳转函数里完成以下步骤关闭SysTick并清除SysTick计数标志关闭所有已使用的外设时钟比如__HAL_RCC_GET_FLAG和RCC_DeInit操作将所有优先级分组设置为默认关闭全局中断直到App完成VTOR设置并重新开启。地址检查听起来简单却是我见过很多人忽略的坑。跳转地址不是随便算的必须确认跳转地址处的栈指针SP有效复位地址处的值为合法地址。标准写法是通过两个断言判断typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t jump_addr *(volatile uint32_t *)(app_addr 4); pFunction jump_func (pFunction)jump_addr; __disable_irq(); // ... 外设清理操作 ... if (((*(volatile uint32_t *)app_addr) 0x2FFE0000) 0x20000000) { __set_MSP(*(volatile uint32_t *)app_addr); jump_func(); } // 如果栈顶指针不在RAM区间内说明该地址没有有效固件 }这里的核心逻辑是校验首字指向RAMSTM32的RAM基址是0x20000000如果校验不通过说明这个分区没有固件或者固件损坏不能跳转。3. OTA升级链路与App端适配3.1 传输协议怎么设计才省心升级协议是整个方案里最重要的环节之一。很多最小实现会选择串口Ymodem协议这个协议非常经典但用来做AB分区升级协议本身没有内置“写入哪个分区”的概念。所以我建议在Ymodem的基础之上或者在自定义协议里增加一个头部管理字段。自定义协议不复杂但能避免很多socket和串口通信里的歧义更容易调试。我用的方案是小包分帧传输结构类似如下帧字段长度说明帧头2字节固定0xAA55帧类型1字节0x01握手0x02数据0x03结束0x04版本查询帧序号2字节数据帧的序号从0递增目标分区1字节0表示A区1表示B区数据长度2字节有效载荷长度最大256字节数据载荷N字节固件内容CRC324字节帧数据校验每一包数据的大小选择为256字节因为F103的Flash页大小是1KB或2KB不同容量型号页大小不同单包不超过页大小会给后续缓冲区处理带来很大方便。上位机每次发送一块下位机接收到完整帧且CRC校验正确后写入Flash的缓冲区凑满一页后整页写入。注意一点千万不要每一帧都直接调用Flash编程函数因为Flash写入操作会阻塞CPU而且频繁的写Flash会导致整个升级过程非常漫长。正确做法是设置一个256字节的RAM缓冲区收到数据先填进缓冲区填满一页之后调用一次Flash编程接口把整页写入。3.2 App端的分区写入与版本记录当App端确认自己当前运行在A分区新固件要写入B分区时写入地址的计算方式如下如果B分区起始地址是0x0800E000那么第n个数据帧写入的起始地址是uint32_t addr APP_B_START n * 256;这看起来很直接但实际代码里需要额外处理尾部不足256字节的分组。上一次写Flash前先调用Flash_Unlock并擦除整个B分区。这个擦除动作虽然耗时但这一步可以放在握手完成后、正式接收固件之前来做。有的实现喜欢一边接收一边擦除但考虑到FLASH擦除时间大概在20~40ms一页升级一个40KB的固件光擦除就要占掉不少时间而且擦除过程无法响应通信容易触发上位机超时重传。在App端写完整个分区后还要写入一个结构化的分区状态记录。我在实际项目里定义了这样一个结构体存放Flash头部的元信息区域typedef struct { uint32_t magic; // 固定魔数0xA5A5A5A5 uint8_t partition; // 当前分区0表示A1表示B uint8_t status; // 0x01待启动0x02运行中0x03升级成功 uint16_t version_major; uint16_t version_minor; uint32_t firmware_len; uint32_t crc32_total; } ota_param_t;升级成功后App把这个参数写入参数区并设置“待启动”状态同时指定下一次启动分区为B区。如果下次启动后App运行正常、稳定运行超过30秒就把状态修改为“升级成功”这样Bootloader后续就不会再次回退到老分区。3.3 引导与复位的双保险升级过程需要让Bootloader、App和通信端三方的逻辑闭环。我举个例子设备正在A区运行版本号1.0。上位机发起升级请求App响应并进入升级模式打开一个特殊的升级通道比如串口2配合上位机一帧一帧传输新固件。每成功接收一帧数据App都会向上位机回复一个ACK。如果上位机连续三次收不到ACK就暂停重发避免网络阻塞。在传输过程中如果传输中断、超时或者CRC校验失败App直接丢弃缓冲区数据不需要做任何Flash擦除动作。AB分区方案的优势就在这里——数据根本还没来得及落到Flash的新分区所以旧分区不受任何影响。只有当一个完整固件包全部校验通过并写入完成后才会修改启动参数。升级完成后App调用NVIC_SystemReset()执行软复位。设备重新进入BootloaderBootloader读取参数区发现状态是“待启动、B分区”于是校验B分区的CRC32值确认无误后跳转到B区。如果校验失败则自动回退到A区并清除“待启动”状态。4. 实际复现过程中踩过的坑4.1 CRC32放到最后算还是边传边算我一开始的实现是边接收帧边对每帧做CRC16校验固件全部接收完成后再直接对比上位机发送过来的总CRC32。这个思路本身没问题但有一个陷阱固件文件总长度并不是正好是256的倍数最后一批不足一个完整扇区时缓冲区内对应字节必须补齐。如果不补CRC计算结果和上位机完全不一致导致明明固件没传错却报校验失败。解决的方案有两个一个是上位机和下位机都约定好最后不足512或256字节时填充0xFF这是Flash擦除后的默认值另一个方案更优雅——App端在握手阶段先接收到固件长度然后按照“最后一个包的有效数据长度”精确计算CRC不填充多余字节。后者的代码需要多写几行但逻辑更严谨。4.2 跳转后App死活进不了中断这是复现AB OTA时出现频次最高的问题我之前也在这里栽了跟头。现象是Bootloader跳转到App后App的main函数和业务代码能跑起来但只要一使能串口中断或者定时器中断程序立刻跑飞。排查到最后原因在Bootloader跳转前没有完整清理时钟配置。Bootloader里用过DMA和串口跳转时只是简单关闭了外设时钟但DMA的某些配置寄存器在RCC复位后仍然保留原值或处于异常状态。这导致App初始化串口时状态出现错乱。最终的解决方案是在Bootloader跳转前调用一套完整的复位函数把RCC和SysTick恢复到复位状态然后在App的SystemInit函数里重新配置时钟。同时在跳转汇编执行前__set_PRIMASK(1)关闭全局中断App里设置好VTOR后再打开。这个流程只要顺序对了基本不会再出问题。4.3 参数区频繁擦写导致Flash提前报废早期我把分区状态直接存到一个固定扇区每次升级状态变化都写一次。结果测试设备在反复压测升级/回滚功能时没多久参数区就写不进去了。Flash擦写寿命虽然标称1万次但工程上更多时候是因为擦写逻辑设计不合理把寿命提前耗尽了。一个细节是F103标准库的Flash编程接口执行擦除前需要先解锁擦写完再回锁。参数区更新时如果频繁做整个扇区擦除操作磨损会非常严重。优化方案是把参数区当成一个简易日志缓冲区每次状态更新只写入一条新记录启动时从参数区尾部向头部遍历找到最后一条有效记录作为当前状态。只有当整个扇区满到无法追加时才做一次整扇区擦除。这样同样的扇区寿命就直接放大了几十倍。4.4 看门狗超时时间设置不合理在AB OTA场景里加入看门狗是为了防止升级后的新固件跑飞导致变砖但很多新手在设置喂狗时间时反而踩了坑。如果看门狗喂狗时间太短比如1秒App在处理Flash擦写时单片页擦除需要20~40ms通常没问题但如果升级完成后在参数区写入或者网络交互耗时较长就会在看门狗超时前没来得及喂狗导致设备不断重启。我的做法是Bootloader阶段启动看门狗时超时时间设置为10秒以上App启动后看门狗超时时间重设为5秒并且在App的业务主循环里定期喂狗。特殊升级流程中每成功接收10帧数据就喂一次狗确保长时间传输过程中不会误触发复位。4.5 从App区读取Bootloader配置的权限STM32F103没有MPU来限制访问权限这意味着运行在App分区的代码可以随意读取Bootloader区域的内容。按AB分区理念来说App不应该知道Bootloader内部的数据格式。但在实际开发中我见过有的工程师为了方便把一些参数表直接定义在Bootloader的只读区App去读取。这种做法一旦Bootloader升级后数据结构调整App就像踩雷一样随时崩。经验是Bootloader和App之间的数据交换唯一且受控的通道就是参数区。App需要什么信息Bootloader在跳转前把参数写到指定的RAM地址比如0x20001000App启动后从那里读取。这样两者耦合性最低各自也能独立维护和升级。5. 常见问题与排查技巧实录5.1 升级后Bootloader反复重启遇到这个问题首先检查新App分区的固件写入是否完整。用调试器读内存把B分区起始地址的前64字节dump出来看栈顶指针是否指向RAM范围向量表第一项是否合法。很多时候这个问题出在编译阶段App工程没有设置正确的IROM1地址导致编译出来的固件跳转地址还是0x08000000Bootloader检查地址合法性时直接拒绝跳转然后看门狗复位循环往复。还有一种隐藏原因Flash写完后没有做整固件CRC校验。我在第一个版本里省了这步结果传输过程中一个字节损坏Bootloader跳进新分区后App根本无法运行。后来老老实实在App写入完成后、修改启动标志前进行一次完整的全分区CRC32校验让问题提前暴露。5.2 串口升级到了中途就卡住卡住的原因大概率是Flow Control或者缓存溢出。如果用串口直连PC波特率115200上位机一次性发送大量数据F103的串口RX中断如果处理不及时数据溢出错位整个升级流程就被打乱了。我的排查思路是先降波特率降到38400然后把每个数据包之间的间隔调大一点再看是否还会卡住。如果不再卡基本就是接收处理速度赶不上发送速度。解决方法是开一个较大的环形接收缓冲区用DMA空闲中断接收不定长数据包。这比在串口中断里逐字节处理效率高一个量级。5.3 断电测试仍然出现了变砖AB分区方案也不是绝对的“永不砖”。有一次压测发现在App写完参数区、尚未执行系统复位时突然断电重启后Bootloader读到了“待启动B分区”的状态但是B分区的固件其实只写到一半或者CRC错误。按理来说Bootloader应该自动回退A区但因为我的Bootloader代码里只校验了新分区的“首字节有效”就放行了没有做完整CRC校验所以才出现了跳转到损坏固件的问题。修正方法是Bootloader跳转前必须对新目标分区做完整的CRC校验确认无误后再跳转。虽然这会让启动时间增加几百毫秒但换来的是“永不砖”的可靠性非常值得。5.4 Fake分区标志导致回退失效最后分享一个细节问题A分区和B分区的参数区标志位要避免使用某个简单固定的值比如0x00和0xFF。Flash擦除后默认值是0xFF如果程序里的分区标志恰好定义成0xFF表示“B区有效”那么新芯片第一次烧录时什么都没写Bootloader就会误判B区有效。我的做法是出厂下载Bootloader时先对参数区执行一次初始化将A分区状态写为“升级成功”并设置magic0xA5A5A5A5。之后所有状态判断都必须同时校验magic和statusmagic不匹配直接视为无效。6. 后续还能怎么扩展6.1 升级通道的多样化扩展这套AB OTA方案目前是基于串口逐帧传输的但方案本身和传输通道是松耦合的。如果你后续想迁移到WiFi模组、4G模组或者LoRa通信改动的主要是数据接入层核心的分区管理、代码跳转、CRC校验和回滚逻辑完全不用动。比如在项目中我还把同一套AB OTA逻辑复用到了带WiFi的采集设备上WiFi模块负责从云端下载固件固件数据通过串口转发给F103F103内部走的就是和本文完全相同的B区写入流程。这样做的好处十分明显底层升级机制只做一次上层通道随便换。6.2 多分区动态规划与差分升级AB双分区方案在资源紧张时可以进一步扩展为双区共享的云备份区也就是A区B区之外再加一个下载中转区。新固件先写到中转区整体校验通过后再拷贝到目标分区这样能减少在线操作时间。对于资源更宽裕的芯片还可以研究差分升级只传输新旧固件的差异部分进一步降低通信流量。不过那需要对固件做严格的内存地址对齐难度会大不少。我在实际使用中的一个体会是升级方案从来不是越复杂越好AB双分区之所以在中低端物联网设备上普及率极高就是因为它提供了一个“简单、可靠、可回退”的基准方案。先把这套基准跑通后面不管换芯片还是换通信方式都会从容很多。最后再分享一个小技巧在Bootloader里保留一段打印日志的代码每次跳转后通过串口输出当前分区和校验结果。这样你在现场排查问题时不用接调试器拿个USB转串口板就能快速定位设备是卡在分区标志上还是卡在CRC校验上。这套方案我前后移植过三轮踩过的坑基本都写在上面了照着做能省下不少排查时间。
返回列表