STM32F103这颗芯片,说它是“老兵”一点不过分。十年前的产品在用,五年后依然有不少新项目在选它,原因无非是成本低、资料全、稳定可靠。但你要是想给基于F103的设备加上OTA固件升级能力,网上的资料就明显不够用了,东一篇讲Boot跳转、西一篇讲Flash擦写,没人把整条链路串起来。我最近把量产项目里沉淀下来的AB分区升级方案重新过了一遍,从分区规划、代码实现到打包工具,整理出一条可以直接照着复现的路径,分享出来。
这次要复现的项目叫STM32F103_AB_OTA,核心内容是用AB双分区策略让F103具备可靠的固件升级能力。它解决的问题很直白:传统单区升级一旦中途断电或程序写错,设备直接变砖,只能拆壳接烧录器救。AB方案用两个互为备份的App分区,做到升级失败自动回滚,这是量产设备必须具备的可靠性。适合谁看?只要你会用标准库或HAL库点过LED、能自己建一个最小工程,这篇文章就能带你走通F103 AB OTA的完整流程,不用再去翻碎片化资料。
1. 为什么F103要做AB分区OTA
1.1 单区升级到底有哪些隐患
先把最原始的做法拿出来说。很多人第一次做远程升级,Flash布局是这样的:Boot区放在最前面,后面跟着唯一的App区。Boot负责接收新固件,擦掉旧App,写入新App,然后跳转。逻辑上没毛病,但实际量产中坑特别多。
最大的坑就是升级过程中掉电。芯片擦除一个扇区要几十毫秒,写完整份固件按115200波特率算可能要几分钟,这段时间里任何一次掉电,都可能让App区处于“擦了一半”或者“写了一半”的状态。单片机上电后Boot发现App校验不过,又没有备份可回退,设备就只能躺在那等着返厂。
第二个坑是“升级了坏的固件”。代码编译过了不代表逻辑就对,万一新固件里有严重bug,跑到一半死机、外设配置异常、通信全断,Boot已经把启动权交出去了,整台设备照样变砖。单区方案对这种“逻辑层面”的失败完全没有招架能力。
第三个坑容易被忽略,就是升级过程本身被打断。比如用户在现场突然断电、通信线路松动、看门狗超时复位,Boot重新启动后还得继续处理半截升级,状态恢复逻辑写起来非常痛苦,稍不留神就陷入反复擦写Flash的坏循环。
AB分区方案正是针对这三个痛点设计的。它不做“覆盖式升级”,而是把新固件先写进另一个空闲分区,等固件完整且校验通过之后,再修改启动标志去引导新分区。运行区始终保留一份已知可用的固件,出问题随时切回去。
1.2 AB双分区的核心设计思想
AB分区的思想其实和电脑上常见的双系统启动非常像。Flash里不只有一个App区,而是有两个大小相同的App区:A区和B区。Boot启动时根据参数区里保存的“启动目标”,决定跳到A区还是B区。
正常工作时,比如当前在A区运行,收到升级包后,App把新固件写入B区。写入完成后做一次全量CRC校验,确认没问题,就把参数区的启动标志改成“下次启动B区”,然后软复位。Boot看到标志,跳到B区。如果B区跑起来正常,整个升级就算完成了。
如果B区跑起来有问题,比如看门狗没喂上、系统反复重启,Boot侧可以加一套看门狗检测逻辑,发现目标分区连续复位超过N次,就回滚到另一个分区。这样即使新固件在逻辑上是个炸弹,也不会炸死设备,老版本还能拉一把。
这套逻辑放到F103上有个天然优势:F103的Flash虽然不大,但高容量型号有512KB,分给Boot 32KB,A区和B区各224KB,对大多数应用固件来说完全够用。而且AB方案把升级链路中最复杂的“接收、校验、写Flash”逻辑放在App侧,Boot只需要做跳转和仲裁,Boot本身的体积可以做得非常小,也降低了Boot自身出bug的概率。
1.3 Flash资源盘点与分区规划
动手写代码之前,先把芯片型号确认清楚。F103家族里,常见的STM32F103C8T6只有64KB Flash,分成AB两区后会非常局促,我一般不推荐在C8上做AB OTA,除非你的固件压到20KB以内。真正适合AB方案的是256KB和512KB的大容量型号,比如STM32F103RCT6、STM32F103ZET6。
以STM32F103ZET6的512KB Flash为例,分区规划可以这样定:
| 区段 | 地址范围 | 大小 | 用途 |
|---|---|---|---|
| Bootloader | 0x08000000 - 0x08007FFF | 32KB | 上电跳转仲裁逻辑 |
| App A区 | 0x08008000 - 0x0803FFFF | 224KB | 可运行固件区 |
| App B区 | 0x08040000 - 0x08077FFF | 224KB | 可运行固件区 |
| 参数区 | 0x0807F800 - 0x0807FFFF | 2KB | 启动标志、版本号、升级状态 |
这里有几个细节要特别说明。首先,Boot区放在0x08000000,这是STM32的默认启动地址,上电后芯片从这里开始执行,不需要任何额外配置。其次,A区和B区大小必须一致,因为两区存放的都是同一种固件,只是地址不同。最后,参数区单独留出2KB,专门存启动标志和升级状态,避免和固件区混在一起,擦写逻辑也清晰。
关于F103的Flash扇区结构,大容量型号每个扇区是2KB,前4个扇区是16KB,后面有大扇区。但在代码里我们用标准外设库的FLASH_ErasePage函数,函数内部会根据地址自动识别扇区,不需要自己关心这个结构。不过参数区地址尽量选在2KB边界上,也就是末尾的0x0807F800,这样一次擦除正好一个扇区,省心。
2. 整体方案设计与关键原理
2.1 Boot只做跳转仲裁,下载逻辑放在App侧
这是整个方案里最值得讲清楚的设计决策。很多人在设计OTA时,习惯把升级协议也塞进Boot里,让Boot直接接收固件、写Flash。这种思路对Boot的代码量、内存占用、协议处理能力要求都很高,F103的Boot还承担着“救砖”的职责,代码越复杂越容易出问题。
而这个项目采用的做法是:Boot永远不直接参与固件下载。Boot要做的事情只有三件——读取参数区的启动标志、校验目标分区的有效性、跳转过去。哪怕A区和B区都坏了,Boot也不会自己写固件,它只会停在原地等你用烧录器救。
那固件下载谁来干?由当前正在运行的App干。比如当前跑在A区,A区收到升级指令,就会把新固件写入B区,校验通过后改标志、复位。当前跑在B区,就写入A区。这个设计有个很妙的地方:能运行App说明系统基本是健康的,把复杂的协议解析、Flash擦写、CRC计算交给健康侧,把风险集中在本来就要被替换的“非活动区”,比在Boot里处理靠谱得多。
用一句话总结就是:Boot负责决策,App负责干活。这也是为什么AB方案在做量产级OTA时特别稳,因为它的逻辑链路非常短,出错面小。
App侧OTA模块的代码结构也相对独立,不影响主业务。我习惯把OTA逻辑封装成一个独立模块,提供三个接口:初始化、周期轮询、收到升级命令时抢占执行。这样主业务和升级逻辑之间互不干扰,升级完成后再复位切区。
2.2 Boot跳转App的核心原理
Boot跳App这件事,原理上理解起来不难。STM32上电后,CPU从0x08000000取出栈顶指针,从0x08000004取出复位中断向量并跳转执行。App其实就是一段编译好的程序,起始地址处也放着自己的栈顶指针和复位向量。Boot跳转要做的,就是用软件模拟一次“重新上电”的过程。
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 ((app_sp & 0xFFF00000) != 0x20000000) { return; } // 跳转前关闭全局中断、复位外设 __disable_irq(); RCC_DeInit(); SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; // 设置中断向量表偏移 SCB->VTOR = app_addr; // 重设主栈指针 __set_MSP(app_sp); // 跳转到App复位向量 pFunction jump = (pFunction)app_pc; jump(); }这里有几个必须注意的细节。第一,跳转前一定要关闭全局中断,并复位SysTick。否则跳过去之后,如果还有一个SysTick中断挂着,CPU会以旧向量表去取中断入口,直接HardFault。第二,SCB->VTOR是Cortex-M3内核里的中断向量表偏移寄存器,在F103上地址是0xE000ED08,设置成App的起始地址后,后续所有中断都会从新向量表取地址。第三,栈顶指针要手动设置,App启动文件的第一件事就是设置SP,但我们希望在jump()函数里完成类似“复位”的效果,所以提前把MSP设好更保险。
跳转函数写完之后,Flash中的App还有一个硬性要求:它的中断向量表必须放在它自己的起始地址处。也就是说,如果App运行在0x08008000,那么0x08008000处必须是栈顶指针,0x08008004处必须是复位向量。这个要求由链接脚本保证,后面会细说。
2.3 中断向量表偏移与固件格式
F103上App和Boot共用一套中断向量表机制,但App要想跑在非0x08000000地址,必须处理两件事:一是链接时把镜像放到目标起始地址,二是运行时设置SCB->VTOR。
Keil MDK下设置App的起始地址,靠的是分散加载文件。Boot工程通常不需要改,默认0x08000000即可。App工程的分散加载文件里,IROM1的起始地址要改成App区的起始地址,比如0x08008000,大小改成0x00038000。很多新手在这里踩坑,只改了编译地址,忘了在代码里设置SCB->VTOR,结果跳过去就死。
固件格式方面,我强烈建议不要往App镜像前面硬塞自定义头部。网上有些方案会把魔数、版本号、CRC等字段直接放在固件开头,这样固然方便,但会破坏向量表布局,App的栈顶指针就不是0x08008000了,Boot跳转前还得把头部跳过去,逻辑一复杂就容易出错。
更稳妥的做法是:固件文件就是纯编译产物,版本号、固件长度、CRC32这些信息在升级开始时,由上位机单独通过“升级启动帧”发给App侧。App接收完整个升级数据后,再对写好的Flash区域重新算一次CRC,和启动帧里携带的CRC比对。这样Boot侧只需要校验向量表里的栈顶指针是否合法,整个链路最清晰。
2.4 升级链路与帧协议设计
F103本身不带以太网和无线功能,所以这里说的OTA,更准确地说是“通过任意可靠字节链路升级”。串口是最容易跑通的载体,很多人项目里本来就有RS232或RS485总线,直接复用即可。如果产品是CAN总线组网,把传输层换成CAN帧也可以;如果以后要接WiFi模组或2.4G模块,只要把底层收发接口抽象出来,协议层完全不用动。
但不管用什么物理链路,帧协议和应答机制必须设计好。我使用的帧结构是固定头部加数据体的形式,放在App侧解析:
| 字段 | 字节数 | 说明 |
|---|---|---|
| 帧头 | 2 | 0xA5 0x5A |
| 命令字 | 1 | 0x01查询版本、0x02开始升级、0x03数据帧、0x04结束 |
| 分区号 | 1 | 0表示写A区,1表示写B区 |
| 块序号 | 2 | 从0开始,每块大小和Flash扇区匹配 |
| 数据长度 | 2 | 本帧有效数据字节数 |
| 数据 | N | 最大2KB |
| CRC32 | 4 | 对前面所有字节的CRC32校验 |
为什么块大小选2KB?因为F103大容量型号的Flash扇区正好是2KB。上位机每发一帧,App侧攒够2KB就一次性擦写一个扇区,逻辑最简单。串口接收缓冲区开4KB足够,一帧数据最多2KB,处理完才收下一帧,不会溢出。
应答机制做得也很简单,App每写成功一块,回一个ACK,带上块序号;如果CRC不对或者块序号不是期望的下一个,回NAK。上位机收到NAK或者超时未收到ACK,就重发当前块。这套机制在115200波特率下实测非常稳定。
3. 从零复现:代码实现与编译打包
3.1 工程结构建议
动手之前先把工程结构想清楚,否则后面会乱。我建议整个项目分成三个工程目录:
- Boot工程:独立编译,烧录到0x08000000
- App工程:独立编译,链接到0x08008000
- 上位机工具:Python脚本,负责发送升级包
Boot工程和App工程可以是两个独立的Keil工程,也可以放在同一个工作区里。工程名建议带分区标识,比如Boot_F103、App_ABOTA,避免调试时下错镜像。我见过太多人把Boot镜像和App镜像下载反了,芯片直接卡死,那种问题排查起来很痛苦。
App侧建议新建一个ota_ab.c和ota_ab.h,把升级逻辑完整封装起来,不干扰主业务代码。主函数里在一个周期任务中调用ota_ab_poll()即可,收到完整升级命令后,代码内部会暂存当前状态,升级完成后不复位到正常业务流程,而是直接软复位。
3.2 Boot工程的完整实现
Boot工程的代码量非常小,核心流程可以用一个主函数说清楚。
int main(void) { uint8_t target; // 系统时钟初始化,和App保持一致 SystemInit(); // 读取参数区的启动标志 target = read_boot_target(); // 根据启动标志选择启动分区 if ((target == TARGET_A) && check_app_valid(APP_A_BASE)) { jump_to_app(APP_A_BASE); } else if ((target == TARGET_B) && check_app_valid(APP_B_BASE)) { jump_to_app(APP_B_BASE); } else { // 标志异常或目标分区无效,回退到A区 jump_to_app(APP_A_BASE); } while(1); }check_app_valid函数做的事很简单:读目标分区起始地址的4字节,检查里面存的栈顶指针是否落在RAM地址范围内。正常情况下,一个有效的App镜像第一行一定是栈顶指针,其值必定在0x20000000附近。这个判断虽然不严格,但对Boot来说已经足够了,相当于给跳转上了一道锁,防止跳到空Flash区域。
参数区的读取要做一下容错。read_boot_target读到的值不是预定义的TARGET_A(比如0xA5)也不是TARGET_B(比如0x5A)时,直接当成A区处理,不要往未知分区跳。
Boot工程里不需要初始化串口,不需要初始化任何外设,保持最小系统状态即可。只有一个例外:如果产品对启动时间有严格要求,可以把看门狗开起来,防止Boot卡死在未知状态。
3.3 App侧OTA模块的完整实现
App侧的OTA模块是这个项目的核心,主要处理三件事:帧解析、Flash写入、启动标志更新。
帧解析用状态机实现,这是嵌入式处理串口流式数据的标准做法。每来一个字节,根据当前状态推进,直到收完一整帧再统一处理。这里要注意,串口中断函数里千万不要做Flash擦写等耗时操作,只收数据,帧处理放在主循环里。
typedef enum { FRAME_IDLE, FRAME_HEAD1, FRAME_HEAD2, FRAME_CMD, FRAME_PART, FRAME_SEQ_H, FRAME_SEQ_L, FRAME_LEN_H, FRAME_LEN_L, FRAME_DATA, FRAME_CRC, } frame_state_t; void ota_ab_rx_byte(uint8_t byte) { static frame_state_t state = FRAME_IDLE; static uint16_t data_len = 0; static uint16_t data_cnt = 0; switch (state) { case FRAME_IDLE: if (byte == 0xA5) state = FRAME_HEAD1; break; case FRAME_HEAD1: if (byte == 0x5A) state = FRAME_HEAD2; else state = FRAME_IDLE; break; // 其余状态机步骤略,按帧结构逐字节推进 default: break; } }收完一帧后,验证CRC32通过,再按命令字分发。如果是数据帧,就写入Flash。写Flash的代码需要格外小心,F103的Flash编程是以半字(16位)为单位操作的,所以数据长度必须是偶数,起始地址必须半字对齐。代码核心就是标准库的擦写接口。
void flash_write_app_block(uint32_t dest_addr, uint8_t *data, uint16_t len) { uint16_t i; uint16_t *ptr = (uint16_t *)data; FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); for (i = 0; i < len / 2; i++) { FLASH_ProgramHalfWord(dest_addr + i * 2, ptr[i]); } FLASH_Lock(); }实际项目中,我不会每收到一帧就写一次,因为擦写Flash时CPU取指会暂停,对串口接收有影响。更稳的做法是:上位机发完整块再等待ACK,App收到2KB整块后一次性擦写,同时上位机那边做超时重发,这样即使擦写时间长一点也不会丢数据。
3.4 Flash分区标志的读写策略
分区的启动标志存在参数区,我用一个64字节的结构体来管理:
#define PARAM_BASE_ADDR 0x0807F800 typedef struct { uint32_t magic; // 固定为0xA5A55A5A,用于判断标志有效性 uint8_t boot_target; // 0xA5写A区,0x5A写B区 uint8_t version[4]; // 固件版本 uint32_t last_result; // 上次启动结果,成功跑起来后App会更新 uint32_t reserved[10]; } boot_param_t;写入标志有一个避不开的问题:Flash写之前必须先擦除,而擦除是按扇区来的,参数区所在的2KB扇区整个都会被擦掉。这样一来,每次升级都要擦一遍参数区,F103的Flash擦写寿命标称是1万次,对经常升级的产品来说需要稍微注意下。
我采用的缓解方案是“双槽位轮换”。参数区里准备两个槽位,比如槽位0和槽位1,每次写标志时轮流使用。写槽位0之前只擦槽位0对应的区域?不行,还是同一扇区。那就换成另一招:用两个独立扇区各存一份标志,交替写。但F103 Flash从扇区0到255,最后两个扇区拿来做参数区,每个扇区2KB,完全够用。这次升级写扇区A,下次升级写扇区B,配合一个简单的计数器判断哪份有效。这个方案在量产里跑得很稳,代码也不复杂。
更简单的方案是接受1万次擦写寿命。对大多数设备,即使每周升级一次,也能用将近200年,所以实际压力并不大。不过有一个原则必须坚持:先擦参数区、再写参数区、再复位,逻辑顺序不能乱。有些教程为了省事,把改变启动标志放在升级数据校验通过之前,一旦断电就会出现老固件被覆盖但标志已经指向新分区的局面,那就真是灾难了。
3.5 分散加载文件与固件打包
App工程的分散加载文件是AB OTA能否跑起来的又一个关键点。Keil工程里直接修改Linker配置即可,也可以用.sct文件精确控制。一个针对上述分区的App分散加载文件示例:
LR_IROM1 0x08008000 0x00038000 { ER_IROM1 0x08008000 0x00038000 { *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 0x00010000 { .ANY (+RW +ZI) } }注意这里RAM大小按F103ZET6的64KB配置,如果你的芯片RAM不一样,要相应调整。修改分散加载文件后,编译出的App镜像地址就从0x08008000开始了,烧录时必须烧到App区对应地址,不能直接在Debugger里按默认地址下载。
固件打包用Python脚本完成,脚本要做的事情是:把编译生成的.axf转成.bin,然后在上位机里按照前面定义的帧协议,把版本号、长度、CRC32封装到升级启动帧里,剩下的活就是分块发送。
import serial import struct import zlib ser = serial.Serial('COM3', 460800, timeout=2) fw = open('app.bin', 'rb').read() fw_len = len(fw) fw_crc = zlib.crc32(fw) & 0xFFFFFFFF version = b'1.0.0' # 发送开始升级命令 head = struct.pack('<BBBBHI', 0xA5, 0x5A, 0x02, 1, fw_len, fw_crc) ser.write(head) # 分块发送数据帧,块大小2048 block_size = 2048 seq = 0 for offset in range(0, fw_len, block_size): data = fw[offset:offset + block_size] frame = struct.pack('<BBBBHH', 0xA5, 0x5A, 0x03, 1, seq, len(data)) frame += data frame += struct.pack('<I', zlib.crc32(frame) & 0xFFFFFFFF) ser.write(frame) # 等待ACK,超时重发 ack = ser.read(4) while ack != struct.pack('<BBH', 0xA5, 0x5A, seq): ser.write(frame) ack = ser.read(4) seq += 1实际传输中还要加上进度打印和超时统计,上面的代码只是核心逻辑。这里有个经验:波特率我直接用了460800,F103的USART完全扛得住,配合2KB数据块,下载速度比115200快4倍,升级体验会好很多。
4. 常见问题与排查技巧实录
4.1 跳转后立刻HardFault
这个几乎是新手必踩的坑。现象非常典型:Boot里调用jump_to_app之后,程序直接跑飞,进HardFault中断,屏幕上的调试输出全部消失。
排查顺序要固定。第一步看App工程分散加载文件是不是改对了,确认App镜像确实链接到了目标地址,不是还搂在0x08000000。第二步看SCB->VTOR有没有在App的main函数最前面设置,这一步经常被漏掉,App的中断没起来,只要一开中断就死。第三步看跳转前是否关闭了所有外设和中断,有时候Boot初始化了串口或定时器,跳过去旧外设还在产生中断,App又没初始化这些外设,中断一进来就找不到处理函数。
还有个细节:__disable_irq()只关了全局中断标志,已经挂在NVIC里的pending中断不会消失。所以跳转前最好把NVIC清一下,简单粗暴的办法是在RCC_DeInit()之后把所有外设时钟关掉。
4.2 升级中途掉电后无法启动
升级过程掉电是硬件现场无法避免的事,AB方案对此必须给出明确应答。我的设计是:升级启动帧里携带一块“升级进度”信息,App每写成功一个扇区就在参数区更新一次进度。下一次Boot启动时看到升级没完成,直接忽略不完整的区,跳回老区继续运行。
但这要求参数区数据在升级过程中一直保持“目标还没切换”的状态。前面提到的“先校验通过再改启动标志”就是核心保障:哪怕整个B区写到一半断电,参数区里的启动目标依然是A区。等App下一次恢复运行时,它发现B区有半截数据,可以让上位机从断点继续传,或者干脆重新开始。
另外要注意看门狗策略。如果升级过程中看门狗在跑,每次写Flash耗时太长都可能触发复位。我踩过一次坑之后,在升级期间果断把看门狗暂停了。如果产品有严格的安全要求不能关看门狗,那就要把每两个数据帧之间的间隔压短,保证看门狗能被及时喂到。
4.3 串口传输总是丢块或CRC错误
升级过程中上位机不停地报CRC错误,重传率居高不下,这个问题多半不是协议问题,而是底层串口配置。
先检查波特率误差。F103的USART波特率由PCLK分频而来,如果系统时钟不是精确的72MHz,或者外部晶振有偏差,460800这种高波特率下误差会被放大。我用外部8MHz晶振时一切正常,换成板载HSE配置不对就频繁出错。建议量产固件里把系统时钟配置和串口波特率做成编译期校验。
再看串口缓冲区和中断处理。F103的USART硬件FIFO只有1字节,如果中断响应不及时,数据就丢了。升级时应使用DMA接收,或者至少把串口中断优先级调到最高,接收中断里只做存字节,不做事,数据处理全部放到主循环。这两个调整之后,460800波特率下连续传几百KB数据,基本零丢包。
如果还丢,那就查接地。串口线过长、USB转串口模块质量差、上位机电脑接地不良,都会在高速率下引入误码。这时候把波特率降回115200通常能解决,而协议层的NAK重发机制保证了即使降速也不会出错。
4.4 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 跳转后HardFault | App的VTOR未设置/分散加载错误/外设中断未关闭 | 按4.1顺序逐项排查 |
| 升级后启动标志指向B区,但Boot跳到A区 | 参数区被擦除或标志值非法 | 检查参数区擦写逻辑,Boot默认回退A区 |
| 升级过程中CRC大量报错 | 波特率误差/接地不良/串口中断响应不及时 | 降波特率,开DMA,检查硬件连接 |
| Flash擦写失败 | 未调用FLASH_Unlock/Flash被写保护 | 检查FLASH_Unlock和写保护状态 |
| 升级完成后App反复重启 | 新固件逻辑异常,看门狗复位 | Boot侧增加连续复位次数检测,回滚分区 |
| 编译出的App镜像地址不是目标地址 | 分散加载文件未生效 | 重新配置Linker,使用.sct文件 |
| Boot跳转后串口输出乱码 | Boot和App主频配置不一致 | 统一两边的系统时钟宏定义 |
4.5 这次复现后的几点体会
整套AB OTA复现下来,我最大的感受是:F103做OTA不是不行,而是要把方案做“小”。所谓小,不是功能砍掉,而是逻辑层次要极简——Boot小到不碰任何协议,App侧的OTA模块独立成块,上位机协议明确到每个字节都有唯一含义。任何一部分做大做复杂,都会给排查带来成倍负担。
如果后续想把这套方案用到自己的产品上,建议第一步只做串口链路,把AB分区跑通,再考虑接CAN、RS485或者无线透传。传输层替换其实非常容易,我在项目里把串口换成CAN总线时,改动的地方只有底层收发函数,帧协议和Flash策略一点没动。这也是AB OTA方案本身的价值:它把“传输”和“升级策略”解耦了,你只需要关心业务自身的通信方式,升级可靠性由分区机制兜底。
最后说一个容易被忽略的小细节:App侧升级完成后,复位前记得把串口发送缓冲区的数据清干净,避免复位瞬间调试助手里还残留半截旧数据,误导你判断Boot有没有正常启动。这个坑我排查了整整一个下午,后来发现根本就是调试工具显示的问题。写出来提醒一下,少走点弯路。