☰
STM32F103 AB双分区OTA升级实战:从Bootloader到回滚机制
2026/9/27 3:41:50 网站建设 项目流程

我先把话说明白:这篇教程不是给你讲一堆“AB分区很牛逼”的宏观概念,而是把 STM32F103 上 AB 双分区 OTA 从芯片选型、Flash 布局、Bootloader 跳转、App 向量表重映射、串口升级协议、Flash 写入,到回滚机制整个链路完整走一遍。你在网上搜到的“STM32F103”“AB分区”“OTA升级”多半是零碎的技术点,要么只讲 IAP 跳转,要么只贴一段 UART 升级代码,很少有人把 AB 双分区涉及的状态机、启动判定、健康确认讲清楚。

这篇文章的定位就是给你一套能直接复现的工程思路。我以 STM32F103 标准外设库 V3.5 为例,用串口作为升级通道,把 128KB Flash 器件上的 AB OTA 从 Bootloader 第一行代码到 App 的最后一处配置全部拆开讲。适合已经会跑一个流水灯、但一直没把 OTA 链路串起来的开发者,也适合在现有产品上想从单分区 IAP 切换到 AB 双分区方案的工程师。

1. AB双分区到底解决了什么问题:先看单分区IAP翻车现场

1.1 一个升级断电,Boot+App方案如何变砖

传统 Boot+App IAP 方案的 Flash 结构很简单:Bootloader 在最前面,后面跟一个 App 区。Bootloader 负责检查是否有升级请求,有就从串口、CAN 或无线模块接收固件,擦掉 App 区,把新固件写进去,然后跳转启动。

这个方案最典型的问题出现在升级中途断电或者传输校验出错:Bootloader 已经把旧的 App 区擦掉了一部分,新数据却没写完。最轻的情况是 App 区的前几个向量表已经被破坏,Bootloader 尝试跳转时读到一个随机地址,程序直接 HardFault;最严重的情况是旧 App 无法启动,Bootloader 又没有处理这种故障的能力,设备只能靠外部烧录器救回来。

我在一个量产项目上踩过这个坑。当时用 GPRS 模块做远程升级,现场信号稍差,固件传输了大半,最后几包数据重传超时,Bootloader 判断超时后做了复位重启。复位之后 Bootloader 发现 App 区首地址的数据校验不对,直接停在原地等串口数据。那时候我已经回家了,现场同事对着一个无法工作的设备干瞪眼,最后只能让现场人员拆机用 ST-Link 重刷。

这就是单分区方案的致命弱点:新版本和旧版本共享同一个 Flash 区域,升级过程中任何一步出错,旧的没了,新的又没起来,设备就变砖。想靠“升级前备份 App 到另一个区”来缓解,那你本质上已经在做双分区了。

1.2 AB双分区的玩法:永远留一手

AB 双分区的做法更本质:在 Flash 里保留两个完整的 App 区,通常叫 App_A 和 App_B,还有一个独立的标志区用来记录当前状态。Bootloader 每次上电先读标志区,根据状态决定启动 App_A 还是 App_B。

当系统跑在 App_A 里时,OTA 下载的新固件不会覆盖当前运行的 App_A,而是完整写入 App_B。写入并校验成功后,把标志区改成“下一次启动 App_B”,然后复位。Bootloader 发现新状态,启动 App_B。如果 App_B 运行正常,就通过健康检查逻辑把标志区改成“确认 App_B 健康”;如果 App_B 起不来或者运行几秒就崩溃,Bootloader 在下一次复位时根据回滚标志切回 App_A。

整个过程里,永远有一个已知能跑的旧版本在旁边兜底。哪怕升级过程断电、传输出错、新版本逻辑有 bug,最多是新版本起不来,旧版本还能被 Bootloader 拉起来继续工作。这个设计在手机上已经很成熟,这几年大量 MCU OTA 方案也在往 AB 分区方向转,本质上都是同一个思路:不拿唯一的固件区去赌一次升级过程的可靠性。

1.3 代价清单:Flash减半之外还要注意什么

AB 双分区当然不是免费的。最直观的代价是 Flash 占用翻倍:你原本留给 App 的 64KB 空间,现在要拆成 App_A 和 App_B 两个 32KB,或者干脆把 Flash 翻倍来迁就分区。在选型时,这往往已经不是“够不够用”的数学题,而是直接决定了你要不要从 64KB 器件换到 128KB 甚至 256KB 器件。

第二个代价是工程复杂度上升。你在 Keil 里不再是一个 App 工程,而是至少三个东西:Bootloader、App_A 链接配置、App_B 链接配置。三个工程的链接地址、向量表偏移、宏定义各不相同。你还要维护一个公共的升级协议、标志数据结构、回滚计数逻辑。

