news 2026/9/9 11:06:23

STM32F103 AB分区OTA升级方案详解:Bootloader设计、掉电保护与断点续传实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32F103 AB分区OTA升级方案详解:Bootloader设计、掉电保护与断点续传实现

STM32F103都发布十几年了,按说早该进"教科书"了,但这两年找我聊AB分区OTA的人反而特别多。原因也不难猜:一方面F103成本低、货源稳,大批量产品还在跑;另一方面,现在客户越来越懂行了,动不动就问"支持远程升级吗""升级失败会变砖吗",传统的单分区IAP方案在可靠性上确实有些跟不上。我这次就把自己从零复现STM32F103 AB分区OTA的完整过程整理出来,从分区设计、Bootloader编写、固件打包到断点续传和掉电保护,每一步都给出可直接抄作业的方案。

1. AB分区OTA的整体设计思路

1.1 为什么F103这种老MCU也需要AB分区

很多朋友一开始会问:F103片内Flash才512KB,RAM才64KB,资源这么紧,搞AB分区是不是太奢侈了?

这个问题问得很实在。传统单分区IAP的思路是:Bootloader接收完整固件包,先全部写到备份区,校验通过后再整体拷贝到App区,最后跳转。流程上也算严谨,但有一个绕不开的隐患:如果固件拷贝到一半掉电,或者新固件本身存在启动即崩溃的致命Bug,设备就直接变砖了,只能开盖用烧录器恢复。

AB分区的思路则完全不同:Flash里同时存在A槽和B槽两份固件,Bootloader永远从当前有效的那个槽启动。升级时新固件写入非活动槽,完整校验通过后只需把"活动槽标志"切换一下,下次开机就从新槽启动。如果新固件启动失败,Bootloader检测到启动标志未被清除,自动回滚到旧槽。整个过程不涉及整片拷贝,从根上解决了掉电变砖和启动崩溃的问题。

F103虽然老,但它的Flash是128KB一页,按32KB、64KB这样的粒度切分非常规整。以最常见的256KBFlash型号(如STM32F103RCT6)为例,完全可以划出两个96KB的槽位加一个8KB的Bootloader区,剩下56KB做参数存储和OTA缓存。这个空间规划对绝大多数应用来说是够用的,代价完全可控。

1.2 AB分区的核心机制与正常启动链路

先讲清楚AB分区最核心的三个概念:活动槽、非活动槽和槽位标志。

  • 活动槽(Active Slot):当前正在运行的固件所在分区,Bootloader每次启动都会跳转到这个槽位。
  • 非活动槽(Inactive Slot):用于接收新固件的分区,平时不参与启动。
  • 槽位标志(Slot Metadata):存放在独立的Flash扇区,记录哪个槽是当前活动槽、哪个槽待升级、以及每个槽的固件状态(有效/无效/待提交)。

我们在实际项目里把槽位标志放在Bootloader末尾单独划出的一个扇区(2KB),专门存放这类关键状态信息。硬件复位后,Bootloader第一件事就是读槽位标志,判断三个问题:现在活动槽是A还是B?非活动槽有没有完整的待升级固件?当前活动槽固件有没有正常跑起来过。

启动链路分两种场景:

正常启动:Bootloader读标志,发现A槽有效且为活动槽,直接跳转A槽App,App跑起来后调用一个接口向上层报告"启动成功",Bootloader据此决定是否清除回滚保护标志位。

升级启动:Bootloader读标志,发现非活动槽有完整固件且校验通过,则跳转到非活动槽。此时活动槽已被切换为新槽。如果App启动后发现固件异常,无需人工干预,启动代码直接调用回滚指令,下次复位时Bootloader发现活动槽固件未确认有效,自动切回旧槽。

这个机制的关键在于"确认"这一步。我们不会让Bootloader直接信任新固件,而是要求App在正常运行一段时间后主动确认固件可用,才把回滚保护解除。这是从汽车电子OTA里学到的经验,很值得借鉴。

1.3 分区规划:256KB Flash怎么分才够用

以STM32F103RCT6为例,片内Flash 256KB,起始地址0x08000000,扇区大小统一1KB,但按128字节编程粒度写入。我这里给出一种经过实际验证的分区方案:

区域起始地址大小用途
Bootloader0x0800000016KBIAP引导程序
A槽App0x0800400096KB活动固件槽A
B槽App0x0802200096KB活动固件槽B
槽位标志0x080400002KB槽位状态、固件CRC、回滚标志
参数存储0x08040800余下空间用户参数、日志缓存

有几点要特别说明:Bootloader给到16KB,用标准库V3.5实现UART接收、Flash擦写、CRC校验、跳转逻辑足够。如果想加GUI升级或者加密解密,建议Bootloader扩到24KB以上。两个槽位等分96KB,就意味着App固件编译链接时必须控制在96KB以内。你在Keil里把IROM1起始地址改为0x08004000,Size改为0x00018000(96KB),编译后如果出现overflow错误,就需要精简代码或压缩固件。

