STM32F103 AB分区OTA升级实战:从Bootloader到上位机
2026/9/11 14:31:38 网站建设 项目流程

做单片机开发的朋友应该都遇到过这个场景:产品已经量产发货了,结果客户现场反馈有个小Bug,或者需要增加一个新功能。这时候要是没有远程升级能力,就只能派工程师带着烧录器跑现场,或者让客户把设备寄回来,时间成本和经济成本都直接拉满。

我今年在STM32F103项目里完整落地了一套AB分区OTA升级方案,把整条链路从Bootloader到App端到上位机工具全部跑通。这篇文章就基于这个项目,完整复盘我的实现思路、关键代码和踩坑记录,标题就叫《STM32F103_AB_OTA_从零复现教程》。如果你正准备给自己的F103设备加远程升级能力,或者想搞明白AB分区和OTA到底是怎么回事,这篇文章应该能帮你少走不少弯路。

先说清楚这套方案能做什么:设备出厂时烧录Bootloader和App_A,之后所有固件更新都通过串口完成,不需要再连接调试器。系统里同时保留App_A和App_B两个应用分区,当App_A运行时,新固件写入App_B;写入完整并通过CRC校验后切换启动标志,下次重启就运行App_B。如果App_B启动失败,Bootloader自动回滚到App_A,相当于有一道保险。这个思路最早在Android系统里大规模使用,现在搬到MCU上同样适用。

STM32F103虽然是个老芯片了,但市场上存量极大,资料多、成本低、生态成熟,很多工业控制、智能硬件项目都在用。关键的是,F103的Flash支持页擦除和半字编程,IAP(In-Application Programming)能力完整,做OTA没有任何硬件层面的障碍。标准库V3.5和HAL库都行,我这次用的是标准库V3.50版本,网上随便就能下到,配合Keil MDK5开发,整个项目都能直接复现。

1. 内容整体设计与思路拆解

1.1 为什么选AB分区方案而不是传统单分区方案

最早接触OTA的时候,很多人第一反应都是单分区方案:Bootloader + App,App区固定大小,新固件直接覆盖写进App区,写完跳转,完事。这个方案逻辑简单,实现也不难,但它有一个致命的缺陷——升级过程中如果掉电、断连、或者固件本身有问题,App区就废了,设备直接变砖。你总不能指望客户现场的工人会用烧录器救砖吧?

AB分区方案相当于给系统装了一个“双系统”:App_A和App_B各占一块Flash区域,任何时候总有至少一个分区是可用的。升级时,当前运行在A区,新固件写入B区;写入完成后通过标志位切换启动目标,重启进入B区;如果B区程序跑不起来(比如CRC校验失败、启动超时、应用崩溃),Bootloader检测到异常就回滚到A区。整个过程对用户侧的破坏风险几乎为零。

当然,AB分区也有它的代价——Flash占用翻倍,一个App需要预留两块区域。比如你的App编译出来是80KB,那App_A和App_B加起来就要160KB。所以这套方案在F103C8T6(64KB Flash)上会很紧张,我实际用的是512KB的F103ZET6或者256KB的F103RCT6,具体选择看你App大小。如果你的Flash足够宽裕,AB分区的可靠性完全对得起那点空间开销。

1.2 整个OTA链路由哪些环节组成

OTA升级从来不只是单片机上写几行代码的事情。我从系统角度看,整条链路可以分为四个部分:

  • Bootloader(引导程序):负责Flash分区规划、启动标志判断、固件接收、Flash写入、CRC校验和跳转执行。这是整个OTA的核心,也是工作量最大的部分。
  • App应用固件:业务逻辑所在,必须支持编译地址偏移(链接脚本调整)、中断向量表重映射,还要提供升级触发指令、升级状态上报等接口函数。
  • 通信通道:我这次用串口(UART)作为传输通道,物理层走RS232电平转换,PC端通过USB转串口模块连接。如果你需要远程升级,可以在此基础上把传输部分替换成4G模块、WiFi模块或者LoRa,上层协议不用大改。
  • 上位机工具:PC端的小工具,负责读取bin文件、分包发送、显示进度、处理重传等。我用Python写了一个命令行版本的发送工具,简洁高效,后面会给出核心代码。