第三个代价是测试成本。单分区 IAP 的测试重点是“能不能收到数据、能不能写进 Flash”,AB 双分区要额外覆盖:升级过程中断电、新分区启动失败、回滚计数达到阈值、两个分区都不健康时 Bootloader 怎么处理。这些边界条件每一个都对应一段测试用例,没有充分测试就上线,比单分区翻车更难看。

2. 复现前的硬软件准备:硬件、工具链和通信底座

2.1 选型:C8T6、CBT6还是RCT6?AB OTA不只看内核

很多初学者手上有一颗 STM32F103C8T6,觉得“反正都是 F103,做 AB OTA 应该没问题”。结论先说:问题很大。C8T6 标称 64KB Flash,20KB RAM,去掉 Bootloader 区域和标志区域之后,两个 App 分区每个能用的空间可能只有 20KB 左右。如果你的固件功能稍微多一点,20KB 根本塞不下。

这还不是最坑的。C8T6 这颗料在市面上有相当一部分实际 Flash 是 128KB,但厂商规格书按 64KB 写,手册里的 Flash 页大小描述也不完全一样。你在开发阶段发现擦写后面 64KB 好像也能用,于是放心大胆地按 128KB 来设计分区,结果量产换了一批芯片后,某些批次实际容量就是 64KB,OTA 一升级就出问题。

做 AB OTA 我建议直接用 128KB 起步的 CBT6 或者 256KB 的 RCT6。RCT6 的优势不仅仅是 Flash 大,它的 RAM 也到了 48KB,后续如果要做加密、压缩、断点续传这些更复杂的功能,空间余量更舒服。我自己复现这套教程用的是 STM32F103CBT6,128KB Flash,中等密度器件,Flash 页大小 1KB,地址计算简单,写起来不容易出错。

2.2 最小系统板接线与 ISP 兜底

复现 AB OTA 我们用的是最朴素的最小系统板:STM32F103 芯片、8MHz 晶振、复位电路、BOOT0 和 BOOT1 跳线、3.3V 供电,再加上一个 USB 转串口模块。串口接 USART1 的 PA9、PA10,波特率我用 115200,8 位数据位、1 位停止位、无校验。

BOOT0 跳线这个细节值得单独说。它看起来和 OTA 没关系,但它是你整个调试过程的保底手段。STM32F103 内置了一段出厂 BootROM,当 BOOT0 拉高、BOOT1 拉低时,芯片上电会进入系统存储器引导模式,在这个模式下可以用串口 1 通过厂家协议直接下载固件到 Flash。

这意味着即使你的 Bootloader 在开发过程中写坏了,也可以把 BOOT0 跳线拨到高电平,用 Flash Loader Demonstrator 或者 STM32CubeProgrammer 通过串口重新刷 Bootloader,完全不需要额外接 ST-Link。我建议在做 Bootloader 实验之前先把 BOOT0 跳线的用法确认一遍,别等到 Bootloader 跳转逻辑写错了才发现串口下载通道没打通。

2.3 标准库V3.5 vs HAL:我们这次怎么选

搜索“STM32F103 标准库 V3.5”的朋友大致分两类:一类是在旧项目或公司原有代码库基础上开发,只能沿用标准库;另一类是刚入门,被各种教程推荐标准库。我的看法比较简单:如果你手头已有能跑的标准库工程,AB OTA 逻辑完全可以在标准库上实现,不用为了 OTA 特意迁移到 HAL。

标准库对 Flash 操作封装的很直白,FLASH_Unlock、FLASH_ErasePage、FLASH_ProgramHalfWord、FLASH_ProgramWord 这几个函数名字即所义,不需要像 HAL 那样先初始化一个 Flash 对象再调用接口。F103 的绝大多数厂家参考例程也都是基于标准库,你在排查问题时更容易找到对照代码。

当然用 HAL 也可以,搜索词里也有不少人在问“STM32CubeMX 下 F103 的 HAL 库 SPI 读写”之类的问题,说明新项目选 HAL 的趋势很明显。但 HAL 库对中断向量表重定位、Flash 读写、串口中断接收这些底层操作的封装层次更多,对新手来说反而更容易“不知道为什么就出问题”。AB OTA 的重点不在库,而在链接地址、向量表偏移、状态机设计,所以我下面所有示例代码都按标准库 V3.5 的写法,这和具体使用哪种库并不冲突。

2.4 串口之外:升级通道能扩展到哪些

这次教程用串口作为升级通道,主要是因为串口调试最简单,一个 USB 转 TTL 模块就能解决。但你在实际项目中大概率不会用串口做远程 OTA,更多还是 ESP8266、4G 模块、CAN 总线,甚至是本地 USB。