槽位标志区比较特殊,因为它需要频繁擦写,建议使用"双页交替"策略:标志写在两个扇区内,每次写入前先判断哪个扇区有效,避免一次掉电把标志区写坏。这个细节我是踩过坑的,后面会展开讲。

1.4 方案选型:为什么不用内置IAP而要完全自研Bootloader

STM32本身支持内置Bootloader,从系统存储器启动后可以通过USART1、USB等接口下载固件。但内置Bootloader有两个问题解决不了:一是它无法跳转到任意固定地址的App启动,二是它根本不认识我们自定义的固件包格式和槽位标志。所以自研Bootloader是必须的。

在自研Bootloader的传输协议选择上,我对比过三种方案:

方案优点缺点
YMODEM协议简单,广泛支持无断点续传,一包错全重传
自定义UART协议灵活可控,便于加CRC、加密、断点续传需要自己造轮子
基于MQTT/TCP的远程升级支持远程海量设备F103资源紧张,需外接通信模块,适合网关场景

这次教程先讲UART场景下的自定义协议,同时会给出接入Wi-Fi/4G模块进行远程升级的思路。YMODEM做本地升级其实也足够,但断点续传是硬需求时只能自定义。因为F103写Flash是128字节粒度,我最终设计的帧格式就是一帧128字节有效载荷,加8字节头部和2字节CRC16,每帧独立校验、独立确认,这样断点续传只需要记录最后成功写入的帧号,重新连接后从下一帧继续传,不需要整包重来。

2. Bootloader工程搭建与Flash驱动详解

2.1 基于标准库V3.5创建Bootloader工程

Bootloader工程我基于标准外设库V3.5搭建,Keil MDK 5环境。为什么不推荐用HAL库?Bootloader代码量小、对实时性要求高,标准库更贴近寄存器操作,逻辑清晰,也更容易控制Flash擦写时序。当然,用CubeMX生成HAL库工程也没问题,但建议你至少把Flash驱动换成寄存器版本,减少不必要的外设开销。

工程创建步骤如下:

  1. 新建工程文件夹,加入标准库的CMSIS核心文件和启动文件startup_stm32f10x_hd.s(高密度型号选这个,中低密度选md/ls)。
  2. 把system_stm32f10x.c加进去,系统时钟配置到72MHz。
  3. 添加GPIO、USART、Flash、CRC等外设驱动文件。
  4. 配置魔法棒选项:Target页勾选Use MicroLIB,确保printf重定向到串口的代码体积最小。

第一版Bootloader我做了一个串口命令行交互界面,收到"OTA_START"命令后进入升级模式,再接收固件包。这个交互方式对于产线烧录和本地调试都很直观。

链接脚本里必须把Bootloader的ROM地址设为0x08000000,长度0x00004000(16KB)。App的中断向量表偏移我们会在App工程里设置,与Bootloader分区规划配套。

2.2 128字节页编程:F103的Flash操作时序

STM32F103的片内Flash是一个很"传统"的内存:读操作可以按字节/半字/字并行执行,但写操作必须按16位半字进行。Flash编程的最小单位是一个半字(16位),但为了和外部通信帧对齐,我们一般按128字节一块下发,内部循环64次半字编程实现。

这里重点说时序。很多人第一次调Flash驱动遇到"写不进"或者"写一半HardFault",基本都是时序问题。标准流程是:

  1. 解锁Flash:向FLASH_KEYR依次写入0x45670123、0xCDEF89AB。
  2. 检查FLASH_SR的BSY位,确保Flash空闲。
  3. 写入数据:对目标地址连续写半字,每写一个半字都要检查BSY位,等硬件忙完。
  4. 全部写完擦除需要单独操作,按下页(1KB)擦除或整片擦除。
  5. 加锁Flash:向FLASH_CR写入0x00000080(LOCK位)。

页擦除我之前不严谨,直接一次性给整页发擦除命令,然后立刻读状态,结果偶尔出现"擦除失败"——原因是擦除操作是异步的,命令发出后需要等到BSY清零才算完成。后面所有擦写操作都统一加了轮询BSY的代码,问题就再没出现过。

2.3 固件校验:CRC32还是CRC16,放在包的什么位置

固件校验这一步,决定了OTA方案靠不靠谱。我见过很多入门项目只用累加和做校验,实际传输过程中出现误码很难发现。我们选用CRC32,基于查表法实现,速度很快,F103跑72MHz算96KB固件大概也就几毫秒级别。

CRC32的计算范围是整个固件包内容(包含头部、固件数据、对齐填充),计算结果放在固件包头部的CRC字段里。Bootloader拿到包后先解析头部,再对整包做CRC32比对,一致才允许写入Flash。写入过程中,每个128字节帧还有单独的CRC16做链路层校验,双层校验,出错的概率极低。

固件包头我们自己定义,格式如下:

偏移长度字段说明
04Magic固定0xA5A5A5A5,用于识别固件包
44FirmwareSize固件数据长度,不含包头
82FirmwareVersion版本号,高字节主版本,低字节次版本
104CRC32对"FirmwareSize+FirmwareData"计算的结果
142HeaderCRC对包头前14字节计算的CRC16
16NFirmwareData固件数据,不足128字节按0xFF填充

