
1. 为什么“从零复现” AB OTA先扯清楚这套方案要解决什么问题先交代一下背景。我不是第一次接触OTA但早先在STM32上做的都是那种“一个App分区一个Bootloader”的简易升级——也就是现在大家常说的单分区方案。这种方案做起来快逻辑也简单Bootloader收到新固件直接往App区覆盖写入写完跳转运行。但用过的人心里都清楚这玩意儿有一个致命伤升级过程中如果断电、串口线松动、或者固件本身就有Bug设备直接变砖。你能做的只有重新上电再进Bootloader再来一次如果是在现场、在客户手里那就是一场灾难。所以当我拿到“STM32F103_AB_OTA_从零复现”这个项目需求时第一反应不是“我要写多少代码”而是先问自己一个更本质的问题这套方案到底要解决什么问题答案是两个字可靠。AB OTA也叫A/B分区升级、双备份升级本质上是一种“先验证、后切换”的升级策略。它不像单分区那样把新固件直接砸到旧固件的头上而是把Flash里分出两个独立的App区——A区和B区。当前运行的是A区新固件就下载到B区B区下载并校验完成后标记自己“可用”然后重启切换到B区运行如果B区运行一段时间或者上电自检发现不对Bootloader还能帮你自动回滚到A区。这个概念放到手机上大家都很熟——Android从7.0开始推的A/B无缝升级就是这套逻辑。但在STM32F103这种Cortex-M3小芯片上实现它考虑的维度完全不一样Flash只有512KB大容量型号RAM只有48KB还要省着用没有MMU没有操作系统级的进程隔离甚至连“运行一段时间再判断是否回滚”都需要自己设计状态机。这一切都得靠裸机逻辑扛下来。这篇教程写的就是我完整复现这套方案的全过程。适合谁看适合已经能跑通STM32基础外设Flash读写、串口收发、定时器、中断、想给自己的产品加上“升级不砖”能力的同学。如果你只是想写个点灯程序这篇不适合你但如果你在做真正的产品比如工业控制器、数据采集器、网关设备这套AB方案就是刚需。下面我把整个项目的设计思路、分区布局、Bootloader实现、App侧配合逻辑、以及我在实际调试中踩过的坑全部拆开来讲。2. 整体设计拆解AB OTA不等同于“两份固件代码”2.1 先明确AB方案的核心链路我在设计这套方案时先把整个升级链路画了一遍。画完之后我才意识到AB OTA并不是简单地把Flash切成两份那么简单它是一个完整的闭环设备当前在A区运行业务代码正常跑。上位机/服务器下发新固件通过串口Modbus RTU协议承载、或TCP、或SD卡等渠道传输。Bootloader接收固件数据包写入B区也就是非当前运行的另一个分区。固件传输完成后Bootloader做完整性校验CRC32或MD5。校验通过将“启动标志”设置为“下次启动B区”然后软复位。Bootloader读取启动标志跳转到B区执行。B区App运行后上报自己的版本号/运行状态如果B区运行异常看门狗复位、启动后主动请求回滚Bootloader在下一次复位时回滚到A区。这条链路的本质是在升级过程中始终保持至少一个“已知可用”的固件在Flash里。A跑着的时候B坏了A继续跑B运行了但有问题A还在那儿躺着任何时候都能回头。这是AB方案和单分区方案最根本的区别单分区是“要么新的、要么砖”AB是“总有一个能用的”。理解这一点后续的所有设计决策就都有了依据。2.2 为什么选STM32F103而不是F4/H7有人可能会问F103这么老的片子学它还有意义吗我的看法是恰恰因为它老、因为它资源紧张才更适合用来理解AB OTA的本质。STM32F103系列在内核上完全一致Flash容量从64KB到512KB都有覆盖了从入门到中高端产品的典型区间。在资源受限的环境下你必须精打细算每一条Flash写入、每一次RAM拷贝、每一毫秒的擦除时间这种“抠”的过程会让你真正理解OTA的底层机制。很多工业设备里存量最大的就是F103你学会了这套可以直接迁移到实际项目中不用等“换个新芯片再做”。我这套复现用的是STM32F103ZET6大容量产品512KB Flash不过是144脚的大片子开发板也便宜。如果你的板子是C8T664KB这种中容量型号需要把分区大小重新规划一下后面我会专门说。2.3 AB方案的选型对比单分区 vs AB分区 vs 双Bank在动手之前我把市面上的几种常见升级方案放在一张表里做了对比这也是我建议你动手前必须做的一步对比项单分区升级AB双分区双Bank升级F4/F7/H7专用Flash占用1份App空间2份App空间2份App空间升级失败后设备状态可能变砖仍可运行旧版本仍可运行旧版本是否需要外部存储空间不需要不需要不需要是否支持无缝回滚不支持支持支持对Bootloader的要求极简覆盖写即可需管理双分区索引和启动标志需配置Bank切换寄存器实现复杂度低中中F103上是否可实现可以可以不可以F103无Bank概念F103没有硬件双Bank的支持那是F4、F7、H7系列才有的特性所以AB双分区是F103上实现可靠升级的最优解——它不需要你在两个区之间做硬件层面的无缝切换只需要在Bootloader里做软件层面的“选择启动”完全可行。3. 分区规划与AB包设计动手前的关键决定3.1 Flash分区布局——先算账再动笔我在做这一步的时候拿了一张纸把STM32F103ZET6的512KB Flash从0x08000000开始往下划。F103的Flash扇区分布比较特殊前4个扇区每个16KB后面全是64KB的扇区不同型号有差异ZET6的详细分布可以查参考手册。这意味着分区边界最好落在扇区边界上否则你擦除一个扇区时会把隔壁分区的数据也擦掉。我的分区规划如下区域名称起始地址大小用途Bootloader0x0800000032KB升级管理、启动选择、串口接收、Modbus RTU实现App A区0x08008000224KB正常运行的主固件AApp B区0x08040000224KB备用固件BOTA目标区参数区0x0807F8002KB存储启动标志、固件信息、回滚计数有人会问Bootloader用32KB是不是太奢侈了我的考量是Bootloader里除了最基本的跳转逻辑还要跑完整的Modbus RTU从站协议、固件分包接收、CRC校验、Flash擦写驱动这些加起来可能就10KB到15KB了但多留一点余量以后想加加密校验、加日志功能不用动分区布局。要知道分区布局一旦定下来改起来是所有环节里最痛的事——你要同时改Bootloader的链接脚本、App的链接脚本、flash地址宏定义、下载算法牵一发动全身。剩下的224KB×2给App。这个大小对绝大多数F103应用足够了。你要是做简单的数据采集、协议转换App通常不会超过50KB224KB的空间甚至可以装上小型RTOS正式版。3.2 参数区设计启动标志是AB方案的生命线参数区在整个AB方案里最容易被忽略但它恰恰是灵魂。我在最初的一版设计里把启动标志只设计成一个字节——0xAA表示启动A区0xBB表示启动B区。后来实测打脸了。问题出在哪Flash不是RAM它只能按扇区擦除一次擦掉2KB然后按半字16位写入。你每次写入一个字节需要先读回整个扇区、修改对应字节、擦除扇区、再整个写回。而且Flash擦写次数是有限额的F103数据手册标称10K次擦写频繁改写启动标志会把参数区扇区寿命快速耗尽。所以我改成了双字64位状态槽设计类似Flash磨损均衡的思路参数区分成两个状态槽每个槽16字节。每次写入时只写当前活跃的槽位要切换启动分区时先写第二个槽位、抹掉第一个槽位的数据下次写再反过来。4字节Header 4字节状态 4字节App版本 2字节CRC 2字节标志值。这样一来实际擦写次数减半生命周期翻了倍。参数区里我存了这些东西magic4字节固定为0xA5A5A5A5用于判断参数区是否已被初始化。boot_and_version4字节高字节存储上一次尝试启动的分区号A/B/None低3字节存储固件版本号。status4字节当前状态包括SLOT_EMPTY空、SLOT_PENDING新固件就绪待启动、SLOT_CONFIRMED新固件运行正常已确认、SLOT_FAILED新固件运行异常待回滚。crc162字节对以上数据的CRC16校验。这个参数区的设计逻辑是Bootloader每次上电第一件事就是读参数区根据状态决定这次跑哪个区。3.3 AB包结构不仅仅是“固件二进制”网上很多教程把OTA固件包直接定义为app.bin裸二进制文件做完CRC就完事。这么做有一个问题Bootloader拿到这个bin后不知道它是给A区的还是给B区的也不知道版本号是多少、编译时间是什么时候。所有的信息都得靠烧录端额外传一旦通信出点错双方就“对不上话”了。我在这个项目里给固件包设计了一个轻量级的头部结构姑且叫它AB包#pragma pack(1) typedef struct { uint32_t magic; // 0xABABABAB标识这是一个AB固件包 uint32_t version; // 固件版本号每次发布递增 uint32_t length; // 固件数据有效长度不含头部 uint32_t crc32; // 固件数据的CRC32校验值 uint32_t timestamp; // 编译时间戳 uint8_t target_slot; // 目标分区0x01 A区, 0x02 B区 uint8_t reserved[7]; // 保留字节为将来扩展留余地 } ab_ota_header_t; #pragma pack()这个头部总共32字节放在固件bin的最前面。Bootloader在接收到完整数据后第一步就是解析这个头部校验magic、CRC、版本号、目标分区是否合法。这样固件本身的元数据就和数据绑定了不存在“两边对不上”的问题。你可能会问为什么不直接把头部放在Flash的某个固定地址非要和固件数据混在一起我的想法是保持固件包的独立性。这个bin文件可以直接用STM32烧录器烧到A区当出厂固件也可以通过网络传到Bootloader里做升级两种方式用的是同一个文件不会出现“用烧录器烧的固件和用OTA传的固件格式不一致”的坑。4. Bootloader核心逻辑从跳转到回滚的完整实现4.1 Bootloader的主循环状态机我在写Bootloader的时候没有用“顺序执行”的老套路而是设计了一个简单的状态机。因为OTA升级过程中需要随时响应超时、通信异常、看门狗复位等事件顺序结构的代码在这种场景下会变得非常僵化。这个状态机大概是这样的POWER_ON上电初始化读取参数区。CHECK_BOOT_FLAG检查启动标志决定进入哪个分支。WAIT_HOST等待上位机指令比如Modbus的写寄存器指令等待时间可配置默认3秒。如果收到升级指令进入RECEIVING状态如果没收到且参数区状态正常直接跳转App。RECEIVING接收固件分包数据写入目标分区。VERIFYING所有数据接收完毕后对整个分区做CRC32校验。UPDATE_BOOT_FLAG校验通过后更新参数区把启动状态设为PENDING软复位。JUMP_TO_APP跳转到对应App分区执行。其中最关键的一点是每次复位包括意外复位后Bootloader都会重新检查参数区状态。这也正是AB OTA实现回滚的基石。4.2 跳转App的详细代码解析跳转App这件事看起来简单__set_MSP() 函数指针跳转两行代码搞定。但真正落地时坑很多至少有三个细节必须处理干净第一个细节是关闭中断。在跳转之前必须关掉全局中断、禁掉SysTick、把外设时钟和中断向量表恢复到上电状态。如果你不这么做App启动后一个定时器中断突然触发但App还没初始化到那个外设直接HardFault。第二个细节是确认中断向量表在App区的起始位置。F103的向量表默认在0x08000000App要用SCB-VTOR APP_ADDR把向量表挪到自己的分区。注意我用的标准库V3.5版本在跳转之前必须手动设置VTOR不像新版的HAL库的SystemInit()里会自动处理。第三个细节是栈指针的合法性检查。跳转前要读App区起始地址处的4字节这是App的初始栈顶指针如果这个值不在SRAM范围内0x20000000~0x2000C000说明这个分区根本没有有效的固件跳过去必死无疑。以下是我在项目中使用的跳转代码typedef void (*pFunction)(void); void boot_jump_to_app(uint32_t app_addr) { uint32_t app_msp *(volatile uint32_t *)app_addr; uint32_t app_reset *(volatile uint32_t *)(app_addr 4); // 检查栈顶指针是否在SRAM范围内 if ((app_msp 0xFFF00000) ! 0x20000000) { // 非法固件不能跳转 return; } // 关闭全局中断 __disable_irq(); // 恢复默认中断向量表跳转前先复位 SCB-VTOR app_addr; // 关闭SysTick避免旧的中断处理函数残留在NVIC中 SysTick-CTRL 0; // 设置主栈指针并跳转 __set_MSP(app_msp); pFunction jump (pFunction)app_reset; jump(); }这里有个小细节值得注意app_reset并不是App的main函数地址而是App的Reset_Handler地址。在标准库/ST提供的启动文件里Reset_Handler会依次完成复制.data段、清零.bss段、调用SystemInit()、最后调用__mainC库初始化再到main()。所以Bootloader直接跳到Reset_HandlerApp就能像冷启动一样完整地跑起来。4.3 固件下载与写入Flash操作的工程化封装接着是重头戏——固件怎么从串口进入Flash。这部分我分成了三层底层Flash驱动、中层数据缓冲、上层Modbus指令。底层Flash驱动没什么玄学核心就是STM32标准库提供的FLASH_ProgramHalfWord()、FLASH_ErasePage()和FLASH_Unlock()/FLASH_Lock()。但有几个关键经验擦除操作耗时很长。STM32F103擦除一个64KB的大扇区实测要几百毫秒。如果上位机还在持续发数据这个时间段内串口接收缓冲区很容易溢出。所以我在Bootloader里做了双缓冲乒乓设计一个512字节的缓冲接收固件数据另一个512字节的缓冲在擦除期间持续接收擦除完成后再把第二个缓冲中的数据写入Flash。写入必须按半字16位对齐。Flash写入本质上是一个16位的编程操作而串口收到的数据是按字节流的。所以每1KB数据要凑成偶数奇数时报错或补0xFF。写入前的擦除范围要精确计算。我在接收未知大小的固件前会先解析头部里的length字段然后根据这个长度计算出需要擦除哪些扇区一次擦完。如果每收到几个扇区的数据就擦写一次不仅慢而且频繁擦写对Flash寿命影响极大。中层数据缓冲的伪逻辑我用一个简单的状态结构体来管理typedef struct { uint8_t buffer_a[512]; uint8_t buffer_b[512]; uint8_t *active_buf; uint16_t buf_len; uint32_t total_written; uint32_t target_length; uint32_t target_crc; uint8_t rx_state; // 状态等头/等数据/等校验 } ota_rx_ctx_t;每次收到一帧数据先把数据塞进当前激活的缓冲缓冲满了就切到另一个缓冲并触发Flash中断写入。这样MCU在擦除Flash时串口中断照常工作不会丢字节。4.4 Modbus RTU通信协议的移植Freemodbus V1.6的取舍由于这个项目希望支持通过RS232串口、用标准的Modbus RTU协议来进行升级传输我需要一个稳定的Modbus从站协议栈。网上一搜最常用的就是Freemodbus V1.6。这个库在STM32F103上移植并不复杂网上教程一堆但在OTA场景下有几个点需要特别注意。Freemodbus默认的处理方式是“请求-响应”主机发送一个请求帧从机处理完再回一帧。它的核心API是eMBPoll()通常在main循环里轮询调用。这个框架本身没毛病但问题出在固件传输的效率上——如果你一个Modbus写寄存器请求只带200个字节的数据那传一个200KB的固件要发1000多次请求每次还有3.5字符的帧间隔和响应等待总时间非常长。我这里做了两个优化第一个优化是自定义寄存器地址映射把固件传输的操作全部映射到几组寄存器上寄存器10x10命令寄存器写入0xAA触发升级开始写入0x55查询状态。寄存器20x11写入启动地址/目标分区。寄存器30x12写入当前分包序号用来做断点续传。寄存器4~200数据寄存器按顺序写入固件数据。寄存器2010xC9写入0x01表示“数据包已接收请确认CRC”。每次写寄存器组实际上是把当前分包的512字节数据写入挂在Modbus协议栈上的数据缓冲。一个分包写完后主机再发一个“确认帧”从机执行Flash写入和CRC累加。实测下来一个512字节分包的完整过程在115200波特率下大约要12ms200KB固件全传完大约5~6分钟包括擦除时间这对于现场升级来说是可以接受的。第二个优化是开启Freemodbus的超时机制。默认情况下Freemodbus在收到不完整帧后会一直傻等直到下一个字节到来或超时。在OTA过程中主机可能因为网络卡顿暂停几秒如果没有超时处理从机就会一直卡在等待状态升级流程无法推进。我把EV_READY状态与一个软件定时器绑定超过5秒没有完整帧就强制复位接收状态机。4.5 双Bank方案的替代思路为什么不做真正意义上的双Bank前面说过F103没有硬件双Bank那能不能用软件的方式模拟一个“更接近双Bank”的效果我在项目讨论阶段确实考虑过比如把两个App区在Linker层面映射到同一段虚拟地址运行哪个区就把哪个区的数据“复制”过去——这其实就是常见的“影子拷贝”方案。后来我放弃了这个方向原因有两条一是F103的Flash执行性能本来就不算强零等待状态最多到24MHz主频如果App代码放在外部SPI Flash里或者通过拷贝到RAM执行会显著增加系统的延迟和功耗得不偿失。二是AB方案本身已经提供了足够的可靠性。系统的核心诉求是“升级失败还能回滚”AB已经做到了。影子拷贝方案虽然能让“A和B运行时的起始地址保持一致”看起来更优雅但引入的复杂度远远超出收益尤其不适合新手复现。所以最终方案就是两个App分区使用不同的链接起始地址A区0x08008000B区0x08040000各自编译、各自生成bin通过OTA时选择目标分区决定烧到哪里。这样做在代码层面唯一的麻烦是升级到B区时App里的FLASH_APP_ADDR相关宏需要跟着变。但这可以通过编译时宏来控制不用改逻辑代码。5. App侧配合不只是“被跳转”还要主动汇报状态5.1 App启动后的状态确认机制很多人写AB OTA教程只讲Bootloader怎么接收固件、怎么跳转却把App侧完全当成了“被动受害者”——好像App只要在那儿等着被跳转就行了。这种理解是片面的。AB方案的核心能力——自动回滚——必须由App主动参与才能实现。我在App侧做了三件事第一件事是上电后延迟确认。App跑起来之后并不会立刻把参数区的状态从“PENDING”改成“CONFIRMED”而是先正常运行等到一个“确认时间窗”我设置的是10秒可配结束。如果这10秒内系统没有发生严重故障看门狗复位、HardFault、内存溢出导致的复位App才写参数区把状态改为“CONFIRMED”。这样做的目的很明确给新固件一个“试用期”试用期内出问题还有机会回到旧版。第二件事是主动上报版本号。App的main函数里通过之前定义好的Modbus寄存器向上位机上报自己的版本号。上位机可以据此判断升级是否成功。这一步在调试和生产阶段都很重要否则你从日志里根本看不出当前跑的到底是A还是B。第三件事是联动看门狗。如果B区App启动后因为一个偶发Bug卡死了硬件看门狗会周期性复位MCU。Bootloader在每次上电时发现参数区的状态还是“PENDING”且启动次数超过阈值比如3次就自动把分区切回A区。这就实现了真正意义上“无人工介入”的回滚。核心代码逻辑如下// 在App的main函数开头调用 void app_ota_startup_check(void) { ota_param_t param; ota_param_read(param); if (param.status SLOT_PENDING) { // 说明当前是从OTA升级启动的 app_ota_trial_mode_enable(); // 延迟确认由定时器触发10秒后执行 } } // 10秒后回调 void app_ota_trial_timer_callback(void) { if (app_ota_is_system_healthy()) { ota_param_set_status(SLOT_CONFIRMED); // 状态确认后以后每次复位都会正常启动当前分区 } }5.2 App与Bootloader之间的通信约定App侧还需要知道一个关键信息当前我自己是被放在哪个分区运行的因为OTA升级的目标分区是动态的——这次可能从A升到B下次可能从B升到A——App不能假设“我永远在A区”。获取这个信息有两个办法一是直接读参数区里的boot_and_version字段二是用一个固定地址存“当前运行分区号”的全局变量。我选了前者因为参数区数据掉电不丢失而且本身就有CRC保护。App启动后调用ota_param_read()读到当前分区号存到一个全局变量里。后续App里的升级模块就可以根据这个变量决定“新固件应该写入哪个分区”uint8_t app_get_current_slot(void) { ota_param_t param; ota_param_read(param); return param.current_slot; // 0x01 A, 0x02 B } uint8_t app_get_target_slot(void) { uint8_t current app_get_current_slot(); return (current SLOT_A) ? SLOT_B : SLOT_A; }这套逻辑最大的好处是无论当前在哪个分区App的逻辑代码完全一致不需要因为“在B区运行”就改一套代码。唯一需要区分的是当App主动触发OTA升级时它要把升级请求命令发给Bootloader通过软复位进入Bootloader而不是自己直接去擦除Flash——毕竟擦除过程中App自己也在运行如果把正在运行的代码区擦掉了那就真的“自毁”了。5.3 中断向量表的偏移带来的连锁反应有个容易被忽略的坑是App在B区运行时中断向量表必须指向B区地址。我在编译工程时通过VECT_TAB_OFFSET宏来控制#define VECT_TAB_OFFSET_A 0x08008000 #define VECT_TAB_OFFSET_B 0x08040000然后在system_stm32f10x.c的SystemInit()函数里设置SCB-VTOR FLASH_BASE | VECT_TAB_OFFSET;但我强烈建议不要只依赖SystemInit里的VTOR设置因为SystemInit执行时机是在startup文件里、声明全局变量之前。更稳妥的做法是在main()的最开头再显式地设置一次int main(void) { // 确保中断向量表正确这一步必须放在任何外设初始化之前 SCB-VTOR FLASH_BASE | APP_BASE_ADDR; // 后续初始化... }要是忘了这一步在A区运行还好因为A区的起始地址0x08008000和默认向量表有一定偏移但只要不开中断就不会炸但到了B区如果向量表不偏移任何一个外设中断触发都会跳到A区对应的中断处理函数里而那时A区的代码可能已经被擦除了结果就是HardFault。这个坑我印象太深了调试时查了两天才定位到。6. 实际调试过程从编译、烧录到一次完整升级的实录6.1 编译产生的两个Bin文件由于AB双分区方案的App起始地址不同一套源码需要编译出两个不同地址的bin文件。有人可能会想“我就编译一份用OTA工具修改头部里的目标分区字段不就行了”不行因为SCB-VTOR和链接脚本里的FLASH起始地址是在编译时确定的。A区和B区的bin文件机器码里跳转地址、常量池地址都是不同的。我在工程配置里建了两个Build TargetTarget A链接脚本stm32f103_flash_A.icfROM起始地址0x08008000大小0x38000224KB。Target B链接脚本stm32f103_flash_B.icfROM起始地址0x08040000大小也是0x38000。编译完成后用fromelf --bin --output app_a.bin和app_b.bin导出两个bin文件。然后把对应bin文件加上AB包头部。我在上位机工具里写了一个Python脚本自动完成“bin 头部”的封装import struct import zlib import argparse def build_ab_package(bin_file, version, target_slot, output_file): with open(bin_file, rb) as f: data f.read() length len(data) crc32_val zlib.crc32(data) 0xFFFFFFFF header struct.pack(III I B 7x, 0xABABABAB, version, length, crc32_val, target_slot) with open(output_file, wb) as f: f.write(header) f.write(data) print(fAB包生成成功: {output_file}, 有效数据长度{length}, CRC320x{crc32_val:08X})上位机再把封装好的AB包通过Modbus RTU协议逐个分包发送给Bootloader。这一步的完整流程是上位机先发“升级开始命令”Bootloader回“就绪”然后上位机发“帧号512字节数据”Bootloader每收满一个分包就回一个确认所有分包发完后上位机发“结束命令”Bootloader做CRC总校验。6.2 我用的烧录与调试工具链我在这次复现中用到的工具链很常规没有花哨的东西列出来供你参考IDEKeil MDK 5.37用的标准库V3.5注意标准库对编译器的优化等级比较敏感我建议用-O2不要开-O3实测-O3偶发初始化时序问题。烧录器ST-Link V2配合STM32 ST-LINK Utility也可以直接用Keil下载。串口工具一个USB转RS232模块FT232RL芯片的稳定一个串口监视器我用的是SecureCRT可以同时开两个标签页一个看Modbus交互一个看App串口日志。Modbus主机调试工具Modbus Poll图形化界面短时间内大量连续读写非常方便。调试过程中有个非常实用的技巧Bootloader和App各自用不同的串口打印日志。Bootloader的调试信息走USART1PA9/PA10App的日志走USART2PA2/PA3。这样当Bootloader跳转到App后你能从两个独立的日志流里看到各自的启动日志定位问题会快很多。要是共用同一个串口跳转瞬间日志可能会交错很难排查。6.3 一次成功升级的过程实录下面这一段是我的串口日志记录展示了一次从A区升级到B区的完整过程关键部分做了脱敏和简化--- Bootloader v1.2 启动 --- [0.000] 参数区读取: magic0xA5A5A5A5, statusCONFIRMED, slotA [0.010] 等待上位机指令 (3秒超时)... [2.340] 收到升级命令, 目标分区B [2.345] 擦除B区扇区... 完成 (耗时623ms) [2.970] 开始接收固件数据包... [2.980] 分包序号: 1/..., 长度: 512, CRC16: 0x3A2B ... [68.230] 所有固件数据接收完成, 总长度128456字节 [68.240] 校验固件CRC32: 0x18F29A31 ... 匹配 [68.250] 更新参数区: statusPENDING, boot_slotB [68.260] 软复位启动... [68.310] --- Bootloader v1.2 重新上电 --- [0.000] 参数区读取: magic0xA5A5A5A5, statusPENDING, slotB [0.005] 跳转App B区: addr0x08040000, msp0x20005000, reset0x08040125 [0.010] --- App B v2.0 启动 --- [0.020] 中断向量表已设置: 0x08040000 [0.030] 当前分区: B [3.000] OTA试用期开始: 10秒后确认状态... [13.010] OTA试用期通过, 确认新固件正常, 参数区状态CONFIRMED从日志可以看到整个升级过程大约70秒115200波特率下其中耗时最多的是数据传输擦除和校验的时间只占很小一部分。升级完B区运行后A区仍然保留着完整的旧固件随时可以回滚。6.4 回滚机制的实际验证光测成功路径不够我还专门测了回滚路径。做法是故意让B区固件在启动后第5秒触发一个HardFault比如访问非法地址看看系统会不会自动回到A区。实测日志如下[0.310] --- Bootloader v1.2 重新上电 --- [0.000] 参数区读取: magic0xA5A5A5A5, statusPENDING, slotB [0.005] 跳转App B区: addr0x08040000 [0.010] --- App B v2.0 启动 --- [0.020] 中断向量表已设置: 0x08040000 [5.000] 模拟故障发生, 触发HardFault... [5.003] 硬件复位 [0.310] --- Bootloader v1.2 重新上电 --- [0.000] 参数区读取: magic0xA5A5A5A5, statusPENDING, slotB [0.005] 尝试次数: 1 [0.010] 继续跳转B区... [5.002] 硬件复位 [0.310] --- Bootloader v1.2 重新上电 --- [0.000] 参数区读取: magic0xA5A5A5A5, statusPENDING, slotB [0.005] 尝试次数: 2 [0.010] 继续跳转B区... [5.001] 硬件复位 [0.310] --- Bootloader v1.2 重新上电 --- [0.000] 参数区读取: magic0xA5A5A5A5, statusPENDING, slotB [0.005] 尝试次数: 3, 达到阈值, 回滚! [0.020] 更新参数区: statusFAILED, boot_slotA [0.030] 跳转App A区: addr0x08008000 [0.030] --- App A v1.8 启动 ---这个过程中你会看到Bootloader会给B区最多3次启动机会我设置的这个阈值可以根据产品需求调整每次复位后尝试次数加1。三次都失败了就果断回滚到A区。这个机制很糙但在无人工介入的条件下确实管用。7. 常见问题与排查技巧实录这一部分是我整个项目调试过程中踩坑的汇总每条都是我实打实解决过的希望能帮大家少走弯路。7.1 升级后App跑不起来一直复位现象固件下载完成Bootloader也跳转了但App没起来或者起来后几秒又复位。排查思路先看Bootloader在跳转前打印的app_msp和app_reset。如果app_msp不在SRAM范围那基本可以断定是App的bin文件不对——最常见的原因是你把A区的bin文件当成B区的包发过去了或者编译时的链接地址和实际下载地址不一致。还有就是中断向量表的问题。如果App启动后没进main而是直接HardFault优先检查SCB-VTOR设置。很多标准库工程默认只设置了一次VTOR且是在SystemInit里用的固定宏你换分区编译时如果忘了改宏就会跳进错误的中断处理函数。7.2 串口传输过程中丢字节导致CRC校验失败现象固件传了一大半最后Bootloader校验CRC失败整个升级中止。原因绝大多数情况下是串口在擦除Flash期间没有数据缓冲或者缓冲太小。我在最初版本里只用了512字节的接收缓冲结果是每次触发Flash擦除串口中断里收进来的数据全部丢光。解决方案双缓冲乒乓设计 擦除期间暂停校验逻辑。具体做法前面已经讲过这里再强调一下擦除动作要放在主循环里做不要在串口中断里做。串口中断只负责往缓冲里塞数据数据满就切缓冲主循环检测到缓冲满了才去执行Flash写入和下一页擦除。这样能保证串口连续的数据流不中断。7.3 Modbus RTU通信偶发超时或响应错乱现象上位机发读固件状态指令时偶尔收到错误响应甚至完全无响应。原因Freemodbus对帧间隔时间3.5字符时间很敏感。在115200波特率下3.5字符时间大约是0.4ms如果你的主循环里有Flash擦除这种长时间操作Modbus从站的轮询会被卡住导致帧间隔计算错误。解决方案三个方向同时下手。把Modbus的eMBPoll()调用放在定时器中断里或优先级较高的定时任务里保证周期性执行。Flash擦除期间暂时屏蔽Modbus的响应让主机重试。适当延长上位机的超时和重试次数。7.4 参数区被写坏启动一直失败现象某次升级后Bootloader日志显示“参数区CRC错误恢复默认值”然后每次启动都走默认路径。原因我在调试过程中遇到过两次一次是写入参数区时刚好断电另一次是参数区的两个状态槽之间切换时一个槽的数据还没擦干净就写了另一个槽。解决方案写参数区不能直接“擦除-写入”必须做“双槽冗余”。我给每个状态槽加了独立的CRCBootloader启动时优先读最近写入的槽如果CRC错误再读另一个槽。两个都错那才执行参数区格式化。这套保险在工业现场真的能救命——毕竟升级时断电是最常见的事故如果参数区一坏设备只能人工返厂刷机那就白做AB方案了。7.5 Flash写入时程序死机定位到FLASH_ProgramHalfWord卡住现象Bootloader在写入Flash时死机单步调试发现卡在FLASH_ProgramHalfWord内部。原因多半是Flash写操作没有遵循F103的约束。F103的Flash编程是按16位半字操作的而且编程前对应的字节必须为0xFFFFFFFF。如果你擦除不彻底或者上一个分包写入时有残留数据就会导致写入失败。另一个常见原因是程序在Flash写入操作期间又去读Flash代码区——比如固件数据缓冲放在了一个同时被写入的Flash扇区。排查方法先把App分区全部擦除干净再重新传输一次。如果还死机检查缓冲数组是否被定义在Flash地址空间Linker脚本错误或者检查是否有中断服务例程在Flash写入期间修改了Flash控制寄存器。7.6 升级过程中误触发了看门狗复位现象升级传输到一半尤其是擦除Flash的时候系统被看门狗复位所有进度清空。原因Bootloader的主循环里如果长时间执行Flash擦除几十到几百毫秒没有喂狗硬件看门狗就爆发了。解决方案在Bootloader的擦除循环里每擦完一个扇区就喂一次狗。但要注意擦除一个扇区的时间绝对不能超过看门狗超时阈值否则怎么喂都没用。我的做法是把看门狗超时设成2秒而F103擦除单个扇区最多几百毫秒完全来得及。7.7 Flash容量不够B区根本放不下固件现象编译后App bin超过分区大小或者两个分区加Bootloader超出了芯片Flash总量。原因你用的芯片Flash太小或者App功能膨胀太严重。解决方案用C8T664KB这种小容量芯片建议放弃AB方案改用“外部Flash存固件 内部Flash单分区”的方案。用ZET6512KB这种大容量芯片只要App小于224KB就没问题。合理裁剪Bootloader代码比如不用Modbus协议改用更简单的私有协议能把Bootloader压到8KB以内给App多留空间。8. 拓展方向与个人体会到这里整套STM32F103 AB OTA方案基本上讲透了。回顾整个复现过程我觉得最值得说的不是某一段代码怎么写而是这套方案背后的可靠性思维——它不是“把固件传过去”就完事了而是围绕“万一出问题怎么办”设计了一整套兜底机制。分区冗余、启动状态机、试用期确认、回滚计数每一层都是对意外的一种防御。在我实际的项目经验里AB OTA这几个字听起来简单真正落地时涉及的是对整个系统启动流程、存储布局、通信协议的设计重构。如果你正在给自己的产品加OTA功能我的建议是先别急着写代码拿出一张纸把“升级失败的所有可能原因”列一遍然后针对每条原因想出对应的应对策略。等这张纸上的策略都有了你的AB方案就已经成型了代码只是把它翻译成C语言而已。最后再分享一个小技巧这套方案做完之后我还在Bootloader里加了一个“进入升级模式”的外部触发条件——上电时检测PA0引脚电平如果被拉低就强制进入升级模式等待上位机连接。这样即使设备跑的是A区正常固件现场工程师插上跳线帽也能手动触发升级不用依赖上位机随时在线。这个功能在生产测试和维护场景下非常实用强烈推荐你加上。另外如果后续想继续扩展这几个方向都值得尝试给固件包加RSA或AES加密签名防止非法固件刷入。支持更大固件体积把固件先存到外部SPI Flash或SD卡再内部搬移。加一个“升级进度上报”机制让上位机能显示百分比。这就是我这次从零复现STM32F103 AB OTA的全部内容。代码本身不复杂复杂的是对每个细节的考量和验证。希望这篇教程能帮你绕过我踩过的坑顺利做出属于自己的可靠OTA方案。