这也是我为什么要把升级协议单独拆成一章的原因。AB OTA 的“升级通道”其实分两层:底层是物理传输介质,上层是帧协议。串口只是其中一种物理介质,你在串口上跑通的帧协议、CRC 校验、分块重传逻辑,换成 ESP8266 的 TCP 透传也就是改一下数据来源,换成 CAN 总线就是改一下底层收发接口,上层协议和 AB 分区状态机完全不用动。所以先把串口链路做通,后续迁到无线或者总线方案会非常快。

3. Flash布局和状态标志:AB OTA的骨架

3.1 128KB器件上的四段布局预算

分区方案是所有 AB OTA 教程的第一步,也是我见过最多人做错的一步。很多人随手把 App_A 放 0x08000000,App_B 放 0x08008000,Bootloader 不知道塞哪,标志区也不知道塞哪。等编译出来发现镜像超过 32KB,又得重新改链接地址,来回折腾。

下面这个布局是我在 CBT6 上验证过的,页大小为 1KB,地址对齐到页边界:

起始地址结束地址大小用途
0x080000000x08003FFF16KBBootloader
0x080040000x0800FFFF48KBApp_A
0x080100000x0801BFFF48KBApp_B
0x0801C0000x0801FBFF15KB预留/日志
0x0801FC000x0801FFFF1KB标志页

Bootloader 给 16KB 是保守且舒服的。F103 上用标准库实现一个带串口升级协议的 Bootloader,编译出来一般不超过 8KB,16KB 已经包含了大量余量,以后想在 Bootloader 里加 CRC 校验、RSA 验签、简单的引导日志,都不用动分区。

两个 App 容量各 48KB。如果只是演示 AB OTA,48KB 绰绰有余;如果你的产品固件有 60KB,那 128KB 器件就不够了,要么上 256KB 的 RCT6,要么精简代码。这个分区表最核心的原则是:Bootloader 和两个 App 区域之间,绝不能重叠,也尽量别只差几个字节,擦写以页为单位的特性决定了分区边界必须对齐页边界。

3.2 页大小、对齐和擦除边界

STM32F103 的 Flash 页大小和密度有关。中等密度器件(64KB/128KB)每页 1KB,高密度器件(256KB/512KB)每页 2KB。你在 OpenFlash 或者 Flash 手册里看到“页大小”这个参数,直接决定了分区地址应该按 1KB 还是 2KB 对齐。

页对齐为什么重要?因为 Flash 擦除的最小单位就是页。如果你的 App_B 起始地址落在某个页的中间,擦除 App_B 时会把上一页末尾的数据也擦掉,或者面临“想擦一页但擦到了别的功能区的数据”这种尴尬局面。更严重的情况是,标志区和 App 区共用一页,你在升级时想修改标志位,结果把上一个 App 的最后一部分代码擦光了。

关于的练习,所以建议在编写链接脚本时写一个编译期断言或者至少在对照表中先核对一遍页边界。比如 0x08004000 除以 0x400 等于 0x40,整除,1KB 对齐;0x08010000 也一样。你自查分区表时,每个地址都除以页大小,能整除才算过关。

3.3 标志页的数据结构与状态机

标志页是 AB OTA 里最容易设计混乱的地方。它要回答三个问题:当前应该启动哪个分区、新分区有没有被确认健康、如果新分区起不来,回滚到哪个分区。

我设计了一个 32 字节的简单结构:

#define OTA_MAGIC 0x4F544131 /* "OTA1" */ #define SLOT_UNKNOWN 0 #define SLOT_A 1 #define SLOT_B 2 #define STATE_IDLE 0xFFFFFFFF #define STATE_PENDING 0xA5A5A5A5 #define STATE_CONFIRMED 0x5A5A5A5A typedef struct { uint32_t magic; /* 固定魔数,0x4F544131 */ uint32_t active_slot; /* 当前 Bootloader 启动的分区 */ uint32_t state; /* IDLE / PENDING / CONFIRMED */ uint32_t boot_count; /* 连续启动失败计数 */ uint32_t reserved[4]; } ota_flag_t;

注意一个关键背景:STM32F103 的 Flash 不支持“原地修改”,要改标志页里的任何字段,都得先把整个页擦成 0xFF,然后再重新写入。所以标志页的所有更新逻辑,本质上是“读当前值、构造新值、擦页、重新编程”。

正常情况下,这个标志页的数据流是这样走的:

  1. 系统从 App_A 运行,OTA 下载新固件到 App_B,完成后把active_slot改为SLOT_B,state改为STATE_PENDING,擦写标志页,然后复位。
  2. Bootloader 读到active_slot == SLOT_B且state == STATE_PENDING,相信这是“需要启动新分区”的状态,于是把boot_count加 1,启动 App_B。
  3. App_B 正常完成初始化后,App 自己把标志页的state改成STATE_CONFIRMED,boot_count清零。
  4. 如果 Bootloader 已经连续多次尝试启动 App_B,每次都运行几秒后看门狗复位,boot_count达到阈值,Bootloader 就把active_slot改回SLOT_A,回滚到旧版。