版本号在Bootloader里做判断:新固件版本必须大于等于当前活动槽固件版本,否则拒绝写入,这是防止"误刷旧版本"的一道闸门。

2.4 Bootloader启动流程的状态机实现

Bootloader的main函数不跑裸循环,而是跑一个精简状态机,状态转移如下:

  1. 上电初始化:时钟、串口、Flash、看门狗。
  2. 读取槽位标志,解析A/B槽状态。
  3. 检查回滚保护标志:标记待回滚的直接回退到旧槽。
  4. 检查非活动槽是否需要提交:如果新固件已运行且确认过,则清除回滚标志。
  5. 跳转到当前活动槽App,或者在收到升级命令时进入升级模式。

进入升级模式后,状态机转移到接收帧状态。每收一帧,做解析、CRC校验、写入Flash、回ACK。最后一帧收完做整包CRC校验,再更新槽位标志,设置"待确认"状态,最后软复位重新走一遍启动链路。整个流程不到30个状态,写起来很清晰,比传统顺序执行代码好维护得多,也好测试。

3. App工程改造:重定位、中断向量与远程升级适配

3.1 中断向量表重定位:为什么App一跑就死

把App编译链接地址改到0x08004000后,直接烧录运行,大概率一进中断就死机。原因不复杂:Cortex-M3的核心有一个"向量表偏移寄存器"叫VTOR(0xE000ED08),默认值为0x00000000,内核触发中断时会去0x08000000读中断向量,也就是Bootloader的向量表。而App的中断向量排在0x08004000,地址对不上,一个中断进来CPU直接跑飞。

解决方案有两种:

  • 在启动文件里设置VTOR:在SystemInit()函数里加一行汇编代码,在main()之前完成重定位。推荐这种方式。
  • 运行时用寄存器写VTOR:在main()最开头使用SCB->VTOR = FLASH_BASE | 0x4000。对F103这种不带TZ的单片机来说,VTOR已经解锁可直接写。

注意,如果你的App还用到了DMA中断、串口空闲中断这些,向量表重定位后一切照常,但有一个坑:如果Bootloader和App同时用同一个串口,Bootloader初始化过串口后跳转前最好把串口复位(RCC复位外设),否则可能电平状态冲突。

我在实际项目里把VTOR设置放在SystemInit()里最稳,因为这个函数在标准库启动文件里第一批执行,比main()还要早,几乎覆盖了所有可能的早期中断,连调试阶段的断点都不会受影响。

3.2 在Keil中配置App工程的分区链接脚本

App工程的配置是AB分区复现里最容易出错的环节之一,很多朋友改完启动文件忘改链接地址,或者改了链接地址忘了改VTOR。

我用的App工程配置(以Keil MDK 5为例):

  1. 打开Options for Target → Target页。
  2. IROM1:勾选,起始地址改0x08004000,大小填0x00018000(96KB)。
  3. IRAM1:起始0x20000000,大小0x0000C000(48KB),F103RCT6的RAM就是48KB。如果你的型号RAM是64KB(比如F103ZET6),就填0x00010000。
  4. C/C++页的Define里加上VECT_TAB_OFFSET=0x4000,这样标准库的system_stm32f10x.c里会根据宏自动设置VTOR偏移。

还有一种做法是在system_stm32f10x.c里直接改,硬编码偏移0x4000。我建议用宏定义,因为以后如果调分区大小,只需要改一处。链接完成后记得在Build Output窗口确认Code和RO-data总大小没有超过96KB。

3.3 App主动上报启动状态与固件确认机制

AB分区OTA能不能做到"失败自动回滚",关键在App侧的一个小函数,我习惯叫它ota_fw_up_confirm()。这个函数在App正常运行30秒后主动执行一次,把"已确认"标志写入槽位标志区,表示当前固件是可用的。Bootloader看到这个标志后才知道可以解除回滚保护。

具体实现思路:

  1. App启动后,读取当前槽位状态,确认自己在A槽还是B槽。
  2. 启动一个软件定时器,默认30秒。
  3. 定时器到期后,如果系统各项外设初始化成功(比如通信模块已经注册到网络),调用确认函数,把该槽位的状态从"待确认"改为"已确认"。
  4. 如果30秒内发生硬件故障、看门狗复位等异常,没有执行确认函数,Bootloader会因回滚标志未清除,自动跳回旧槽。

这样做的原因是:单纯"能跑main"不代表固件是真的健康的,外设有没有正常初始化,网络模块有没有正常注册,都要在运行一段时间后才能确认。30秒这个值可以按实际业务调整,但不要太短,至少要覆盖App完整的启动周期。

3.4 串口双发缓存与断点续传机制

断点续传是我这次重点实现的功能,也是和普通YMODEM方案的最大区别。思路很简单:Bootloader端维护一个全局变量last_written_frame,每成功写入一帧Flash,就把它写到槽位标志区的"断点续传记录"里。上位机重新连接后,先发一个查询帧,Bootloader返回已写入的帧号,上位机从这个帧号的下一帧开始续传。

