STM32F103 A/B分区OTA升级完整方案与Bootloader实现 📅 发布时间:2026/9/9 9:57:53 👁 浏览次数: 在做嵌入式产品的时候固件升级一直是绕不开的坎。尤其是用 STM32F103 这种老而弥坚的芯片很多人一开始图省事直接在应用里做IAP结果设备出货后碰到升级掉电、固件校验失败、刷完变砖售后想死的心都有。我这次把 STM32F103 上实现 A/B 分区 OTA 的完整过程从零复现了一遍把 Bootloader、App 双区切换、固件校验、传输通道、回滚机制全部打通。这篇教程不是抄手册是把我实际踩过的坑和验证过的方案整理出来适合已经会用 STM32F103 标准库开发、但想把 OTA 做得更稳的工程师也适合刚接触 Bootloader 想系统搞清楚的入门者。文中会给出分区地址表、CRC32 实现、跳转代码、Flash 写入代码、以及一套可以直接照抄的调试顺序。1. 为什么要给STM32F103上A/B分区OTA1.1 单区升级方案的问题早年做 IAP 最常见的做法是 Bootloader 一个用户 App 区升级时先把新固件下载到预留的临时缓冲区或者直接用通信方式边收边擦写 App 区。听起来简单但这里有三个致命麻烦。第一掉电风险。升级中途一旦断电App 区可能处于擦了一半或者写了一半的状态。下次上电 Bootloader 发现 App 校验不通过设备就成了砖。第二临时缓冲区的容量问题。很多 F103 型号 Flash 本来就不大你要留一个跟 App 等大的下载区Flash 占用直接翻倍。如果不留缓冲区而是直接擦写 App 区那升级过程中系统完全不可用而且依然有掉电风险。第三没有回滚手段。就算你升级成功发现新固件有 bug设备已经跑起来了只能再烧录一次旧固件无法自动恢复。我做过的几个项目里就遇到过现场升级掉电导致设备需要返厂的情况。从那之后凡是要求可靠性高的设备我都倾向用 A/B 双分区方案。A/B 方案的核心思路就是把固件分成两份一份是当前正在运行的一份是专门用来接收新固件的备份区。平时系统跑 A 区升级时把新固件写入 B 区校验通过后再切换启动入口。1.2 A/B双分区的优势与代价A/B 分区本质上是一种“双保险”设计。想象一下你住在一栋有两个门的房子里装修一个门的时候你还可以从另一个门进出就算装修失败也只是把坏门锁上生活不受影响。设备升级也是一样当前系统在 A 区跑着新固件在 B 区下载和校验整个过程 A 区一直可用不会出现设备在升级过程中变成板砖的情况。用表格列出关键区别更直观方案掉电安全升级期间可用性回滚能力Flash占用传统单区IAP低擦写中断即砖低升级时覆盖运行区无较少独立下载区IAP中掉电可重新下载中系统停更有限较多A/B双分区高任意时刻均有可用固件高切换原子完成强可自动回滚两倍App空间当然代价也很直接Flash 占用翻倍、逻辑复杂度上升。对于 F103 这类芯片来说A/B 是否能落地第一步就要算清楚 Flash 容量够不够。1.3 先算一笔资源账F103能不能玩A/B我个人建议固件体积在 64KB 以内时选 256KB 以上的 F103比如 F103VCT6做 A/B 分区比较从容固件如果到 100KB 以上直接上 512KB 的型号比如 STM32F103ZET6 或者 RET6这两个型号都是 512KB Flash、64KB RAM做 A/B 非常合适。我在本教程中使用的是一个比较宽松的 512KB 分区方案Bootloader 占 32KB、独立标志区占 4KB、A 区和 B 区各 192KB、最后留 92KB 给参数和日志存储。算下来刚好填满 512KB具体分区表下一节给出。这里有个经验Bootloader 不要只给 16KB。很多初期 Bootloader 只有跳转逻辑16KB 够了但后续如果要加加密验签、固件加密、多协议支持就会发现 16KB 非常局促。前期多留一点后面不用返工。2. 整体架构与存储布局2.1 Flash分区表设计STM32F103 大容量型号的 Flash 是 1KB 一页擦除必须按页操作所以分区地址尽量按 1KB 对齐而且每个分区起始位置最好放在擦除边界上。我第一次设计分区时没注意把 App 的起始地址设成了一个非页对齐的偏地址结果 FLASH_ErasePage 总是把 Bootloader 尾巴擦掉设备直接变砖这个问题排查了很久。推荐的分区表如下分区起始地址大小用途Bootloader0x0800000032KB启动跳转、升级调度、校验与回滚系统标志区0x080080004KB活动分区、升级状态、CRC、版本信息App A0x08009000192KB活动分区A固件主副本App B0x08039000192KB备份分区B新固件下载目标参数存储区0x0806900092KB运行参数、日志、掉电保护数据分区之间不要贴太紧。我的 Bootloader 实际编译出来可能只有 15KB但为了以后扩展还是留足 32KB。A 区和 B 区各 192KB对于大多数 F103 应用绰绰有余。参数区 92KB 可以存放设备配置、校准数据、日志循环缓冲甚至还能放一帧 8KB 左右的掉电保护文件。2.2 标志区数据结构设计A/B 切换需要一套可靠的“元数据”来记录当前状态。这些数据不能放在 App 里因为 App 升级时自己可能被覆盖也不能放在 Bootloader 里因为 Bootloader 是只读的所以必须单独划分一个标志区。我定义的结构体如下#define SYS_FLAG_MAGIC 0xA5A55A5A #define UPDATE_STATUS_IDLE 0x00000000 #define UPDATE_STATUS_READY 0x55555555 // 固件已就绪等待切换 #define UPDATE_STATUS_COMMIT 0xAAAAAAAA // 已提交不需要回滚 typedef struct { uint32_t magic; // 固定魔数校验数据有效 uint32_t active_slot; // 0表示App A1表示App B uint32_t update_flag; // 1表示有待切换的新固件 uint32_t update_status; // 提交状态 uint32_t boot_count; // 新固件启动次数计数 uint32_t image_size; // 固件字节数 uint32_t image_crc; // 固件CRC32 uint8_t version[16]; // 固件版本号字符串 uint32_t reserved[8]; // 预留 } sys_flag_t;这个结构体共 52 字节左右写入 Flash 时建议直接占用一页。F103 按页擦除所以整个标志区 4KB 里第一页放结构体剩余空间可以预留做冗余备份。升级写标志时可以交错写两份结构体防止更新过程中掉电导致结构体半写坏。这里的 update_status 字段特别关键。它的作用是把“升级流程”串起来固件下载完先在标志区写 update_flag1等 Bootloader 切换完分区、App 启动自检通过后App 再写 update_statusCOMMIT表示这个版本已经稳定运行不需要回滚。如果 App 启动后迟迟不提交Bootloader 就会在下次启动时按回滚策略处理。2.3 编译环境与链接脚本准备F103 开发我用的还是经典组合Keil MDK 标准外设库 V3.5这套组合虽然老但资料多、稳定做 Bootloader 这种底层工作很合适。当然你用 HAL 库也没问题原理完全一致只是底层接口不同。A/B 双分区要求 App 工程编译时就能确定自己要烧到哪个地址。A 区 App 的烧录地址是 0x08009000B 区 App 的烧录地址是 0x08039000。我通常的做法是建立两个 App 工程共用代码只在链接脚本和宏定义上区分。Keil 里对应的是分散加载文件.sctA 区版本如下LR_IROM1 0x08009000 0x00030000 { ER_IROM1 0x08009000 0x00030000 { *.o (RESET, First) *(InRoot$$Sections) .ANY (RO) } RW_IRAM1 0x20000000 0x0000C000 { .ANY (RW ZI) } }B 区版本唯一区别就是把0x08009000换成0x08039000。注意这里0x00030000是 192KB 的区域长度不要写错。编译完成后烧录时分区烧Bootloader 烧到 0x08000000A 版 App 烧到 0x08009000B 版 App 烧到 0x08039000。链接脚本改完之后还要同步修改系统初始化文件里的向量偏移。在system_stm32f10x.c中有一个VECT_TAB_OFFSET宏A 区 App 设为0x00009000B 区 App 设为0x00039000。前期最容易踩的坑是只改了链接脚本忘了改向量偏移结果程序一跑中断全乱飞表面现象就是定时器不工作、串口收不到数据。3. Bootloader端实现3.1 Bootloader主流程与状态机Bootloader 是整个 A/B OTA 的大脑它的核心任务不是“下载固件”而是“决定启动谁、以及在需要的时候切换和回滚”。我的 Bootloader 主流程按以下顺序执行初始化时钟、串口调试、看门狗。读取标志区的 sys_flag_t 结构体检查 magic 是否合法。如果 update_flag1进入升级切换流程。校验备份分区的固件 CRC 和大小。校验通过后切换 active_slot清零 boot_count把 update_flag 保留update_status 置为 READY。校验失败则清掉 update_flag保持旧分区不变。根据 active_slot 计算当前 App 起始地址跳转。如果用图来画就是上电 - 读标志 - 判断是否需要切换 - 校验 - 切区 - 跳转。这里最关键的是第 4 步的校验不能省。有些人图快收到升级命令就直接切 active_slot结果发现固件下载一半根本没写完设备直接起不来。我见过最惨的一次就是同事把这个步骤简化了几百台设备全部需要返厂。看门狗在 Bootloader 里也要注意。由于升级过程可能耗时较长尤其是串口下载 192KB 固件可能要几分钟看门狗超时值要设计得足够长或者在校验过程中周期喂狗。建议在 Bootloader 中加一套简单的日志输出比如串口打印[BL] check crc: ok、[BL] jump to A这样现场出问题能快速定位。3.2 固件校验CRC32查表实现固件校验我用了 CRC32先在生产电脑上计算好固件的 CRC32 和大小通过网络或串口把它写入标志区然后 Bootloader 在启动时对备份分区逐字节重新计算 CRC32两个值一致才算通过。CRC32 的查表实现非常成熟在 F103 上跑 192KB 数据大约只需要几毫秒到几十毫秒完全可接受。查表法实现如下static uint32_t crc32_table[256]; void crc32_init_table(void) { for (uint32_t i 0; i 256; i) { uint32_t c i; for (int k 0; k 8; k) { if (c 1) { c 0xEDB88320UL ^ (c 1); } else { c 1; } } crc32_table[i] c; } } uint32_t crc32_calc(uint8_t *buf, uint32_t len) { uint32_t crc 0xFFFFFFFFUL; for (uint32_t i 0; i len; i) { crc crc32_table[(crc ^ buf[i]) 0xFF] ^ (crc 8); } return crc ^ 0xFFFFFFFFUL; }Bootloader 里不能直接对整个备份分区用这个函数一次性计算因为内存不够。我采用分段读取的方式准备一个 1024 字节的缓冲区一页一页地读 Flash 并喂给 CRC32 状态最后得到整个固件的 CRC。注意CRC32 是流式的分段计算和一次性计算的最终结果是一样的前提是每一段的顺序和长度一致。校验时还有一个容易被忽视的问题固件大小不对齐。如果固件实际字节数是 123456而你写入 Flash 时按 4 字节对齐补了 123456 的整倍数那 校验时要按真实大小计算不能把 padding 字节算进去。我在第一次调试时就是没处理对齐补位CRC 怎么都对不上后来加了一个image_size字段严格控制计算长度才解决。3.3 跳转函数与中断向量表处理跳转是整个 Bootloader 最薄也最危险的环节。跳之前要确认目标地址确实是一个合法的 App 镜像最简单的方法是检查该地址处的栈顶值是否落在 SRAM 区间内。如果栈顶值都不合法说明 App 区根本没烧录或者已经损坏这时候强行跳转只会 HardFault。我写的跳转函数typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t msp_value *(volatile uint32_t *)app_addr; uint32_t reset_handler *(volatile uint32_t *)(app_addr 4); if ((msp_value 0xFFF00000) ! 0x20000000) { // 栈顶指针不在RAM区间说明App镜像不合法 return; } __disable_irq(); SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; SCB-VTOR app_addr; __set_MSP(msp_value); pFunction jump (pFunction)reset_handler; jump(); }这段代码有几个关键点。第一关闭中断后再跳转防止跳转过程中被未处理的中断打断。第二清零 SysTick因为 SysTick 可能在 Bootloader 里被打过不清理会影响 App 的时钟基准。第三设置 SCB-VTOR 到目标分区基地址F103 的 Cortex-M3 内核支持这个寄存器但很多资料里没提清楚导致很多人跳转后中断不响应。跳转前还应该把 Bootloader 已经打开的外设时钟关掉尤其是串口、DMA、定时器。虽然 App 启动时会重新初始化但如果 Bootloader 里开了外设中断跳到 App 的瞬间外设还在产生中断而 App 的中断向量表还没完全准备好可能一开机就进 HardFault。我通常在跳转函数前面统一调用RCC_DeInit()和GPIO_DeInit()把所有外设复位。3.4 启动计数与自动回滚A/B 方案最有价值的能力就是自动回滚。新固件切换过去后如果系统起不来Bootloader 要能自动切回另一个分区不能让设备变成砖。我用 boot_count 实现这个机制。具体逻辑是Bootloader 每次切换到新分区时把 boot_count 清零并写入标志区然后跳转到新 App。App 启动后如果一切正常应尽快完成自检并调用“提交”流程提交后把 update_status 置为 COMMIT。如果 App 崩溃或无法完成自检boot_count 不会更新。那么什么时候触发回滚我设计的策略是每次 Bootloader 上电读到 active_slot 指向的分区没有被提交就把 boot_count 加 1。如果 boot_count 超过阈值比如 3 次就认为当前 App 有问题Bootloader 自动切换 active_slot 到另一个分区并清除升级标志。这样即使 App 启动即崩溃下次重启 Bootloader 也会主动切回旧版本。这个机制也不是万能的它依赖设备能反复复位。如果 App 挂起但系统没有复位boot_count 就不会增加所以最好配合一个硬件看门狗App 不能正常喂狗就复位形成一套完整的“启动-自检-喂狗-提交”链路。4. App端实现4.1 App的Flash基地址与分散加载文件App 端要解决的第一件事是让自己能在指定分区地址运行。除了前面说的分散加载文件还要特别注意中断向量表的偏移设置。如果你用的是标准库system_stm32f10x.c里会有#define VECT_TAB_OFFSET 0x00009000在 SystemInit 函数最后会判断是否启用向量表偏移。如果你在 main 函数里手动设置也可以SCB-VTOR APP_A_BASE_ADDR;这个设置要在系统时钟初始化之后、任何外设中断使能之前完成。否则中断向量表还指向 0x08000000 的 Bootloader 区一旦某个外设产生中断CPU 会取到 Bootloader 的中断向量行为不可控。还有一个容易踩的坑局部变量数组过大。F103 的 SRAM 只有 64KB 甚至更小如果你在接收固件时定义一个 48KB 的数组来缓冲直接就把栈撑爆了。正确的做法是准备 1KB~4KB 的小缓冲区一边收一边写 Flash而不是把整个固件都缓存在内存里再写。以 2KB 缓冲区为例写一页 Flash 需要 2 次 FLASH_ProgramWord速度完全可以接受。4.2 固件接收与Flash写入App 收到升级命令后要把新固件写入另一个分区。比如当前 active_slot0 对应 A 区那新固件就要写到 B 区。写入过程中有一个关键原则擦写动作不能阻塞系统太久。如果你的设备还有实时性要求建议用 DMA 双缓冲接收主循环里只在缓冲区满时才执行 Flash 写入。写入 Flash 的底层代码void flash_write_page(uint32_t addr, uint8_t *buf, uint32_t len) { uint32_t i; uint32_t word; FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); FLASH_ErasePage(addr); for (i 0; i len; i 4) { memcpy(word, buf i, 4); FLASH_ProgramWord(addr i, word); } FLASH_Lock(); }写入时要考虑跨页问题。因为 F103 的页是 1KB如果你接收的缓冲区是 2KB那么一次写操作实际上跨了两页需要分别擦除两页再写入否则第二次写会失败。更稳妥的做法是按页处理每接收满 1KB就擦除目标页然后写入这一页数据。第一次实现的版本里我图省事直接按 4KB 缓冲区处理结果跨页没擦对写入老失败一度怀疑是 Flash 寿命问题。固件接收过程中的校验也不能只放在最后。我建议在接收过程中边收边计算 CRC32写完后把计算结果和上位机传来的 CRC 值比对不一致就重新下载。毕竟 Flash 擦写次数有限如果每次都是写完才发现数据不对对 Flash 寿命也是一种消耗。4.3 升级触发、版本管理与提交升级触发方式很多串口命令行、按键组合、远程网络命令、甚至云端平台指令。触发条件一定要加版本判断不能收到升级指令就下载。我习惯在升级包里同时带上版本号App 端收到升级指令后先比对版本号只有目标版本高于当前版本才继续。完整升级流程如下收到升级指令包含目标版本、固件长度、CRC32。校验版本号是否高于当前版本。擦写备份分区接收固件数据边收边写边算 CRC。固件全部写入后校验 CRC把 image_size、image_crc、版本号写入标志区。设置 update_flag1软复位。Bootloader 上电校验并切换分区跳转到新 App。新 App 启动执行硬件自检成功则提交失败则依赖回滚机制。第 5 步的软复位用NVIC_SystemReset()就行。这里注意软复位前要把所有挂起的中断清掉防止复位过程被残留中断干扰。实际项目中我还遇到过软复位后 Bootloader 串口初始化失败的问题后来排查发现是复位前 DMA 没关干净导致复位后 DMA 仍在访问总线干扰外设初始化。所以建议在复位前统一调用RCC_DeInit()把所有时钟复位。4.4 健康检查与回滚上报App 端的健康检查决定了回滚机制是否有效。检查项可以根据产品特性设计但至少包括关键外设初始化是否成功、主循环是否正常运行、能否正常上报状态。如果设备有通信功能还可以在启动后尝试上报一次版本号服务器确认新版本在线App 再执行提交。提交操作其实就是写标志区把 update_status 从 READY 改成 COMMIT同时清掉 update_flag。这个动作要尽量放在启动流程的后期也就是所有关键初始化都完成、系统确认稳定运行之后。如果初始化过程中失败就不要提交让 Bootloader 在下一次复位时自动回滚。举个例子我有一个项目里电机驱动板的 App 启动后需要自检编码器是否正常如果自检失败就进入错误状态并且不复位也不提交。后来又加了一路看门狗自检失败就喂不了狗MCU 自动复位Bootloader 计数器加了两次后自动回滚。这样即使现场没人干预设备也能自己恢复到旧版本售后压力小了很多。5. 传输通道实现与扩展5.1 串口Ymodem本地升级在调试阶段和生产阶段我强烈建议先用串口Ymodem 方式验证 A/B 分区逻辑不要直接上网络。因为串口最简单、最容易排查问题把 A/B 切换的每一环都调通之后再换网络通道就只需要替换“接收固件”这一层其余逻辑完全不用动。Ymodem 协议本身有包序号、CRC 校验、结束帧等机制适合传输 192KB 的固件。上位机用 SecureCRT、Xshell 或者专门的 Ymodem 工具就行。在 F103 端Ymodem 接收的关键是处理好 128 字节和 1024 字节两种包长以及每个包的应答时序。有一些移植好的 Ymodem 库可以直接嵌入但建议至少读一遍源码理解包格式后面出了问题才能快速定位。串口波特率建议在调试阶段用 115200稳定后再考虑 460800。更高的波特率对时钟精度和线材要求都更高F103 的 UART 在 72MHz 主频下跑 460800 没问题但 USB 转串口芯片如果质量一般就容易丢字节。我实际测试过 115200 下传 192KB 固件大约需要 20 秒460800 下大约 5 秒对于 A/B 方案来说20 秒完全可接受毕竟升级时设备还在正常运行。5.2 升级服务器方案与网络下载到了产品化阶段OTA 一般走网络通道。F103 本身没有以太网但可以通过 ESP8266、ESP32 或者有线以太网模块扩展。最简单的方式是 F103 通过 UART 接 ESP8266ESP8266 做透传F103 发送 HTTP GET 请求下载固件。我记得有篇文章提到过ESP32 做 OTA 时会实时打印下载进度这个思路在 F103ESP8266 组合中也适用只是把下载的固件写到本地备份分区即可。服务器端我建议用 nginx 做静态文件托管。设备启动时先请求一个 version.json内容类似{ version: 1.2.3, app_file: app_v1.2.3.bin, file_size: 150000, file_crc32: 0xA1B2C3D4 }设备端解析 version.json发现版本号高于当前版本再请求对应的 bin 文件。下载过程中通过 HTTP Content-Length 或者自己管理长度来确认固件完整性。这种结构的灵活之处在于以后要增加签名校验只需要在 json 里加一个 sha256 或者设备端增加 RSA/ECDSA 验签逻辑传输层不用动。网络传输的固有坑是信号中断和服务器超时。我建议设备端实现断点续传或者至少做到“下载失败就删掉备份分区下次重新下载”。由于 A/B 方案本来就是双分区下载过程中原系统不受影响所以即使下载了一百次都失败设备依然在正常运行这种体验非常加分。5.3 升级包的生成与发布流程我专门写了一个小脚本编译完成后自动生成升级包。流程如下编译得到.bin文件。计算文件的字节数和 CRC32。生成 version.json。将.bin和version.json上传到服务器发布目录。这个小脚本看起来简单实际大大降低了发布时的出错率。以前人工计算 CRC 经常算错导致设备升级时反复校验失败排查起来很费劲。现在脚本一步到位发布流程 5 秒完成还能自动打上版本号。发布时还有一个细节旧版本兼容问题。如果设备批量升级是从 v1.0 直接升级到 v1.2而 v1.0 的固件接收逻辑有 bug可能会导致 v1.0 无法正确接收 v1.2 的升级包。这种情况只能制定分步升级策略先升到 v1.1再升到 v1.2。在 A/B 方案中旧版本只要还能正常运行升级通道就还有救不至于彻底无法升级。6. 从零复现的调试顺序与常见问题6.1 按这个顺序做少走弯路我建议严格按照下面的顺序来调每跑通一步再进入下一步否则问题叠加起来会非常难定位。第一步先只写一个最简单的 Bootloader强制跳转到 A 区固定地址A 区 App 点个灯。验证跳转机制和链接脚本没问题。第二步把 App 工程复制一份作为 B 区版本分别烧录到 A 和 B 两个分区Bootloader 通过修改 active_slot 来看能不能跳两边的 App。第三步实现标志区的读写和 CRC32 校验手动在调试器里修改标志位验证 Bootloader 的升级切换和回滚逻辑。第四步接上串口 Ymodem 接收把新固件写到备份分区验证整个升级闭环。第五步再加网络传输、服务器发布、断点续传等扩展功能。这个顺序的核心思想是先把最底层、最危险、最难查的问题解决掉比如跳转和向量表偏移然后再加入传输层。如果你一上来就写好几百行的网络下载代码然后直接烧录测试一旦出问题根本不知道是网络的问题还是 Flash 写入的问题排错成本会非常高。6.2 常见问题速查表实际操作中我从这些坑里一个个踩出来总结成表格现象可能原因解决办法跳转后程序跑飞/HardFault链接脚本基地址错误或向量偏移未设置检查 App 编译地址和 SCB-VTOR中断全部不响应向量偏移没设置或跳转后未设置 VTOR在跳转函数中设置 SCB-VTOR升级后校验一直失败CRC 计算范围包含 padding 字节用 image_size 字段限定计算长度Flash 写入失败目标地址跨页未擦除按 1KB 页对齐接收写入软复位后外设不正常DMA/外设未复位关闭跳转前调用 RCC_DeInit串口接收丢字节波特率过高或接收缓冲区太小降低波特率增加双缓冲新固件启动后自动回滚App 没有在预期时间内提交检查 App 端自检和提交逻辑标志区数据损坏写标志时掉电写两份冗余标志启动时取合法副本这个表里每一项都是我实际遇到过的。最坑的还是“跳转后中断不响应”有时候表现为串口没输出有时候表现为定时器突然不准如果你不知道是向量偏移的问题可能会白白浪费一整天去查外设配置。6.3 排障三板斧最后分享我的排障三板斧。第一板斧是串口日志Bootloader 和 App 各用不同前缀比如[BL]和[APP]每个关键步骤都打日志进入 Bootloader、读取标志、开始校验、校验完成、切换到分区、即将跳转。第二板斧是 LED 状态灯Bootloader 用红色 LED 表示等待升级绿色 LED 表示升级成功慢闪表示回滚。这样现场没有电脑也能用灯色判断状态。第三板斧是调试器读取标志区直接查看当前 active_slot、update_flag、boot_count 的值结合串口日志能迅速定位问题出在升级流程的哪一环。在这套方案调试到稳定之后我又加了一个小功能Bootloader 启动时如果检测到参数存储区的魔法数被破坏会自动使用 Flash 内部默认参数恢复并记录一条恢复日志。这个功能不是 A/B OTA 的必要部分但让整个产品在异常掉电场景下更加稳健。我自己的体会是A/B 方案不是一个“加一段代码”就能完成的点状改动它需要 Bootloader、App、发布脚本、服务器配合起来是一个完整的系统设计。每次看到设备升级时毫不慌张地下载、校验、切换我心里都会觉得当初多花的那几天时间非常值。