这个数据结构并不复杂,但实际调起来容易乱,因为每个环节都要清楚“我是谁、我这次要写什么、写完是不是要擦页”。我自己第一次实现时把state和active_slot搞反了,结果新分区明明已经跑起来了,App 写状态确认的时候又把active_slot改回了旧分区,来回折腾一个晚上。

4. Bootloader编写:启动判定、有效性检查和分区跳转

4.1 引导流程只有四件事

Bootloader 的完整逻辑可以压缩成四步:

  1. 读标志页,根据active_slot决定目标地址。
  2. 检查目标地址处的 App 镜像是否合理。
  3. 处理回滚计数和状态机。
  4. 跳转执行 App。

很多人一开始就把 Bootloader 写得很复杂,又是检查升级命令,又是等待串口数据,结果跳转本身反而没做好。正确的做法是:升级数据的接收和 Flash 写入可以放在 Bootloader 里,也可以放在 App 里,但上电引导链路必须短、快、健壮。F103 从复位到进入 App,正常应该在几十毫秒内完成,一旦 Bootloader 逻辑太长,看门狗配置、外设初始化都会变成不可控因素。

4.2 App镜像有效性检查怎么写

跳转之前必须确认目标地址处确实有一个能跑的 App。这个检查最核心的是两个字段:栈顶地址和复位向量。

Cortex-M3 的启动机制是:芯片复位后从 0x00000000 取栈顶地址,从 0x00000004 取复位向量。当 Bootloader 要跳转到 App 时,需要从 App 区域的起始地址读取这两个字段并做合理性验证。

static int is_valid_app(uint32_t app_addr) { uint32_t sp = *(volatile uint32_t *)app_addr; uint32_t pc = *(volatile uint32_t *)(app_addr + 4); /* 栈顶地址应该在 SRAM 范围内 */ if ((sp & 0xFFF00000) != 0x20000000) { return 0; } if (sp < 0x20000000 || sp >= 0x20005000) { return 0; } /* 复位向量应该落在 Flash 的 App 区域附近 */ if ((pc & 0xFFF00000) != 0x08000000) { return 0; } return 1; }

注意栈顶检查的地址范围,不是随便填的。F103CBT6 的 RAM 是 20KB,地址范围是 0x20000000 到 0x20004FFF,所以栈顶地址必须在 0x20005000 以内才合理。如果你换用 48KB RAM 的 RCT6,那就要把范围扩到 0x2000BFFF。这个检查能挡住大部分“Flash 全 0xFF 或者全 0”的异常情况,但挡不住固件本身逻辑写错的场景。

4.3 跳转前的中断处理和栈指针切换

App 镜像有效性检查通过之后,跳转前有几步操作不能省。第一步是关闭全局中断,第二步是关掉 SysTick,并把所有外设中断清零,最稳的做法是把 NVIC 的中断使能寄存器全部清掉。

为什么要这么做?因为 Bootloader 在初始化阶段可能开了串口、定时器、DMA,跳到 App 之后,如果这些外设依然处于开启状态,App 不知道它们的存在,中断一来就进了一个 App 无法处理的回调,直接异常。更麻烦的是,有些外设占用了和你 App 启动代码冲突的 GPIO 状态,导致 App 初始化外设时读到的寄存器状态不符合预期。

清理完中断之后,还要把主栈指针 MSP 切到 App 的栈顶。Cortex-M3 里__set_MSP()可以直接修改主栈指针,但千万不能用__set_SP(),后者在有些编译器里会同时影响 PSP 和 MSP,行为不可控。

typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t app_sp = *(volatile uint32_t *)app_addr; uint32_t app_pc = *(volatile uint32_t *)(app_addr + 4); if (!is_valid_app(app_addr)) { return; } __disable_irq(); SysTick->CTRL = 0; SysTick->VAL = 0; for (int i = 0; i < 8; i++) { NVIC->ICER[i] = 0xFFFFFFFF; /* 清空所有中断使能 */ NVIC->ICPR[i] = 0xFFFFFFFF; /* 清空所有挂起中断 */ } __set_MSP(app_sp); ((pFunction)app_pc)(); while (1) ; }

这里有个常见误区:有人把SCB->VTOR = app_addr;写在 Bootloader 里,然后在跳转时试图把向量表也切过去。实际上 SCB->VTOR 的设置在 F103 上更推荐放在 App 启动代码的开头。理由后面第 5 章会讲,当前只要知道:Bootloader 跳转时不需要设置向量表,App 自己会设置。