这里有个细节值得提醒:Flash写入是按128字节帧对齐的,但最后一帧肯定不满128字节。我约定固件包总长度对帧对齐后,不足部分统一填0xFF,写入时原样写入。App的启动逻辑没有约束必须对齐,但因为LLVM/Keil生成的bin文件本来就是整数倍,所以当固件长度不是128整数倍时,最后不足一帧的数据必须自己处理,不能用上一帧的缓存残留。

断点续传还有一层保障:每次擦除页之前先判断当前页是否已擦除,如果Flash内容全为0xFF则跳过擦除操作,直接写入。这样断电恢复后,断电时未写入的部分保持0xFF,不会出现"擦除一半又写一半"的混合状态。

3.5 适配远程升级:从UART到MQTT/TCP的扩展思路

F103做UART OTA只是第一步,现在很多产品都需要走Wi-Fi或以太网远程升级。我在另一套方案里用ESP8266或W5500做透传模块,把固件包拆成TCP帧发给MCU,MCU侧做一层协议封装,复用的还是这套Bootloader框架。

具体改动点有三个:

  1. 通信模块驱动独立成接口,Bootloader不再关心底层是UART还是SPI以太网,只维护一个fw_update_transport结构体,内部提供send/recv两个函数指针。
  2. 远程升级时建议加一层简单的加密和鉴权,防止恶意固件包直接刷入。我用AES-128-CBC加密固件内容,密钥烧录在独立Flash区域,Bootloader解密后再写App区。
  3. 空中升级失败率的控制:远程场景下网络波动比有线严重得多,断点续传机制和帧级别CRC就更加关键。实测下来,基于这个框架的远程升级成功率能到99.5%以上,剩余0.5%基本是网络彻底断开后需要人工介入。

4. 固件打包、传输协议与上位机工具实现

4.1 为什么不能用裸bin直接传,固件包要加什么

我刚开始做OTA时也图省事,直接拿Keil生成的bin文件按起始地址往Flash里写。后来发现三个问题:一是没有版本号,Bootloader无法判断固件新旧;二是没有全局CRC,传输出错要等整个固件跑飞后才发现;三是无法支持多槽位,不知道该写到A还是B。所以固件包必须加自定义头部。

固件打包工具我写了一款跨平台的Python脚本ota_pack.py,输入Keil生成的bin文件,输出带头部和CRC的.ota文件,同时生成一个firmware_info.txt保存固件的版本、大小、CRC32值,方便产线追溯。

打包脚本的Linux版本我会用Python标准库实现,不依赖第三方库,在任何Linux发行版和Windows上都能跑。核心逻辑如下:

import struct import zlib import sys def build_ota(src_bin, dst_ota, version_major, version_minor): with open(src_bin, 'rb') as f: fw_data = f.read() # 对齐到128字节 if len(fw_data) % 128 != 0: fw_data += b'\xFF' * (128 - len(fw_data) % 128) fw_size = len(fw_data) # CRC32覆盖固件数据 crc32_val = zlib.crc32(fw_data) & 0xFFFFFFFF header = struct.pack('<IIHHI', 0xA5A5A5A5, fw_size, (version_major << 8) | version_minor, crc32_val, 0) # HeaderCRC占位,先填0 header_crc = zlib.crc32(header) & 0xFFFF header = header[:14] + struct.pack('<H', header_crc) with open(dst_ota, 'wb') as f: f.write(header) f.write(fw_data) print(f'build ok: {dst_ota}, size={fw_size}, crc32=0x{crc32_val:08X}') if __name__ == '__main__': build_ota(sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4]))

4.2 传输帧格式设计与滑动窗口重传

固件包构建好之后,通过串口一帧一帧传给Bootloader。帧格式我定义为固定头部加可选负载:

字节序号长度字段说明
01FrameType0x01=数据帧,0x02=结束帧,0x03=查询帧,0x04=命令帧
12FrameSeq帧序号,从0递增,最多65535
34PayloadLen本帧负载长度,数据帧固定128
72CRC16对帧头部+负载计算
9NPayload固件数据(数据帧)/ 版本信息(查询帧)

发送逻辑采用"等确认"模式:上位机发一帧,等专属ACK帧,收到后才发下一帧。实测在115200波特率下,传96KB固件大约需要70秒。如果觉得慢,有两个优化方向:一是提高波特率到460800或921600(需要确认线材和驱动支持);二是做"窗口机制",一次连续发4~8帧再统一收ACK,吞吐量可以提升3~4倍,占用RAM也不大。

我在正式方案里做了一个小滑动窗口,窗口大小为4帧。上位机连续发4帧,Bootloader按seq顺序写Flash,写完后回一个含最大连续帧号的ACK,上位机继续发这个帧号之后的4帧。如果某一帧CRC错,Bootloader回NACK并带上错误帧号,上位机从这个帧号重传当前窗口。窗口重传比逐帧确认快得多,实测同样波特率下96KB固件只需约18秒。

