
STM32F103 的 A/B 分区 OTA 升级方案是我在为一个现场维护成本极高的设备做远程升级改造时踩了三周坑后彻底跑通的一套流程。当时设备装到了全国十几个城市固件版本有个隐蔽 bug客户反馈一个接一个打过来去现场拆机烧录的成本算下来比设备本身还贵。后来咬咬牙决定给片内 Flash 512KB 的 STM32F103ZET6 做 A/B 分区 OTA过程相当折腾但跑通之后远程升级设备就像点外卖一样简单。这篇文章就是把整个从零复现过程拆开揉碎A/B 分区为什么比传统 IAP 方案安全、怎么给 F103 规划 Flash 分区、Bootloader 怎么写跳转逻辑、App 工程怎么改链接脚本和中断向量表、固件怎么打包校验、回滚机制怎么设计以及我实际踩过的一堆坑。内容面向已经能正常点亮 STM32F103、会建 Keil 工程、用过串口烧录的朋友如果你是纯新手建议先把最小系统跑起来、能烧录点灯之后再回来看这篇。1. 为什么是A/B分区OTA升级不是简单远程烧录1.1 传统IAP方案的问题升级失败就变砖先说第一个我踩进去的坑。最初我给设备做的是传统 IAP 方案就是 Bootloader 单 App 分区Bootloader 负责在启动时检查是否有升级请求没有就跳转到 AppApp 里跑一个远程升级模块收到升级包后写入 App 所在 Flash 区写完复位Bootloader 再跳转到新固件。听起来没什么问题但在真实场景中单分区 IAP 有致命的薄弱点升级数据在传输过程中如果因为信号干扰、串口断帧、网络超时出现损坏而完整性校验又没拦住坏固件直接进 Flash复位后 Bootloader 跳到一个不可用的固件单片机就变砖。升级过程中意外断电是最恶心的。Flash 擦除写到一半断电App 区既不是旧固件也不是新固件Bootloader 检不出问题跳转必死。新固件本身有 bug即使完整下载、校验通过、正常跳转运行几秒后死机这时候设备只能现场拆机。说白了传统的单分区 IAP 把“升级失败”和“设备变砖”直接画了等号这对远程设备来说不可接受。1.2 A/B分区的本质双保险加自动回滚A/B 分区方案概念上很像电脑装系统的双系统分区的“安全性简化版”。片内 Flash 里同时维护两份 App 固件A 分区是当前正在运行的分区称为活跃分区 active。B 分区是备用分区存放同一份或更新版本的固件称为非活跃分区 inactive。升级时运行在 A 分区的 App 把新固件写到 B 分区写入完成后做一个完整校验然后设置“本次启动切换到 B”的标志复位。Bootloader 启动后读取标志跳转到 B。如果 B 分区的新固件正常启动并运行稳定就写一个确认标志后续都从 B 启动。如果 B 分区的新固件在试运行期间崩溃或死机Bootloader 在下一次复位时自动把启动目标切回 A设备就还能继续跑旧固件。这个方案的本质是引入了“回滚机制”把系统从一个必须一步走对的升级变成了可容错的升级。代价是 App 区占用的 Flash 空间翻倍但在 STM32F103 大容量芯片上这个代价完全可以接受。ZET6 有 512KB Flash哪怕分 192KB 给每个 App 区对绝大多数项目也绰绰有余。顺便提一句这个思路业界有现成实现比如 MCUboot我当时也下载源码研究过但最终决定自己从零写一版。项目对代码体积敏感、也不想引入复杂依赖更重要的是自己写一遍 Bootloader 才能真正理解 A/B 切换背后的状态机、向量表偏移、Flash 编程时序这些底层细节。后续出问题排查也完全可控。2. Flash资源盘点与分区设计先算清家底再动手2.1 STM32F103片内Flash结构与地址映射STM32F103 的内部 Flash 从地址 0x08000000 开始容量不同页大小也不一样。这里有个容易踩坑的地方不同型号并不是“容量越大页越大”这么简单型号系列典型型号Flash容量Flash页大小低密度C8T664KB1KB中密度RBT6128KB1KB高密度RCT6256KB2KB高密度ZET6512KB2KB写 Flash 时一次最多写 2 字节半字只能把 1 写成 0要改成 1 必须先执行整页擦除。这个物理特性直接决定了分区规划必须按页对齐否则会莫名奇妙地把别的分区数据擦掉。Flash 地址映射基本是固定的0x08000000 Flash起始地址也是复位后取向量表的位置 0x08000000 FlashSize 片内Flash结束 0x1FFFF000 系统存储器内部Bootloader固件出厂自带 0x20000000 SRAM起始地址这里有个关键概念Cortex-M3 内核上电后从 0x08000000 地址读取栈顶指针和复位向量。正常裸机工程里这段地址放的就是中断向量表。A/B 分区方案中两个 App 不可能同时占 0x08000000所以 Bootloader 要负责“选择从哪个地址开始运行 App”App 则需要配合修改代码位置和向量表偏移。2.2 四段式分区方案Bootloader、App_A、App_B、标志区以 STM32F103ZET6 512KB Flash 为例我最终落地的分区是这样分区起始地址大小页数2KB/页用途Bootloader0x0800000064KB32页启动引导、升级下载、回滚控制App_A0x08010000192KB96页活跃固件区域App_B0x08040000192KB96页非活跃固件区域Flag区0x0806F0004KB2页分区状态、升级标志、试运行计数为什么 Bootloader 给 64KB 而不是 16KB 或 32KB因为 Bootloader 里要放的不只是跳转代码还包括完整的串口/CAN 下载驱动、Flash 驱动、CRC 校验、状态机。如果还要给 Bootloader 做自升级空间越紧张越容易出问题。64KB 是这个方案的舒适区间实际编译出来只有 20KB 左右剩下的是冗余。App 分区每个 192KB则是因为大多数 F103 项目的固件都在几十 KB 到 150KB 之间能给后面加功能留足够空间。如果芯片是 STM32F103RCT6256KB可以调整为 Bootloader 64KB、App_A 96KB、App_B 96KBFlag区占用最后一页 2KB也完全跑得起来。Flag 区单独占两个页很关键。Flash 的特性是页擦除是整页的如果状态标志放在 App 分区末尾App 升级时整页擦除就会把标志一起抹掉而把标志独立出来整个生命周期里可以按字节修改不需要反复擦页。2.3 升级通道选型串口、CAN还是无线模块A/B 分区 OTA 只解决“固件怎么存、怎么切换”真正把新固件传进来的是升级通道。STM32F103 常见的三种通道我这次都验证过结论很明确串口实现最简单只要 USB 转串口就能复现整个升级流程调试信息还可以共用。缺点是速度慢115200 波特率下 192KB 固件大概要传 17 秒左右但对 OTA 场景完全够用。CAN工业设备上很常用抗干扰强、距离远。F103 的 bxCAN 一次最多传 8 字节数据需要自己做多帧传输协议复杂度高一些但跑在设备总线上不用额外接线。无线模块如 LoRa、NB-IoT、Wi-Fi 模块转串口本质还是串口通道只是把数据送到远程协议层可以复用。我的建议是第一步先把串口通道跑通。串口的调试手段最直接能看二进制流、能抓错误帧等升级流程完全稳定了再考虑换成 CAN 或无线。3. 自己写Bootloader跳转逻辑、启动标志与下载流程3.1 向量表重映射的底层原理要理解 Bootloader 跳转 App必须先理解向量表。STM32F103 这类 Cortex-M3 芯片上电后固定从 0x08000000 读取初始栈顶指针 MSP 和复位向量然后开始执行。如果没有做任何重映射中断来了之后 CPU 从 0x08000000 位置的向量表里找中断服务函数地址而这个位置是 Bootloader 的向量表。App 被放在了 0x08010000它的向量表也跟着到了 0x08010000。如果 App 里的串口中断、定时器中断触发CPU 去 0x08000000 找向量找到的是 Bootloader 的中断处理函数轻则中断失效重则 HardFault。解决办法是修改中断向量表偏移寄存器 SCB-VTOR让内核知道向量表不在默认位置#define APP_BASE_ADDR 0x08010000 SCB-VTOR APP_BASE_ADDR;这一行代码必须在 App 内部任何中断使能之前执行。最稳妥的位置是在 SystemInit() 函数的最开头。3.2 Bootloader主流程启动检查、跳转App、进入DFU我把 Bootloader 设计成三个阶段的循环流程上电复位 | v [1] 读取Flag区的启动配置 | -- 如果标志是进入升级进入DFU下载模式 | -- 如果标志是新固件待确认记录启动次数跳转到新分区 | -- 正常情况下跳转到当前active分区核心跳转函数是最先要写的C 语言里不能直接“函数跳转”要通过函数指针取出 App 的复位向量typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t stack_top *(volatile uint32_t *)app_addr; uint32_t reset_vector *(volatile uint32_t *)(app_addr 4); pFunction app_entry; // 关键检查栈顶指针必须落在SRAM范围内 // ZET6 SRAM是64KB即 0x20000000 ~ 0x2000FFFF // 这里留了判断余量 if ((stack_top 0x2FFF0000) ! 0x20000000) { // 栈顶地址非法不跳转进入错误处理 return; } // 关闭全局中断避免跳转过程中被打断 __disable_irq(); // 关闭SysTick定时器清零计数值 SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; // 设置主栈指针为App的栈顶 __set_MSP(stack_top); // 取复位向量地址并跳转 app_entry (pFunction)reset_vector; app_entry(); }这段代码有三个细节必须注意。第一跳转前一定要做栈顶地址合法性检查。如果不检查App 区是空的或者数据损坏时栈顶值可能是一个非法地址芯片会立即 HardFault。检查方法就是看地址是否落在 0x20000000 到 0x2000FFFF 区间内这也是 Cortex-M3 上做 IAP 跳转的标准做法。第二跳转前关中断、关 SysTick。如果 Bootloader 里开了串口中断、定时器中断跳进 App 后内核外设状态不会自动复位中断可能在 App 初始化到一半时触发导致不可预知的问题。尤其 SysTick 的中断里如果有调试计数很容易让 App 启动异常。第三如果 App 工程里使用了 RTOS比如 FreeRTOS跳转前的处理还要更彻底——关闭所有外设时钟、清理 PendSV 和 SysTick 状态。不过 F103 裸机场景先不用管那么多。3.3 Flash驱动擦写Flash时的中断禁忌Bootloader 接收新固件后需要把数据写到非活跃分区。STM32F103 的 Flash 擦写有几个严格约束写操作必须在解锁状态下进行FLASH-KEYR 0x45670123; FLASH-KEYR 0xCDEF89D3;。写入单位是半字16位地址必须 2 字节对齐。写操作前必须先擦除整个页页大小按芯片型号区分。操作期间 Flash 接口处于忙碌状态代码不能同时从 Flash 取指。最后一条是最容易出问题的。标准外设库的 FLASH_ErasePage 函数本身放在 Flash 里当它执行擦除命令时CPU 如果继续从 Flash 取指就会因为没有指令可取而卡住。实际测试中大部分场景擦写能完成但一旦擦写期间来了中断中断服务程序也在 Flash 里系统会卡死或者擦写数据出错。解决方案有两个方案一擦写期间关闭所有中断。这个最简单擦写函数前后加 __disable_irq() 和 __enable_irq()只要擦写不被打断就行。方案二把擦写函数放到 RAM 里执行。用 Keil 的话在函数定义前加attribute((section(.ARM.__at_0x20002000))) 这种属性或者用分散加载文件把函数分配到 RAM 段。这个方法更稳妥推荐把它作为生产方案的标配。我看过不少人早期跑得挺好一旦下载过程中偶发中断就翻车基本都是这个原因。3.4 下载协议设计帧结构、握手和状态机Bootloader 的 DFU 模式需要一套和上位机约定好的协议。我不会一上来就上复杂协议而是用一个足够简单但可靠的自定义帧格式帧头(2字节) 命令(1字节) 长度(2字节) 数据(N字节) 校验(2字节) 0xAA 0x55 0x01 0x00 0x80 .... CRC16命令字只需要几个0x01 握手上位机发命令Bootloader 返回版本号和当前状态。0x02 擦除擦除非活跃分区的指定页。0x03 写入把一帧数据写入指定地址。0x04 读回校验把写入的数据读出来回传上位机比对或者本地计算 CRC 对比。0x05 启动切换设置分区切换标志然后复位。帧校验我用的是 CRC16查表法对一帧几百字节的载荷来说足够。固件整体校验则用 CRC32后面会专门讲。下载流程的状态机建议这样设计IDLE - 收到握手命令 - HANDSHAKE HANDSHAKE - 收到擦除命令 - ERASE_PENDING - ERASE_DONE ERASE_DONE - 收到写入命令 - WRITE_PENDING - WRITE_DONE WRITE_DONE - 收到读回校验命令 - VERIFY_DONE VERIFY_DONE - 收到启动命令 - BOOT_SET - RESET状态机的好处是每个阶段都有明确的入口和出口任何一帧数据异常都能直接回到 IDLE 重新开始不会卡死在中间状态。这比简单地“收到什么就执行什么”健壮得多。4. App端工程改造链接脚本、中断向量表与升级触发4.1 修改链接脚本让App从0x08010000开始Bootloader 负责“跳过去”App 则必须自己“待在该待的位置”。App 工程里首先要改的是代码存放地址。用 Keil 的话最直观的方式是在魔棒界面修改 IROM1 的起始地址和大小IROM1 Start: 0x08010000 IROM1 Size: 0x00030000 ; 192KB如果工程用的是 GCC 工具链则对应修改 ld 脚本里的 FLASH 起始地址MEMORY { FLASH (rx) : ORIGIN 0x08010000, LENGTH 192K RAM (rwx) : ORIGIN 0x20000000, LENGTH 64K }这里有个容易忽略的细节修改 FLASH 起始地址后中断向量表的 LMA加载地址也变成了 0x08010000但内核默认向量表还是 0x08000000所以必须配合设置 SCB-VTOR两个动作缺一不可。RAM 的起始地址一般不用改因为代码里引用的变量地址、堆栈地址都在 SRAM 里不会冲突。但如果 App 里用了绝对地址访问就要小心是否越界。4.2 SystemInit中的向量表偏移设置修改 SystemInit 是最常见也最不容易出错的方案。在 system_stm32f10x.c 文件末尾加几行void SystemInit(void) { // 原本的时钟配置代码... // 将中断向量表偏移到App所在Flash区 // VECT_TAB_OFFSET是App起始地址相对于Flash起始地址的偏移 // 如果App从0x08010000开始偏移就是0x10000 SCB-VTOR 0x08010000 | (VECT_TAB_OFFSET (uint32_t)0x1FFFFF80); }为什么向量表偏移要对齐到 0x80因为 SCB-VTOR 的低 7 位是保留的实际有效位是从偏移 7 位开始的。你在代码里如果直接写一个奇奇怪怪的偏移量硬件会自动忽略低位。我在第一次实验时就犯过写错偏移导致中断全乱的错误。向量表大小也要心里有数。STM32F103 的向量表包含 16 个系统异常 60 个左右外部中断每个向量 4 字节总共大概 300 字节左右。因此 Bootloader 分区给 64KBApp 起始地址放到 0x08010000中间的空隙完全没有问题。4.3 升级触发与重启接管的实现App 收到上位机的“开始升级”指令后不能直接复位就完事要做好三件事固件包头部校验先看魔数、版本号、固件大小是否合理。固件数据流式写入F103 的 RAM 通常只有 20~64KB整包固件不可能一次性放进 RAM。我用的办法是分块流式写入每收到 256 字节就写一次 Flash。设置“请求升级”标志在 Flag 区的固定偏移写入约定的魔数然后复位。标志写入的代码相当简单但要注意先读擦后写#define FLAG_SECTOR_ADDR 0x0806F000 void set_upgrade_request_flag(void) { uint32_t flags[2]; flags[0] 0xA5A5A5A5; // 请求进入升级模式 flags[1] 0x5A5A5A5A; // 冗余备份 // 解锁Flash FLASH_Unlock(); // 擦除标志页 FLASH_ErasePage(FLAG_SECTOR_ADDR); // 写入标志 FLASH_ProgramWord(FLAG_SECTOR_ADDR, flags[0]); FLASH_ProgramWord(FLAG_SECTOR_ADDR 4, flags[1]); FLASH_Lock(); // 复位进入Bootloader NVIC_SystemReset(); }标志为什么写两份冗余因为单份标志在极端掉电场景下可能正好写了半截值不确定。写两份同样的值Bootloader 解析时只要两份一致就认为有效不一致就按无标志处理安全性高很多。关于“App 直接写非活跃分区”还是“App 只设置标志由 Bootloader 下载”我最终选了后者。理由很简单App 里本来就有业务逻辑如果再内嵌完整的升级下载流程复杂度增加一倍。而且升级协议后期免不了调整改协议只改 BootloaderApp 侧不用动省心不少。当然如果 App 性能要求极高不能长时间停在升级流程或者希望升级过程对 App 完全透明那就只能把升级逻辑全放 Bootloader。这是架构取舍问题没有绝对答案。5. 固件打包与校验链路让镜像文件可靠落地5.1 固件镜像头部设计从 Keil 编译器出来的 .axf 文件如果直接转 .bin 发给设备Bootloader 无法判断这个文件是否合法、给哪个分区用、代码量多大。我在实际项目里把整个固件做成了一个自定义镜像格式头部 32 字节typedef struct { uint32_t magic; // 魔数固定0xA5A55A5A uint32_t version; // 固件版本号如0x00000100表示1.0 uint32_t image_size; // 固件有效数据长度不含头部 uint32_t crc32; // 对整个固件数据区计算的CRC32 uint32_t target_slot; // 目标分区A或B uint8_t reserved[8]; // 保留字段应对后续扩展 uint32_t header_crc; // 对前面28字节计算的CRC32 } ota_image_header_t;头部加 header_crc 是为了防止头部本身在传输过程中被改坏。即使数据区 CRC 没问题头部关键字段错一位也可能导致写入错误位置。5.2 使用Python脚本完成镜像打包每次编译完还要手动敲命令太痛苦了我写了一个简单的 Python 脚本在 Keil 的 After Build 里调用一键生成最终烧录和 OTA 用的镜像import struct import zlib import sys import argparse def build_image(input_bin, output_ota, version, target_slot): with open(input_bin, rb) as f: firmware f.read() image_size len(firmware) crc32 zlib.crc32(firmware) 0xFFFFFFFF header struct.pack( IIIIIIII, 0xA5A55A5A, # magic version, # version image_size, # image_size crc32, # crc32 target_slot, # target_slot 0, 0, # reserved 0 # header_crc占位 ) header_crc zlib.crc32(header) 0xFFFFFFFF header header[:24] struct.pack(I, header_crc) with open(output_ota, wb) as f: f.write(header) f.write(firmware) print(f[OTA] {input_bin} - {output_ota}, size{image_size}, crc0x{crc32:08X}) if __name__ __main__: build_image( input_binbuild/app.bin, output_otabuild/app_v1_2.ota, version0x00000102, target_slot1 # 1表示写入B分区 )里面有个值得注意的点打包脚本里的 CRC32 算法要和 Bootloader 里的 CRC32 实现一致。标准外设库没有现成的 CRC32 函数Bootloader 里我是用查表法实现的。如果打包用 zlib 算出来的 CRC32 和单片机算的对不上升级就会卡在校验不通过。所以我在写 Bootloader 的 CRC 函数之前先用 PC 端同样的数据跑了一遍查表法的结果确保两个端算出来一样再往下走。5.3 流式写入协议与超时保护192KB 固件分 256 字节一包大概要传 768 包。每一包的传输都应该有超时保护。我见过不少人做完下载功能测试时全速传输没问题一到现场网络卡顿就卡死在半路。我的协议里约定上位机发出握手帧后Bootloader 必须 2 秒内响应否则上位机报错重试。写入帧收到后Bootloader 把数据写入 Flash 并返回 ACK上位机等待 ACK 的超时时间是 1 秒。Bootloader 端每帧数据也必须做 CRC16 校验收到坏帧直接丢弃并回传 NAK上位机重发这一包。这套握手 ACK/NAK 超时重传的机制比单纯广播数据可靠得多。6. 回滚策略与异常兜底A/B分区的灵魂6.1 启动计数与确认机制的设计如果 B 分区新固件一启动就崩回滚应该自动发生不能让人去现场手动干预。我针对这个问题设计了“试运行确认”机制逻辑是这样的Flag 区里除了升级标志还保存一个 4 字节的试运行计数App 升级完成、Bootloader 准备切到 B 前把 trial_count 清零。Bootloader 每次跳转到新分区时如果发现该分区处于“待确认”状态就把 trial_count 加 1。App 在新分区启动成功后启动看门狗运行到某个稳定点比如联网成功、传感器自检通过后写一个 confirm 标志表示固件确认可用。Bootloader 启动时如果发现 active 分区处于“待确认”且 trial_count 已经大于等于 3就判定新固件不可用把 active 切回旧分区。这个阈值 3 不是拍脑袋定的。一次试运行至少要覆盖上电初始化和外设建立连接的过程如果这个阶段通过后面崩溃概率就大大降低。阈值太低容易把一次瞬时干扰当成固件故障阈值太高会让用户等待很长时间后才回滚3 次是个平衡点。6.2 回滚触发条件与IWDG的配合独立看门狗 IWDG 在回滚机制里扮演了“最终裁判”的角色。原理是新 App 试运行期间IWDG 一旦使能就无法软件关闭App 必须周期性喂狗。如果 App 死机或者主循环跑飞IWDG 超时后强制复位Bootloader 再次启动时发现 trial_count 超限执行回滚。我印象最深的一次测试是故意在新固件里加了一个死循环 bug联网 3 秒后系统卡死。IWDG 在 4 秒后复位芯片Bootloader 检测到新固件未确认且试运行计数到了阈值干净利落地切回 A 分区。整个回滚过程不到 8 秒设备重新用旧固件跑起来了。注意一点IWDG 的喂狗代码放在 App 主循环的空闲部分不能放在中断里。有些新手喜欢把喂狗放 SysTick 中断结果主循环已经死透了中断还在喂狗看门狗完全失去了作用。6.3 人为强制回滚与双保险除了自动回滚我还留了一个手动触发的途径上位机可以给 Bootloader 下发一条“强制切回指定分区”的命令。这个命令用于新固件能正常启动但业务不正常的场景比如新固件把设备某些配置清空了或者网络连接始终失败但系统没死机自动回滚条件触发不了人工远程下发一条命令就能切回去。这时候“双份固件 双份冗余标志”的优势就很明显。A 和 B 都各自保留上一版已知良好的固件即使当前在用的 A 被新固件覆盖B 里也还有一份旧版本可用。我建议每个分区里都保留 release note 式的版本号字段这样调试时一眼能看出设备跑的是哪个版本。7. 从零复现的完整流程与踩坑清单7.1 一套完整的复现路线如果你打算照着做我建议按这个顺序来每完成一步验证一步不要直接上来写全部代码准备工作STM32F103ZET6 最小系统板、ST-Link 或 DAP-Link 下载器、USB 转 TTL 串口模块、几个按键和 LED。Boot0/Boot1 引脚都拉低确保从片内 Flash 启动。建立 Bootloader 工程先不写任何升级逻辑只实现启动后默认跳转到 0x08010000 的跳转函数并在跳转前点亮一个 LED 做标识。建立 App 工程修改 IROM1 起始地址为 0x08010000在 SystemInit 里设置 SCB-VTOR实现串口打印“App v1.0 running”。验证跳转把 Bootloader 烧到 0x08000000App 烧到 0x08010000复位后如果串口能打印出 App 的日志跳转链路就通了。在 Bootloader 里实现串口下载通道和 Flash 写驱动先不要 A/B 切换只实现在固定地址写固件。实现镜像头部校验和 CRC32 校验验证坏固件能被拦截。最后实现 A/B 分区标志切换、试运行确认和回滚逻辑。做几轮模拟测试整包下载、断点续传数据中途停几秒、下载中途断电、新固件启动立即死机。把每种情况下的行为列个表格验证是否符合预期。7.2 硬件层面的常见坑第一个坑是 Boot0/Boot1 引脚对下载的影响。STM32F103 的 BOOT0 和 BOOT1 引脚状态决定复位后从哪个存储区启动。如果用 DAP 或 ST-Link 下载完程序但发现没跑起来先量一下 BOOT0 是不是被意外拉高了。更隐蔽的是 BOOT1 和 PB2 复用有些板子的 PB2 默认有上拉如果被外围电路拉到高电平在某些情况下也会影响启动模式判断。调试时建议把这两个引脚都用跳线帽固定到 GND。第二个坑是串口 TX/RX 交叉。这个问题低级但极其常见USB 转 TTL 模块的 TX 要接 MCU 的 RX模块的 RX 接 MCU 的 TX很多人第一次做 OTA 下载时发现数据发过去一直没响应查了半天发现是两根线接反了。第三个坑是整个系统供电不稳定。Flash 擦写瞬间的电流变化比较大如果用劣质 USB 供电电压跌落可能导致 Flash 操作失败。升级时最好用示波器看一眼 VDD 是不是稳定在 3.3V。7.3 软件层面的关键细节软件上我遇到的坑比硬件多得多挑几个有代表性的第一个坑启动文件选错。STM32F103 高密度芯片要用 startup_stm32f10x_hd.s中密度用 startup_stm32f10x_md.s如果启动文件选错芯片上电后时钟和内存配置全乱。标准外设库 v3.50 自带的启动文件是按密度区分的直接移植时很容易忽略。第二个坑编译优化把跳转相关代码优化掉。跳转函数里的函数指针如果被优化掉可能直接跳过跳转步骤。跳转函数建议加 volatile 和优化等级单独控制或者在调试阶段先用 -O0 跑通再逐步开优化。第三个坑App 里 SystemInit 的时钟配置不能忘。如果 App 是直接从 Bootloader 工程复制出来的有些教程里会把时钟初始化写在 main 里而不是 SystemInit 里导致 App 跳转后时钟不是外部晶振串口波特率全错。第四个坑升级过程中接口的看门狗。App 在等待固件下载时不能喂独立看门狗否则下载过程会不断复位但也不能完全不处理超时。我最后的办法是App 进入升级模式前先关闭 IWDG交给 Bootloader 的下载流程管理超时。Bootloader 下载阶段用了一个软定时器 外部复位机制而不是 IWDG避免下载耗时较长时误复位。第五个坑向 Flash 写数据时缓冲区对齐。STM32F103 Flash 编程要求地址和数据长度都是半字对齐的如果上位机发过来的包长度是奇数最后一字节要单独处理。我协议里直接把每包长度固定为 256 这样的偶数既满足对齐要求又方便计算 CRC。第六个坑裸机工程里 SystemCoreClock 不更新。修改了时钟初始化后SystemCoreClock 变量如果还是默认值依赖它的延时函数和波特率配置都会出问题。检查方法很简单串口打印一下 SystemCoreClock 的值确认和实际晶振配置一致。7.4 测试顺序与验收标准当整个流程编写完成后我强烈建议做这样一组系统测试并且把每一个用例的结果记录下来。不要只跑一次“能升级成功”就认为完成了。测试场景预期行为验收标准从A分区正常启动设备运行旧固件串口打印版本号及应用日志完整升级到B分区下载完成后自动切换到B复位后运行新固件且版本号正确新固件启动即崩溃试运行计数超限后自动回滚自动回到旧固件且恢复业务升级途中断电再次上电能回到旧固件或重新开始下载设备不变成砖固件包CRC错误Bootloader拒绝写入提示校验失败且不影响当前固件连续升级多次A/B分区来回切换正常两次以上升级都能稳定完成我在测试这套方案时专门用继电器做了一个随机掉电测试仪在升级过程中随机切断电源连跑了一百多轮最终统计下来没有一次把设备弄到需要现场烧录的状态。这个结果出来后才敢真正用到远程设备上。8. 我复现过程中最值得分享的三点体会整个 A/B 分区 OTA 做下来技术上的细节前面都讲了最后说几句更偏经验层面的东西。第一先把状态迁移图画清楚再写代码。A/B 分区 OTA 真正的复杂度不在 Flash 驱动、不在串口协议而在“系统处于什么状态、下一步该往哪走”。我把 Bootloader 的四个状态正常启动、升级下载、试运行确认、回滚画成一张状态图贴在工位上写代码的时候每个分支都对照着图画少走了很多弯路。第二调试手段和产品功能要分开。Bootloader 里我保留了串口打印功能但只在宏开关开启时输出调试日志量产固件里关掉。这样既能现场排障又不影响性能。日志里一定要带时间戳和状态字段否则回滚发生时根本不知道从哪个状态回滚的。第三回滚机制的验证要故意制造故障。不要只测“一切正常”的升级路径要在固件里注入各种问题故意写错版本号、故意在 App 启动后卡死、故意让固件包缺几个字节。只有把这些异常路径全部走一遍A/B 分区的价值才真正体现出来。这套方案后来在几十台远程设备上跑了将近一年累计升级十几次没有一台需要现场拆机恢复。当时花三周时间从零复现现在看来非常划算因为每次远程升级省下的差旅费早就超过当时的开发成本了。如果你的产品也有远程设备维护的刚需STM32F103 的 A/B 分区 OTA 是非常值得投入的一个能力。