4.4 看门狗别乱喂:Bootloader超时也要考虑

如果在产品里开了独立看门狗 IWDG,Bootloader 阶段要特别小心。我在项目里见过一种情况:App 启动后发现问题主动复位,Bootloader 被看门狗反复咬住,因为 Bootloader 在等待串口升级数据时没喂狗,导致设备不断复位,升级界面永远起不来。

AB 模式下的看门狗思路应该是:Bootloader 只在“需要等待升级数据”这个特定阶段喂狗,而且这段等待时间还要有明显上限。一旦从串口收到完整固件并写入完成,立即停止喂狗,让系统正常复位进入新的引导链路。如果 Bootloader 在等待升级数据时收到不完整数据包,超时要直接复位而不是傻等,这样设备才不会被一个挂起的串口进程锁死。

5. App端改造:向量表重映射、链接地址与OTA触发

5.1 为什么必须在App里重映射向量表

Cortex-M3 有一个全局向量表偏移寄存器 SCB->VTOR,位于 0xE000ED08。默认值是 0x00000000,也就是从地址 0 开始取中断向量。F103 上 Flash 的起始地址是 0x08000000,Bootloader 烧在 0x08000000,向量表也就默认在这个位置。

如果 App 链接到 0x08004000,你跳转进去后,CPU 运行 App 的 main 代码没问题,但只要一产生中断,比如 SysTick 或者串口中断,CPU 还是到 0x08000000 的向量表里找中断服务函数。0x08000000 是 Bootloader 的向量表,里面存的是 Bootloader 的中断函数地址,就算地址在 Flash 范围内能被跳转执行,也是完全错误的代码。

解决办法就是在 App 启动最早期设置:

SCB->VTOR = APP_A_BASE_ADDR; /* 0x08004000 */

这行代码必须放在 App 工程里的SystemInit()之后、任何外设初始化和中断开启之前。因为一旦开了中断,向量表重定位晚了,就会产生一个中断然后跑飞。

5.2 Keil分散加载配置:让两个App地址各就各位

App_A 和 App_B 的绝大部分源码是相同的,区别只在于链接地址和宏定义。在 Keil MDK 里,点击 Options for Target -> Target 页签,把 IROM1 的起始地址改掉:

App_A 工程:

IROM1 Start: 0x08004000 Size: 0xC000 IRAM1 Start: 0x20000000 Size: 0x5000

App_B 工程:

IROM1 Start: 0x08010000 Size: 0xC000 IRAM1 Start: 0x20000000 Size: 0x5000

然后在两个工程的 C/C++ 页签下分别定义一个宏:

/* App_A 工程 */ #define APP_SLOT (1) #define APP_BASE_ADDR (0x08004000) /* App_B 工程 */ #define APP_SLOT (2) #define APP_BASE_ADDR (0x08010000)

这样你在代码里就可以根据APP_SLOT判断当前运行在哪个分区,根据APP_BASE_ADDR设置SCB->VTOR。OTA 下载时,当前分区如果是 App_A,目标写入分区就是 App_B,这个逻辑用宏一判断就完成。

5.3 App如何知道自己跑在哪个槽位

这一步说起来很抽象,但其实是检查 AB OTA 是否搞对的关键。App 的main()里第一件事是设置向量表,第二件事就可以读取自己的槽位:

uint8_t cur_slot; uint32_t inactive_base; if (APP_SLOT == 1) { cur_slot = 1; inactive_base = 0x08010000; } else { cur_slot = 2; inactive_base = 0x08004000; }

当然,你也可以不依赖编译宏,而是从标志页读active_slot,或者从当前函数的链接地址反推。但这样相对绕。直接用APP_SLOT宏是最小成本的方案。

值得注意的是,App_B 工程编译出来的固件只能烧进 App_B 区域。如果用户手里只有一整套编译产物,误把 App_B 地址的固件当作普通 App 用串口下载到 App_A,那就会导致中断向量表指向错误地址,系统根本起不来。所以做 OTA 时,发布包的文件名最好带上槽位标记,比如firmware_A_1.0.0.bin、firmware_B_1.0.0.bin,减少人工搞混的概率。

5.4 升级请求入口:串口收包还是外置模块送数

App 里可以做一个串口升级接收逻辑,通过 PA9、PA10 接收 PC 上位机发来的升级协议,这种方案在原型验证阶段最方便。你也可以在 App 里预留一个 IO 触发升级,比如检测到一个按键长按,或者收到一条 Modbus 指令,就进入“等待升级数据”状态。

我在项目里更常用的是第二种,因为产品可能没有一个“PC 上位机”直接连着串口,实际数据来自 GPRS/4G 透传模块。App 从网络收到完整的 OTA 固件包后,逐帧解析、写入备用分区,然后置标志位复位。