窗口大小不是越大越好,4帧是比较平衡的值:F103串口缓冲有限,Flash写入128字节需要约2ms,窗口太大容易丢帧,太小又浪费带宽。

4.3 上位机QT工具开发要点

上位机我做的是一套QT Widgets界面,支持串口配置、固件包选择、发送进度条和日志窗口。两个核心组件:

串口类继承QSerialPort,读写全程走信号槽,避免界面卡死。接收线程按帧解析,碰到ACK更新发送队列。

发送状态机:

  1. 准备阶段:读取.ota文件,计算总帧数,打开串口。
  2. 查询阶段:发送查询帧,等Bootloader返回已写帧号,确认是否续传。
  3. 发送阶段:按窗口连续发送数据帧,处理ACK/NACK。
  4. 结束阶段:发送结束帧,等待Bootloader回复"更新成功,准备跳转",然后读取槽位状态显示在界面上。

工具有一个容易忽略的点:进度条不能只按"已发送帧数/总帧数"算,因为断点续传时起始帧号不为0,进度要按(last_written_frame + sent_frames) / total_frames计算,否则会看到进度条先跳后回退。

4.4 产线烧录与加密签名流程

AB分区OTA落地到产品上,还要考虑产线场景。我们产线流程是:

  1. 用ST-Link直接烧录Bootloader到0x08000000。
  2. 烧录一个"出厂固件"到A槽(可以通过串口或者直接下载bin到A槽地址)。
  3. 槽位标志区初始化:活动槽=A,状态=已确认,版本=出厂版本。
  4. 功能测试通过后封装发货。

如果产品已经有联网功能,可以支持产线"空板预刷Bootloader,后续全远程升级"。这种情况下出厂固件可以简化,只要包含基本的通信驱动即可,应用逻辑后续通过AB OTA推上去。实际产线效率会高很多,但注意在Bootloader里做一个"锁定保护":当槽位标志里的安全标志置位后,禁止通过串口明文升级,只允许加密升级,防止生产环境被恶意刷机。

5. 掉电保护、回滚机制与状态恢复实战

5.1 状态标志的双页交替写入

AB分区系统最怕的不是固件损坏,而是"状态标志损坏"——一旦Bootloader不知道哪个槽位是有效的,整个系统就彻底瘫痪。所以我用两个2KB扇区循环保存标志,避免反复擦写同一扇区导致寿命耗尽。F103的Flash擦写寿命标称10,000次,按每天升级50次算也只能用200天,双页交替可以把寿命直接翻倍。

双页交替写入的逻辑:

  1. 启动时同时读扇区0和扇区1的标志。
  2. 比较两者序号(Sequence),序号大的视为最新有效标志。
  3. 写入时始终写入序号较小(较旧)的扇区,写入后序号+1。
  4. 如果两个扇区都有效且序号相同,说明上次写入时在"擦除第二个扇区之前"掉了电,此时以扇区0为准回滚写扇区1,保证数据不分裂。

这个策略实际上是小型文件系统里常见的"日志式写入",实现起来并不复杂,但可靠性提升非常明显。我做过一个掉电循环测试,随机在写入中途断电300次,没有一次出现标志丢失或双有效冲突的情况。

5.2 三次启动失败自动回滚机制

通常的做法是Bootloader里维护一个连续启动失败次数(BootAttemptCount)。每次跳转App前,先把计数+1并写回Flash;如果App正常运行并调用了ota_fw_up_confirm(),则把这个计数清零;如果App启动后很快复位,计数会持续累加。

当计数达到3时,Bootloader判定当前活动槽固件不可用,执行回滚操作:把活动槽切换为另一个槽,并清零计数。回滚只涉及改几个标志字节,不涉及Flash大区擦写,速度极快。实测从复位到回滚到旧槽总共不到100ms。

这里有一个关键细节:计数必须写在掉电也不丢失的区域,同时要注意写次数限制。每次启动都抛光一次Flash显然不现实,所以我把计数放在固定变量并定期回写,而不是每次都写。具体实现是:RAM里维护一个值,只有从"未确认"变为"已确认"或者"新槽首次启动失败"时才写Flash。这样即便每天开关机100次,Flash写入次数也很少。

5.3 升级中断电后的恢复验证

升级中最极端的场景是:固件写到一半突然断电,恢复供电后Bootloader要能判断"当前处于中断的升级状态",并做出合理响应。我们遇到过一次:写入到第300帧时断电,恢复后Bootloader读取标志发现非活动槽还是旧状态,没有被篡改,活动槽A依旧有效,于是直接跳过升级流程,正常启动A槽。用户完全无感知。

还有一次意外:非活动槽的"待升级"标志已经写入了,但固件只写了一半,断电了。恢复供电后Bootloader发现非活动槽有"待升级"标志,但整包CRC校验失败,直接丢弃该标志,把非活动槽清为无效,继续从活动槽启动。这个判断逻辑非常适合放在升级状态机的前置检查里。

所以我的经验是:永远不要在收到结束帧并且整包校验通过之前更新"待升级"标志。只有完整固件落在Flash里了,才允许动槽位状态。这样即使中途掉电,错误状态最多就是"非活动槽残留一段无效固件",不会影响当前运行。