这四个部分缺一不可。很多人做OTA只盯着Bootloader,结果App端中断向量表没改,跳转后直接死机;或者上位机和Bootloader的通信协议对不上,数据发过去全是乱码。我下面会把这些环节全部串起来讲。

2. 环境准备与工程搭建

2.1 基础硬件配置与最小系统检查

做OTA调试,硬件环境首先要稳定,不然出问题你分不清是代码Bug还是硬件干扰。我用的核心板型号是STM32F103ZET6,板载8MHz晶振,通过内部PLL倍频到72MHz主频。电源用USB供电,同时引出了3.3V引脚给外部RS232电平转换模块供电。

这里有一个特别容易被忽略的细节——Bootloader和App使用的时钟配置必须一致。我一开始Bootloader里用外部8MHz晶振,App里却用内部HSI时钟,结果跳转后串口波特率全乱了,排查了整整一天才找到原因。推荐的做法是统一使用外部晶振 + PLL 72MHz,并且把SystemInit和GPIO初始化逻辑在Bootloader和App中保持一致,至少保证时钟树配置相同。

硬件接线上,串口我用的是USART1,PA9(TX)和PA10(RX),经MAX3232芯片转成RS232电平后连接PC。如果你的板子上没有RS232接口,直接用CH340或者CP2102的USB转TTL模块连接PA9/PA10也可以,注意RX和TX交叉连接,GND必须共地。

2.2 开发工具链与工程文件组织

整个工程我用的工具如下,你有等价工具也完全OK,重点是方案和代码逻辑:

  • Keil MDK 5.36:编译和调试环境,F103支持非常成熟
  • STM32F10x标准外设库 V3.5:也就是常说的StdPeriph_Lib V3.50,处理GPIO、USART、Flash、CRC等外设很方便
  • STM32F103中文参考手册:查FLASH寄存器操作、USART配置时离不开
  • Python 3.8 + pyserial库:开发上位机发送工具
  • J-Link / ST-Link V2:用于第一次烧录Bootloader和App_A,以及调试救砖

工程的目录结构建议这样组织,方便后续维护和扩展:

project/ ├── bootloader/ │ ├── Core/ // 启动文件、中断处理 │ ├── OTA/ // OTA协议、Flash驱动、CRC │ ├── Peripheral/ // 串口、GPIO、定时器驱动 │ └── MDK-ARM/ // Keil工程文件 ├── app_a/ // 业务固件(基础功能版) │ ├── Core/ │ ├── App/ // 业务逻辑 │ └── MDK-ARM/ ├── app_b/ // 业务固件(新版本,通过OTA部署) │ └── ... 同app_a结构 └── tools/ └── ota_sender.py // 上位机发送脚本

Bootloader和App使用同一个标准库,不要分别复制一份库文件,而是把库文件放在公共目录,通过Keil的Include Path引用,维护起来省心很多。这个习惯我在多个项目里受益,虽然初期搭目录多花半小时,但后期改库文件时不用同步两个工程。

3. 核心细节解析与实操要点

3.1 Flash分区规划:地址、大小和避免踩坑

分区规划是整个OTA的第一步,也是最重要的一步。我的做法是在项目一开始就把整个Flash的用户区画清楚,然后所有分区地址都定义在头文件里,Bootloader和App都引用同一个头文件,保证两边不会“对不上”。

以F103ZET6(512KB Flash,起始地址0x08000000)为例,我用的分区如下:

分区起始地址大小说明
Bootloader0x0800000032KB (0x8000)引导程序,一般不超过32KB
App_A0x08008000224KB (0x38000)当前运行的应用固件A
App_B0x08040000224KB (0x38000)备用的应用固件B
参数区0x0807E0008KB (0x2000)保存启动标志、升级状态、版本号等

这组参数里有几个细节要注意:

  • 地址必须按Flash页对齐。F103的Flash页大小对中容量和高容量芯片不同,高容量(ZET6、RCT6)的页大小是2KB。所以分区起始地址必须是0x800的整数倍,否则擦除和写入时你会非常痛苦。
  • Bootloader大小不要抠太死。很多人觉得自己Bootloader只写了10KB,就只分配16KB,结果后面加功能时空间不够,又得从头改分区表。我直接给了32KB,空间富余,后续增加握手协议、加密校验这些功能时不用动分区。
  • 参数区独立放在最后。不要把启动标志存在App区里,因为每次升级擦除App区时标志就没了。单独划出一块参数区,专门存放启动标志和版本信息。我用的是Flash最后两页(8KB),加上写保护和掉电保护逻辑,稳定性足够。