无论哪种入口,App 侧的升级接收都不需要把 App 跳回 Bootloader 去执行。这也是 AB 双分区和传统 IAP 的一个明显区别:传统 IAP 经常把整个升级过程托管给 Bootloader,App 只是发一个“我要升级”的命令,然后 Bootloader 接管串口。AB 双分区则可以完全由 App 完成“下载到备用分区”“校验”“置位”“复位”这一整条链路,Bootloader 只在启动时做分区选择和回滚,职责更纯粹。

6. 升级包传输与写入备用分区:协议、校验和Flash编程

6.1 简易升级帧格式:帧头、命令、长度、CRC

升级协议我从不建议搞得太复杂,除非你真的需要兼容多种业务场景。一个够用的帧格式如下:

字段长度说明
帧头2字节固定 0x5A 0xA5
命令1字节0x01 开始传输 / 0x02 数据帧 / 0x03 结束
长度2字节数据域长度,小端模式
数据N字节固件内容或元信息
CRC162字节整个帧的 CRC16,多项式 0x8005

开始传输帧的数据域里放本次固件的总长度和目的槽位,目标槽位由当前运行槽位推导。数据帧每包我限制在 256 字节,避免单包太长导致串口缓冲溢出或者 Flash 写入时中断响应不及时。CRC16 计算覆盖从帧头到数据域的每一个字节,接收端收到一帧先算 CRC,不对直接丢。

这里有一个很实用的细节:不要把 CRC 计算放在每包数据的while循环里傻算一遍,至少要在数据接收完成后单独算。F103 主频 72MHz,算 256 字节的 CRC16 也就是微秒级的事,但在串口中断处理函数里做大量运算容易影响后续字节接收,所以我在上位机发送时会主动做流控,每发完一帧等待接收端返回 ACK 后再发下一帧。

6.2 状态机收包:别用循环等待拖死主流程

写升级接收逻辑时,新手最容易犯的错是在串口中断里做 Flash 擦写,或者在一个while循环里等待接收完成,期间所有中断都被卡死。F103 擦除一页 Flash 要 20~40ms,如果这一过程关闭了中断,串口就会丢包,之后的固件校验全都对不上。

我推荐的架构是:串口中断只负责把字节放进环形缓冲区或者一个简单的接收数组,主循环里不断调用一个状态机解析函数:

void ota_protocol_parse(uint8_t byte) { static uint8_t rx_buf[300]; static uint16_t index = 0; static uint8_t expect_len = 0; rx_buf[index++] = byte; if (index == 2) { if (rx_buf[0] != 0x5A || rx_buf[1] != 0xA5) { index = 0; /* 错帧,重新找帧头 */ } } else if (index == 5) { expect_len = (rx_buf[4] << 8) | rx_buf[3]; /* 简单防呆 */ if (expect_len > 256) { index = 0; } } else if (index == (uint16_t)(5 + expect_len + 2)) { /* 收到完整一帧,校验 CRC、处理命令 */ if (crc16_check(rx_buf, 5 + expect_len) == 0) { ota_frame_handler(rx_buf); } index = 0; } }

状态机的核心就是:没有数据时不做事,有数据时逐字节推进,收到完整一帧才进入处理逻辑。Flash 擦写和写页都在主循环里同步执行,串口中断始终开着,不会因为擦除 Flash 而丢包。

6.3 F103 Flash编程流程与RAM执行问题

STM32F103 的 Flash 编程步骤在标准库里封装得很简洁:

void ota_flash_write_buffer(uint32_t dst_addr, uint32_t *buf, uint32_t word_len) { FLASH_Unlock(); FLASH_ErasePage(dst_addr); for (uint32_t i = 0; i < word_len; i++) { FLASH_ProgramWord(dst_addr + i * 4, buf[i]); } FLASH_Lock(); }

实际操作时不能这么粗暴。一页是 1KB,也就是 256 个字,你不可能每收到 256 字节就擦一次页。比较稳妥的做法是:App 收到固件数据后先放在 RAM 缓冲区,等凑满一页或者收到结束帧时,再一次性擦除目标页并写入整页数据。我用的缓冲区大小是 1KB,每次收到 256 字节的数据帧就往缓冲区里填,填满 1KB 后执行一次页擦写。

这里有个 STM32F103 上非常值得注意的细节:在 Flash 擦除和编程期间,Flash 控制器会占用内部总线,CPU 无法从 Flash 读取指令,只能暂停等待 Flash 操作完成。这意味着如果你在擦除一页 Flash 时从 Flash 里执行代码,CPU 会被暂停一段时间。大多数情况下 20ms 的暂停不影响功能,但如果你的应用有严格的中断响应要求,或者串口波特率很高,这段时间就可能丢数据。