5.4 看门狗在OTA过程中的特殊处理

Bootloader的看门狗(IWDG)在升级模式下容易成为坑。因为擦写Flash会阻塞CPU几百毫秒到几秒,看门狗计数来不及喂,Bootloader就被强制复位了。这个问题我遇到过很多次,尤其是在擦除整页时,因为F103页擦除约20~40ms,如果一页页擦还好,整区擦除直接卡死。

我的处理方案是:升级模式下关闭独立看门狗,或者把超时时间调到最长(IWDG最大约32.7秒,但这不是无限大)。如果不希望关闭看门狗,可以在擦写函数里分块喂狗:擦除每页之间做一次喂狗操作,写Flash每128字节做完也喂一次。考虑到Flash写128字节约2ms,喂狗不会造成性能瓶颈。

我的建议是:Bootloader启动时默认不开启IWDG,等跳转App前才初始化并启动。这样Bootloader自身升级过程不受看门狗干扰,而App运行期间依然有独立看门狗兜底,防止App死循环或跑飞。跳转前要确保把看门狗配置好,否则App启动后看门狗没开就是裸奔状态。

5.5 异常场景恢复操作指南

尽管机制做得很完善,但总有极端情况需要人工介入。我在文档里给现场工程师写了一份排查流程图式的操作指南,这里摘录核心几条:

  • 设备反复重启且无法进入升级模式:用串口连接,持续发送0xAA 0x55握手包10秒以上,Bootloader检测到连续有效握手包会强制进入升级模式。
  • 升级过程中意外复位,重新升级时提示版本不匹配:查看槽位标志里的版本字段,如果没有正常更新说明标志写入失败,需要手动执行"强制擦除标志区并恢复出厂设置"。
  • 两个槽位都显示无效:这是最严重的场景,通常只会在Bootloader本身有Bug时出现。解决办法是使用烧录器重新烧录Bootloader和出厂固件,然后恢复槽位标志。
  • A/B槽版本相同但启动异常:检查两个槽位固件编译时间戳,确认是否真的是两份相同固件,排除加载错误。

6. 全流程联调与测试实录

6.1 联调环境搭建

我这次联调用的硬件非常简单:

  • STM32F103RCT6最小系统板一块,板上带CH340串口芯片。
  • ST-Link V2一个,用于烧录Bootloader和调试。
  • USB转TTL模块一个,连接PC串口和评估板USART1。
  • 杜邦线若干,电源用5V/3.3V双路。

软件环境是Windows 10 + Keil MDK 5.36 + STM32标准库V3.5 + Python 3.9 + Qt 5.15。上位机用QSerialPort,打包脚本用Python标准库。

烧录Bootloader时,建议在Keil里勾选"Reset and Run",这样烧录完自动运行。然后用串口助手验证Bootloader串口输出"Bootloader v1.0 ready",说明工程基本没问题。

6.2 正常升级全流程:从打包到重启验证

先跑一遍完整升级,把整个流程走通。我用的示例App是一个LED闪烁程序,版本号从V1.0升级到V1.1,App运行后串口每隔1秒输出一行"App v1.1 running"。

  1. 用Keil编译V1.1工程,生成bin文件。
  2. 运行python ota_pack.py app.bin app_v1.1.ota 1 1生成固件包。
  3. 打开上位机工具,选择串口COM3,波特率115200,选择固件包,点击"连接"。
  4. 单击"开始升级",观察进度条和日志窗口。
  5. 升级完成后,设备自动软复位,Bootloader跳转到B槽,串口输出"App v1.1 running"。

这个流程看起来很平淡,但它验证了Bootloader解析包头、计算CRC、写Flash、切换槽位、跳转的全链路。我特别注意到跳转后的第一个串口字节是否正常,如果出现乱码,多半是时钟配置或串口波特率有问题,和Bootloader跳转前的外设复位状态有关。

6.3 注入错误的CRC如何触发拒绝写入

为了验证CRC校验的有效性,我手动把一个字节的固件数据改错,重新打包。Bootloader解析包头后应该计算CRC32失败,直接回NACK并返回错误码0x03(CRC_MISMATCH),不写任何Flash。实测上位机窗口弹出"CRC校验失败,请检查固件包",进度条停在0%,日志记录错误码,设备继续正常启动A槽。

这个小实验强烈建议大家自己跑一遍,能很直观地体会到"帧校验+整包校验"双保险的价值。如果只想校验链路不校验包,可以在上位机里手动改掉一帧数据的CRC16,Bootloader会回复NACK并等待该帧重传。链路层校验的作用和整包CRC各有侧重,前者保证传输正确,后者保证固件完整。

6.4 新固件启动崩溃触发的自动回滚

这块是整个方案最让我放心的部分。我把V1.1固件故意改成启动后5秒触发HardFault,然后执行升级。流程如下:

  1. 升级成功后Bootloader切换为B槽,跳转B槽App。
  2. B槽App启动,运行到第5秒触发HardFault。
  3. 系统复位,Bootloader读取到"待确认"状态和"启动失败计数=1",继续尝试B槽。
  4. 第二次复位后计数=2,第三次复位后计数=3。
  5. Bootloader判定B槽固件无效,自动切换回A槽,计数清零,启动失败计数归零。
  6. 串口输出"Rollback to A slot success"。

