STM32F103 AB双分区OTA升级方案详解与实战
发布时间:2026/9/6 8:46:24 锦皓数字建站

1. 为什么STM32F103要做AB双分区OTA1.1 一次现场升级失败的教训先说说我为什么非要折腾AB双分区。之前做过一个用STM32F103做主控的采集设备部署在客户现场几十台固件升级用的是最原始的单区IAP方案——bootloader把新固件写进APP区写完后跳转执行。听起来没问题对吧直到有一次客户升级过程中断电APP区写了一半校验失败bootloader直接死循环拒绝启动设备变砖。只能派人带着ST-Link去现场拆机烧录来回折腾两天被现场负责人骂到怀疑人生。从那之后我就学乖了凡是不带备份机制的单区OTA本质上都是赌博。AB双分区方案能从根本上解决这个问题——升级失败、断电中断、固件写坏最坏情况就是回滚到上一个正常版本设备不会变砖。1.2 AB分区到底是什么AB双分区也叫双备份OTA核心思想是在Flash里划出两个大小相同的固件区A区和B区。当前运行在A区升级时就往B区写入新固件B区写完校验通过后切换启动标志让设备下次从B区启动如果B区校验失败或者启动后运行异常自动回滚到A区。整个过程对使用者来说是无感的设备永远有至少一个可用固件版本兜底。用一句话概括单区OTA是要么升级成功要么变砖AB OTA是升级失败就回退永远跑得起来。1.3 适合用什么条件和芯片型号AB双分区OTA对芯片的Flash容量有硬性要求需要两个完整固件区的空间加上bootloader和参数存储区。以STM32F103为例我的建议是芯片型号Flash容量固件大小限制是否适合AB方案STM32F103C8T664KB固件≤24KB勉强可行需精打细算STM32F103RBT6128KB固件≤48KB推荐STM32F103VET6512KB固件≤224KB非常宽裕STM32F103ZET6512KB固件≤224KB非常宽裕注意一个坑STM32F103C8T6虽然标称64KB Flash实际芯片内部是128KB的很多人在64KB容量限制下觉得没法做AB其实是能做的。但这属于钻芯片空子正规产品设计我不建议依赖这一点毕竟ST官方不保证这个行为。1.4 整个方案的核心能力清单在开始动手之前先明确这个教程要复现出来的完整能力支持串口传输固件升级包本教程用USART1可根据实际自由换升级包带CRC校验损坏数据直接拒绝支持AB双区切换当前运行区带标志位管理升级失败自动回滚设备永远有可用固件支持查询当前运行区、升级进度等状态信息2. 整体设计与环境准备2.1 系统层级划分我的做法是把整个系统分成三个独立工程Bootloader引导程序放在Flash最开头负责设备上电时检查运行标志、决定跳转到A区还是B区、接收并写入升级包、擦写Flash。这个工程编译出来的体积控制在8KB以内。APP_A / APP_B应用程序两份完全相同的固件逻辑只是编译时链接地址不同分别放A区和B区。APP负责响应升级指令、接收串口数据、触发跳转等。因为AB两区代码逻辑一样实际维护时只需要维护一份源码编译两次改个宏定义就行。上位机工具电脑端的Python脚本或图形工具负责读取升级文件、分包发送、显示进度。这个不是必须的用串口调试助手也能发但有脚本测试效率会高很多。2.2 开发环境搭建清单我用的是标准外设库std v3.5不是HAL库。原因很简单bootloader对代码体积和时序要求高标准库更直接网上大量参考资料也是基于标准库的遇到问题好查。需要准备的东西Keil MDK 5.x用的5.30其他版本也可以STM32F103标准外设库V3.5ST官方老资源站内到处能下到ST-Link V2下载调试用国产的也能用USB转串口模块推荐CH340方案便宜稳定万用表检查接线用别省一个能跑起来的LED闪烁例程验证基本环境2.3 Flash空间规划这是整个方案最基础的一步规划不好后面全是坑。我以STM32F103RBT6128KB Flash为例做一个典型规划区域起始地址大小说明Bootloader0x0800000016KB引导程序升级接收代码参数存储区0x080040002KB存运行标志、升级标志、版本号等固件A区0x0800480048KBAPP_A运行区之一固件B区0x0801080048KBAPP_B运行区之一保留0x0801C800剩余可存日志、备用参数等固件区选择48KB是因为我的APP编译出来大概35KB左右留了40%余量用于后续功能迭代。你根据自己的固件实际体积来定建议固件区大小至少是编译产物的1.3倍太紧凑的话升级一个功能就要重新规划地址很折腾。3. 分区管理与状态机设计3.1 状态标志位怎么定义和存储AB双分区的核心在于当前该从哪个区启动。这个信息必须持久化存储而且还要能识别升级进行中升级完成启动失败等各种状态。我定义了一个结构体放在参数区typedef struct { uint32_t magic; // 魔数固定为0xA5A5A5A5用于识别参数是否有效 uint8_t active_slot; // 0或1表示当前运行哪个区 uint8_t pending_slot; // 待升级的目标区0或1 uint8_t boot_count; // 连续启动计数用于启动失败检测 uint8_t update_status; // 0-空闲, 1-升级中, 2-升级完成待重启, 3-启动失败 uint32_t firmware_version; // 固件版本号 uint32_t crc32; // 整个结构体的CRC32校验值 } OtaParams;这里有个关键设计boot_count用来做启动失败自动回滚。机制是这样的每次从A区或B区启动时bootloader先把启动计数加1如果APP在正常运行后比如运行10秒后主动把计数清零说明这次启动是正常的如果APP崩溃复位计数没被清零bootloader下次启动发现计数超过阈值比如3次就判定这个区有问题自动切换到另一个区。3.2 状态机流转逻辑升级过程中的状态流转是整个系统的灵魂空闲状态 │ │ 收到升级请求 ▼ 升级中状态目标区正在擦写 │ │ 数据全部接收且CRC校验通过 ▼ 待重启状态将启动标志指向新固件区 │ │ 复位重启 ▼ 切换到新固件区启动 │ ├── 启动正常运行定时清除boot_count │ → 升级成功进入空闲状态 │ └── 启动失败/看门狗复位/boot_count超限 → 回滚到旧固件区进入空闲状态在参数区存储上我用了双备份机制——在参数区里存两份完全相同的OtaParams每次写入时先写一份校验成功后再写第二份。读取时如果第一份CRC校验失败就读第二份第二份也失败就用默认值从A区启动。这是防止参数区在写入过程中掉电崩溃的兜底手段。3.3 固件区与参数的擦写保护STM32F103没有真正的Flash硬件写保护机制可以用在运行时保护某个区域但可以通过选项字节Option Bytes设置读写保护。实际开发中我一般不启用RDP读保护因为调试太麻烦。如果做正式产品建议至少启用level 1级别的读保护防止固件被读出来逆向。擦写Flash时有个必须注意的点千万别在Flash代码区运行时擦写同一片Flash的其他扇区而不做处理。比如你正从A区运行擦写了B区这没问题但如果代码在Flash里的位置和擦写目标重叠就会触发硬件错误HardFault。业余项目最常见的翻车场景就在这。升级代码要么放在bootloader区要么把Flash操作相关函数放在RAM里执行二选一。我的方案是把升级核心逻辑放在bootloader里APP只负责接收数据并通过标志位触发跳转从设计上杜绝这个问题。3.4 关于扇区大小和擦除方式的细节STM32F103的Flash扇区页大小不是全片统一的小容量和大容量芯片有区别低于512KB Flash的型号前4页每页1KB后面的页每页2KB512KB及以上容量的型号前4页每页16KB后面的页每页64KB擦除必须按页来不能只擦一部分。所以如果你的固件A区起始地址从0x08004800开始这其实跨越了页边界0x08004000到0x08004400是1KB页0x08004400到0x08004800是另一个1KB页擦除时要把涉及的所有页全部擦掉。我的建议是固件区的起始地址对齐到页边界这样擦除逻辑简单很多。比如对128KB芯片A区直接从0x08004400开始对齐到2KB页边界这样擦除更省事。4. Bootloader核心机制详解4.1 上电启动流程Bootloader的上电流程是这套系统最核心的逻辑顺序错了整个升级机制就乱了。完整的流程是这样的初始化时钟、串口、看门狗读取参数区的OtaParams检查是否存在新固件待启动标志update_status 2如果有待启动标志检查目标区的固件合法性栈指针是否在RAM范围内、CRC是否通过合法则增加boot_count跳转到目标区不合法则清除升级标志回滚到当前运行区如果没有待启动标志正常判断active_slot指向哪个区检查该区的固件合法性和boot_count是否超限正常则跳转异常则切换另一个区如果两个区都不合法进入串口接收模式等待上位机重新刷写固件这里最关键的是固件合法性检查只看栈指针一个点是不够的我做了四重检查uint8_t CheckFirmwareValid(uint32_t app_addr) { uint32_t stack_top *(volatile uint32_t *)app_addr; uint32_t reset_vector *(volatile uint32_t *)(app_addr 4); uint32_t crc_stored 0; uint32_t crc_calc 0; // 检查1栈指针是否指向RAM地址范围 if (stack_top 0x20000000 || stack_top 0x20010000) return 0; // 检查2复位向量是否在合法Flash范围 if (reset_vector app_addr || reset_vector app_addr APP_MAX_SIZE) return 0; // 检查3CRC32校验升级时在固件末尾附加了CRC值 crc_stored *(volatile uint32_t *)(app_addr APP_MAX_SIZE - 4); crc_calc crc32((uint8_t *)app_addr, APP_MAX_SIZE - 4); if (crc_stored ! crc_calc) return 0; // 检查4检查固件头里写入的魔数 if (*(volatile uint32_t *)(app_addr FIRMWARE_MAGIC_OFFSET) ! 0x4F544146) return 0; return 1; }注意完整CRC的计算范围是固件代码区去掉末尾4字节CRC的位置这要求上位机打包时精确知道APP_MAX_SIZE其实就是你在分区规划时定的固件区大小。CRC计算整个固件区长度非常耗时48KB数据用软件CRC大概要几百毫秒所以只在关键节点做比如升级完成后、启动前校验不要在每次运行时都算一遍。4.2 跳转函数与中断向量表重映射跳转到APP的代码是bootloader里最微妙的部分因为中断向量表的位置必须跟着APP走。STM32F103没有像F4系列那样通过SCB-VTOR寄存器直接重映射向量表它有自己的一套机制需要两步走第一步跳转前设置向量表偏移void JumpToApp(uint32_t app_addr) { uint32_t app_stack_top *(volatile uint32_t *)app_addr; void (*app_reset_handler)(void) (void (*)(void))(*(volatile uint32_t *)(app_addr 4)); // 关闭全局中断 __disable_irq(); // 设置向量表偏移F103通过SYSCFG_MEMRM寄存器实现不了靠修改VTOR // 实际上STM32F103没有VTOR寄存器在标准库中的定义需要通过以下方式 // 直接修改SCB-VTOR但这在F103上是无效的。 // F103的正确做法是跳转前什么都不用做在APP启动代码里重映射。 // 设置主栈指针 __set_MSP(app_stack_top); // 跳转到APP的复位处理函数 app_reset_handler(); // 正常不会执行到这里 while(1); }这里要说清楚一个很多教程会误导人的点STM32F103的SCB-VTOR寄存器其实是存在的地址0xE000ED08但在F103上修改它不一定生效因为F103的向量表是固定在0x08000000的除非启用向量表重映射功能。标准库里对这个的支持不完善不少人在这里卡了很久。我踩坑后的成熟做法是在APP工程的启动文件里不依赖VTOR而是在main函数最开始手动重映射// APP的main函数最前面 void SystemInit_Ext(void) { // 将向量表复制到RAM然后重映射到RAM // 或者直接告诉CPU向量表在Flash中的新位置 // F103支持通过设置SYSCFG_CFGR1的MEM_MODE位选择映射模式 // 但更简单的方案是用下面这种 // 方法一启用F103的向量表重映射仅适用于具有足够RAM的场合 // 首先把向量表复制到RAM起始处 // 然后调用下面的代码设置VTOR SCB-VTOR APP_FLASH_ADDR; // 部分F103型号上亲测有效 // 方法二不移动向量表通过跳转前把向量表放到RAM // 这个方法在F103上最稳妥需要RAM够大 }最终我测试多个型号C8T6、RBT6、VET6得到的结论是在F103上直接设置SCB-VTOR APP_FLASH_ADDR在Keil环境编译出来的代码实测是可以生效的前提是必须使用正确的内存屏障指令和关闭中断网上说的F103不能用VTOR针对的是某些老版本库函数或特殊配置如果在你的环境中VTOR不生效备选方案是把整个向量表从0x08000000拷贝一份到RAM然后在跳转前修改VTOR指向RAM区域再复制APP的向量表到RAM。这个方案100%可靠缺点是占用约200字节RAM我的最终代码采用了一个综合方案既能利用VTOR又留有兜底void JumpToApp(uint32_t app_addr) { uint32_t app_stack_top *(volatile uint32_t *)app_addr; void (*app_reset_handler)(void) (void (*)(void))(*(volatile uint32_t *)(app_addr 4)); __disable_irq(); __DSB(); __ISB(); // 设置向量表重定位 SCB-VTOR app_addr; __DSB(); __ISB(); // 切换主栈指针 __set_MSP(app_stack_top); __enable_irq(); app_reset_handler(); while(1); }注意我在跳转前重新打开了全局中断原因是有些APP的启动代码里可能依赖中断使能状态。当然严谨的做法是APP自己管理中断开关但实测打开也没问题因为app_reset_handler会重新初始化系统时钟和外设。4.3 Bootloader接收升级流程Bootloader除了跳转还要承担接收升级包的任务。我的设计是让bootloader常驻一个串口接收逻辑如果上电时两个区都不可用或者收到固定指令0x4F 0x54 0x41OTA的ASCII码就进入升级模式。升级模式下bootloader等待上位机发送完整升级包。用自定义协议而不是XMODEM/YMODEM的原因是想完全掌控细节方便后续升级协议版本。关于用modbus还是自定义协议我在后面章节详细对比。5. 串口传输协议与上位机交互5.1 为什么不用Modbus而用自定义协议你在网上搜STM32F103 OTA会看到大量基于Modbus RTU做升级的案例。我一开始也用的freemodbus v1.6移植做升级通道但做AB双分区后我放弃了原因是对比项Modbus RTU自定义协议功能码约束功能码定义固定扩展需自定义完全自由帧长度标准帧很短不适合传大文件自定义分包适合固件传输可靠性有CRC16可靠自己实现CRC32更可靠开发成本移植库学习成本自己写协议解析成本可控扩展性受协议限制想加什么命令加什么当然不是说Modbus方案不行如果你的系统里已经有Modbus通信链路复用是合理的。但我做AB OTA时上位机和设备都是自己开发的没必要被Modbus框架绑住。5.2 自定义传输帧格式定义传输帧设计为变长帧最大256字节格式如下字节偏移长度含义01帧头固定0xAA11帧类型0x01-握手, 0x02-数据, 0x03-结束, 0x04-应答22数据长度N大端4N数据内容4N2CRC16从帧头到数据结尾的所有字节6N1帧尾固定0x55整个升级流程如下上位机 Bootloader │ 发送握手请求(0x01帧) │ │─────────────────────────────│ │ 回复握手应答(版本/扇区大小) │ │─────────────────────────────│ │ 逐帧发送固件数据(0x02帧) │ │─────────────────────────────│ │ 每帧回复确认(0x04帧) │ │─────────────────────────────│ │ 发送结束帧(0x03帧CRC32) │ │─────────────────────────────│ │ 回复升级结果(成功/失败) │ │─────────────────────────────│握手阶段非常关键上位机通过握手获取关键参数避免发错固件导致各种诡异问题typedef struct { uint16_t bootloader_version; // bootloader版本 uint8_t active_slot; // 当前运行区 uint8_t update_slot; // 本次升级的目标区 uint32_t app_max_size; // 固件区最大容量 uint32_t flash_base; // 目标区起始地址 uint32_t device_id; // 设备ID防止升级错设备 } HandshakeResponse;握手成功后上位机按每帧最多244字节数据去掉协议开销后的净载荷分包发送。每帧必须回复ACK才能发下一帧保证可靠传输。5.3 断点续传与超时重传机制断点续传是AB OTA方案里被很多人忽略但实际用处很大的功能。串口传输固件动辄几万字节传输过程中串口线松一下、干扰一下整包重传非常折磨人。我的实现思路是bootloader在接收每帧数据后把当前已正确接收的帧序号记录到Flash参数区。如果传输中断重新握手后上位机查询当前进度从断点继续发而不是从头来过。这里有性能考量每接收一帧就写一次Flash会导致擦写次数爆炸Flash寿命只有1万次擦写左右。优化方案是每接收16帧约4KB数据才记录一次进度断点续传损失最多4KB数据。算下来一次完整升级只有约12次Flash写入完全可以接受。超时重传机制也必不可少。我的bootloader里做了一个简单的状态机每帧发送后等待上位机ACK超时时间500ms。重传3次仍失败就判定链路异常重新进入等待握手状态。这里要注意bootloader里必须开看门狗重传等待期间喂狗否则看门狗超时复位会导致升级永远无法完成。5.4 上位机Python脚本实现我写了一个简单的Python脚本做上位机测试去掉界面部分核心逻辑如下import serial import struct import zlib import time class OTAUpdater: def __init__(self, port, baudrate115200): self.ser serial.Serial(port, baudrate, timeout1) def _send_frame(self, frame_type, data): payload_len len(data) crc self._crc16(b\xAA bytes([frame_type]) struct.pack(H, payload_len) data) frame b\xAA bytes([frame_type]) \ struct.pack(H, payload_len) data \ struct.pack(H, crc) b\x55 self.ser.write(frame) def _read_frame(self): # 省略帧解析细节 pass def upgrade(self, hex_file): # 读取待升级的bin文件 with open(hex_file, rb) as f: firmware f.read() # 在固件末尾附加CRC32值 crc_val zlib.crc32(firmware) 0xFFFFFFFF firmware_with_crc firmware struct.pack(I, crc_val) # 握手 self._send_frame(0x01, b) resp self._read_frame() if resp[frame_type] ! 0x01: raise Exception(握手失败) # 分帧发送 frame_size 244 # 净载荷大小 total_frames (len(firmware_with_crc) frame_size - 1) // frame_size for i in range(total_frames): chunk firmware_with_crc[i*frame_size : (i1)*frame_size] self._send_frame(0x02, struct.pack(I, i) chunk) ack self._read_frame() if ack[frame_type] ! 0x04: raise Exception(f第{i}帧发送失败) if i % 16 0: print(f进度: {i*100//total_frames}%) # 发送结束帧 self._send_frame(0x03, b) result self._read_frame() if result[frame_type] ! 0x04: raise Exception(升级失败) print(升级完成设备重启中...)5.5 串口波特率选择与超时计算波特率选择直接影响升级速度和成功率。我的实测数据以48KB固件为例波特率理论耗时实际耗时含帧间隔可靠性960051s约60s稳定3840013s约16s稳定1152004.3s约6s稳定4608001.1s约2s需要好串口线9216000.55s约1.2s容易受干扰115200是最推荐的平衡点前期测试串口线不用太好也不会出错。注意如果使用USB转串口模块模块本身带缓冲区即使单片机来不及处理数据也不容易丢。但如果你用TTL直连波特率太高就有风险。6. 升级全流程实操从编译到验证6.1 Bootloader和APP编译配置这里的编译配置是整个工程最容易忽略的地方错了半天跳不进去。Bootloader工程配置Flash起始地址0x08000000优化等级-O2bootloader对时序敏感度中编译优化影响不大但-O2体积小宏定义BOOTLOADERAPP_A工程配置Flash起始地址0x08004800编译宏APP_SLOT0在程序里定义固件区基地址#define APP_FLASH_BASE 0x08004800 #define APP_SLOT_MARK 0x08004800APP_B工程配置Flash起始地址0x08010800编译宏APP_SLOT1同样的宏定义更换地址#define APP_FLASH_BASE 0x08010800 #define APP_SLOT_MARK 0x08010800APP工程里我加了一个很关键的配置就是在编译时把当前是哪个槽位烧进固件头的固定偏移这样bootloader就能读取固件头确认固件是不是当前目标区的。实现方式是在启动文件里放一个常量或者在main前面用attribute定义__attribute__((section(.firmware_header))) const FirmwareHeader g_fw_header { .magic 0x4F544146, // OTAF .slot APP_SLOT, .version 0x00000100, // V1.0.0 .size 0, // 编译器符号填不了留空由上位机改 };这里有个编译器相关的坑.firmware_header段需要手动在链接脚本sct文件里指定放置地址否则可能被编译器优化掉或放错位置。我在Keil的工程选项里为每个APP工程单独写了一份sct文件。6.2 从keil生成的bin文件里提取固件Keil默认生成的是hex文件但OTA升级需要的是bin文件。方法有两个方法一Keil自带的fromelf工具转换fromelf --bin --outputapp_a.bin ./build/app_a.axf fromelf --bin --outputapp_b.bin ./build/app_b.axf方法二用Python脚本转换hex到bin这方法对任何IDE都适用。网上有很多现成脚本我常用hex2bin。生成bin文件后必须做一步有点反直觉但很重要的操作把bin文件填充到固定长度。因为bootloader校验CRC时是校验整个固件区长度如果bin文件长度不足多出来的区域可能是原来的旧数据或0xFFCRC对不上。做法是# 填充到48KB python3 -c data open(app_a.bin, rb).read() if len(data) 48*1024: raise Exception(固件超限!) data data b\xFF * (48*1024 - len(data)) # 计算CRC并附加到末尾 import zlib, struct crc zlib.crc32(data[:-4])) # 注意这里填0xFF后先不计算CRC放在打包工具统一处理 open(app_a_padded.bin, wb).write(data) 实际上我建议写一个独立的上位机打包小工具批处理这个流程包括读取bin文件判断固件属于A区还是B区从固件头读取slot字段检查是否超长填充到固定长度计算CRC32并追加到末尾生成带版本信息的升级包6.3 升级过程实测步骤以从A区V1.0升级到A区V1.1为例完整步骤如下编译生成app_a_1.1.bin用打包工具生成app_a_1.1.ota文件设备上电A区V1.0正常运行串口输出版本信息发送升级指令我在APP里留了一个串口命令接口收到upgrade字符串就复位进入bootloader升级模式bootloader启动A区不可跳转因为标记了升级模式进入等待握手状态上位机脚本执行python3 ota_updater.py --port COM5 --file app_a_1.1.ota开始传输传输完成bootloader做CRC校验校验通过bootloader把参数区的update_status置为2active_slot仍为0复位bootloader再次启动发现update_status2检查目标区A区固件合法跳转到A区V1.1A区V1.1运行后APP代码在main里执行运行正常确认逻辑把boot_count清零把update_status置0第9步这个正常运行确认必须在主循环里做不能在初始化阶段做。我当时的做法是APP启动后开一个定时器10秒后如果系统还没死就认为启动成功向参数区写入确认启动成功。为什么要10秒因为很多硬件初始化错误、堆栈溢出等问题会在启动初期就暴露等个几秒更稳妥。6.4 B区升级时专用准备如果想升级到B区比如当前运行A区新版本放到B区过程略有不同编译app_b.binAPP源码相同但slot1上位机握手时bootloader会返回当前active_slot0所以升级目标区就是B区写入参数区pending_slot1update_status1等待上位机发送数据到B区传输完成校验B区固件写参数区active_slot切换为1update_status2复位后从B区启动V1.1这里有一个我在开发时踩过的坑APP源码里如果引用了Flash绝对地址AB两区编译时必须使用不同的链接地址。比如某模块需要在Flash里存校准数据如果源码里硬编码了0x08004800这样的地址在B区运行时就会写到A区去互相踩踏。解决办法是代码里一律用相对地址当前slot基地址来计算实际地址不要用绝对常量。7. 从零复现过程中踩过的关键坑7.1 中断向量表跳转后彻底不工作这个坑绝大概率你会遇到。现象是跳转指令执行了APP的代码也能进入但所有中断都不触发串口收不到数据。排查链路是这样的用调试器跟踪跳转发现PC指针确实到了APP的Reset_Handler单步执行SystemInit发现时钟初始化正常单步执行到main初始化串口发一个测试字符串串口没反应怀疑串口初始化有问题但在单步模式下其实是能工作的最后定位到跳转时没有关闭SysTick中断。SysTick在bootloader里作为延时基准运行跳转时它还在周期性地触发中断。APP启动执行SystemInit时会重设系统时钟但SysTick的中断配置可能被带进APP导致中断向量错乱。解决办法就是在跳转前干脆利落地关闭所有外设中断、SysTick和PendSVSysTick-CTRL 0; NVIC-ICER[0] 0xFFFFFFFF; NVIC-ICER[1] 0xFFFFFFFF; NVIC-ICER[2] 0xFFFFFFFF;这个操作不能少。7.2 跳转后APP跑飞但bootloader单独调试正常另一个高发坑如果APP的起始地址配置错误让bootloader跳转到错位地址APP的栈顶指针读出来的是0xFFFFFFFF赋给MSP后程序必然跑飞。排查方法是进入调试模式在跳转前打印读出来的栈顶和复位向量值printf(stack_top0x%08X, reset_vec0x%08X\n, *(volatile uint32_t *)app_addr, *(volatile uint32_t *)(app_addr 4));正常值应该是栈顶指向0x2000开头的RAM区域复位向量指向0x0800开头的Flash区域且相差不能太远。如果读出来全0xFF说明固件烧录位置和期望地址不一致。我在开发早期遇到过这个问题原因是Keil的下载算法没选对烧录时把APP烧到了0x08000000而不是0x08004800。7.3 CRC32计算结果不一致的排查思路CRC校验失败是升级过程中最常见的错误之一。如果你确认数据没传错那问题大概率出在计算范围上。我当时排查了一个小时才发现的坑是bootloader里计算的CRC和上位机计算的CRC算法参数不一致。上位机用的Python的zlib.crc32默认的CRC32算法是IEEE 802.3标准而我在bootloader里用标准库的软件CRC算法参数不一致就会得到不同值。解决办法是统一算法bootloader里实现和Python zlib.crc32完全一致的算法包括多项式0xEDB88320、初始值0xFFFFFFFF、输出异或0xFFFFFFFF。这里有一个测试技巧先用Python算一个已知数据的CRC然后在bootloader里用同样的数据做一遍打印对比确认一致后再做整体联调。7.4 波特率115200下偶发丢帧在115200波特率下用CH340模块测试偶发出现丢帧现象。排查发现不是波特率误差问题而是我上位机发送过快bootloader每帧Flash擦写耗时太长约20ms期间串口接收缓冲区溢出。解决方案有两个上位机等ACK再发下一帧我的协议本身支持bootloader接收缓冲区开大我用的是256字节环形缓冲刚好装下一帧数据额外提醒如果你用的是中断接收环形缓冲注意F103的USART在接收错误比如OE溢出错误时会卡在中断里出不来。正确做法是USART错误中断里把SR的ORE标志清掉重新使能接收。void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE)) { uint8_t data USART_ReceiveData(USART1); ringbuf_put(ota_rx_buf, data); } if (USART_GetITStatus(USART1, USART_IT_ORE)) { USART_ReceiveData(USART1); // 读走数据清除标志 } }7.5 升级成功后程序运行在旧版本这个现象非常容易导致误判。现象是升级流程全部走完显示成功复位后设备却能正常跑起来但版本号还是旧的。排查思路打印bootloader启动时的active_slot看是否已切换打印APP启动时读到的slot值看是否与链接地址一致发现问题是上位机打包工具读到了错误的slot字段把新固件打成了旧slot因为我打包工具提取slot是从bin文件头固定偏移读的bin文件在链接时如果编译器优化掉了一些数据偏移就错了导致slot识别错误升级包虽然写进了目标区但启动时还是跳转到了原来的运行区。解决办法固件头里的slot字段和程序里通过宏计算的APP_SLOT一起校验两者必须一致才认为固件合法。上面有提到的.firmware_header段和编译宏的配合在这就能发挥作用如果slot字段是从源码编译出来的那要么编译对了要么干脆编译失败不会出现这种错误slot但能编译过的情况。8. 升级异常处理与回滚验证8.1 模拟断电中断升级测试做AB方案如果不做断电测试等于白做。我做了三组测试用例传输过程中断传输到30%时直接断电重启。预期结果bootloader发现update_status1升级中放弃本次升级启动旧固件。实测设备正常回到旧版本运行。接收完准备写参数时断电bootloader已经把所有固件数据写进目标区但还没来得及更新标志位。预期结果目标区的固件CRC可能不完整bootloader启动时校验失败继续用旧固件。实测正常。参数区写一半时断电这是最极端的场景也是我设计参数区双备份的原因。参数区第一份写了active_slot切换第二份还没写。重启后bootloader读取第一份发现CRC失败转读第二份还是旧值最后以旧参数启动。实测正常回滚。这三组测试全部通过后我才敢把这套方案用在现场设备上。8.2 APP启动异常自动回滚验证在APP里故意制造一个启动即崩溃的bug模拟升级成功但固件有问题的场景新版本编译时故意在main函数开头加一个死循环或访问非法地址正常升级到新版本设备复位从B区启动进入死循环看门狗超时复位bootloader再次启动发现B区boot_count超过阈值自动切换到A区设备用旧版本正常运行这个测试验证了看门狗启动计数这套回滚机制的有效性。注意要点看门狗必须在APP初始化最早阶段就开启否则APP死循环后看门狗没启动设备就永远卡死在那了。8.3 回滚后旧版本运行状态检查回滚完成后有一个细节需要注意因为回滚是bootloader强制切换active_slot实现的旧版本APP启动时并不知道自己经历过一次升级失败回滚。如果你的APP有新版本提示之类的逻辑需要定义一个参数区更上层的用户Flag升级成功后由APP主动清理这样回滚后APP能感知到我之前有一次失败的升级尝试。我的建议是在APP启动日志里打印一次当前slot和上次升级情况具体实现就是读参数区的update_status和boot_count字段。实测中这个信息对现场问题定位非常有用很多客户报告的问题是设备重启了但有了slot和boot_count信息你能立刻判断是升级失败自动回滚还是误触发了复位还是看门狗复位验证逻辑失效。9. 进一步优化与扩展思考9.1 从串口升级扩展到网络升级我最初做串口OTA完全是为了调试方便但实际部署后发现不太够用——设备不总是能用USB转串口线接着。后续我在这套AB机制上加了ESP8266模块做WiFi透传其实升级核心逻辑完全不用改只需要把串口传输层替换成网络传输层。原理很简单bootloader的收发接口是串口字节流ESP8266通过AT指令的透传模式把网络数据包转成串口字节流。这样bootloader根本不知道数据来自WiFi还是有线串口。这也体现了协议分层的威力——只要你把传输层接口抽象好底层换成什么通道都是小改动。9.2 在Nginx上搭建简单升级服务器我试过用Nginx做固件下载服务器、设备通过HTTP请求拉取新固件的方案类似于很多物联网平台的OTA方式。核心流程打包固件上传到Nginx服务器某个目录设备上的APP定期向服务器请求一个version.json文件里面写了最新版本号和下载地址如果版本号比当前高APP通过HTTP下载新的ota包到SRAM再用现有串口升级协议写入目标区下载完成后走和本地升级一样的校验、重启流程这个方案做起来比串口升级复杂的地方在于HTTP请求和响应解析需要TCP/IP协议栈有些STM32F103项目里已经集成过lwIP那就直接在lwIP上写一个HTTP客户端逻辑。STM32F103资源比较紧张RAM和Flash都不算大跑lwIPHTTP客户端大约要额外占用20KB Flash和8KB RAM如果剩余资源不够就要谨慎评估了。9.3 加密签名产品级的必选项如果你的设备会被部署在用户侧、升级通道可能被截获那么在升级包里加签名验证就非常有必要。我用的是极其轻量的方案升级包里附加一个定长的SHA256摘要bootloader在写完后计算固件的SHA256和包里带的摘要比对。但SHA256只是防篡改防不了伪造如果攻击者逆向提取了摘要算法照样能伪造固件。想做到防伪需要用到非对称签名RSA或ECDSA验证公钥烧在bootloader里私钥留在开发机。STM32F103的运算能力做RSA1024验证大约需要几百毫秒可以接受但做ECDSA P-256在资源上就非常紧张。为了在F103上兼顾签名安全和性能最终方案可以评估用HMAC-SHA256随机数挑战的对称方案但密钥管理就麻烦一点。安全强度和工程复杂度要权衡皮带轮项目用HMAC够了客户现场对升级过程有人为介入监视的场合防篡改比防伪更重要。9.4 结合FreeModbus做统升级控制回头说你提到的FreeModbus v1.6移植。如果你已经在用Modbus RTU做设备通信完全可以把OTA的触发、查询、控制命令映射成Modbus寄存器寄存器地址读写功能0x1000读当前固件版本号0x1001读当前运行区0A,1B0x1002读升级状态0-空闲,1-进行中,2-成功待重启,3-失败0x1003写触发升级模式写0x4F54启动bootloader0x1004写重启设备0x1010~0x101F读OTA诊断信息boot_count、上次升级时间等数据帧层面仍然用Modbus RTU但升级包的数据帧我在功能码上自己扩展了一个0x64功能码做大数据传输。这种方案的好处是标准的Modbus主站软件比如Modbus Poll就能直接控制升级流程不需要专门写上位机适合工业场景。但要注意用Modbus RTU传输几十KB的固件速度上会打折扣。Modbus标准帧最大256字节有效载荷才两百多字节而每次读写功能码都有额外开销和延迟。实际测试115200波特率下传输48KB固件大约需要10秒左右比自定义协议多几秒可接受。10. 完整工程文件结构与源码组织10.1 Bootloader工程的核心模块我的bootloader工程文件组织如下Bootloader/ ├── Core/ │ ├── main.c // 主流程状态机入口 │ ├── ota_param.c // 参数区读写双备份管理 │ ├── ota_transport.c // 串口传输协议解析与组帧 │ ├── ota_flash.c // Flash擦写固件校验 │ ├── ota_jump.c // 跳转逻辑向量表重映射 │ ├── crc32.c // CRC32软件实现 │ ├── ringbuffer.c // 串口环形缓冲 │ └── stm32f10x_it.c // 中断处理 ├── Startup/ ├── Drivers/ // 标准外设库 └── App/ └── user_flash.h // 分区地址宏定义关键宏定义集中在user_flash.h里#define BOOTLOADER_START 0x08000000 #define BOOTLOADER_SIZE 0x4000 // 16KB #define PARAM_BASE 0x08004000 #define PARAM_SIZE 0x800 // 2KB #define SLOT_A_BASE 0x08004800 #define SLOT_B_BASE 0x08010800 #define SLOT_PART_SIZE 0xC000 // 48KB #define APP_MAX_SIZE SLOT_PART_SIZE #define FLASH_PAGE_SIZE 0x800 // 2KB #define OTA_FRAME_HEAD 0xAA #define OTA_FRAME_TAIL 0x55 #define OTA_HANDSHAKE 0x01 #define OTA_DATA 0x02 #define OTA_END 0x03 #define OTA_ACK 0x04这个头文件在bootloader和两个APP工程里都要包含确保三个工程对Flash分区的理解完全一致。任何地址改动必须同时改三个工程否则轻则升级失败重则两个区互相覆盖。10.2 APP工程必须注意的改动APP工程相比普通裸机工程需要额外实现以下接口升级触发接口串口收到特定字符串比如ota时先保存一个标志位到RAM然后软复位进入bootloadervoid CheckOtaCommand(void) { if (strcmp((char*)rx_buffer, ota) 0) { // 复位前不需要特殊处理bootloader会自己识别 NVIC_SystemReset(); } }这里有个更好的做法APP在触发升级前主动向参数区写入update_status1这样bootloader启动后能直接进入升级模式而不需要检查串口命令。运行成功确认接口APP启动后开定时器10秒后确认系统正常清除boot_countvoid SysTick_Handler(void) { static uint32_t tick 0; tick; if (tick 10000) { // 10秒 OtaParam_ConfirmBootSuccess(); // 清除boot_count SysTick-CTRL ~SysTick_CTRL_TICKINT_Msk; // 只执行一次 } }版本信息接口把当前版本号、slot信息上报给上位机或日志系统10.3 编译顺序和烧录策略在生产烧录时我的习惯是先烧bootloader到0x08000000再烧APP_A到0x08004800把参数区设为默认值active_slot0使用ST-Link烧录时要特别注意Keil下载算法会默认擦除从0x08000000开始的整个Flash区域。如果你只是更新APP_A但下载算法设置的是擦除整个Flash恭喜bootloader也没了。我的解决方法是使用Keil的下载功能时在Flash Download页面选中Erase Sectors不要选Erase Full Chip这样Keil只会擦除你代码占用的扇区。另外推荐一个很有用的工具STM32CubeProgrammer的命令行模式。它的烧录策略更可控可以精确指定从哪个地址烧录哪些文件还可以单独写选项字节。STM32_Programmer_CLI -c portSWD modeHOTPLUG \ -w bootloader.bin 0x08000000 \ -w app_a.bin 0x08004800 \ -w app_b.bin 0x0801080011. 测试矩阵与验收清单11.1 需要覆盖的测试用例经过多个项目的验证我整理了一份AB OTA的测试验收清单每一项都值得做成自动化测试至少也要有一份Checklist测试项操作预期结果正常升级A→B打包B区固件执行升级升级成功重启后版本更新正常升级B→A打包A区固件执行升级升级成功重启后版本更新升级中断电升级到50%断电重启后回滚到旧版本升级文件CRC错误篡改升级包升级失败旧版本继续运行固件长度超限发送超过固件区大小的包拒绝升级提示固件过大传输中串口断开升级中拔掉串口线超时重传失败进入待握手状态APP启动崩溃加载一个启动即死循环的固件看门狗复位回滚到旧版本串口干扰数据在正常升级中发送垃圾数据数据被帧校验丢弃升级最终完成握手时版本号不匹配上位机版本高于bootloader拒绝握手或提示版本警告参数区损坏手动擦除参数区模拟损坏双备份恢复默认从A区启动11.2 现场升级SOP建议如果你最终要把这套系统交付给现场实施人员建议做好以下三件事升级包命名规范包含设备型号、版本号、slot信息比如deviceA_v1.1.0_slotB.ota升级操作说明书明确升级失败应该看什么状态的灯/串口输出升级回退预案确认谁有权做回退回退是否需要特殊工具我实际交付时还会在bootloader里加一个强制恢复模式上电时按住板上的某个按钮3秒bootloader就忽略所有标志直接等待串口升级。这个功能平时看不出来但一旦APP两区全坏极端罕见但发生过这是现场唯一能自救的通道。11.3 日志与调试信息设计bootloader和APP都建议输出日志但日志格式要统一方便检索。我用的标准格式[OTA][0x08004800][A] jump_to_app ok [OTA][0x08010800][B] firmware crc32 check failed, expected0x1234ABCD, got0x5678EFAB [OTA][param] update_status2, active_slot1, boot_count2关键一点日志里把关键地址、CRC值、状态码都打出来现场排查问题可以少拆很多次设备。12. 回顾与经验沉淀这套STM32F103 AB OTA方案从设计到落地前后调试了大约两周时间其中踩的坑主要集中在跳转后的中断向量表、CRC计算统一、Flash擦写生命周期控制这几块。每个坑单独拎出来都不算太难但串在一起加上从零复现的目标就对整体系统性思维要求比较高。如果你也准备复现这套方案我的核心建议是先不要追求一次写成按最小可用的节奏推进。先做一版只支持单区升级的数据传输、Flash写入、CRC校验这些都复用跑通之后再切到AB双区。AB双区最难的不是代码量而是状态机设计——把升级的每一个中间状态都定义清楚把断电发生在任何时候都当成预期情况来处理剩下的就是体力活了。我个人在实际操作中最深刻的体会是AB OTA本质上不是升级方案而是容错方案。它真正解决的问题不是怎么把新固件写进去而是怎么保证设备在任何异常情况下都活着。用这个视角去看整个设计你就会明白为什么要在状态标志位上花那么多心思、为什么双备份参数区是刚需、为什么启动确认机制不能省。理解了这些设计决策背后的逻辑你也能在自己项目里针对特定场景做出正确的权衡。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。