很多做嵌入式开发的兄弟,手里都有一两块STM32F103开发板。这种经典的MCU,跑裸机或者简单的RTOS都没问题,但一旦产品做到量产阶段,就会面临一个实际问题:设备固件怎么升级?拆壳烧录显然不现实,于是OTA升级就被提上了日程。
标题里提到了“STM32F103_AB_OTA_从零复现”,这个组合很有意思。STM32F103代表了非常经典的Cortex-M3内核MCU,AB代表当前主流的双分区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是64KB,RAM是20KB。做一个Bootloader(大约占4KB到8KB)+ A分区(约24KB)+ B分区(约24KB)+ 参数区(约2KB)基本上是极限了。如果应用代码超过24KB,建议换更大容量的芯片,比如STM32F103RCT6,它的Flash是256KB,分区的弹性就大得多,A和B各分96KB都没问题。
我以128KB Flash的STM32F103ZET6或者RBT6为例,给出一套典型分区表:
| 区域名称 | 起始地址 | 大小 | 说明 |
|---|---|---|---|
| Bootloader | 0x08000000 | 16KB | 引导程序、升级逻辑 |
| A分区(App A) | 0x08004000 | 40KB | 默认运行区,出厂固件 |
| B分区(App B) | 0x0800E000 | 40KB | 备用运行区,OTA目标区 |
| 参数区 | 0x08018000 | 4KB | 存放分区状态、版本信息、升级标志 |
| 末尾保留 | 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区间内,说明该地址没有有效固件 }这里的核心逻辑是校验首字指向RAM(STM32的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字节 | 固件内容 |
| CRC32 | 4字节 | 帧数据校验 |
每一包数据的大小选择为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表示A,1表示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()执行软复位。设备重新进入Bootloader,Bootloader读取参数区发现状态是“待启动、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地址(比如0x20001000),App启动后从那里读取。这样两者耦合性最低,各自也能独立维护和升级。
5. 常见问题与排查技巧实录
5.1 升级后Bootloader反复重启
遇到这个问题,首先检查新App分区的固件写入是否完整。用调试器读内存,把B分区起始地址的前64字节dump出来,看栈顶指针是否指向RAM范围,向量表第一项是否合法。很多时候这个问题出在编译阶段:App工程没有设置正确的IROM1地址,导致编译出来的固件跳转地址还是0x08000000,Bootloader检查地址合法性时直接拒绝跳转,然后看门狗复位,循环往复。
还有一种隐藏原因: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分区状态写为“升级成功”并设置magic=0xA5A5A5A5。之后所有状态判断都必须同时校验magic和status,magic不匹配直接视为无效。
6. 后续还能怎么扩展
6.1 升级通道的多样化扩展
这套AB OTA方案目前是基于串口逐帧传输的,但方案本身和传输通道是松耦合的。如果你后续想迁移到WiFi模组、4G模组或者LoRa通信,改动的主要是数据接入层,核心的分区管理、代码跳转、CRC校验和回滚逻辑完全不用动。
比如在项目中,我还把同一套AB OTA逻辑复用到了带WiFi的采集设备上,WiFi模块负责从云端下载固件,固件数据通过串口转发给F103,F103内部走的就是和本文完全相同的B区写入流程。这样做的好处十分明显:底层升级机制只做一次,上层通道随便换。
6.2 多分区动态规划与差分升级
AB双分区方案在资源紧张时可以进一步扩展为双区共享的云备份区,也就是A区+B区之外再加一个下载中转区。新固件先写到中转区,整体校验通过后再拷贝到目标分区,这样能减少在线操作时间。对于资源更宽裕的芯片,还可以研究差分升级,只传输新旧固件的差异部分,进一步降低通信流量。不过那需要对固件做严格的内存地址对齐,难度会大不少。
我在实际使用中的一个体会是:升级方案从来不是越复杂越好,AB双分区之所以在中低端物联网设备上普及率极高,就是因为它提供了一个“简单、可靠、可回退”的基准方案。先把这套基准跑通,后面不管换芯片还是换通信方式,都会从容很多。
最后再分享一个小技巧:在Bootloader里保留一段打印日志的代码,每次跳转后通过串口输出当前分区和校验结果。这样你在现场排查问题时,不用接调试器,拿个USB转串口板就能快速定位设备是卡在分区标志上,还是卡在CRC校验上。这套方案我前后移植过三轮,踩过的坑基本都写在上面了,照着做能省下不少排查时间。