实测从第一次B槽崩溃到最终回滚到A槽,大约经历了3次复位循环,耗时不到5秒。这里要强调:回滚机制必须搭配Bootloader的串口日志一起调试,否则你只看到设备反复重启,很难判断是崩溃还是正常的失败重试流程。

6.5 断点续传与重复刷入相同版本固件的边界测试

断点续传我实际测了三个场景:升级到一半拔掉串口线再插回、升级到一半断电再上电、升级完成后立刻重复刷一遍相同版本固件。

场景一:拔掉串口线再插回。上位机重新连接后发查询帧,Bootloader返回已写帧号100,进度条自动从63%左右继续,后续帧正常写完,升级成功。这个场景模拟手滑碰掉USB线的常见情况,实测完全无感。

场景二:断电再上电。恢复后Bootloader发现非活动槽的"待升级"标志被清掉了,因为断电发生在整包校验之前,所以当前活动槽A继续正常运行。此时重新发起升级,Bootloader会把非活动槽的旧数据擦掉重新写,不冲突。

场景三:重复刷相同版本。上位机发送V1.1固件给已经运行V1.1的系统,Bootloader检查到版本号不高于当前活动槽版本,直接拒绝写入并返回错误码0x05(VERSION_NOT_NEWER)。这个策略有效防止了产线上重复刷写浪费时间和Flash寿命。

7. 踩坑记:我在这套方案里栽过的五个跟头

7.1 坑一:中断向量表偏移在启动文件里写错了

最早我图省事,把VTOR设置直接写在App的main()开头,但App在进入main之前,系统时钟初始化已经完成,串口中断是否触发取决于外部来的电平。某次调试发现App启动后串口第一个字节能收到,但第二个字节开始乱码,排查半天发现是USART1中断在VTOR设置生效前就已经来了,CPU按Bootloader的向量表去跳,自然跑飞。

解决办法就是老老实实把VTOR设置放到SystemInit()里,让它在任何外设初始化之前生效。另外还要注意Keil里Define宏VECT_TAB_OFFSET=0x4000只对标准库有效,如果你用的是自己写的SystemInit,务必确保这个宏被正确读了,否则偏移不生效。

7.2 坑二:Bootloader串口打印和App串口打架

Bootloader跳转前用USART1打印了一堆日志,跳转后App也用USART1,结果发现Bootloader最后打印的几个字节残留在发送缓冲区里,把App的第一行日志冲掉了一部分。

解决办法是跳转前做一次彻底的外设复位:调用RCC_APB2PeriphResetCmd(RCC_APB2Periph_USART1, ENABLE),延时几个微秒后再关掉复位,最后把发送缓冲区数据清零。跳转后App重新初始化串口,就不会有残留了。

7.3 坑三:F103 Flash "写0"和"写1"的特性导致标志区被搞坏

Flash只能把1写成0,不能把0写成1,想恢复成1只能擦除整个扇区。槽位标志区如果频繁修改某个字节,这个字节很快就变成0x00了,如果恰好在掉电瞬间写入,整个扇区可能无法使用。

我最终用"双页交替+序号比较"解决,但还有一层保护:所有标志数据在Flash里都要冗余存储。比如"待确认"这个状态,不仅存一个0x01,还要存一个0x01的反码0xFE,读取时两者都匹配才认为状态有效。这样即便一个字节被写坏,另一个还能兜底。代价是标志区容量占用更大,但2KB的空间足够。

7.4 坑四:上位机发送过快导致Bootloader缓冲区溢出

初期我用的是逐帧发送,Bootloader每帧处理完再回ACK,所以不会溢出。后来优化成4帧窗口后,Bootloader如果还在擦Flash,新的4帧已经堆在串口缓冲区里,超过F103的串口接收缓冲区容量(一般1KB),就直接丢帧。

解决方法是把接收处理做成"状态机+环形缓冲"。串口中断只负责把收到的字节压进环形队列,主循环空闲时从队列取数据解析处理。Flash擦写时队列不阻塞,等擦写完成继续处理。环形缓冲区大小设为2KB,窗口4帧128字节加头部大约540字节,绰绰有余。实测窗口模式下连续传输50次,无一丢帧。

7.5 坑五:用printf调试时固件体积不受控

Bootloader 16KB空间本来很有富余,但我在调试阶段随手加了一堆printf输出,结果编译出来接近16KB上限,差点放不下。后来统计发现,MicroLIB下的printf约占用3KB,但如果用了浮点格式化,体积会爆炸到10KB以上。

解决方法是调试信息尽量精简,浮点一律用整数转换,能不开printf的地方就用ITM_SendChar或者直接写串口寄存器输出固定字符串。最终Bootloader编译体积稳定在10.5KB左右,留了5.5KB余量做后续功能扩展。

8. 从UART到无线:这套方案还能怎么扩展