更稳的做法是把 Flash 擦写函数放到 RAM 里执行。Keil 下可以直接用__ramfunc关键字修饰函数:

__ramfunc uint8_t ota_flash_erase_page(uint32_t addr) { FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); FLASHStatus status = FLASH_ErasePage(addr); FLASH_Lock(); return (status == FLASH_COMPLETE) ? 0 : 1; }

但即便如此,FLASH_ErasePage 底层代码本身不带__ramfunc,它仍然在 Flash 里。标准库的 Flash 操作函数放 RAM 并不是自动的,所以更彻底的办法是自己用寄存器操作实现简单的页擦写函数,并且整段放入 RAM。我在工程里是复制了一份最精简的 Flash 擦写代码,用__ramfunc修饰放到 RAM,实测擦页和写字期间串口接收完全不丢包。

6.4 写完校验和“待生效”标志的置位时机

固件完整写入备用分区后,千万别急着置位标志。先读回整个镜像做一次 CRC 或者逐字比较。这一步虽然费时,但它是 AB OTA 的最后一道防线。我在测试中遇到过一次情况:串口传输全部完成了,因为最后一页缓冲区的数据没对齐,最后几个字节没写进去,但当时没有回读校验,直接置位标志复位,Bootloader 启动了一个不完整的镜像,只能靠回滚机制救回来。

校验通过后,App 才更新标志页:

ota_flag_t flag; flag.magic = OTA_MAGIC; flag.active_slot = SLOT_B; /* 假设当前从 A 升级到 B */ flag.state = STATE_PENDING; flag.boot_count = 0; flag.reserved[0] = 0; flag.reserved[1] = 0; flag.reserved[2] = 0; flag.reserved[3] = 0; FLASH_Unlock(); FLASH_ErasePage(FLAG_PAGE_ADDR); /* 0x0801FC00 */ uint32_t *src = (uint32_t *)&flag; for (uint32_t i = 0; i < 8; i++) { FLASH_ProgramWord(FLAG_PAGE_ADDR + i * 4, src[i]); } FLASH_Lock(); NVIC_SystemReset();

注意这里active_slot在 App 看来是“我想让 Bootloader 下次启动哪个分区”,而不是“我自己现在这个分区”。很多人在这一步搞混,写成了当前运行的槽位,结果 Bootloader 启动的还是老分区,新固件永远起不来。

7. 回滚机制:新固件翻车后怎么自动回老版本

7.1 新分区“假启动”的判据

回滚的核心是判断“新分区到底有没有真正跑起来”。判断标准通常分两层:第一层是新分区是否有合法的栈顶和复位向量,这由 Bootloader 的is_valid_app()完成;第二层是新分区是否在业务层面完成了初始化,这需要 App 自己主动上报。

所谓“假启动”,是指新分区跳转进去了,向量表也对,栈也能用,但 App 初始化某个外设时卡死,或者业务逻辑进入死循环,看门狗不喂被复位。这种情况下 Bootloader 只看向量表是判断不出来的,必须靠 App 主动写标志位来证明“我真的起来了”。

我的做法是:Bootloader 启动某个分区前,把标志页的state设为STATE_PENDING,然后等待 App 在一个较短的确认窗口(比如 3 秒)内把state改成STATE_CONFIRMED。如果确认窗口内没有确认,并且产生了复位,Bootloader 的boot_count就会递增,累计超过阈值就切回另一个分区。

7.2 启动计数器的实现与阈值设计

boot_count的更新逻辑放在 Bootloader 里比较合理。Bootloader 发现标志页状态是STATE_PENDING,说明这次启动的是新版本尚未确认,于是:

if (flag.state == STATE_PENDING) { flag.boot_count++; if (flag.boot_count >= OTA_ROLLBACK_THRESHOLD) { /* 连续多次启动失败,回滚到另一个槽位 */ if (flag.active_slot == SLOT_A) { flag.active_slot = SLOT_B; } else { flag.active_slot = SLOT_A; } } /* 更新标志页,然后跳转到 active_slot */ }

阈值我推荐设 3。这样在新固件确实有问题但还能撑几秒不死的情况下,设备最多经历 3 次启动失败后自动回滚。阈值太低容易误触发,比如升级完成后第一次启动时串口线抖动导致异常复位,就直接回滚;阈值太高则会让用户面对反复重启的设备更久。

App 确认成功后,要把boot_count清零,这本质上是把当前运行的分区“转正”。注意这个过程要避免在 App 刚初始化时就确认,我一般要求 App 完成所有核心外设初始化、自检通过、跑完至少一轮主循环后才确认。

7.3 实测里最容易误触发的几种回滚

