STM32F103 AB双分区OTA升级实战:Bootloader设计与回滚机制全解析 📅 发布时间:2026/9/12 5:34:43 👁 浏览次数: STM32F103 上做 AB 双分区 OTA这事我断断续续折腾了小半年从最初连 Bootloader 和 App 的 Flash 地址怎么划都搞不明白到最后把升级流程、回滚策略、断点续传这些坑一个个填平算是把整套逻辑跑通了。今天把整个从零复现的过程完整写出来包括我当时为什么这么设计、踩过哪些坑、哪些代码可以直接抄希望对正在做类似方案的人有点帮助。先说清楚这套方案到底解决什么问题。STM32F103 这颗芯片在量产产品里太常见了但传统的小容量单片机升级都是用串口 ISP、JTAG/SWD 这种离线烧录方式设备一旦装到现场再想升级固件就得派人带着烧录器跑一趟成本高、周期长。OTAOver-The-Air空中下载就是让设备通过网络、串口、蓝牙这类通信链路自己把新固件下载下来并完成升级不用人工介入。而 AB 双分区简单说就是把 Flash 里放固件的区域分成两个槽位一个跑当前版本另一个留给新版本升级时往空闲槽位写写完再切换启动这样即使新固件有问题旧版本还在随时可以回滚。这个方案适合谁参考正在用 STM32F103 做产品、需要在现场升级固件的工程师或者在学习单片机 Bootloader 原理的学生。我默认你至少能用 STM32CubeMX 建工程、会用 Keil 编译下载、对 Flash 读写有基本概念。如果你这些还不太熟建议先把基础过一遍再来看这篇不然有些步骤会卡住。我用的硬件是 STM32F103C8T6 最小系统板这是最便宜的型号Flash 64KBRAM 20KB。通信链路用的是串口因为 F103 没有以太网控制器Wi-Fi 模块又是通过串口转的本质上还是串口通信。你在实际产品里如果走 4G 模组、LoRa、蓝牙原理完全一样只要保证能把固件数据流式地喂给 Bootloader 就行。1. 整体设计与 AB 分区方案选型1.1 为什么是 AB 分区而不是其它方案在嵌入式固件升级领域除了 AB 双分区常用的还有单分区方案和带外部存储的方案。先说单分区这是最传统的做法Flash 里只有一份固件Bootloader 收到新固件后直接擦除原有 App 区域把新固件写进去写完跳转执行。这个方案省 Flash 空间实现也简单但有个致命问题——如果升级过程中断电、通信中断或者新固件本身有 bug设备就变砖了。因为旧固件已经被擦掉了新固件又不完整或不能跑没有任何恢复手段。带外部存储的方案是先把新固件完整收到外部 Flash 或 SD 卡里校验通过后再一次性搬进内部 Flash。这样做的好处是升级过程中即使断了旧固件还在跑不会中断业务坏处是硬件上要额外加存储芯片BOM 成本和 PCB 面积都上去了。对于 STM32F103C8T6 这种定位低成本、小资源的芯片很多产品根本不想加外部 Flash。AB 双分区是中间路线不需要外部存储Flash 空间允许的前提下把 App 区一分为二两个槽位轮流用。升级时 Bootloader 往空闲槽位写固件写完做校验校验通过就把启动标志切过去下次启动从新槽位跑如果校验失败或者新固件启动后上报异常就把启动标志切回旧槽位。整个过程中旧固件一直都在随时可以回滚。我当时评估下来AB 分区最大的优势是安全性和业务连续性兼顾——升级过程中旧版本全程可用新版本出问题能立即回滚而且不需要额外硬件。代价是 Flash 空间利用率低了但对 64KB 的 F103 来说只要固件控制在 28KB 以内就完全够用。1.2 Flash 空间怎么划分才能不打架STM32F103C8T6 的 Flash 是 64KB地址从 0x08000000 到 0x0800FFFF页大小是 1KB一共 64 页。要注意的是F103 的 Flash 不是按扇区管理的是按页擦除擦除粒度 1KB写操作支持 16 位半字和 32 位字。这个特性直接影响分区规划——分区边界最好按页对齐也就是 1KB 的整数倍否则管理起来非常别扭。我采用的划分如下分区起始地址大小说明Bootloader0x080000008KB0x2000启动管理和升级逻辑固定不变App A0x0800200028KB0x7000固件槽位 AApp B0x0800900028KB0x7000固件槽位 B参数区0x08010000 - 0x200末尾512B存放启动标志、版本号等Bootloader 放在最前面因为芯片复位后固定从 0x08000000 取向量表这个改不了。Bootloader 做 8KB 是因为我的升级程序要处理串口接收、Flash 擦写、CRC 校验8KB 编译出来大概 6KB 多留了一点余量。如果你要加加密、压缩、更复杂的协议Bootloader 得加做到 10-12KBApp 就得缩。两个 App 槽位各 28KB这个尺寸主要受限于 C8T6 的 64KB Flash。如果你的芯片是 F103ZET6512KB Flash那完全可以把分区调大App A/B 各给 200KB 都没问题。分区大小不是死的核心原则是Bootloader 够用 App 槽位能装下你的最大固件 参数区单独划出来三者之间留少量余量。参数区我放在 Flash 末尾而不是紧跟 App B有一个很实际的原因如果 App 版本膨胀你想把 App B 扩大可以直接往末尾方向扩参数区动一下地址就行但如果参数区夹在中间要扩 App 就得搬参数区麻烦。Flash 末尾 512 字节存的是启动标志、App 版本号、升级状态、CRC 值每次擦写这个区域寿命也要考虑F103 Flash 擦写寿命标称 1 万次实际按参数区这种频繁写场景最好用磨损均衡不过我实测下来一天升级 10 次用 3 年也就 1 万出头刚好卡在寿命边缘真要做到产品级建议还是加磨损均衡或者把参数区扩大到 2KB 以上分块轮写。1.3 为什么用串口而不是网口或 USBF103C8T6 没有内置以太网控制器也没有 USB OTG只有 USB Device 但做 Host 麻烦。如果做网口 OTA得外挂 SPI 接口的以太网控制器比如 W5500或者用 ESP8266 这类 Wi-Fi 模组转串口。我在原型验证阶段直接用串口原因有三第一串口是最低成本的验证链路一根 USB 转 TTL 线就能开始调不用搭网络环境。第二串口在工业产品里依然是极常见的现场调试口很多设备即使有网口也保留一个调试串口走串口 OTA 等于零额外硬件成本。第三调试方便一边跑升级一边在串口终端看日志出问题能立刻定位。但串口 OTA 有一个必须注意的问题速度。115200 波特率下实际有效数据吞吐大概 11KB/s传 28KB 的固件要 2.5 秒以上加上协议开销和 Flash 擦写时间全程大概 5-6 秒。这个时间在大多数场景下可以接受。如果你要追求更快可以用 460800 或 921600 波特率但要注意 STM32F103 的 UART 在高速率下对时钟精度更敏感如果用内部 HSI 时钟建议还是老老实实用 115200。我用的通信协议是自己定义的一个极简帧格式没有上 Ymodem 或者 XMODEM。原因是 Ymodem 协议虽然成熟但它有独立的文件传输会话管理嵌入到 Bootloader 里代码量偏大而且要处理各种状态机的边界情况XMODEM 又太简陋没有版本号、没有目标分区选择这些信息。自定义协议的好处是轻量、可控、随时加字段坏处是如果要和别的工具互通就得自己写上位机。我两种都做过这里先讲自定义协议因为它的逻辑最直观适合理解 AB OTA 的本质。2. 启动流程与分区切换的核心逻辑2.1 从复位到 App向量表重映射是关键中的关键STM32 上电复位后CPU 从 0x08000000 取出栈顶指针MSP从 0x08000004 取出复位中断向量然后跳转执行。在 Bootloader 方案里0x08000000 放的必须是 Bootloader 自己的向量表。Bootloader 完成升级判断后要跳转到 App这里最核心的动作是把向量表切换到 App 所在地址。如果你用的是标准外设库SPL需要操作的是SYSCFG-MEMRMP寄存器但在 F103 上这个寄存器叫AFIO_MAPR的 SWJ_CFG 位跟向量表无关。F103 实际支持的是通过设置SYSCFG-MEMRMP的MEM_MODE字段来重映射系统存储器但 F103 向量表重映射的正确方式是在跳转前把 App 的向量表地址写入SCB-VTOR寄存器然后把 MSP 更新为 App 向量表首地址存的值最后跳转到 App 的复位中断入口。如果是 HAL 库代码是这样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); // 关闭所有中断防止跳转过程中有中断进来走错向量表 __disable_irq(); // 更新向量表偏移 SCB-VTOR app_addr; // 设置主栈指针 __set_MSP(msp_value); // 跳转到 App 复位中断 void (*app_reset_handler)(void) (void (*)(void))reset_handler; app_reset_handler(); }这段代码有个容易踩的坑__set_MSP()之前最好把当前中断屏蔽干净否则跳转瞬间如果来了一个中断CPU 会尝试从新的向量表找中断服务函数这时候 App 还没有初始化中断服务函数可能访问了未初始化的外设直接 HardFault。我一般还会加上__DMB()指令保证内存访问顺序。然后 App 工程必须做的另外一件事是在编译时指定运行地址和向量表偏移。在 Keil 里Target 选项卡的 IROM1 起始地址改成你的 App 槽位起始地址比如 App A 就是 0x08002000大小填 0x700028KB。同时在 system_stm32f1xx.c 里把VECT_TAB_OFFSET改成对应的偏移量。我用的是标准外设库system_stm32f10x.c 里默认VECT_TAB_OFFSET是 0App A 要改成 0x2000App B 改成 0x9000。这一步漏了App 启动必死而且死法很怪——有时候能跑但是任何中断一进来就死机。调试时看到中断进不去优先检查这里。2.2 启动标志和分区轮转机制AB 分区的核心理念是当前有效分区和待升级分区是动态的。我设计了一个参数结构体存在参数区里typedef struct { uint32_t magic; // 魔数用来判断参数区是否有效固定 0xA5A5A5A5 uint32_t boot_count; // 启动次数计数器 uint32_t active_slot; // 当前生效的槽位0 表示 A1 表示 B uint32_t update_pending; // 是否有待确认的升级1 表示刚升级完等待确认 uint32_t app_crc; // 当前 App 固件的 CRC32 值 uint32_t app_size; // 当前 App 固件的大小 uint32_t app_version; // 当前 App 固件版本号 uint32_t bootloader_version; // Bootloader 版本号 uint32_t reserved[6]; // 保留字段 } ota_param_t;每次 Bootloader 启动时执行以下判断流程读取参数区检查 magic 是否有效。如果无效说明参数区从未初始化或者被破坏了直接执行出厂固件默认 App A。检查 update_pending 标志。如果为 1说明上次升级后没有收到 App 的确认消息此时要判断启动次数计数或确认超时机制如果超过设定次数判定新固件启动失败回滚到另一个槽位并把 update_pending 清零。如果 update_pending 为 0说明当前是正常启动直接跳转到 active_slot 指向的槽位。App 启动后正常完成初始化跑起业务逻辑会向上位机或服务端发送一条应用启动成功的消息。上位机收到后往参数区写一个确认指令或者通过另一条命令让 Bootloader 在下一次启动时清除 update_pending。当然实际产品里很少有上位机去清这个标志的更常见的做法是 App 自己通过 IAP 接口写参数区把 update_pending 清零。我在参考代码里两种都做了串口命令由上位机触发App 内部在 main 里延时 5 秒后自动确认。2.3 升级失败回滚是怎么做到的AB 分区最值钱的地方就是回滚。升级流程是这样的设备当前在 App A版本 1.0运行。上位机下发升级指令包含目标版本号和新固件长度Bootloader 或 App 根据当前 active_slot 判断应该往哪个槽位写——当前是 A就写 B。把新固件版本 2.0通过串口分块发送Bootloader 逐包接收、校验 CRC、写入 B 槽位。全部写完对 B 槽位的固件做整体 CRC 校验确认无误后更新参数区active_slot Bupdate_pending 1app_version 2.0app_crc 新 CRC。系统复位 Bootloader 发现 update_pending 1跳转到 B 槽位App 2.0 开始启动。App 2.0 正常启动后清除 update_pending完成升级。如果第 5 步之后App 2.0 根本起不来比如固件本身有 bug、恰好在启动早期崩溃App 无法清除 update_pending此时 Bootloader 在下一次复位时可能是硬件看门狗复位也可能是用户手动重启发现 update_pending 仍然为 1。我会在 Bootloader 里写一个计数器记录带 update_pending 标志的启动次数如果连续 3 次都没等到 App 确认就判定新版本不可用自动回滚到 A 槽位。这个启动失败连续计数的思路其实参考了 Android 的 A/B 机制只不过我用一个简单的计数器和超时逻辑模拟了它的核心行为。值得注意的是仅靠 update_pending 是不够的因为 App 可能启动后跑了几秒才崩溃这时候 update_pending 早就被清了系统会以为升级成功了。解决方式有几种最简单的是 App 内做业务巡检发现问题后主动通过 IAP 接口回滚或者做健康标志——App 在完成关键业务初始化后写一个我活着的标记Bootloader 每次启动检查这个标记。这个可以根据产品对可靠性的要求来选我后面会单独提到。3. 串口升级协议与 Flash 驱动实现3.1 通信指令集设计细节我定义的串口升级协议帧格式如下| 帧头(2B) | 指令(1B) | 长度(2B) | 数据(N B) | CRC16(2B) | | 0xAA 0x55 | CMD | Len | payload | CRC |帧头固定 0xAA 0x55用来做字节流同步。指令一字节长度两字节小端模式CRC16 覆盖从指令到数据的所有字节。上位机只需要按这个格式发Bootloader 在串口中断里一字节一字节收收满一帧再解析。核心指令集如下指令代码方向说明CMD_PING0x01上位机→设备建立通信设备回 PONG包含 Bootloader 版本和当前 App 版本CMD_ERASE0x02上位机→设备擦除目标分区参数为目标槽位CMD_WRITE0x03上位机→设备写入一帧固件数据参数含偏移地址和长度CMD_VERIFY0x04上位机→设备对 App 区做整体 CRC 校验返回结果CMD_ACTIVATE0x05上位机→设备设置分区切换更新参数区并复位CMD_CONFIRM0x06上位机→设备确认升级完成CMD_ROLLBACK0x07上位机→设备强制回滚到另一分区CMD_GET_INFO0x08上位机→设备查询当前各分区状态和版本内容每个指令的设备回复格式是帧头 0x81~0x8F响应指令 长度 状态码 数据 CRC。其中状态码做了区分0x00 表示成功0x01 表示 CRC 错误0x02 表示地址超范围0x03 表示 Flash 写失败0x04 表示未擦除写入前必须擦除0x05 表示指令非法。这样上位机排查问题非常直观不用猜。3.2 Flash 擦写时的关键约束F103 的 Flash 编程有几个硬约束我不止一次见过新手在这里翻车第一个写入前必须先擦除而且擦除粒度是页1KB。如果你往一个没擦除过的地址写数据结果全是 0xFF 和原始数据做 AND 的结果根本不是你要写的值。第二个写操作不能跨页。F103 的 Flash 编程是按 16 位半字或 32 位字写入的如果你在页边界附近写入跨越页边界的半字硬件会直接报错。所以写固件时每写一包数据我要检查当前地址和写入长度是否跨越页边界如果跨了就拆分写入。第三个CPU 执行 Flash 编程指令期间不能访问 Flash。这不是说你不能在中断里写 Flash而是说 CPU 在执行 FLASH_Program 函数的时候如果此时发生中断中断服务函数里如果访问了 Flash比如查表、执行位于 Flash 的代码就会导致总线忙写失败。极端情况是中断里调用的函数就在 Flash 里一进中断就卡死。解决方法是写 Flash 时关闭所有中断写完再打开或者把 Flash 写函数和中断服务函数都放到 RAM 里执行。ST 官方手册有说明在有中断的场景下推荐把编程函数复制到 RAM 执行。我用的是最省事的方式——写 Flash 是原子操作时间极短半字写入大约 20-40us关中断 40us 根本不影响业务。void flash_write_buffer(uint32_t addr, uint8_t *buf, uint32_t len) { // 确保地址按半字对齐 FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); __disable_irq(); for (uint32_t i 0; i len; i 2) { uint16_t half_word buf[i] | (buf[i1] 8); FLASH_ProgramHalfWord(addr i, half_word); } __enable_irq(); FLASH_Lock(); }3.3 传输层的连续性和分块策略串口 OTA 的传输层设计有一个核心矛盾帧太大一包传错重传成本高帧太小协议开销占比大速度上不去。我实测下来256 字节 一帧是比较舒服的折中。115200 波特率下256 字节有效数据加 7 字节帧头协议开销传输时间约 23ms处理时间 Flash 写 256 字节大约 5ms128 次半字写入整体速率约 10KB/s传 28KB 固件约 3 秒。每个 WRITE 帧里包含目标槽位、偏移地址和数据内容。Bootloader 收到后先做 CRC 校验校验通过再写 Flash写完后回复OK 已写入的结束地址。上位机收到确认后再发下一包。这样是停等协议简单可靠但效率低一些。如果想提高吞吐可以做成滑动窗口、连续发多包再统一确认但调试复杂度和异常处理难度都会上升我第一次做 OTA 的时候先用的停等稳定后才改的批量发送。有一个非常容易被忽视的细节App 在升级过程中是不能继续跑业务的因为 Flash 擦写操作会阻塞 CPU 一段时间而且我们擦写的可能正好是当前 App 正在执行代码的区域如果从 A 升级到 BA 还在跑没问题但如果从 A 升级时错选了 A 槽位那就是自杀式升级。所以在协议设计上CMD_ERASE 之后 Bootloader 会进入升级模式整个升级过程 App 处于暂停状态。对用户来说设备在这一两秒内是不可用的。如果你的产品对业务连续性要求很高比如正在控制电机、正在记录关键数据就需要考虑双 Bank 那种并行运行方案或者把 App 拆成两部分——一部分长期运行一部分可升级。当然这是更复杂的架构F103 的资源未必够多数场景下短暂停机是可接受的。4. 实操复现从空工程到跑通完整升级4.1 Bootloader 工程搭建我用的开发环境是 Keil MDK 5.x STM32F1 标准外设库 V3.5没有用 CubeMX因为 Bootloader 代码量不大手写更可控。工程结构上Bootloader 只需要初始化这几个外设串口 1115200 8N1PA9/PA10、一个 GPIO 用来做状态指示 LED、SysTick 做超时管理再加 Flash 驱动。Bootloader 的主循环逻辑精简后如下int main(void) { uart1_init(115200); led_init(); ota_param_load(); // 读取参数区到全局结构体 // 检查是否有升级请求串口在启动时等待 500ms收到 PING 就进升级模式 uint8_t enter_upgrade 0; uint32_t timeout systick_get_ms() 500; while (systick_get_ms() timeout) { if (uart1_receive_byte(ch) SUCCESS) { if (process_boot_cmd(ch) CMD_PING) { enter_upgrade 1; break; } } } if (enter_upgrade) { ota_server_loop(); // 进入升级服务循环处理所有指令 } else { // 正常启动流程判断分区和回滚 handle_boot_logic(); jump_to_app(app_addr); } }这里的核心思想是Bootloader 启动后短暂等待串口指令如果在超时时间内收到 PING就进升级模式否则直接启动 App。这个短暂等待的长短要根据产品定500ms 是经验值太短了上位机来不及发指令太长了每次重启都慢半拍。4.2 App 工程的移植配置App 工程是在普通 F103 工程基础上改的关键改动有三处第一Keil Target 里 IROM1 的起始地址改成 App 槽位的地址。App A 是 0x08002000App B 是 0x08009000。注意 IROM1 的 Size 写 0x7000不要超过分区大小否则编译出的 hex 会覆盖到参数区甚至另一个槽位。第二system_stm32f10x.c 里修改#define VECT_TAB_OFFSET 0x2000 /* App A 用 0x2000App B 用 0x9000 */我这里踩过一个很隐蔽的坑如果你用 Keil 的魔术棒勾选了Use Memory Layout from Target Dialog链接器会自动生成分散加载文件这时 VECT_TAB_OFFSET 和 IROM1 起始地址必须保持一致否则中断向量表错位App 有可能启动后一切正常但一有中断就死机。第三App 里需要预留一个接收升级指令并跳转 Bootloader的接口。我用的方式是在 App 里定义了一个专门的串口命令处理函数收到特定的升级指令后先把参数区写成进入升级模式状态然后执行NVIC_SystemReset()软复位。复位后 Bootloader 读到升级模式标志在启动时不会被 App 拖住直接进入升级服务循环。这里要说明的是App 触发跳转 Bootloader 的时候如果是从 A 升级到 BApp 本身还活着直接软复位没问题但如果是想升级 A也就是当前正在跑的分区那我们在 CMD_ERASE 指令发出时Bootloader 会先判断目标分区是否等于当前 active_slot如果相等就拒绝执行因为擦除正在运行的代码区域会直接导致 HardFault只能通过修改参数区让 Bootloader 下次启动切换槽位后再升级。这个保护逻辑非常重要。uint8_t ota_handle_erase(uint8_t slot) { if (slot g_ota_param.active_slot) { return ERR_CANNOT_ERASE_ACTIVE; // 0x06 } // 擦除目标槽位所有页 uint32_t start_addr (slot 0) ? APP_A_ADDR : APP_B_ADDR; uint32_t end_addr start_addr APP_SLOT_SIZE; for (uint32_t addr start_addr; addr end_addr; addr FLASH_PAGE_SIZE) { FLASH_ErasePage(addr); } return ERR_OK; }4.3 上位机发送脚本怎么写很多人以为 OTA 一定要写一个带界面的上位机其实验证阶段用脚本就够了。我用 Python 写了一个简单的升级脚本核心逻辑如下import serial import struct import zlib import time ser serial.Serial(COM9, 115200, timeout1) def send_frame(cmd, payloadb): frame b\xAA\x55 bytes([cmd]) struct.pack(H, len(payload)) payload crc zlib.crc32(frame[2:]) 0xFFFF frame struct.pack(H, crc) ser.write(frame) return ser.read(64) def wait_pong(): data ser.read(64) return data def ota_upgrade(hex_path): # 1. 建立连接 resp send_frame(0x01) # PING wait_pong() # 2. 擦除目标分区这里以升级到 B 为例 resp send_frame(0x02, struct.pack(B, 1)) # 擦除 B time.sleep(1) # 等待擦除完成 # 3. 分块发送固件 with open(hex_path, rb) as f: data f.read() addr 0x08009000 # App B 起始地址 chunk_size 256 for i in range(0, len(data), chunk_size): chunk data[i:ichunk_size] payload struct.pack(BI, 1, addr i) chunk resp send_frame(0x03, payload) # 检查响应状态码 # 4. 校验 resp send_frame(0x04, struct.pack(B, 1)) # 检查 CRC 结果 # 5. 激活新分区 resp send_frame(0x05) # 切换并复位这个脚本在 Windows 和 Linux 上都能跑串口号自己改。需要注意的坑是串口打开时的 DTR/RTS 电平有时候会把 STM32 的 BOOT0 拉高导致芯片进入 ISP 模式不自检。如果发现脚本连着但设备毫无反应先确认 BOOT0 跳线帽在低电平。4.4 编译地址烧写的坑与经验编译 Bootloader 和 App 的工程时有几个我踩过的坑这里集中记录第一个坑是 Bootloader 的 hex 和 App 的 hex 不能直接同时烧进去除非你精确控制烧录地址。正确做法是先用 ST-Link 把 Bootloader 烧到 0x08000000把 App A 烧到 0x08002000参数区保持空白全 0xFF。然后把 Bootloader 工程的 Flash Download 选项里勾选Erase Sectors但 App 工程的 Flash Download 要改成Erase Sectors并手动指定起始地址 0x08002000否则每次下载 App 都会从 0x08000000 开始擦把 Bootloader 擦掉。第二个坑是 Keil 烧录时如果勾选了Reset and Run下载完设备会立即跑 App但此时如果参数区的 magic 无效Bootloader 会执行默认启动逻辑跳转 App 时如果 App 的向量表设置正确能正常跑起来。但如果参数区被擦成全 0xFFBootloader 判断 magic 无效后要做一次参数区初始化把 active_slot 设成 0App A同时把 update_pending 清零。这个初始化代码一定要在首次下载后自动执行不然没有参数区一切免谈。第三个坑是调试时用 ST-Link 在线调 Bootloader跳转到 App 后调试器断点失效。这是因为跳转后 PC 已经跑到 App 地址而 ST-Link 的调试会话还停留在 Bootloader 的符号解析状态。解决方式是直接改成离线运行用串口打印日志来观察。5. 常见问题排查与可靠性加固5.1 我踩过的坑汇总现象原因解决办法升级后设备完全无法启动App 向量表偏移没设置或设置错误检查 system_stm32f10x.c 里 VECT_TAB_OFFSET 和 Keil IROM1升级到 50% 左右停住不动写入跨页导致 Flash 编程报错分块写入时做页边界检测跨页拆分写 Flash 时程序突然进 HardFault写 Flash 期间中断访问了 Flash写 Flash 期间关中断或用 RAM 执行函数升级完成后跳转新 App串口无输出新固件本身有 bug启动即崩溃加看门狗自动复位配合回滚计数串口偶发丢包115200 波特率时钟误差累积用外部晶振而不是内部 HSI或用 57600参数区被频繁写坏每次升级都擦写参数区寿命耗尽磨损均衡或降低写次数升级过程中断电再上电设备黑屏新固件写到一半参数区仍指向旧 App其实不该黑屏确认 upgrade_pending 只在全部写完并校验后置位Keil 下载把 Bootloader 冲掉了Flash Download 起始地址配错App 工程手动指定起始地址为 App 槽位起始5.2 回滚可靠性的更深一层设计前面提到的启动失败连续计数是基于参数区里一个计数器的。这个计数器只依赖 Bootloader 的代码逻辑但有一个漏洞如果新 App 每次都能启动起来但跑 5 分钟后才崩那 update_pending 早就被清了回滚机制不会触发。针对这种情况我补充了一个健康心跳设计。思路是这样的参数区分出一块区域专门存最近一次正常运行时间戳和运行正常标志。App 在完成初始化、跑进主循环后每隔 30 秒检查自己的业务是否正常比如传感器数据是否有效、任务调度是否正常如果正常就把参数区的健康标志置 1 并记录当前时间。Bootloader 每次启动时检查健康标志如果健康标志为 0 或者很久没更新说明上一版 App 没有正常运行直接回滚。实现上可以更轻量——不需要时间戳就用一个字节的正常启动确认标志App 启动后 3 秒内写入这个标志Bootloader 启动时如果发现 App 槽位刚切换过update_pending1且健康标志没写入就回滚。这个逻辑比计数器更贴近实际使用场景能覆盖启动即崩和运行后不稳定两种情况。5.3 升级断电中断怎么保证不砖有的工程师担心如果升级过程中突然断电Flash 写了一半是不是设备必砖其实在 AB 分区里这个问题天然被规避了。原因是参数区的 active_slot 只在固件全部写完并校验通过后才更新。断电时固件写到一半参数区指向的还是旧 AppBootloader 下次启动时发现 update_pending0active_slot 没变直接启动旧 App一切正常。但这里有一个前提参数区的写入必须是原子的不能在更新过程中断电导致参数区处于半写状态。F103 的 Flash 写入是半字粒度的一个结构体几十字节分多次写入如果写完前几个字节断电参数区可能变成一半新值一半旧值。所以我做了一个双缓冲参数区参数区占据 Flash 末尾两页每次更新先擦写备用页再擦写主用页主用页写完 magic 后备用页才生效。实际实现简化成每次把参数区备份到另一个地址先写新值再写 magic 字段Bootloader 读到 magic 无效时自动用备份。这个保护非常需要我早期没加这个有一次升级测试时刚好断电参数区被写坏设备反复异常重启。5.4 从 F103 到其它平台的可移植经验这套方案不只是 F103 能用。如果你换到 ESP32、GD32、HC32 这些芯片核心逻辑完全可以直接搬只需要改三处Flash 驱动地址、页大小、擦写函数、向量表重映射方式、中断保护策略。以 ESP32 为例它的 Flash 是 SPI 接口的外部 FlashBootloader 和 App 分区由分区表管理不需要手动改 VTOR。但它的 OTA 机制本身就更强大有esp_ota_ops这个现成的 API直接调用esp_ota_write和esp_ota_set_boot_partition就行完全不用自己造轮子。反而是 F103 这种没有硬件 OTA 支持的芯片更能帮人理解 OTA 的本质——分区、引导、切换、回滚这些概念在哪个平台都一样。换到 GD32F103 时要注意GD32 的 Flash 虽然是兼容 STM32 的但有些型号的页大小、擦除时间有差异而且 Flash 操作的解锁 key 可能不同移植时一定要查对应数据手册。如果是 GD32E103 或者 GD32F303它们的 Flash 性能更强支持双 Bank 并行操作但逻辑上仍然是分区切换那一套AB 分区的思想可以直接复用。6. 完整复现清单与经验心得最后我把整个复现过程整理成一个清单照着做基本不会跑偏画好 Flash 分区表用 Excel 算清楚每个分区的地址和大小确保页对齐。建 Bootloader 工程初始化串口、LED、SysTick实现参数区读写和启动逻辑。建 App 工程改 IROM1 起始地址和 VECT_TAB_OFFSET。用 ST-Link 把 Bootloader 烧到 0x08000000App A 烧到 0x08002000。串口连接用 Python 脚本发 PING确认 Bootloader 通信正常。写一个最简单的 App 升级包blinky 级别测试完整升级流程。加入参数区更新和 update_pending 逻辑测试正常升级、升级失败回滚、断电中断三种场景。加看门狗、健康标志、双缓冲参数区等可靠性加固。反复做断电测试推荐用继电器控制电源随机时间断电直到连续 100 次不出问题。整个项目做下来我最大的体会是OTA 看起来是个升级功能本质上是个可靠性工程。真正花时间的不是把固件从串口写到 Flash而是把所有异常场景想清楚——升级到一半断电怎么办、新固件起不来怎么办、参数区写坏了怎么办、用户重复触发升级怎么办。每一步设计都要围绕即使出错了也要让设备能恢复到可用状态这个目标。另一个让我印象深刻的是调试方法的问题。Bootloader 和 App 是两个独立的工程调试时要频繁切换非常容易晕。我后来用了一个技巧在 Bootloader 和 App 的串口日志里都加上统一的模块前缀Bootloader 打[BL]App 打[APP]这样看日志能一眼确认当前跑的是哪个程序。另外每次做分区规划变更都要同步更新 Bootloader 里的地址宏、App 里的 Keil 配置、Python 脚本里的地址常量三处漏一处就出事。这个三处同步的教训是我踩过最多次的坑。如果后续想扩展可以在协议层加固件加密和签名认证防止固件被篡改可以在传输层加断点续传应对网络不稳定场景可以在 Bootloader 里支持多协议比如同时支持串口和 CAN 总线升级。F103 的资源有限但把这些都装进一个精心设计的 Bootloader 里8KB 确实有点紧我建议如果功能需求多考虑升级到 F105/107 或者直接用带双 Bank 的芯片。不过话说回来用一颗 3 块钱的 F103 就能实现 AB OTA这本身就说明这套方案的实用价值很高。