
在嵌入式开发里给STM32F103做OTA已经不是什么新鲜事大部分人都做过串口IAP。但“AB双分区OTA”就少一些因为F103的资源摆在那儿Flash不大、RAM也不宽裕很多人第一反应是“这芯片跑AB分区是不是太勉强了”。我最初也这么想后来在几个实际项目里被传统IAP反复坑过——升级中途断电变砖、程序跑飞没法回退、现场返修成本高——才下定决心把AB方案搬到F103上认真做一遍。这篇教程就是把整个从零复现的过程完整记录下来分区怎么划、Bootloader怎么跳、链接脚本怎么改、升级协议怎么定、Flash擦写要注意什么以及我在实测中踩过的坑和总结的排查方法。这篇内容适合两类人一类是做工业设备、传感器节点、便携仪器手头正在用F103、又对固件远程升级可靠性有要求的开发者另一类是已经写过普通IAP、想进一步理解双分区机制的人。我会默认你熟悉STM32标准库的基本开发流程但涉及双分区关键设计的部分会尽量讲细。硬件上我用的是一块STM32F103ZET6核心板通过RS232串口连接电脑调试不需要额外仿真器以外的昂贵设备。1. 动手之前AB OTA到底能解决什么问题1.1 传统IAP最大的痛点就是没有“后悔药”先还原一个很多读者都经历过的场景你用常规的IAP方案Bootloader跳转到AppApp跑起来后接收新固件把新数据直接写到当前正在运行的App区。整个升级过程中只要出现一次意外——串口线被碰掉、上位机崩溃、断电、看门狗复位——Flash里可能已经写入了一半的新固件。这时候系统重启Bootloader一看当前App区有内容就跳过去执行了结果指令流是残缺的程序不是HardFault就是死循环。设备当场变砖只能开壳用SWD重新烧录。这种体验做一次就够难受了。一些补救办法是在App里保持一段“下载模式”的逻辑比如上电时检测到某个按键按下就停留在Bootloader这样至少能重新进入下载流程。但问题在于如果App本身已经跑飞了按键检测逻辑根本没有机会执行如果下载中断发生在App区的启动向量位置Bootloader甚至无法稳定跳到你的下载模式判断代码。说白了单分区方案再怎么设计系统都缺少一个“已知可用”的备份没有后悔药。AB分区方案的核心思想很简单把固件存放区分成两个槽位一个槽位作为当前运行槽位另一个槽位作为备用槽位。新固件永远写入备用槽位写入完成后整体校验无误再把启动标志翻转让Bootloader下一次跳转到新槽位。如果运行中出现问题或者校验失败就回到旧槽位继续跑。F103虽然资源小但这种“双保险”的思路本身并不依赖多少资源关键是把分区大小、跳转机制、擦写策略规划清楚。1.2 再往前一步F103做AB分区难点到底在哪里F103做AB OTA真正的难点不是“双分区”这个概念而是在几个硬件限制下把细节做对。第一点是Flash容量分配以ZET6为例有512KB Flash如果每个App槽位分配192KB加上一个32KB的Bootloader剩下还能给参数存储留出一点空间但如果是C8T6这种只有64KB Flash的芯片两个槽位只能各分24KB左右这就要求你的App编译后必须控制在24KB以内否则分区间互相覆盖风险极大。所以开题第一步就应该算清楚自己的App实际占用多大Flash算完再决定用什么容量等级芯片。第二点是指针和向量表的处理细节。App在0x08000000地址编译的还是被放到0x08010000后编译的这直接影响启动流程。STM32上电后从0x08000000读取栈顶指针、复位向量去执行那个“0x08000000”是硬件写死的映射地址。你如果把App链接到了0x08020000就必须要让Bootloader在跳转前把0x08020000处的栈顶指针和复位向量准备好同时App代码内部必须把中断向量表的重定位偏置设置好。否则会发生什么中断来了CPU跑到0x08000000附近找向量表找到的是Bootloader的向量表结果调用了一个完全错误的中断处理函数。这种问题不遇到还好一遇到就是很隐蔽的随机故障。第三点是擦写策略。F103的Flash是按页擦除的对ZET6来说小页1KB、大页2KB不同型号规格页大小有差异。写入的时候一次最多写2字节也就是半字。如果你没有对写入地址和内容长度进行对齐处理Flash操作很容易产生HardFault。更关键的是Flash擦除是有寿命限制的早期我把整个后备槽位最后一页当“升级状态标记”区每次升级都要擦写一次频繁测试几天后发现状态丢失用示波器查Flash操作时间发现擦写消耗的时间也在明显变化。后来我改成把状态标记写在EEPROM仿真区或者单独参数Flash页中并增加了磨损均衡逻辑这样才稳定下来。2. 方案设计分区划分、传输协议与状态机2.1 Flash分区表怎么算从Bootloader大小反推在动手写代码前第一步先画一张清晰的分区表。这里的每个数字都得有依据不能随手拍。我以STM32F103ZET6为例它的主Flash容量为512KB起始地址0x08000000结束地址0x0807FFFF。整个Flash被分为256页每页2KB。我的设计目标是在Bootloader里实现串口接收、Flash擦写和固件跳转功能App是两个完全相同的固件镜像槽位再加一个单独的参数存储区保存升级次数、当前活动槽位标识和校验信息。具体分区如下区域起始地址容量大小说明用途Bootloader区0x0800000032KB占16页上电启动、串口升级、标志校验、跳转控制App A区Slot A0x08008000192KB占96页主固件槽位AApp B区Slot B0x08020000192KB占96页主固件槽位B参数区Param0x0803C00016KB占8页保存Boot标志、固件CRC、版本号等用户数据区0x08040000剩余其余约256KB文件系统、日志等这里Bootloader只分32KB是刻意做的约束。因为一旦Bootloader太大就要压缩App空间而App代码在现代功能需求下并不小。只要我把串口驱动、Flash驱动、协议解析和跳转逻辑做得紧凑一些32KB完全够用。标准库包含的启动文件和系统初始化大约占几KB剩下30KB左右给Bootloader业务逻辑是足够的。App链接地址怎么定如果当前应用跑在Slot A编译时链接地址就设成0x08008000如果跑在Slot B链接地址就设成0x08020000。这意味着两个槽位的固件本质上是用同一份源代码、以不同的链接地址编译出来的两个不同bin文件。但也有一个更优雅的办法在编译后通过脚本把同一个偏移地址的bin直接改写到不同地址的镜像里不过这样做对偏移处理要特别小心。最不容易出错的方式还是项目配置中直接修改链接地址生成两个bin。2.2 直接裸调串口协议还是用Modbus RTU壳子接下来要决定的是升级包怎么传。选择特别多可以用YMODEM协议、可以用厂商自定义协议也可以在成熟协议框架上定制。有些项目里本来就移植了FreeModbus v1.6做Modbus RTU通信那么是否可以直接复用这套通信栈来承载固件数据是值得权衡的。Modbus RTU适合做工业控制类指令交互它的帧结构简洁CRC16校验标准功能码体系清晰。但把大块固件数据塞进Modbus帧里有一个问题标准Modbus RTU的单帧数据长度是有限制的比如03功能码一次最多读125个寄存器250字节05功能码只写一个线圈06功能码只写一个寄存器即使功能码支持写多个寄存器也最多写123个寄存器也就是246字节。如果你想一次传更多的数据需要自己扩展或者拆帧。我在几个项目里试验过两种路径。第一种路径是彻底自定义一套二进制协议帧头、地址、命令、长度、数据、CRC各字段清清楚楚传输效率高处理逻辑也非常容易控制。第二种路径是直接基于Modbus RTU做扩展定义几个私有功能码比如0x60表示“开始升级”、0x61表示“传输数据块”、0x62表示“结束升级并校验”。这本质上是披着Modbus壳的自定义协议好处在于如果你已经在设备端集成了FreeModbus协议栈的收发状态机和CRC校验可以直接复用省掉不少底层工作量。如果你本来就在跑Modbus RTU那我建议保留它用私有功能码实现OTA因为系统的其他控制帧和升级帧共用一套串口收发逻辑代码维护成本低。如果整个项目并没有其他Modbus需求只为OTA而引一个Modbus协议栈就有点重了。F103的RAM和Flash都不是富余到可以随便挥霍的轻量自定义帧结构往往是更务实的选择。2.3 升级状态机让每一步都有迹可循OTA升级不能靠“开始写、写完跳”这种一把梭的逻辑你必须把过程拆成状态并且把状态持久化存储下来这样即使升级中途断电重启Bootloader也能根据现场状态判断如何恢复。我整理了五状态模型状态状态值含义处理动作IDLE0x01空闲无升级任务跳转当前活动槽位READY0x02已收到升级请求元信息等待数据停留在Bootloader中等待数据帧RECEIVING0x03正在接收固件主体数据每帧写Flash记录写偏移VERIFYING0x04数据接收完成固件校验中读取Flash做CRC32整体校验COMMIT0x05校验通过准备切换激活槽更新激活槽位参数然后软复位启动App这个状态机里状态值本身被保存在参数区的独立页中。每次状态切换时我先把新状态写入影子缓冲区做一次备份切换然后才执行实际业务逻辑。这么设计的原因有二第一掉电时状态可能已经落盘了但数据块还没写完整所以RECEIVING状态必须记录合法的偏移量重启后可以断点续传第二如果整个状态记录损坏Bootloader会退回到一种安全策略两个槽位都尝试运行如果某个槽位的启动向量和CRC检查不过就换另一个槽位。当系统处于RECEIVING状态并写了一半Flash时断电重启后Bootloader不会立刻跳转而是检查参数区里的状态。如果状态处于“接收中”就直接跳过当前启动回到升级等待命令状态同时向用户返回“上次升级中断等待重新下发”的提示。这种机制比普通IAP人性化得多——至少不会让设备因为一次断电就彻底变砖。3. 工程落地Bootloader与App端的关键实现3.1 Bootloader工程的初始化与串口收发Bootloader代码本身就是一个独立的STM32标准库工程。工程模板可以直接复用标准库v3.5的样例需要注意的是不要把整个App的业务逻辑代码拷进BootloaderBootloader只做三件事——底层外设初始化、升级数据处理、跳转调度。我在初始化阶段只开启了需要的时钟和串口没有开启额外的外设。串口我建议用查询和中断结合的方式接收用中断发送用查询或DMA。典型初始化代码如下void Bootloader_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); USART_InitStructure.USART_BaudRate 115200; USART_InitStructure.USART_WordLength USART_WordLength_8b; USART_InitStructure.USART_StopBits USART_StopBits_1; USART_InitStructure.USART_Parity USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode USART_Mode_RX | USART_Mode_TX; USART_Init(USART1, USART_InitStructure); USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); NVIC_InitStructure.NVIC_IRQChannel USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 0; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); USART_Cmd(USART1, ENABLE); }这个初始化过程里有一个我反复强调的细节波特率选择。OTA升级时如果使用115200波特率每传1KB数据大约需要90ms左右192KB固件理论传输时间在17秒上下加上协议开销约20秒出头。如果用9600波特率那个时间就变成四分钟以上了现场用户绝对等不了。如果你的硬件连线比较长、或者现场电磁干扰强适当降到57600也可以接受。前提是Bootloader和App里的串口波特率配置必须一致很多人在升级完跳转后串口无响应查了半天最后发现是Bootloader侧用115200、App侧却初始化成9600。串口接收端我推荐做好环形缓冲或者直接一帧一帧处理。在Bootloader里整个逻辑是单任务循环不需要太复杂的缓冲结构只要保证一个数据帧传完后程序能完整处理、处理完再去收下一帧即可。因此我在Bootloader的串口中断里只做数据搬运把收到的字节存到缓冲区后就用标志位通知主循环取走。这样处理简单、可预测不容易被中断嵌套搞乱。3.2 跳转函数不是简单函数指针跳一下这么简单从Bootloader跳转到App的核心代码网上非常多基本都是这样的模板typedef void (*pFunction)(void); void JumpToApp(uint32_t app_addr) { uint32_t stack_addr 0; pFunction app_entry 0; if (((*(volatile uint32_t *)app_addr) 0x2FFE0000) 0x20000000) { stack_addr *(volatile uint32_t *)app_addr; app_entry (pFunction)(*(volatile uint32_t *)(app_addr 4)); __disable_irq(); SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; for (uint8_t i 0; i 8; i) { NVIC-ICER[i] 0xFFFFFFFF; NVIC-ICPR[i] 0xFFFFFFFF; } __set_MSP(stack_addr); app_entry(); } }跳转这个动作本身不难难点在于跳转前处理那些“看不见的状态”。第一是关闭你在Bootloader中开启的所有中断尤其串口中断、定时器中断。不要让它们在跳转过程中插入执行。第二是关闭SysTick因为SysTick可能是实时操作系统或者延时函数的心跳残留的中断请求会在App启动阶段造成干扰。第三是把系统时钟重新初始化这方面很多初次做的人会忽略。Bootloader里可能用了外部高速晶振HSE而App的SystemInit函数会重新配置时钟树如果App侧做时钟切换时检测到已有PLL锁定某些配置会异常所以我通常在跳转前先把时钟恢复到上电默认状态再让App完整初始化一遍。还有一点是针对F103系列的建议如果App使用了需要缓冲区的外设比如DMA、USB、CAN等在跳转前做好复位操作否则这些外设的DMA缓冲区地址还指向Bootloader的RAM区域一旦App启动后访问了不该访问的内存轻则数据错乱重则HardFault。3.3 App侧必须处理的偏移向量表和链接脚本App端的代码修改是整个复现过程中最容易被忽视的部分。如果你编译了一个链接地址为0x08008000的App把它烧写到0x08008000然后从Bootloader跳过去却发现所有串口中断、外部中断都不进多半是向量表没有重定位。F103的Cortex-M3内核有一个寄存器叫VTOR向量表偏移寄存器它位于系统控制块SCB中地址是0xE000ED08。在标准库中我们可以通过NVIC_SetVectorTable函数来设置偏移。如果没有这个函数也可以直接写SCB-VTOR。设置代码很简单SCB-VTOR APP_ADDRESS;但必须放在main函数的最开头早于任何外设中断的使能。这是铁律不要放在SystemInit之后很久才执行。因为一旦在设置向量表之前发生中断CPU会去读取0x08000000处的向量Bootloader的向量表这时的中断处理就可能找错人了。链接脚本层面使用MDK或IAR的话需要在工程配置里设置IROM的起始地址和大小。以MDK为例你打开Options for Target - Target页面在IROM1区域如果App链接地址是0x08008000那起始地址就填0x08008000大小填0x00030000192KB。如果你生成的是两个槽位各自独立的工程那就保存两份工程配置一个填A槽地址一个填B槽地址。用Makefile和GCC工具链也一样修改链接脚本中的FLASH起始地址和长度即可。这里有一个使用技巧我强烈推荐在App代码中加一个软件版本号和一个编译时间戳并把它们放到固定地址的只读区。上位机在升级前可以查询版本号判断是否真的需要升级避免重复刷写无意义的旧版本固件。同时App启动后把自身所在槽位的标识、当前固件CRC上报给上位机这样能帮助后台确认设备究竟在哪个槽位上运行。3.4 Flash擦写函数别直接用寄存器一把梭F103内部Flash编程的接口非常简单但正因为简单很多人会忽略其中容易踩坑的细节。我封装了几个关键函数擦除整个槽位、写半字、读校验。在标准库中我们通常使用的函数有FLASH_Unlock、FLASH_ErasePage、FLASH_ProgramHalfWord、FLASH_Lock。先看一个比较完整的擦写函数void OTA_WriteFlash(uint32_t addr, uint8_t *buf, uint16_t len) { uint16_t i; uint16_t halfword; FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); for (i 0; i len; i 2) { halfword buf[i] | (buf[i 1] 8); FLASH_ProgramHalfWord(addr i, halfword); } FLASH_Lock(); }这段代码看起来没问题但实际使用中有几个重要细节。第一F103的Flash编程地址必须按半字2字节对齐如果buf长度是奇数最后一字节直接忽略会导致固件错位所以协议帧结构里应当保证每个数据块的字节数为偶数。第二Flash擦除是按页的写入前必须确保该地址已经被擦除过否则写入结果全为0xFF与数据的按位与结果不是你想要的数据。第三F103在编程过程中不允许中断和取指冲突严重虽然实际写Flash时会暂停CPU但是对于中断频繁的系统最好在擦写之前暂时屏蔽某些高优先级中断完成后恢复否则可能触发总线错误。还有一个被很多人忽略的点运算单元。写入Flash前一定要先解锁操作完成后再锁定。有些代码把解锁放在main函数开头一次搞定之后不再锁定这样会让Flash控制寄存器长时间处于可写状态。我看到一些量产产品中某一页无意被改写就是因为有野指针操作了Flash控制寄存器相关地址。进入OTA升级流程时才临时解锁完成或退出时即刻加锁能最大程度降低风险。在擦除策略上我比较建议每次升级前先把整个目标槽位逐个页擦除而不是等数据一帧一帧来的时候擦一页写一页。比如接收数据块大小是1KB每个块到位之后才发现对应页还没擦再去执行擦除会让本次升级的时间出现不确定的波动。而且如果擦除过程中掉电Flash页可能处于“半擦除”状态导致该页不可用。我通常在收到“开始升级”命令后先擦除目标槽位的所有页并把擦除结果回传让上位机知道准备工作是否成功。4. 实测过程从编译到一次完整升级的流水线4.1 上位机发送流程和交互时序如果把升级过程中Bootloader和上位机的交互画成事件流大致是这样的设备上电进入Bootloader上位机发送查询命令设备回应当前活跃槽位、软件版本号、Bootloader版本号。上位机判断需要升级后发送升级开始命令帧里带目标槽位地址、固件总长度、固件CRC32值。Bootloader收到后擦除目标槽位回传擦除完成应答。随后上位机一帧一帧发送固件数据每帧可以是256字节或512字节Bootloader写Flash后回传当前写入偏移作为应答。最后一帧固件发完后上位机发送结束命令Bootloader把整个槽位读出来做CRC32校验如果和元数据一致就把活动槽位翻转并软复位。设备重启后从新槽位启动App上报自己的版本和槽位号上位机确认升级成功。在整个过程中我遇到的第一个问题就是固件分包大小。最开始我图省事一帧放1KB但F103的串口接收缓冲和Flash页擦除时间不匹配软件在擦除期间如果又接收了新数据UART的RXNE会一直置位我的接收中断没有及时读取数据寄存器就会产生Overrun Error导致数据流断掉。后来我仔细看了HAL库工程里常见的处理方式也看到很多人在搜索UART_Error相关的处理方法最终把协议帧调整成256字节一帧。这样每一帧的Flash写入时间非常短接收中断不会因长时间擦除而被堵塞。实际升级一个192KB的槽位按每帧256字节来算需要768个数据帧每帧有约十几个字节的协议开销115200波特率下整体时间大约28秒。这个时长在量产现场勉强可以接受如果还想更快可以把波特率提到460800或921600前提是你的USB转串口模块和线材质量能扛住我实测大部分CH340模块在460800下都还行但921600就需要比较扎实的线缆。4.2 几个让我折腾到半夜的“玄学”问题第一个让我头疼的问题Bootloader跳转后App死活不跑用调试器看PC指针却停在HardFault_Handler里。排查了很久发现是跳转前用了__disable_irq()关中断但跳转后App里没有重新使能中断。Cortex-M3的PRIMASK位一旦置1所有可屏蔽中断都会一直被屏蔽哪怕App初始化了外设也不会触发中断。解决办法是在App的main函数最早期调用__enable_irq()或者在Bootloader跳转前不要全局关中断只关掉那些具体的外设中断。第二个问题是向量表偏移寄存器VTOR在F103上不是所有库函数都提供。旧版标准库V3.5里有一个NVIC_SetVectorTable函数可以直接设置偏移但如果你用的是某些精简版本或自建工程这个函数可能不存在。我自己在标准库工程里通常会做一层兼容如果编译报错找不到定义就直接用SCB-VTOR写寄存器确保任何库版本下都能编译通过。第三个问题非常隐蔽两个槽位固件编译后的CRC一样但运行行为完全不同。这种问题往往是编译时优化选项不一致导致的。我踩过的一个大坑是在建第二个槽位工程时不小心把优化等级从-O2改成了-O0导致两个bin文件长度不同、运行速度差别巨大而且现场偶发时序故障。后来我建立了自动化脚本两槽位的编译必须使用完全相同的编译器参数和链接脚本只允许IROM地址段不同。这样做完之后两个槽位的行为表现才完全一致。第四个问题和Flash的“备份标志位”有关。升级成功后我把活动槽位参数写入参数区有时候写入成功、有时候写入失败失败后重启又跑到旧固件里。查下来发现是因为这些标志我在写之前没有先做页擦除。F103的Flash在写0之前必须保证该地址已经是0xFF状态也就是必须先整页擦除。我只把标志页当成普通RAM用直接重复写几次就开始出错。修正方式很简单参数区使用“先读-整页擦除-再写整页”-的方式或者使用双页轮番交替记录的方式避免频繁擦写同一个页。对OTA来说每次升级只切换一次槽位标志这种频率并不高但设计成双页交替会更稳健。我加一个实用排查表如果你照着做完还是有问题可以直接按这个方向定位现象可能原因排除方法跳转后App无任何反应未设置SCB-VTOR或中断未使能在main最早期设置向量表并调用__enable_irq()串口能输出但中断不进App链接地址和实际烧写地址不一致或向量表没生效检查链接脚本IROM起始地址烧写时确认bin文件起始位置正确Flash写入后数据全变成0xFFFlash未擦除或Flash被锁写前先解锁并整页擦除擦除后回读校验升级到一半串口卡死擦除耗时导致UART Overrun减小数据帧长度擦除前暂停接收中断并做缓冲处理两个槽位运行效果不一致编译参数不同或优化选项不一致自动化编译严格保证除地址外配置完全一致参数区标志读出来总是错误未先擦除就重复写Flash改用备份双页方式保存关键参数4.3 如何在下载失败时把现场人员从“变砖恐慌”中救回来即使AB方案也有一个极端情况两个槽位都不可用了。比如你当前运行在A槽升级写B槽写完后校验失败那B槽不激活A槽继续跑没问题。但如果A槽本身出厂时被刷了一个有问题的固件而你现场又要升级升级写入B槽成功后切换激活槽到B槽然后B槽固件恰好和你想要的不一样也出现严重Bug这时候就有风险了。所以AB方案还需要一个最后兜底策略Bootloader在跳转到App之前会先检查当前要运行的槽位是否满足两个条件——栈顶指针在RAM范围内以及入口地址在Flash范围内。如果条件不满足它会直接尝试跳转另一个槽位。如果两个槽位都无法通过基本检查Bootloader就停留在下载模式等待用户重新下载固件。这套兜底逻辑可以避免绝大多数“永久砖”的情况。Flash的基本健康度检查也值得加进去。我在Bootloader里会读取各槽位前16字节判断是否为0xFFFFFFFF或者是否有越界迹象防止意外跳转到空槽位导致HardFault。不要小看这几行判断它能让Bootloader在错误场景下不慌不忙地进入升级等待循环而不是让单片机像无头苍蝇一样执行随机Flash内容。5. 工程管理双槽位代码与自动化发布的一些经验这件事做完之后我意识到代码能不能可靠运行一半取决于逻辑设计另一半取决于构建发布流程。如果你要复现AB OTA并且长期维护建议从一开始就把两个槽位的编译和发布流程自动化。我自己的方法是使用Makefile管理通过宏定义区分APP_ADDRESS和版本号一条命令编译A槽固件一条命令编译B槽固件同时生成带CRC和版本信息的升级包头部。这样既避免了人为改错链接脚本也方便持续集成系统在发布时自动产出升级文件。升级包内部结构我也设计成了统一的镜像头格式先是固定长度的包头包含魔数、固件版本、编译时间、固件长度、固件CRC32、目标槽位等字段然后才是固件本体。上位机下载升级包后先解析包头再根据目标槽位发起升级。这个设计让后期增加加密验签能力变得容易——你只需要在包头扩展一个签名区Bootloader在接收完固件后先验签再激活即可。关于上位机实现如果你不打算写专门的PC工具只用手头的串口调试助手也能完成功能测试但流程会非常痛苦。我建议哪怕写一个简易的Python脚本把读bin文件、切包、发送、等待应答、重发机制全部流程化也会使复查效率提升明显。python的serial库收发几百个字节的包完全没压力再加上一个简单的状态机整个升级脚本通常不超过200行。在这个阶段还可以把日志系统引进来。我在Bootloader里保留了串口日志输出的能力打印每一帧的接收偏移、帧序号、CRC校验结果、Flash写入返回状态。批量测试的时候这些日志能帮助我搞清楚升级中断到底发生在那一步。平时这些日志可以关闭只在调试宏打开时输出避免量产设备被调试信息拖慢速度。6. 更多心得在资源受限MCU上做可靠升级的几点体会最后聊聊我自己的体会。以前我在别的方案上做OTA喜欢把代码写得特别“完整”什么都往工程里塞结果就是升级链路本身不透明出现问题很难排查。这次在F103上做AB OTA因为资源有限反而倒逼我把Bootloader写得尽可能简单、直接这反而提升了整个系统的可维护性。一个bootloader的代码量控制在几百行到一千行左右是最理想的它不需要去管业务逻辑只负责引导、传输、写Flash、跳转这些非常固定的动作。关于是否要在Bootloader里加入看门狗我的观点是建议加但需要考虑状态。如果看门狗在整个升级过程中不断复位那升级永远无法完成。我的做法是进入正式接收数据状态后周期喂狗接收一帧喂一次如果超过了某个阈值没有收到下一帧看门狗就复位系统重新进入Bootloader。这就在“防止程序卡死”和“允许下载过程有间断”之间取得了一个平衡。关于App运行状态的上报我建议把活动槽位标识、固件版本、CRC校验结果放到一个简单的信息页中。每次App启动都会更新这个信息页并在Bootloader跳转时作为判断依据之一。这样万一出现了什么难以复现的问题至少可以从信息页里看到设备最后一次的运行状态。这篇文档的整个流程我自己反复跑了不下二十遍其中包含各种注入的断电异常、串口干扰、半包数据等情况。稳定之后这套升级方案在我后续好几个基于F103的产品中都直接复用了只是预留的App资源有些调整。如果你的工程当前已经大量使用了标准库v3.5整个升级方案的代码改动量其实不大最花时间的是链接脚本和地址映射的确认以及把升级协议设计得能适配你的串口波特率和帧格式。把这些搞定之后剩下的写Flash和跳转逻辑基本就是套路代码了。