AB分区OTA框架天然支持多种传输介质。UART只是最简单的例子,换成ESP8266、4G模块、LoRa、以太网,核心逻辑都不用动,只需要换掉传输层接口。我把这套框架移植过几个平台,总结出三个高频扩展方向:

第一个方向是远程升级网关。很多工控场景里MCU本身不联网,但有一台上位机或网关可以联网。这种情况下MCU跑AB分区OTA,网关负责从云平台拉取固件包,通过UART/RS485/CAN转发给MCU。帧格式和协议完全复用,只是把"上位机"换成了"网关"。

第二个方向是CAN总线OTA。汽车电子、工业现场的CAN总线升级需求很大。CAN每个标准数据帧只有8字节,没法直接放128字节负载,所以需要把帧格式改成多帧组合:一个逻辑包拆成N个CAN帧发送,接收端攒够128字节再写Flash。CAN的断点续传和窗口重传逻辑和UART基本相同,需要注意的是CAN总线负载率不能太高,一阵猛发容易出问题。

第三个方向是加密签名。如果产品需要防止固件被逆向或者防止恶意固件注入,建议在AB分区基础上叠加AES-128-CBC加密和ECDSA签名校验。加密保证固件内容不泄露,签名保证固件来源可信。Bootloader里实现ECDSA验签对F103来说是有些吃力的,实测验签一次约需1~2秒,可以接受。AES解密是流式的,可以在写入Flash时边解密边写,不会显著降低升级速度。

这几个月做下来,我的核心感受是:AB分区OTA的核心难点不在"写代码",而在"状态管理"。Flash擦写、串口传输这些都是成熟技术,真正容易出问题的是各种掉电、重启、异常状态下,系统还能不能回到一个确定的状态。用这套方案的这段时间,我最大的安心来自于"无论怎么折腾,设备永远不会变砖"——最多是升级失败回滚到旧版本,然后重新联网下载再试一次。

最后再分享一个小技巧:调试AB分区OTA时,一定要在Bootloader和App里同时加上版本号打印,并且在串口日志里区分当前是在哪个槽位运行的。比如App每次启动都打印App v1.1 slot=B,Bootloader跳转前打印Jump to slot B @0x08022000。这样现场排查问题时,一眼就能看出设备当前状态,不用猜。这套方案后续我计划再扩展到远程签名升级和多固件(比如Wi-Fi模块固件、字库文件)联合升级,到时候再回来补一篇新文章。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 11:06:19

SpringBoot+Vue3+MyBatis+MySQL汽车维修预约系统实战

前阵子把这套汽车维修预约服务系统完整撸了一遍&#xff0c;从需求梳理、数据库设计到前后端联调&#xff0c;坑没少踩。项目本身不算复杂&#xff0c;但该有的模块都有&#xff1a;SpringBoot 做后端接口&#xff0c;Vue3 做管理端页面&#xff0c;MyBatis 负责数据库交互&…

作者头像 李华
网站建设 2026/9/9 11:05:54

超导磁能储存系统SMES的Simulink建模与仿真实践

提到储能&#xff0c;很多人第一反应是锂电池、抽水蓄能或者飞轮&#xff0c;但在电力电子和电力系统领域&#xff0c;超导磁能储存系统&#xff08;SMES&#xff09;一直是个独特存在。它不是通过化学能或势能存储能量&#xff0c;而是直接把电能以磁场形式“锁”在超导线圈里…

作者头像 李华
网站建设 2026/9/9 11:05:41

Android利用Onvif实现局域网摄像头发现与RTSP取流的完整实践

简介&#xff1a;在Android平台上使用Onvif协议完成局域网摄像头自动发现&#xff0c;并获取可播放的视频流地址&#xff0c;是安防与物联网项目开发中常见的需求。资源面向具备Android基础、需要对接不同品牌Onvif兼容设备的开发者&#xff0c;围绕设备发现、身份认证、媒体服…

作者头像 李华
网站建设 2026/9/9 11:03:48

Scrapy分布式爬虫调试与性能优化实战指南

分布式爬虫跑到第三周&#xff0c;你大概率会遇到一个特别无语的场面&#xff1a;任务还在跑&#xff0c;数据量却在悄悄往下掉。Redis队列里的待抓取URL越积越多&#xff0c;日志里没有任何报错&#xff0c;每台节点的CPU看起来也不高&#xff0c;但整个集群就是越来越慢。更折…

作者头像 李华
网站建设 2026/9/9 11:03:15

outskirts和suburb区别:从边缘位置到功能社区,一文讲透

先说结论&#xff1a;outskirts 和 suburb 虽然中文里都能翻译成“郊区”&#xff0c;但它们在英文里的使用场景、语义边界和语感完全不是一回事。你拿“suburbs”去描述一个荒凉的公路边缘地带&#xff0c;或者在正式文书里用“outskirts”指代一个成熟的居住社区&#xff0c;…

作者头像 李华
网站建设 2026/9/9 11:01:50

强化学习模型部署到RK3566足式机器人:从GPU到ARM的完整踩坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华