另外,App_A和App_B的编译地址(Linker脚本里的ROM起始地址)必须和分区表严格一致。这个后面在App工程配置里会细说,但在这里就要先规划清楚。

3.2 数据包协议设计:长度、序号和CRC校验

OTA通信最怕的就是数据错位。串口传输虽然没有电磁干扰那么玄乎,但波特率误差、缓冲区溢出、线缆接触不良,都可能导致数据丢字节。所以Bootloader和上位机之间的协议必须做到三点:定界清楚、能检错、能重传

我设计的协议比较简单实用,格式如下:

帧头(2B) | 命令字(1B) | 数据长度(2B) | 数据区(NB) | CRC32(4B) 0xAA 0x55 | CMD | Len | Payload | CRC32

命令字主要有这几个:

  • 0x01:握手请求,上位机询问Bootloader版本和App分区状态
  • 0x02:握手应答,Bootloader返回版本号和当前可写分区
  • 0x10:固件头信息,包含固件大小、目标分区、CRC预值
  • 0x11:固件数据包,一次最多512字节
  • 0x12:结束确认,接收完成,进行整体校验
  • 0x30:错误报告,CRC错误、地址越界等错误码

关于CRC:建议用CRC32而不是CRC16。F103的硬件CRC外设只支持CRC32(以太网CRC),标准库里有现成的CRC计算函数,直接拿来用就行。虽然Flash写入时一包只有512字节,但包头包尾加上整体固件的CRC校验,能保证绝大多数异常情况都被识别出来。整体校验的逻辑是:上位机在发送固件前先对整个bin文件计算一次CRC32,通过0x10命令发给Bootloader;Bootloader接收完整包后再计算一次,两个值比对一致才算成功。

为什么要把CRC分两层?因为单包校验只能保证单包传输无误,不能保证包的顺序对、包没丢。整体校验相当于最终大考,确保收到的数据就是上位机发送的那个完整bin文件。

3.3 Flash操作的库函数本质与注意事项

STM32F103的Flash操作,说白了就是三个动作:解锁、擦除、编程。标准库里对应的函数是FLASH_UnlockFLASH_ErasePageFLASH_ProgramHalfWord。这里我要特别提醒你:F103的Flash编程是半字(16位)编程,不是字节编程,也不是字编程。

很多新手在这块栽跟头。比如你从串口收到一个512字节的buffer,直接调用FLASH_ProgramWord写入,结果数据在Flash里错位了。正确做法是把缓冲区的数据按2字节一组拆开,分别调用FLASH_ProgramHalfWord写入目标地址。C语言里可以这样写:

