前阵子有个客户现场的设备需要修一个逻辑bug,设备装在十几公里外的农田排灌站里,跑一趟光高速费就够喝一壶,从那时起我意识到:OTA升级是嵌入式产品绕不开的必修课。于是我用最经典的STM32F103做了一套完整的AB双分区OTA升级方案,并在实际开发中把它从零复现了一遍。这篇文章把底层逻辑、Bootloader代码、App端配合、Flash布局以及调试过程中踩过的坑完整写出来,适合正在做产品固件升级、尤其在STM32F103这类Cortex-M3平台上做远程/串口OTA的工程师参考,也适合刚接触AB分区概念的嵌入式新手按步骤实操。
1. 为什么要做AB OTA——从一次"变砖"事故说起
很多团队第一版OTA升级其实是这么过来的:MCU里留一块Bootloader区,收到上位机发来的升级包后,直接擦掉App区,把新固件写进去,然后跳转。这个流程看着没毛病,但有一个致命风险——如果固件写到一半断电、传输断流,或者新固件本身有bug,板子当场变砖,现场只能拆机用下载器救。单分区升级没有后悔药,故障恢复窗口几乎为零。
1.1 传统单分区升级与Bootloader救砖的边界
单分区方案不是不可用,它的问题是"升级过程和运行固件共用同一个存储区域",新固件覆盖旧固件的瞬间,系统就失去了能稳定运行的旧版本。很多产品为了降低变砖概率,会再加一个"强制升级模式",让Bootloader在启动时检测某个按键或者串口指令,超过N秒没有外部响应才继续跳转App。这样就算App挂了,还能通过Bootloader重刷。
但这种方式仍然存在两个明显问题:
- 现场缺人:按键强制升级需要人工介入,远程设备做不到。
- 重刷过程本身会再次失败:如果传输链路不稳定,刷到一半断掉,板子还是废。
我实际经历过一次:批量生产的门禁控制器要加一个新功能,用单分区Bootloader远程下发固件,后台显示升级成功,但第二天现场反馈设备全部离线。后来排查下来是新固件里一个全局变量初始化依赖了一个外设寄存器,而这个外设在Bootloader里被改过状态,App复位后直接HardFault。旧固件已经被覆盖,没有任何回退手段,只能一块一块拆回来重刷。
1.2 AB双分区的核心思想:写新、切换、确认
AB分区的思路本质上和"双系统+恢复分区"一样,只不过在MCU上是靠Flash存储隔离实现的。核心流程可以拆成三件事:
- 写新:把新固件写入当前未运行的另一个分区(比如当前在A分区跑,就写B分区),整个过程不影响A分区的正常运行。
- 切换:固件写完并校验通过后,Bootloader在下一次启动时跳转到新分区。
- 确认:新分区运行后,由App主动向Bootloader写一个"当前固件正常"的标记。如果新分区起不来,或者运行后一段时间内没有确认,Bootloader自动回退到旧分区。
用一个不太准确但很好懂的类比:把AB分区想象成你在装修新房子,老房子还住着人。装修期间生活不受影响,装好了搬家过去,住一段时间觉得没问题才把老房子拆了;如果住进去发现漏水停电,还能搬回老房子继续住。
1.3 什么场景必须上AB,什么场景没必要
STM32F103的Flash容量从64KB到512KB都有,AB分区至少要多占用一倍的App空间,同时Bootloader复杂度和测试成本也会上升。我个人的判断标准是:
| 场景特征 | 是否建议上AB |
|---|---|
| 设备部署在野外、机柜、无法人工到达的现场 | 强烈建议 |
| 固件升级失败会导致安全事故或严重业务损失 | 强烈建议 |
| 设备数量大、批量远程维护需求高 | 强烈建议 |
| 成本极敏感、Flash小于等于64KB且功能简单 | 不建议 |
| 有人员可随时通过本地工具重新烧录 | 可不上 |
汽车ECU、医疗设备、工业控制器,基本都是AB方案的标准用户。而一个简单的智能灯、玩具遥控器,Flash就那么大,上AB反而得不偿失。
2. 复现前的工程底座:硬件、工具链与Flash布局
做AB OTA不是一个"写个函数就能跑"的事,前期的基础工作占了整个工作量的三分之一。硬件选型、工程配置、Flash地址规划这三样没做好,后面调起来全是玄学问题。
2.1 最小硬件体系
我复现用的板子是STM32F103ZET6,512KB Flash,属于大容量产品。如果你手里只有C8T6(64KB Flash)也可以跑,只不过分区尺寸要按比例缩小。
- MCU:STM32F103ZET6最小系统板,8MHz外部晶振。
- USB转串口:CH340模块,用于和上位机通信,也用于打印调试日志。
- 状态指示:一个LED接在PB0,一个按键接在PA0,用来模拟"确认正常"和"触发升级"。
- 调试器:ST-Link V2,用来烧录Bootloader和第一次烧录App。
- 上位机:电脑上的串口助手或自写的Python脚本,负责发送bin文件。
这里有个容易忽视的点:AB OTA对串口的稳定性要求高,CH340模块尽量选带独立供电的,不要用那种直接从MCU引脚取电、电流不够导致电平抖动的劣质模块。我最初用了一根断线较多的杜邦线接串口,升级测试时经常卡在某个帧,后来换了短而粗的线,问题消失。
2.2 软件工具链与工程底座
我用的是STM32标准外设库V3.5.0,编译环境为Keil MDK5。虽然现在HAL库是主流,但F103上标准库依然有大量存量项目,而且标准库代码直白,看寄存器操作比HAL库清楚很多,适合理解AB OTA的原理。
工程底座只需做好三件事:
- 配置系统时钟为72MHz,串口1初始化到115200、8N1。
- 配置一个1ms的SysTick作为软件定时器基础。
- 初始化需要的GPIO(LED、按键)。
在这个底座之上,先写一个"点灯+串口回环"的测试程序,确认烧录、复位、串口收发都正常后,再继续后面内容。这步看似很基础,但能提前把串口接线、芯片型号不匹配这些低级问题全部过滤掉。
2.3 Flash地址规划
F103ZET6的512KB Flash地址范围是0x08000000到0x0807FFFF。我按下面的布局来划分:
| 区域 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| Bootloader | 0x08000000 | 64KB | 启动引导、OTA搬运、回退逻辑 |
| App A | 0x08010000 | 128KB | 业务固件槽位A |
| App B | 0x08030000 | 128KB | 业务固件槽位B |
| 参数区页1 | 0x08050000 | 2KB | AB状态、版本、标记 |
| 参数区页2 | 0x08050800 | 2KB | 参数冗余备份 |
| 预留 | 0x08051000 | 剩余 | 日志、字库或其他数据 |
Bootloader用64KB对F103来说比较富余,哪怕要扩展加密验签逻辑也足够。两个App槽位各128KB,正常业务固件控制在60KB以内,留足余量。参数区我特意安排了两页,因为Flash页擦除是整页擦的,参数要被频繁改写,双页存储可以避免"擦除过程中掉电导致参数全丢"的情况。
关于页大小:
- 中容量和大容量F103的Flash页大小是2KB(小容量是1KB)。
- 擦除以页为单位,写入可以按16位半字或32位字进行。
这个特性决定了你在做参数区设计时,不能像EEPROM那样逐字节改,而是要"整页擦了重写"。
2.4 Bootloader与App都依赖的公共参数结构
AB分区的核心是参数区的"状态机"。我定义了一个结构体,Bootloader和App共用,放在一个公共头文件里:
typedef struct { uint32_t magic; // 固定值 0xA5A5A5A5,用于判断参数是否有效 uint32_t version; // 升级包版本,单调递增 uint32_t active_slot; // 当前活动槽位:0表示A,1表示B uint32_t boot_flag; // 开机标记:0正常启动,1表示升级后首次启动 uint32_t boot_count; // 升级后启动次数 uint32_t upgrade_pending; // 有新的升级固件待确认 uint32_t crc; // 整个结构体的CRC32,防篡改 } AB_Param_t;这里面的字段各有用途:
magic用来判断这页Flash是否被写入过有效参数,读出来不等于0xA5A5A5A5就当无效处理。active_slot记录当前正在运行的是A还是B,Bootloader根据它决定下一次跳到哪里。boot_flag和boot_count一起实现"新固件运行确认"逻辑,后面在Bootloader章节详述。upgrade_pending表示当前有没有升级任务在等待确认。
写这个结构体时要注意:由于Flash不能覆盖写,每次更新参数都需要先擦除整页再写入。我在两个参数页之间轮流写,写之前检查哪一页的magic有效,就擦掉另一页再写入新值。
3. Bootloader设计:双分区切换的逻辑核心
Bootloader是整个AB OTA的裁判,它决定每次上电该跑哪个槽位、什么时候该回退、什么时候该把升级标记清除。这一章放在最前面讲,因为App端的很多逻辑都围绕它来设计。
3.1 上电启动流程的状态机
Bootloader上电后做的事要越少越好,因为每一句不可靠的代码都可能拖慢启动时间、增加出问题的概率。我设计的启动流程如下:
- 初始化时钟、串口、LED。
- 从参数区读取
AB_Param_t,如果两页参数都无效,说明是首次烧录,此时直接跳到默认槽位A。 - 如果
upgrade_pending为真,说明上次升级流程没有走完,需要进入升级确认流程。 - 校验活动槽位的App区头部(Magic、长度、CRC),如果校验通过就跳转,不通过则尝试另一个槽位。
- 跳转前把当前的分区和参数状态通过串口打印出来,方便排查问题。
这个流程的状态转换用文字描述就是:正常就跳,异常就换,换到最后没得换就停在Bootloader等待串口升级。不是每一版Bootloader都必须做得很复杂,这一版最核心的就是"找到能跑的槽位跑起来"。
3.2 关键跳转函数:从Bootloader进入App
这是整个工程里技术含量最高的一个函数,它的本质是把CPU的控制权从Bootloader交到App的复位向量。代码很短,但每一步都有讲究:
typedef void (*AppFunc)(void); void JumpToApp(uint32_t app_addr) { uint32_t app_msp = *(volatile uint32_t *)app_addr; uint32_t app_reset = *(volatile uint32_t *)(app_addr + 4); // 检查栈顶指针是否落在SRAM范围内,防止跳到非法地址 if ((app_msp & 0xFFF00000u) != 0x20000000u) { return; } // 关闭全局中断,避免跳转过程中被外设中断扰乱 __disable_irq(); // 复位外设时钟,让App端能够以干净的状态重新初始化 RCC_DeInit(); // 重定位中断向量表 SCB->VTOR = app_addr; // 设置主栈指针,执行App的复位函数 __set_MSP(app_msp); ((AppFunc)app_reset)(); }有几个点,我在实际调试时体会特别深:
- 检查栈顶地址非常关键。如果App区是空的、全是0xFF,读出来的
app_msp会是0xFFFFFFFF,直接跳进去必然HardFault。有了这道检查,Bootloader才能优雅地切换到另一个槽位。 - RCC_DeInit()不能省。如果Bootloader初始化了串口、定时器,而跳转App前不复位外设,App里重新初始化这些外设时可能处在未知状态。我遇到过BOOT跳转后串口打印乱码,就是没做外设复位。
- SCB->VTOR重定位。F103内核带VTOR寄存器,设置后中断向量表就指向App区的起始位置。这一步不做的话,App里的任何中断(比如定时器中断)都会去Bootloader的向量表里找处理函数,结果就是进不了中断或者直接跑飞。
3.3 参数区读写逻辑
参数区读写是所有状态切换的数据基础。写参数时要遵循"先擦后写"的原则,并且建议放在关闭中断的环境下执行:
void AB_Param_Write(AB_Param_t *param) { uint32_t target_page = Get_Inactive_Param_Page(); uint32_t addr = target_page; __disable_irq(); FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPERR); // 写入前必须擦除整页 FLASH_ErasePage(addr); // 按32位字写入,写入完成后再擦除另一页旧数据 FLASH_ProgramWord(addr, param->magic); FLASH_ProgramWord(addr + 4, param->version); // ... 依次写入所有字段 ... FLASH_Lock(); __enable_irq(); }这里有个细节:Flash编程是带高压的,整个过程不能被打断。如果在写Flash过程中来了一个高优先级中断,中断执行期间Flash控制器的时序会被拉长,极端情况下可能写失败或者写错数据。所以写Flash前必须关中断,写完再打开。
参数区双页存储的读取逻辑稍微复杂一点:读两页,如果某一页magic等于0xA5A5A5A5且CRC校验通过,就以它为准;如果两页都有效,取version较大或写入时间较新的那页作为最新参数。这样可以最大程度避免"写一半掉电"造成参数区彻底失效。
3.4 新固件运行确认与自动回退机制
AB分区和单分区最大的体验差异就在这个机制上。我设计的确认与回退策略如下:
- Bootloader跳转到新分区前,先把
boot_flag置1,boot_count加1。 - 新版本App启动后,如果业务运行正常,App主动调用
AB_Param_Confirm()把boot_flag清零、upgrade_pending清零、active_slot更新为当前槽位。 - 如果App根本没有起来,或者起来后死机,
boot_count就不会被清零。Bootloader每次上电都会检查这个值,一旦发现boot_count超过阈值(我设为3次),就自动回退到另一个槽位,并把参数区里active_slot改回去。
这个机制参照了Android系统的A/B OTA思想,只是做了MCU场景的简化。实际产品里,你要根据"确认时间窗口"来衡量:App启动多久算正常?如果是联网设备,可以等网络注册成功;如果是串口设备,可以等第一个有效业务请求到来。切不能一上电就确认,那相当于默认升级必然成功,回退机制就形同虚设。
4. App端实现:固件接收、校验、写入与切换标记
Bootloader只是个哨兵,真正干体力活的是App端。App需要把新固件接收下来、校验、写入另一个分区,最后通知Bootloader去切换。这一章我从传输协议开始,一直讲到标记写入。
4.1 升级触发与通信协议设计
我使用的场景是串口传输,但协议设计可以平滑迁移到CAN、以太网、LoRa等任意链路。升级流程从上位机发一个升级请求开始:
- 上位机发送
0xAA 0x55 0x01三个字节,表示"开始升级"。 - App回复
0xCC 0x01表示同意,如果正在运行关键任务则可以拒绝。 - 上位机发送固件包头部,包含版本、长度、CRC等。
- App确认头部信息后,上位机开始按块发送固件数据。
- 全部发完后,App计算整体CRC,与头部声明的CRC比对,一致则写入"待升级标记"并复位。
帧格式我定义得很简单,目的就是好调试、好复现:
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 2字节 | 0xAA 0x55 |
| 帧类型 | 1字节 | 0x01开始升级,0x02数据帧,0x03结束 |
| 帧序号 | 2字节 | 每帧递增,用于重传和乱序检测 |
| 数据长度 | 2字节 | 本帧有效数据长度,最大256字节 |
| 数据 | N字节 | 固件数据或命令附加参数 |
| CRC | 2字节 | 对整帧做CRC16,简化版防错 |
这个协议漏了鉴权,真实产品里至少要对头部做一次密文校验,防止任意串口设备都能触发升级。我复现时为了聚焦AB分区原理,先做了CRC级别的校验,安全扩展在文末会提。
4.2 边接收边写Flash的策略
固件包一般几十KB,STM32F103的SRAM只有64KB,如果把完整固件先收到RAM里再写入Flash,小固件勉强够,大固件直接爆内存。所以必须采用边收边写策略。
我的做法是:
- 上位机每发送一帧256字节数据,App先把数据放进一个256字节的buffer。
- 当buffer填满一页Flash对应的数据量(2KB)后,就擦除目标Flash页并把这2KB数据写入。
- 继续接收下一批,直到固件接收完成。
流程示意:
uint32_t write_addr = target_slot_addr + 32; // 前32字节留给固件头 uint8_t page_buf[2048]; uint16_t page_offset = 0; void On_Data_Frame(uint8_t *data, uint16_t len) { memcpy(&page_buf[page_offset], data, len); page_offset += len; if (page_offset >= 2048) { FLASH_Unlock(); FLASH_ErasePage(write_addr); for (uint16_t i = 0; i < 2048; i += 4) { FLASH_ProgramWord(write_addr + i, *(uint32_t *)&page_buf[i]); } FLASH_Lock(); write_addr += 2048; page_offset = 0; } }这样实现有一个坑:Flash擦写期间不能响应串口中断,如果边擦写边收下一帧数据,串口接收缓冲区可能会溢出。我采用的是最简单的方案:擦写前主动关闭串口接收中断,擦写完成后重新使能,上位机那边在每一帧之间加一个很小的延时。虽然速度慢一点,但稳定性非常好。
4.3 固件头部与整体校验
固件包开头我预留了32字节的头部,Bootloader和App都通过它来识别固件是否合法:
typedef struct { uint32_t magic; // 0xA5A5A5A5 uint32_t version; // 版本号 uint32_t length; // 固件有效数据长度(不含头部) uint32_t crc; // 整包CRC32 uint8_t reserved[16]; } FirmwareHeader;App收到头部后,先检查magic是否正确、version是否大于当前版本、length是否超出槽位容量,全都没问题才继续接收数据。数据传完后,对整个固件区重新计算一遍CRC32,和头部声明的crc对比。
CRC32我直接软件实现,虽然F103有硬件CRC外设,但软件CRC移植性更好,调试时还能和上位机用同一个算法对照。运算量对72MHz主频来说完全不是问题。
这里强烈建议:上位机端和下位机端的CRC算法必须完全一致。我当时一度以为传输有bug,数据明明发完了却校验不过,最后发现是上位机Python里用的CRC32多项式初始化值不对,和MCU端差了一位,白白排查了两小时。
4.4 写入"待升级标记"与复位
全部数据校验通过后,App不能马上跳转到新固件——真正的跳转动作要交给Bootloader在上电时完成。App只需要做两件事:
- 在参数区写入
upgrade_pending = 1,active_slot保持当前槽位不变。 - 将新固件版本号、启动次数等状态写入参数区。
- 调用
NVIC_SystemReset()复位。
逻辑上,App此刻并不关心新固件在哪个槽位,它只知道"我已经把新固件放在非活动槽位了,下次Bootloader启动时去那边看看"。具体跳哪个槽位,由Bootloader根据active_slot取反得到。
4.5 上位机的简化实现
Python的pyserial库做上位机非常方便,我简单贴一下发送逻辑的关键部分:
import serial, crcmod, struct ser = serial.Serial('COM15', 115200, timeout=1) firmware = open('app.bin', 'rb').read() crc32 = crcmod.predefined.mkPredefinedCrcFun('crc32') crc_val = crc32(firmware) # 发送升级请求 ser.write(bytes([0xAA, 0x55, 0x01])) # 发送固件头 header = struct.pack('<IIII', 0xA5A5A5A5, 1, len(firmware), crc_val) ser.write(header) # 分块发送 seq = 0 for i in range(0, len(firmware), 256): chunk = firmware[i:i+256] frame = bytes([0xAA, 0x55, 0x02]) + struct.pack('<H', seq) + struct.pack('<H', len(chunk)) + chunk ser.write(frame) ser.read(1) # 等ACK seq += 1 # 结束帧 ser.write(bytes([0xAA, 0x55, 0x03]))这个脚本虽然简陋,但复现AB OTA够用了。真实产品里还需要加超时重传、断点续传、进度显示,这些属于传输层范畴,不细说。
5. 整个链路最容易翻车的三个细节
AB OTA的原理并不复杂,80%的调试时间会耗在几个非常隐蔽的细节上。这三个坑我全都踩过,单独拎出来讲。
5.1 中断向量表重定向:为什么跳转后进不了中断
很多从单分区Bootloader转过来的朋友,第一次跳转到App后遇到的现象是:点灯能亮、循环能跑,但串口中断、定时器中断全都失灵。原因就是中断向量表还指向Bootloader区。
App端必须在初始化最早期重定位向量表,这段代码放在main()函数第一行:
SCB->VTOR = APP_A_START_ADDR; // 如果当前槽位是B,则用APP_B_START_ADDR注意一个细节:Bootloader跳转前已经设置过一次VTOR,App启动时必须再设置一次。因为App的启动代码(startup文件)默认不处理向量表偏移,你不主动设,它就沿用上一级的值。尤其是你写的App有时候单独烧录调试、有时候从Bootloader跳转运行,这两种场景下VTOR不一致,最容易出现"单独烧录正常、Bootloader跳转后中断异常"的情况。
还有一种更隐蔽的情况:如果代码里用了RTOS,任务切换依赖PendSV和SysTick中断,VTOR没设置对,调度器直接崩溃。而如果你用的是while(1)循环裸机程序,中断没响可能只是功能不对,不至于死机;但在RTOS下表现出来的就是HardFault。所以做AB OTA,裸机可以先跑通,再上RTOS。
5.2 链接脚本偏移:App编译地址必须对应分区地址
Bootloader跳转时,读的是"App区起始地址处的向量表",然后跳转到app_reset,这个函数指针是在链接阶段就确定的绝对地址。
也就是说,你在Keil里编译App时,必须把ROM起始地址改成对应分区地址,否则编译出来的App入口地址还是0x08000000附近,和Bootloader的代码重叠。
Keil里操作位置是:
- Options for Target -> Target 标签页
- 将IROM1的Start改为
0x08010000(对应槽位A) - 将Size改为
0x20000(128KB)
改成之后还需要清理一次目标文件再重新编译,否则Keil有时不会重链接。我试过只改了配置没clean,下载后发现App还是按旧地址链接的。
链接偏移导致的典型症状是:App从Bootloader跳转后完全黑屏,连Bootloader打印都没有。因为你跳转过去的地址是错的,CPU去执行了非代码区或者正好跳到某个随机指令。
有一个更稳的验证方法:编译完App后,在Keil的工程目录里找到.map文件,搜索Reset_Handler,确认它的地址在0x08010000到0x0802FFFF之间。这一步可以在每次改完链接配置后做一次,避免把问题留到烧录环节。
5.3 升级成功但重启后还是旧固件:参数区魔法数陷阱
这个坑最让人头大。现象是:升级流程全走完,App也提示"升级成功,即将重启",结果重启后跑的还是旧版本。
排查链路大致如下:
| 排查点 | 检查方法 |
|---|---|
| 参数区是否真的写入了 | 在Bootloader里打断点读AB_Param_t |
magic是否和Bootloader判定的值一致 | 打印参数区前4字节 |
upgrade_pending流程是否走通 | 检查App复位前各字段值 |
active_slot是否更新 | 确认当前槽位字段 |
| Flash擦除是否成功 | 读擦除后页内容是否为0xFF |
我最终发现的问题并不在写入,而在参数区的双页轮换逻辑:App把新参数写入了页2,但Bootloader读取时优先读页1,页1里还是旧参数,于是Bootloader认为根本没有升级标记,自然跳回旧槽位。
解决办法是统一读写规则:要么固定"写另一页、读有效页",要么固定"总是读最新时间戳那一页"。不要一个地方用页优先,另一个地方又用时间戳优先,两边规则不一致就会出现这种"写了等于白写"的现象。
6. 实测验证方法与坑位盘点
最后聊一下验证方法。AB OTA这种功能,只测"正常升级"是远远不够的,它存在的意义就是应对各种异常。我最建议准备一张用例表,每条用例都要跑一遍并记录结果。
6.1 全链路测试用例
| 用例名称 | 操作步骤 | 预期结果 |
|---|---|---|
| 首次冷启动 | 烧录Bootloader后复位 | 默认跳转A分区,业务正常 |
| A到B正常升级 | 上位机发送新固件,升级目标为B | 复位后运行B分区新固件,业务正常 |
| 升级后确认 | 新固件运行后主动确认 | 参数区active_slot变为B |
| B固件故意跑飞 | 在B固件里制造死循环重启 | 3次重启后Bootloader回退到A分区 |
| 升级中途断电 | 数据传输到一半拔掉串口或断电 | 重启后仍从原分区启动 |
| 校验失败升级 | 发送一个CRC错误的固件包 | App端拒绝写入,当前固件继续运行 |
| 写入越界 | 发送超过分区槽位大小的固件 | App端拒绝,提示空间不足 |
这七条用例全部通过后,AB OTA的基本可靠性才有保障。很多团队只测了第一、第二条就发布,结果在恶劣现场环境里暴露问题,得不偿失。
6.2 实测中记录到的三个细节问题
我在完整跑完这套用例之后,发现了三个有价值的问题:
第一个是串口波特率误差累积。F103使用8MHz外部晶振时,如果把倍频系数配成恰好9倍,得到72MHz主频,串口波特率115200理论误差为0%。但如果板子上用了内部RC振荡器(HSI),波特率误差最高可达2%以上,传输1万个字节就会出现偶发帧错误。复现AB OTA时务必使用外部晶振,并且用串口助手长时间发送数据包验证误码率。
第二个是Flash擦写时间导致的上位机超时。F103擦除一页Flash的时间约20到40毫秒,如果上位机在每帧之间的间隔小于这个时间,MCU就来不及回应ACK,上位机又会因为超时重发,导致数据错乱。我最后在上位机里加了每帧发送后等待ACK并带500毫秒超时的逻辑,整个传输过程就稳定了。
第三个是Bootloader打印信息里隐藏了一个大坑。我在Bootloader里用了printf重定向到串口,跳转前打印完最后一行日志才执行跳转函数。但printf是阻塞式的,如果串口发送还没完成就执行了RCC_DeInit(),发送被中断,跳转后旧数据残留可能导致App端串口收到一个乱码帧。后来把跳转前的调试输出改成了非阻塞方式,或者加一个简单延时等发送完成,问题消失。
6.3 从AB OTA继续扩展的方向
这套AB OTA跑通之后,往产品化走还有几件值得做的事。首先是固件签名验签,CRC只能防随机错误,防不了恶意篡改,汽车和医疗等对安全要求高的领域,普遍做法是RSA或ECDSA签名验证,Bootloader里用公钥验签,私钥放在编译服务器上。其次是断点续传与固件加密,尤其是走无线链路的场景,传输中断不可避免,断点续传能大幅提升升级成功率。再次是远程管理平台对接,把升级触发、进度上报、失败回滚统计接到云端或者网关,让运维人员能在后台直接看升级状态。
但这些扩展方向的前提,都是先把AB分区这套地基打牢。地基不牢,上面加再多功能都是沙上建塔。
按照我自己的实操体会,AB OTA在F103上从零复现,真正的核心不是某个函数怎么写,而是要对Flash操作、启动流程、中断向量表、参数持久化这几件事有一个整体的把握。只要把这一套流程完整走一遍,后面再去碰HAL库、碰其他MCU平台,也就是换层皮的事。最后再分享一个小技巧:调试AB OTA时,尽量在Bootloader和App里都保留UART打印,用不同的前缀区分,比如Bootloader打印[BL]、App打印[APP],启动顺序和跳转路径一眼就能看出来,排查异常时的效率会高很多。