回滚机制在我的测试里误触发过几次,其中有两个场景值得拿出来说。

第一个场景是调试器连接导致的复位。用 ST-Link/J-Link 调试时,调试器复位目标芯片可能不经过完整的电源时序,Bootloader 读到标志页时状态还没稳定,导致boot_count莫名其妙加一。解决办法是在 Bootloader 关键读取操作前加一个几十毫秒延时,或者不要在调试模式下验证回滚逻辑,单独用串口下载和复位键测试。

第二个场景是 UART 空闲中断和 DMA 接收配合不当。App 在升级完成后置位标志、复位,但串口发送完最后一个 ACK 后进入中断,如果中断服务函数里又调用了 FLASH 操作或者写标志页,可能把刚置位的标志改掉,导致 Bootloader 读到的是旧分区状态。这个问题的排查很痛苦,因为它是时序相关的偶发问题。我最终把“写确认标志”的代码放到了所有外设中断关闭之后,彻底避免中断嵌套时对 Flash 的重复操作。

8. 从零复现的完整测试顺序和坑位记录

8.1 先测Bootloader,再测App,最后测AB切换

很多读者一上来就编译两个 App,然后迫不及待地用上位机发固件测试 AB 切换。这个顺序大概率会翻车。我建议按下面这个顺序一步步来:

第一步,先只烧 Bootloader,Bootloader 里临时写死直接跳转到固定的一个地址,比如 0x08004000。用一个最简单的串口打印程序烧在这个地址,验证跳转本身是否正常。

第二步,把 Bootloader 改成读标志页决定跳转地址,暂不启用回滚逻辑。手动把标志页改写为指向 App_A,复位后确认 App_A 能起来;再改写为指向 App_B,确认 App_B 能起来。

第三步,在 App 里加上 SCB->VTOR 重映射和串口收发,确认 App 的串口中断、SysTick 定时器都正常。

第四步,把 OTA 升级写接收逻辑加进去,先测写入备用分区但只触发“查看是否写入成功”,不置复位标志,用调试器或串口命令查看 Flash 内容是否完整。

第五步,才做完整的 AB 切换测试,包括正常切换、断电中断、新版本重复复位后的回滚。

每一步之间如果有问题,解决方案的范围都很小,不会出现“都不知道是 Bootloader 的问题还是 App 的问题”这种困局。

8.2 没有JLINK时怎么定位启动问题

如果你手上只有串口,没有调试器,启动链路出问题时定位起来会比较费劲。我的办法是在 Bootloader 里加一段“启动原因上报”逻辑:每次上电后,通过串口输出一行短的引导日志,比如BOOT: slot A, state confirmed或者BOOT: slot B, state pending, count=2。这段日志可以帮助你快速判断 Bootloader 是否读对了标志页。

App 侧也可以加入类似的日志,比如在 main 开头输出APP: B slot started, firmware v1.0.0。这样一来,即使没有调试器,通过串口日志也能看到完整链路:Bootloader 选择了哪个分区、App 是否成功启动、确认标志有没有写回。

如果串口也没有,就在标志页的整个操作流程里加入一个 GPIO 翻转点,用示波器或者万用表观察引脚电平变化。我在调试回滚逻辑时就靠 PB5 上的波形判断 Bootloader 到底复位了几次。

8.3 我在F103上AB OTA项目的最后心得

这套 AB OTA 我在多个项目上迭代过,最大的体会是:AB 双分区本身不复杂,复杂的是把状态机理清楚。active_slot是“Bootloader 下一次要启动谁”,state是“启动前新分区有没有被确认”,boot_count是“连续失败了几次”。三个字段各有各的职责,写代码时不要让一个字段承担两种含义,否则边界条件会越想越乱。

另一个体会是,AB OTA 对 Flash 寿命的消耗并不像想象中那么可怕。升级一次主要写两个地方:备用分区的固件区域和标志页。标志页在每次升级切换时会被擦写几次,Flash 擦写寿命通常在 1 万次以上。按每周 OTA 一次计算,一年才 52 次,标志页的寿命绰绰有余。真正要担心的是启动失败回滚时频繁擦写标志页,所以回滚计数阈值不要设太大,3 次已经很够用。

最后提醒一个在外设初始化上容易被忽略的细节:App 在设置SCB->VTOR之前,千万不要初始化任何中断,包括 SysTick。我见过一个案例,App 在进入 main 后第一行调用了HAL_Init(),HAL 会初始化 SysTick 并开启中断,然后才设置 VTOR,结果 SysTick 中断在向量表重映射之前就触发了,系统直接 HardFault。正确的写法是进 main 后第一句就设置 VTOR,再初始化任何外设。这行代码在 AB OTA 工程里的地位,比你在普通单分区工程里看到的要重要得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询