
从去年开始陆陆续续有不少同行在群里问 STM32F103 怎么做 AB_OTA翻来覆去都是同样几个问题BootLoader 和 App 怎么切、分区怎么划、升级到一半断电怎么办、跑飞的 App 怎么回滚。这里我把在 F103 上完整跑通的 AB_OTA 方案从头到尾梳理一遍。这不仅是贴代码更重要的是把设计思路和踩坑过程讲清楚。AB_OTA 说白了就是让单片机用两个 App 分区互为备份升级时写入空闲分区失败后能自动退回原分区对大批量部署的设备来说这套机制能把售后成本压得非常低。适合正在做 IAP 升级、远程固件维护或者被“升级一次变砖一次”折磨的朋友参考。1. 为什么说 AB_OTA 比传统单分区升级更适合 F1031.1 传统 BootLoader单 App 方案到底差在哪早期做单片机 IAP最常见的做法是 BootLoader 占一块 Flash后面跟一个 App 区。升级的时候上位机把新固件直接写进 App 区写完后跳转。这个方案代码量少逻辑也简单但它有一个天然的隐患整个设备只有一个 App 区写入过程中任何一步出问题比如串口传输出错、意外断电、新固件本身有逻辑 bug都会导致设备直接没法启动。这种“升级变砖”的情况在产线上还能用 ST-Link 重新烧但在用户手里就非常麻烦。尤其是做物联网设备、智能传感器、小家电控制板的设备分布在各个现场没人愿意为了刷个固件专门跑一趟。传统方案想解决这类问题只能在 App 里再做一层网络引导恢复代码复杂度一下子就上去了效果还不一定好。1.2 AB 分区的核心思想始终留一手可用的备份AB_OTA 的思路其实特别朴素既然一个 App 不够稳那就放两个。平时设备运行在 A 区需要升级时就是把新固件写到 B 区校验通过后再切换启动标志下次复位从 B 区启动。反过来也一样。整个过程不覆盖当前运行的程序所以哪怕升级过程中断电、传输出错顶多是 B 区数据不完整A 区还是好好的BootLoader 检测到 B 区写入失败就继续从 A 区启动。你可以把它类比成电脑装系统时先做镜像备份老系统没删新系统装坏了还能切回老系统接着用。区别在于单片机上的“备份镜像”就是另一个 Flash 分区切换操作靠 BootLoader 里的标志位完成。AB 方案最值钱的地方就是天然自带回滚能力不带任何额外硬件成本靠的多划一块 Flash 区域。1.3 F103 的资源约束和分区规划STM32F103 是 Cortex-M3 内核72MHz 主频大家用得最多的 C8T6 是 64KB FlashCBT6 是 128KB Flash。做 AB_OTA 之前要先想清楚一个现实问题Flash 够不够装下两份 App。拿 C8T6 举例我的划分方式是分区起始地址大小用途BootLoader0x080000000x300012KBIAP 引导、升级收发、状态判断App A0x080030000x600024KB当前运行分区 AApp B0x080090000x600024KB备份分区 B升级目标参数区0x0800F0000x10004KB启动标志、版本号、升级状态为什么 BootLoader 只给 12KB因为串口引导代码本身不大加上精简的协议解析12KB 完全够。24KB 的 App 空间对控制类应用来说也算宽敞但如果你的工程用了全套 GUI 库、文件系统这类大组件24KB 肯定不够那就直接换 128KB 的 CBT6或者继续按比例扩大分区。F103 的中文参考手册里第 3 章就详细写了 Flash 的扇区划分和擦写限制规划前先翻一翻比自己瞎试省时间得多。2. BootLoader 设计跳转很容易跳得稳才见功夫2.1 上电后 BootLoader 的判断流程我实现的 BootLoader 启动流程是这样的上电后第一步读参数区的状态标志根据标志决定是直接跳 App 还是留在 BootLoader 里等待升级。参数区放在 0x0800F000里面存了三个关键字段启动标志 magic、当前激活分区 active_slot、升级状态 update_state。这里最核心的一点是所有状态变化都要遵循“先写意图再写结果”的原则。比如 App 收到升级指令后不会直接复位而是先在参数区写入一个“升级请求”标志然后再软复位。BootLoader 上电一看这个标志就知道用户想要升级于是进入升级模式。等固件接收完成、校验通过之后才把状态改成“切换分区”下次启动就跳新分区。这套流程避免了“只发指令不复位”“复了位不知道要干嘛”的混乱局面。标志位的值我建议用 32 位整数的固定魔数不要用 0 和 1 这种太容易误写的值。我自己的定义是#define BOOT_MAGIC_NORMAL 0xA5A5A5A5 // 正常启动 #define BOOT_MAGIC_UPDATE 0x5A5A5A5A // 升级模式 #define BOOT_MAGIC_VERIFY 0xC33C3CC3 // 新固件已写入等待校验 #define BOOT_MAGIC_OK 0x3C3CC33C // 新分区激活成功标志位写入后最好连续写两份或者写到两个不同的位置做冗余防止 Flash 写了一半掉电导致标志位是脏数据。我是在参数区里每隔 64 字节放一份备份读的时候优先读第一份发现值不合法再读第二份。这个技巧成本极低但非常有效。2.2 跳转 App 的边界检查与代码实现跳转看起来就是一行函数指针调用但实际工程里必须做边界检查。我在跳转函数里做了两个硬性判断从 app_addr 读出的栈顶指针必须在 0x20000000 到 0x20010000 的 RAM 范围内从 app_addr 4 读出的复位向量地址必须在 0x08000000 到 0x08020000 的 Flash 范围内。这两个条件任何一个不满足都说明这个地址里不是合法固件强行跳转只会跑飞。检查通过后再执行跳转void jump_to_app(uint32_t app_addr) { uint32_t app_sp *(volatile uint32_t *)app_addr; uint32_t app_pc *(volatile uint32_t *)(app_addr 4); if ((app_sp 0xFFF00000) ! 0x20000000) { return; } if ((app_pc 0xFFF00000) ! 0x08000000) { return; } __disable_irq(); SysTick-CTRL 0; /* 关闭所有外设时钟、恢复默认系统状态 */ RCC_DeInit(); /* 清除挂起的中断防止跳到 App 后响应残留中断 */ NVIC_SetPendingIRQ(0); memset(NVIC-ICPR, 0, sizeof(NVIC-ICPR)); SCB-VTOR app_addr; __set_MSP(app_sp); ((void (*)(void))app_pc)(); }这里每一步都有讲究关全局中断是为了防止跳转瞬间杀出一个中断SysTick 停掉是避免跳到 App 后旧的中断服务函数还在响应清 pending 中断是防止之前积压的中断请求在 App 开启中断后立刻触发。只要漏掉其中一个就可能出现“跳过去了但系统不稳定”的玄学问题。2.3 App 工程里的中断向量表偏移设置跳转过去只是第一步App 自己也得配合。F103 的中断向量表默认放在 Flash 起始地址 0x08000000也就是 BootLoader 所在的位置。如果 App 编译时把起始地址改到了 0x08003000 而不修改向量表位置那么 App 里任何中断串口、定时器、外部中断触发时CPU 都会去 0x08000000 附近的旧向量表找入口结果就是中断全部无效。解决办法是在 App 的 main 函数最早位置设置 VTOR 寄存器的值SCB-VTOR 0x08003000; // 必须和 App 编译时的 IROM 起始地址一致这里提醒一句如果用了 STM32CubeMX 自动生成的工程部分版本会在 SystemInit 或 HAL_Init 里重设 VTOR所以最稳妥的做法是在 main 函数一进来就手动赋值保证在开启任何中断之前生效。我在开发时还踩过一个坑用标准库 V3.5 时 SCB-VTOR 定义位于 core_cm3.h编译没问题但低版本 MDK 未定义 __CM3_REV 可能会导致 VTOR 相关宏不可用把 MDK 升级到 5.3x 或直接用 CooCox 移植版本就能解决。2.4 回滚机制升级完跑不起来怎么办光有 A/B 两个分区还不够还要考虑新固件本身有问题的情况。如果新固件写入成功也跳过去了但运行不到一秒就 HardFault设备卡死了这时候靠什么恢复我的做法是引入“新分区确认”机制BootLoader 在升级完成后先把新分区标记为“待确认”下次启动跳到新分区App 启动后如果各项自检通过、能正常执行主循环就主动往参数区写入“运行正常”标志。如果 App 根本起不来设备会触发独立看门狗 IWDG 复位BootLoader 再次上电发现新分区没有“运行正常”确认就直接回滚到旧分区启动。// App 自检通过后调用 void ota_confirm_ok(void) { uint32_t state read_param(BOOT_PARAM_UPDATE_STATE); if (state BOOT_MAGIC_VERIFY) { write_param(BOOT_PARAM_UPDATE_STATE, BOOT_MAGIC_OK); } }这段逻辑很关键。如果没有这个确认机制哪怕 AB 分区都在新固件起不来时 BootLoader 还是会反复往坏分区跳用户体验和单分区方案没有本质区别。加了确认和看门狗之后设备最多就是复位重启两三次自己就回到旧分区继续跑整个过程不需要人工介入。3. 升级通道设计串口协议、Flash 写入和状态机3.1 一套够用的固件包格式升级不能直接拿裸 bin 文件丢给 BootLoader至少得带版本号、长度、校验信息否则设备不知道这个文件是给谁的、有没有被传错。我给固件包定义了一个很轻量的头部#pragma pack(push, 1) typedef struct { uint32_t magic; // 0xAA55AA55 uint32_t version; // 固件版本号 uint32_t bin_len; // bin 文件长度 uint32_t bin_crc32; // bin 文件的 CRC32 uint32_t target_slot; // 保留字段设备端自动计算 } ota_header_t; #pragma pack(pop)magic 用来识别这是不是自己家的升级包version 用来避免重复升级bin_crc32 用于校验整个固件内容是否正确。bin 区和头部之间可以预留一定对齐空间方便 BootLoader 直接按页写入。我这里做了一个对齐到 4 字节的处理避免 CRC 计算和 Flash 写入时因为边界错位出现问题。3.2 串口收发包一包一 ACK 的可靠性策略升级通道我用的是 USART1115200-8-N-1。串口传 Flash 数据最大的瓶颈在于F103 擦写 Flash 时 CPU 会被暂停因为 Flash 控制器忙时取指要等待这个过程中串口中断无法及时响应。如果你用裸串口中断收一整包几百字节的数据Flash 一擦写缓冲区立刻溢出丢包立刻出现。所以我的通信协议设计成“一包一 ACK”的滑动方式每包 256 字节。BootLoader 先把这一包完整收进 RAM 数组接着关闭中断把数据写入 Flash写完后重新打开中断向上位机回一个 ACK。上位机收到 ACK 才发下一包。这样 Flash 擦写造成的短暂“盲区”不会影响数据完整性最多就是慢一点稳定第一。typedef enum { OTA_CMD_HEADER 0x01, OTA_CMD_DATA 0x02, OTA_CMD_VERIFY 0x03, OTA_CMD_BOOT 0x04, OTA_ACK_OK 0x10, OTA_NAK_ERROR 0x11 } ota_cmd_t;用串口调试助手就能直接调试命令格式是帧头 命令 长度 数据 CRC16。有人可能觉得再加一层 CRC16 有点重复但头部信息和固件内容的 CRC32 是最后校验用的链路层的 CRC16 是为了保证每一帧传输不错位两者解决的问题不一样建议都保留。3.3 Flash 擦写的关键代码模板F103 的 Flash 是按页擦除的不同型号页大小不一样C8T6 是 1KB 一页。写入最小单位是半字16位写之前必须保证目标地址已经擦除为 0xFF否则写入结果不可预期。Flash 擦写函数在标准库里的写法比较固定void ota_flash_erase_pages(uint32_t addr, uint32_t len) { FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); for (uint32_t offset 0; offset len; offset FLASH_PAGE_SIZE) { FLASH_ErasePage(addr offset); while (FLASH-SR FLASH_SR_BSY); } FLASH_Lock(); } void ota_flash_write_buf(uint32_t addr, uint8_t *buf, uint16_t len) { FLASH_Unlock(); __disable_irq(); for (uint16_t i 0; i len; i 2) { uint16_t half_word (uint16_t)(buf[i] | (buf[i 1] 8)); FLASH_ProgramHalfWord(addr i, half_word); while (FLASH-SR FLASH_SR_BSY); } __enable_irq(); FLASH_Lock(); }Flash 写入期间关闭中断这个操作必须在整个写入循环外围做好不要在每一句里反复开关。另外擦除和写入之后都要做一次回读比对确保数据真实写入成功而不是仅仅收到 FLASH_COMPLETE 返回值。很多量产设备出问题都是栽在“返回值对但数据不对”这个坑上。4. 从零复现 AB_OTA工程配置和完整验证过程4.1 准备环境和工具链我复现时用的配置是一块 STM32F103C8T6 最小系统板、ST-Link V2、Keil MDK5、标准外设库 V3.5.0也就是常说的库 v3.50、STM32CubeProgrammer 用于下载。文档方面重点看 STM32F103 中文参考手册的 Flash 和 USART 章节以及启动文件 startup_stm32f10x_md.s。如果是新手先把最小系统板跑一个点灯例程确认板子、仿真器、下载工具都正常再开始做 AB 工程。最好把 BootLoader 和 App 分成两个独立的 Keil 工程文件不要放在同一个工程里因为它们的链接脚本和起始地址完全不同合在一起管理容易乱。4.2 BootLoader 工程的 Flash 地址配置BootLoader 工程在 Keil 里不需要改代码逻辑但必须把 Flash 起始地址和大小配对。打开 Options for Target - Target 页面修改 IROM1IROM1 Start: 0x08000000IROM1 Size: 0x3000IRAM 保持 0x20000000Size 0x5000。编译后生成 boot.hex。注意 BootLoader 的启动文件和普通工程一样用到中断的模块尽量精简到最少我建议只保留串口和定时器其他外设全关。4.3 App 工程的 Flash 起始地址和链接配置App 工程要改两个地方。第一IROM1 改成IROM1 Start: 0x08003000IROM1 Size: 0x6000第二在 main 函数的开头加上SCB-VTOR APP_A_ADDR;。如果用的是 CubeMX 生成的 HAL 工程路径是 SystemInit 或 HAL_MspInit但保险起见还是要在 main 里再设一次。编译前记得打开工程里的 Map 文件选项。编译完成后打开 map 文件查看 Total RW Size Total ROM Size确认 App 的实际占用没有超过 0x6000。我见过很多朋友在分区大小上翻车App 编译出来 28KB但分区只分了 24KB烧进去没问题升级时直接把后面的分区给覆盖了排查起来非常隐蔽。4.4 用 Python 给 bin 文件加升级头我写了一个简单的 Python 脚本编译完 App 后在命令行跑一下就能生成带 ota_header 的升级包供上位机直接发送。import struct import binascii app_bin app.bin ota_bin ota.bin version 0x00000002 payload open(app_bin, rb).read() crc binascii.crc32(payload) 0xFFFFFFFF header struct.pack(IIIII, 0xAA55AA55, version, len(payload), crc, 0) with open(ota_bin, wb) as f: f.write(header) f.write(payload) print(OTA package generated: %s, size%d, crc320x%08X % (ota_bin, len(header) len(payload), crc))注意这里用IIIII代表小端模式下的 5 个 32 位无符号整数F103 是小端处理器上位机打包时也必须按小端处理否则 BootLoader 读出来魔数就对不上。4.5 一次完整的升级验证我建议按下面的顺序做一次全流程验证把旧版本 App 烧到 A 区地址 0x08003000让它正常运行用串口发送指定字符串触发升级请求比如协议命令OTA_STARTApp 收到后写参数区标志并软复位BootLoader 上电检测到升级标志进入升级模式上位机发送 ota.bin观察调试串口打印进度Header OK、Erase OK、Write OK、Verify OK、Switch OKBootLoader 跳转到 B 区新版本 App 启动串口打印 new version run再升级一次观察目标分区自动切回 A 区。我在实际测试中还故意做了破坏性实验在写入 50% 的时候直接断电重新上电后设备从 A 区正常启动完全不理会 B 区残留数据。这一步验证通过才说明这套方案真正具备断电恢复能力。5. 常见问题排查技巧实录5.1 跳转到 App 后 HardFault 或白屏出现这种情况先按顺序排查三件事。第一跳转前有没有__disable_irq()没关中断就跳到 App旧中断服务函数会瞬间触发大概率跑飞。第二App 有没有设置SCB-VTOR没设置的话所有中断入口都是错的。第三跳转前读出来的栈顶指针是否落在 RAM 范围内检查*(uint32_t *)0x08003000的值是不是 0x2000xxxx 开头如果不是说明 App 固件根本没烧进去或者地址不对。还有一个容易忽略的地方如果 BootLoader 里用了看门狗记得跳转前把看门狗关掉或者喂一次狗再跳。否则看门狗在跳转瞬间溢出App 起来刚初始化到一半就被复位。5.2 串口升级时频繁丢包、校验失败遇到丢包问题不要一开始就怀疑 Flash 写入先把发送节奏放慢。用一包一 ACK 的协议实测是最稳的每发一包等 ACK 再发下一包任何一边不对就重发三次。如果还丢检查串口接收缓冲区大小我固定用 512 字节的缓冲区而每包数据只有 256 字节留出一倍余量。另外如果工程里同时开了 DMA 接收和串口中断要确认两者的触发优先级不会打架。DMA 收完一包后 CPU 只负责把数据搬到 RAM 数组然后立即关串口 DMA、做 Flash 写操作写完再重新打开 DMA。我在调试的时候就是因为 DMA 配置成循环模式导致缓冲区被覆盖了好几次才发现问题。5.3 升级成功后第二次升级失败很多人在第二次升级时翻车因为目标分区算错了。有一种常见做法是上位机的固件包头里带上 target_slot 字段让 BootLoader 按这个字段写。问题是如果上位机服务端忘记切换 slot第二次升级还会写同一个分区把正在运行的 App 直接覆盖掉。我建议设备端自己维护“当前激活分区”变量每次生成升级目标时动态计算A 区激活就写 BB 区激活就写 A。这样上位机只需要发 bin 文件完全不需要关心写入哪个区域逻辑更清晰也不容易写错。参数区里的 active_slot 字段就是为这个准备的。5.4 断电恢复失效、状态标志错乱断电恢复失效大部分原因是标志位写入的代码顺序不对。正确顺序是App 收到升级指令先写入“升级请求”标志BootLoader 开始接收固件前写入“升级中”标志写入完成并 CRC 校验通过写入“新分区待确认”标志新 App 自检通过并确认写入“运行正常”标志。任何一个环节断电BootLoader 都应该根据当前标志做出对应处理。比如在“升级中”断电BootLoader 应该擦掉不完整固件并继续走旧分区。如果发现自己的标志状态只有“0”和“1”两种那断电恢复逻辑大概率写不完整建议把状态拆细一点。5.5 如何从彻底损坏的状态恢复如果 BootLoader 本身也写坏了或者分区表被改得面目全非那就别指望 OTA 自救了。老老实实用 ST-Link 连接 SWD 接口读取芯片选项字节确认读保护没有开然后执行全擦除重新烧 boot.hex 和 app.hex。这里提醒做量产的朋友出厂前最好把 BootLoader、App、参数区都烧录并完整验证一遍再封箱不要只烧 App。写在最后的个人实操体会AB_OTA 这套东西原理并不复杂本质就是“多一个分区、多一点标志位、多做几次校验”。但真正把它跑到稳定花时间的全是边界条件。我最开始做的时候总觉得分区表、跳转地址、CRC 校验这些搞定了就完事结果第一次断电测试就翻车后来才一点点把状态机补完整。我个人建议做这类功能一定要把“断电测试”当成常规测试用例每次改完代码都断几次电看看比写一百遍代码都管用。如果把这套方案迁移到其他型号比如 GD32F103、AT32F413思路完全一致只需要核对各型号的 Flash 页大小和烧录保护位不同即可。最后再分享一个小技巧在 BootLoader 和 App 之间用同一个串口打印协议设计一套简单的调试命令比如version、set_slot、dump_param排查问题时会轻松非常多。