void ota_flash_write(uint32_t addr, uint8_t *buf, uint32_t len) { uint16_t data_tmp = 0; for (uint32_t i = 0; i < len; i += 2) { data_tmp = buf[i] | (buf[i + 1] << 8); FLASH_ProgramHalfWord(addr + i, data_tmp); // 等待BSY位清零,确保写入完成 while (FLASH_GetFlagStatus(FLASH_FLAG_BSY) == SET); } }

写入前要先把目标页擦除,F103的擦除粒度是2KB(高容量型号),所以每次写入新一包数据之前,如果这一包横跨了一个新页的起始地址,就必须先擦除该页。我习惯的做法是:在开始接收数据前,一次性把所有目标分区涉及到的页全部擦除干净,后面写入时就省去了“边写边擦”的边界判断逻辑,流程清晰很多。

擦除全部分区页耗时大约几十毫秒到几百毫秒不等,比写入快得多,不会影响整体升级时长,所以这种“先整体擦除再逐步写入”的策略是可取的。还有一点,Flash操作期间要关中断,尤其要关掉串口接收中断,防止Flash编程过程中被中断打断。在F103上,Flash编程中断会导致写入失败,严重情况下还会产生HardFault。我在代码里用__disable_irq()把临界区保护起来,写完再恢复。

4. 实操过程与核心环节实现

4.1 Bootloader工程实现:从收到数据到写完Flash

既然分区规划定了,协议格式也定了,那Bootloader的核心逻辑就是个大流程控制。我先给一个总体的状态机思路:

状态IDLE -> 收到0x01握手 -> 进入SYNC状态 SYNC -> 收到0x10固件头信息 -> 校验目标分区和CRC -> 进入READY状态 READY -> 收到0x11数据包 -> 写入Flash -> 停在READY状态 READY -> 收到0x12结束命令 -> 整体CRC校验 -> 成功则置标志并跳转 任何状态收到错误包 -> 发送错误应答 -> 回到IDLE

这个状态机看起来简单,但实操中真正要注意的是各个状态下的超时处理和异常重试。比如上位机发到一半突然断连,Bootloader不能一直卡在READY状态等死,我设置了5秒超时,超时后自动回到IDLE状态,等待下一次握手。

Bootloader的main函数大概是这样的逻辑:

int main(void) { uint8_t cmd_buf[64]; uint16_t cmd_len = 0; // 1.初始化 ota_uart_init(115200); FLASH_Unlock(); // 2.检查启动标志 ota_boot_check(); // 3.进入OTA等待循环 while (1) { cmd_len = ota_uart_wait_frame(cmd_buf, sizeof(cmd_buf), 5000); if (cmd_len > 0) { ota_protocol_handle(cmd_buf, cmd_len); } // 超时则继续轮询,同时喂狗 ota_iwdg_feed(); } }

关于ota_boot_check,这是AB方案的精髓所在。它做的事很简单:读取参数区的启动标志,判断应该启动哪个App,只针对即将启动的目标App做有效性校验,如果校验失败则切换标志启动另一个App。我定义的启动标志有:

  • 0xA5A5A5A5:启动App_A
  • 0x5A5A5A5A:启动App_B
  • 0x00000000:无效,默认启动App_A(并对App_A做CRC校验)

校验App有效性的方法,就是对App区的前N个字节(或者整个分区)做CRC计算,与写入固件时保存的CRC比对。如果一致,说明App完整,可以启动;如果不一致,说明App损坏,回滚到另一个分区。为了缩短启动时间,我实际实现中只校验App区的前4KB(0x1000字节),运行前够用,因为写入时的整体CRC已经把完整性确认过了,启动时做一个快速采样,基本能拦住90%的异常情况。

4.2 跳转逻辑和中断向量表偏移的坑

跳转到App是Bootloader的收尾动作,但恰恰是这里最容易出问题。网上有很多跳转代码模板,但不少都有隐患。我最终用的是这样一个稳定的版本:

typedef void (*pFunction)(void); void ota_jump_to_app(uint32_t app_addr) { uint32_t jump_addr = 0; pFunction jump_to_app; // 1.检查栈顶地址是否在RAM范围内 if (((*(__IO uint32_t *)app_addr) & 0x2FFE0000) != 0x20000000) { OTA_ERROR("Stack pointer invalid"); return; } // 2.取复位中断向量地址 jump_addr = *(__IO uint32_t *)(app_addr + 4); jump_to_app = (pFunction)jump_addr; // 3.跳转前关闭全局中断,清空中断标志 __disable_irq(); SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; // 4.设置主栈指针 __set_MSP(*(__IO uint32_t *)app_addr); // 5.跳转 jump_to_app(); }

这里有几个要点:

  • 检查栈顶地址合法性。如果Flash里根本不是有效固件,第一个32位值就应该是RAM地址(0x20000000范围)。这个检查能避免跳到空白Flash导致HardFault。
  • 关闭全局中断和SysTick。Bootloader初始化时开启了串口接收中断、SysTick心跳等,如果不关就跳进App,这些中断一到就会触发,而App的中断向量表已经变了,很可能找不到处理函数,直接死机。
  • App端必须偏移中断向量表。F103的中断向量表默认在0x08000000,但App运行在0x08008000,向量表也得跟着移过去。HAL库和标准库处理方式不同,标准库需要在system_stm32f10x.c里找到宏定义VECT_TAB_OFFSET,设置为0x8000(也就是App_A相对于0x08000000的偏移)。如果你直接在MDK里定义VECT_TAB_OFFSET=0x08008000会直接编译报错,因为这个宏是偏移量,不是绝对地址。

Keil工程里定义一个宏:VECT_TAB_OFFSET=0x8000对应App_A,App_B工程则定义VECT_TAB_OFFSET=0x40000。编两个App用的同一个源码,只是这个宏不同。也可以用一个更灵活的方式,在main函数最开头调用SCB->VTOR = APP_ADDR;,但标准库里默认的向量表复制逻辑会在SystemInit里就执行,你需要确保它在跳转之前已经生效,所以最稳妥还是直接改宏定义。

4.3 App端改造:支持OTA的业务固件怎么配置

App端要做的事比Bootloader少很多,但每一件都关键:

  • 修改链接脚本(分散加载文件)。Keil里默认的STM32F103ZETx的ROM起始地址是0x08000000,大小是0x80000。App_A要改成起始地址0x08008000,大小0x38000。App_B则改成起始地址0x08040000。直接在Keil的Target选项卡里,把IROM1的Start和Size改掉即可。前提是你没有使用自定义的分散加载文件(sct文件)。如果你自己管理了sct文件,记得同步修改里面的LOAD_REGIONEXEC_REGION的起始地址和长度。

  • 实现升级命令支持。App被Bootloader启动了以后,正常运行业务逻辑。我在App里加了一个串口指令:收到UPGRADE字符串后,App把参数区的启动标志设置为0x00000000(无效标识,让Bootloader进入OTA模式),然后执行软件复位(NVIC_SystemReset())。设备重启后进入Bootloader,Bootloader看到标志无效,就进入等待上位机升级的流程。

  • 实现跳转前保存关键数据。业务固件可能在运行中有需要保留的参数(校准值、计数值等),这些如果放在App区内部,升级擦除时就丢了。我建议单独划分一个参数存储区(我在分区表里已经预留了最后的8KB),App运行过程中把重要参数写在0x0807E000往后的地址,升级时Bootloader不会动这块区域,数据就能完整保留。这个设计在工业设备里特别重要。

4.4 上位机工具实现:Python发送脚本核心逻辑

上位机工具是整个链路里最容易被忽略的一环。很多人Bootloader写得花里胡哨,最后用串口助手手动发bin文件,发一半失败重来,效率极低。我直接用Python脚本实现了全流程,核心逻辑如下:

import serial import struct import binascii import time def crc32(data: bytes) -> int: return binascii.crc32(data) & 0xFFFFFFFF def send_frame(ser, cmd, payload): frame = b'\xAA\x55' + bytes([cmd]) + struct.pack('<H', len(payload)) + payload frame += struct.pack('<I', crc32(frame)) ser.write(frame) time.sleep(0.02) def ota_send_firmware(ser, bin_path): with open(bin_path, 'rb') as f: fw_data = f.read() # 1.握手 send_frame(ser, 0x01, b'') ack = ser.read(10) # 简单等待应答 # 2.发送固件头 header = struct.pack('<II', len(fw_data), crc32(fw_data)) send_frame(ser, 0x10, header) # 3.分包发送 chunk_size = 512 total_packets = (len(fw_data) + chunk_size - 1) // chunk_size for i in range(total_packets): chunk = fw_data[i * chunk_size:(i + 1) * chunk_size] send_frame(ser, 0x11, chunk) percent = (i + 1) * 100 // total_packets print(f"\r进度: {percent}% ({i+1}/{total_packets})", end='') # 4.结束确认 send_frame(ser, 0x12, b'') result = ser.read(10) print(f"\n结果: {result}")

这个脚本还有很多值得优化的地方,比如超时重传、进度条显示、拖拽文件路径等,但核心流程就这些。我在项目里还支持了串口号和波特率的命令行参数,方便切换不同设备。

有一个实操技巧:发送时每个帧之间加20ms延时,别小看这20ms。Bootloader虽然接收速度很快,但Flash写入512字节需要时间,如果连续狂发,数据会堆积在Bootloader的串口缓冲区里,导致丢包。20ms对整体速度影响不大(几秒钟而已),但对成功率提升非常明显。

4.5 首次烧录与完整升级流程验证

第1次烧录和后续OTA升级的流程不一样,我分开说。

首次烧录,设备是全新的空Flash,需要分三步:

  1. 用ST-Link/J-Link烧录Bootloader到0x08000000
  2. 用ST-Link/J-Link烧录App_A到0x08008000(或者先用上位机发一次也行,但J-Link更快)
  3. 在参数区写入启动标志0xA5A5A5A5,指示启动App_A

第3步很容易被忽略。如果你不写启动标志,Bootloader读到的Flash默认是0xFFFFFFFF,我代码里默认按无效处理并启动App_A,也可以跑。但为了逻辑统一,建议首次烧录时顺手把标志写了。我写了一个单独的Keil工程或者Python脚本,通过ST-Link把标志写入指定地址,操作很简单。

后续OTA升级流程,完整如下:

  1. 设备当前运行App_A,上位机发送UPGRADE指令
  2. App_A收到指令,置无效标志,软件复位
  3. 设备进入Bootloader,检测到无效标志,进入OTA等待模式
  4. 上位机脚本启动,握手、发送固件头、分包传输、结束确认
  5. Bootloader接收完成,整体CRC校验通过,置启动标志0x5A5A5A5A
  6. Bootloader跳转到App_B地址,App_B启动运行
  7. 升级完成,设备在B区运行新固件

整个流程我实测下来,一个64KB的App,在115200波特率下大约需要40秒到60秒,包括整体擦除和写入的时间。对于大多数设备来说,这个升级时间是可以接受的。如果你希望更快,可以提高到460800或921600波特率,但要注意线缆质量和芯片的稳定性,125K到250K是工业上比较稳妥的选择。

5. 常见问题与排查技巧实录

5.1 跳转后死机:八成是中断向量表没对

这是我在整个项目里遇到的最常见问题,几乎每个第一次做OTA的人都会撞上。现象很典型:Bootloader打印“Jump to App”,然后设备直接卡死,或者反复复位。

排查思路:

  • 先确认App的VECT_TAB_OFFSET是否设置正确。如果你用的标准库,直接去看system_stm32f10x.c里的宏定义;如果是HAL库,在main开头调用SCB->VTOR = APP_ADDR
  • 确认跳转前是否关闭了所有外设中断和SysTick。用__disable_irq()后要确认没有遗漏的DMA中断或者PendSV。
  • 在App的main函数第一行增加一个GPIO翻转或串口打印,如果一点输出都没有,说明跳转根本没执行到;如果打印了但接着死机,那可能是App初始化了某个Bootloader用的外设时配置冲突。

我的经验是:跳转死机八成是向量表,两成是时钟配置,半成是栈指针异常。

5.2 Flash写入失败:需要检查时钟、写保护和源地址

写入Flash失败看起来是Bootloader的问题,但有时根因在App端。我遇到过的情况有:

  • Flash写保护没关。F103的Flash在某些情况下会自动启用写保护,尤其是之前用过读保护功能。在Bootloader的FLASH_Unlock之后,检查FLASH_GetReadOutProtectionStatus,如果有保护,需要按F103的选项字节操作流程解除。这个坑最隐蔽,因为它不是每次都会出现,可能你第一次烧的时候正常,后来某些操作触发了保护。
  • 写地址越界。如果上位机头信息里的固件大小和实际分区不符,或者地址计算有误,写到了非App区,就会触发Flash错误中断。我在实际代码里做了一个保护:所有Flash写操作之前检查目标地址是否在合法分区范围内,不在就直接拒绝。
  • APB时钟配置异常导致Flash等待周期不够。72MHz主频下,Flash需要设置两个等待周期(FLASH_Latency_2)。如果你Bootloader里没设置等待周期,Flash读写出错率会剧增,表现为偶尔写成功偶尔失败。这个在系统初始化时就要处理好。

5.3 通信不稳定:从线缆、波特率和缓冲区三个维度排查

OTA过程中出现乱码和数据包丢失,排查优先级如下:

  1. 硬件连接:TX/RX是否交叉,GND是否共地。这两个错误非常基础但发生率极高。
  2. 电源稳定性:RS232转换模块的供电是否稳定,我试过用一个劣质的USB供电模块,电压波动导致串口数据经常错位,换了一个质量好的适配器就好了。
  3. 波特率误差:F103的USART波特率由APB时钟分频而来,如果你的APB时钟不是精确的72MHz,实际波特率和标称值会有偏差。115200的容错范围比较小,我在调试时用逻辑分析仪看实际波形,确认误差在2%以内。
  4. 缓冲区溢出:Bootloader的串口接收缓冲区如果只有64字节,上位机一发512字节的包,缓冲区直接爆了。我把Bootloader的接收缓冲区扩到1024字节(至少在分包设计上,确保一帧能完整放下),同时用环形缓冲区的思路管理数据。另外发送窗口每个包之间的延时非常关键,20ms是我实测的好参数,不要盲目缩短。

5.4 升级到一半掉电:如何在变砖边缘救回来

AB分区方案最大的优势就是掉电安全。假设设备当前在App_A运行,升级目标是App_B:

  • 如果升级到一半掉电,App_B数据不完整,参数区里的启动标志还是无效值。下次上电Bootloader发现App_B校验不过,自动回滚到App_A,设备正常运行,只是升级没成功。
  • 如果App_B已经写入完并且标志切过去了,但App_B本身有问题启动不了,Bootloader在启动App_B前会做CRC校验,校验失败也会回滚到App_A。

这个回滚机制保证了你永远有一个能跑的应用在兜底。但我建议你在Bootloader里还要加一个“尝试计数”逻辑:每次启动App_B时把尝试次数加1,如果App_B运行超过30秒后由App自己把尝试次数清零,表示“我正常跑起来了”;如果尝试次数超过3次,Bootloader强制回滚到App_A。这个机制能在App_B能启动但业务跑飞的情况下生效,比单纯的CRC校验更智能。

6. 进阶扩展一些有意思的方向

这套AB OTA方案跑通之后,可以快速扩展出很多实用的功能:

  • 支持多通道传输:串口只是传输层的一种。你可以保持上层协议不变,把物理层换成ESP8266/ESP32的WiFi模块、Air724UG的4G模块或者SX1278的LoRa模块。Bootloader和上位机只需要改“接收字节”和“发送字节”的底层驱动,协议帧格式完全不用动。
  • 固件加密和签名:工业产品如果对安全有要求,可以在上位机发送前对bin文件做AES加密,Bootloader接收后解密再写入。或者用SHA256做固件签名,Bootloader端验证签名后才允许升级。我实际项目里加了AES-128-CBC加密,开销不大但对产品安全性提升明显。
  • 版本回退策略:App自身可以保存一个“上一版本”的备份标识,如果客户反馈新版本运行不稳定,可以通过运维指令强制回退到App_A。AB分区的结构天然支持这种双版本切换。
  • 多App分区扩展:如果Flash容量足够,从AB两分区扩展到A/B/C三分区也可以,比如保留一个出厂固件分区(Factory),一个可升级的工作分区(Work),再加一个OTA临时写入区(Temp)。这种模式接近很多商业设备的架构。

7. 一些掏心窝的总结和提醒

这篇文章里的代码和思路,都是我实际在STM32F103上验证过的,不是纸上谈兵。如果你要照着复现,我有几条掏心窝的建议:

第一,先不要急着写Bootloader。先把分区规划想清楚,写成文档,Bootloader和App两个工程都引用同一份头文件。代码写错了可以改,分区规划错了,后面所有地址、链接脚本、升级流程全部得推倒重来。

第二,用J-Link/RTT配合Bootloader调试,效率比串口打印高一个数量级。Bootloader毕竟是引导程序,出问题时连反映问题的通道都可能没有。我在Bootloader里放了一个SWD调试接口,当Bootloader异常时可以挂在调试器上看寄存器状态,这个问题定位速度快很多。

第三,给Bootloader加一个“强制升级”信号。比如上电时某个按键按住不放,Bootloader就无条件进入OTA等待模式,不加载任何App。这个功能在开发调试阶段特别有用,你不需要通过App发送指令才能进升级模式,按键按住就能进。我甚至在正式产品上也保留了这个小功能,万一App跑飞了,现场工人按按键就能恢复升级通道。

第四,版本号要跟上。我在App头部固定位置存储了版本号和编译时间,Bootloader握手时能读到,上位机在升级前先查询当前版本,避免重复升级同一个版本。这个信息量不大,但对运营和排障非常有帮助。

最后说句实在的,AB OTA这套方案我最初也被很多“简化方案”吸引过,比如单分区配合外部Flash做临时缓存、或者用软件标志控制进入IAP模式。但真到了产品阶段,稳定性永远是第一位,AB分区的空间代价换来的是“永远有退路”的安心感。如果你的产品要批量发货、要远程维护,这一课值得提早修。希望这篇文章能帮你把这条路走通,少踩我踩过的坑。

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

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

立即咨询