
做单片机开发的朋友应该都遇到过这个场景产品已经量产发货了结果客户现场反馈有个小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支持页擦除和半字编程IAPIn-Application Programming能力完整做OTA没有任何硬件层面的障碍。标准库V3.5和HAL库都行我这次用的是标准库V3.50版本网上随便就能下到配合Keil MDK5开发整个项目都能直接复现。1. 内容整体设计与思路拆解1.1 为什么选AB分区方案而不是传统单分区方案最早接触OTA的时候很多人第一反应都是单分区方案Bootloader AppApp区固定大小新固件直接覆盖写进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。所以这套方案在F103C8T664KB 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中保持一致至少保证时钟树配置相同。硬件接线上串口我用的是USART1PA9TX和PA10RX经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都引用同一个头文件保证两边不会“对不上”。以F103ZET6512KB Flash起始地址0x08000000为例我用的分区如下分区起始地址大小说明Bootloader0x0800000032KB (0x8000)引导程序一般不超过32KBApp_A0x08008000224KB (0x38000)当前运行的应用固件AApp_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命令发给BootloaderBootloader接收完整包后再计算一次两个值比对一致才算成功。为什么要把CRC分两层因为单包校验只能保证单包传输无误不能保证包的顺序对、包没丢。整体校验相当于最终大考确保收到的数据就是上位机发送的那个完整bin文件。3.3 Flash操作的库函数本质与注意事项STM32F103的Flash操作说白了就是三个动作解锁、擦除、编程。标准库里对应的函数是FLASH_Unlock、FLASH_ErasePage、FLASH_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_A0x5A5A5A5A启动App_B0x00000000无效默认启动App_A并对App_A做CRC校验校验App有效性的方法就是对App区的前N个字节或者整个分区做CRC计算与写入固件时保存的CRC比对。如果一致说明App完整可以启动如果不一致说明App损坏回滚到另一个分区。为了缩短启动时间我实际实现中只校验App区的前4KB0x1000字节运行前够用因为写入时的整体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_OFFSET0x08008000会直接编译报错因为这个宏是偏移量不是绝对地址。Keil工程里定义一个宏VECT_TAB_OFFSET0x8000对应App_AApp_B工程则定义VECT_TAB_OFFSET0x40000。编两个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_REGION和EXEC_REGION的起始地址和长度。实现升级命令支持。App被Bootloader启动了以后正常运行业务逻辑。我在App里加了一个串口指令收到UPGRADE字符串后App把参数区的启动标志设置为0x00000000无效标识让Bootloader进入OTA模式然后执行软件复位NVIC_SystemReset()。设备重启后进入BootloaderBootloader看到标志无效就进入等待上位机升级的流程。实现跳转前保存关键数据。业务固件可能在运行中有需要保留的参数校准值、计数值等这些如果放在App区内部升级擦除时就丢了。我建议单独划分一个参数存储区我在分区表里已经预留了最后的8KBApp运行过程中把重要参数写在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}% ({i1}/{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需要分三步用ST-Link/J-Link烧录Bootloader到0x08000000用ST-Link/J-Link烧录App_A到0x08008000或者先用上位机发一次也行但J-Link更快在参数区写入启动标志0xA5A5A5A5指示启动App_A第3步很容易被忽略。如果你不写启动标志Bootloader读到的Flash默认是0xFFFFFFFF我代码里默认按无效处理并启动App_A也可以跑。但为了逻辑统一建议首次烧录时顺手把标志写了。我写了一个单独的Keil工程或者Python脚本通过ST-Link把标志写入指定地址操作很简单。后续OTA升级流程完整如下设备当前运行App_A上位机发送UPGRADE指令App_A收到指令置无效标志软件复位设备进入Bootloader检测到无效标志进入OTA等待模式上位机脚本启动握手、发送固件头、分包传输、结束确认Bootloader接收完成整体CRC校验通过置启动标志0x5A5A5A5ABootloader跳转到App_B地址App_B启动运行升级完成设备在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过程中出现乱码和数据包丢失排查优先级如下硬件连接TX/RX是否交叉GND是否共地。这两个错误非常基础但发生率极高。电源稳定性RS232转换模块的供电是否稳定我试过用一个劣质的USB供电模块电压波动导致串口数据经常错位换了一个质量好的适配器就好了。波特率误差F103的USART波特率由APB时钟分频而来如果你的APB时钟不是精确的72MHz实际波特率和标称值会有偏差。115200的容错范围比较小我在调试时用逻辑分析仪看实际波形确认误差在2%以内。缓冲区溢出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分区的空间代价换来的是“永远有退路”的安心感。如果你的产品要批量发货、要远程维护这一课值得提早修。希望这篇文章能帮你把这条路走通少踩我踩过的坑。