STM32F103 AB分区OTA升级:从Boot跳转到固件打包的完整实践
STM32F103这颗芯片说它是“老兵”一点不过分。十年前的产品在用五年后依然有不少新项目在选它原因无非是成本低、资料全、稳定可靠。但你要是想给基于F103的设备加上OTA固件升级能力网上的资料就明显不够用了东一篇讲Boot跳转、西一篇讲Flash擦写没人把整条链路串起来。我最近把量产项目里沉淀下来的AB分区升级方案重新过了一遍从分区规划、代码实现到打包工具整理出一条可以直接照着复现的路径分享出来。这次要复现的项目叫STM32F103_AB_OTA核心内容是用AB双分区策略让F103具备可靠的固件升级能力。它解决的问题很直白传统单区升级一旦中途断电或程序写错设备直接变砖只能拆壳接烧录器救。AB方案用两个互为备份的App分区做到升级失败自动回滚这是量产设备必须具备的可靠性。适合谁看只要你会用标准库或HAL库点过LED、能自己建一个最小工程这篇文章就能带你走通F103 AB OTA的完整流程不用再去翻碎片化资料。1. 为什么F103要做AB分区OTA1.1 单区升级到底有哪些隐患先把最原始的做法拿出来说。很多人第一次做远程升级Flash布局是这样的Boot区放在最前面后面跟着唯一的App区。Boot负责接收新固件擦掉旧App写入新App然后跳转。逻辑上没毛病但实际量产中坑特别多。最大的坑就是升级过程中掉电。芯片擦除一个扇区要几十毫秒写完整份固件按115200波特率算可能要几分钟这段时间里任何一次掉电都可能让App区处于“擦了一半”或者“写了一半”的状态。单片机上电后Boot发现App校验不过又没有备份可回退设备就只能躺在那等着返厂。第二个坑是“升级了坏的固件”。代码编译过了不代表逻辑就对万一新固件里有严重bug跑到一半死机、外设配置异常、通信全断Boot已经把启动权交出去了整台设备照样变砖。单区方案对这种“逻辑层面”的失败完全没有招架能力。第三个坑容易被忽略就是升级过程本身被打断。比如用户在现场突然断电、通信线路松动、看门狗超时复位Boot重新启动后还得继续处理半截升级状态恢复逻辑写起来非常痛苦稍不留神就陷入反复擦写Flash的坏循环。AB分区方案正是针对这三个痛点设计的。它不做“覆盖式升级”而是把新固件先写进另一个空闲分区等固件完整且校验通过之后再修改启动标志去引导新分区。运行区始终保留一份已知可用的固件出问题随时切回去。1.2 AB双分区的核心设计思想AB分区的思想其实和电脑上常见的双系统启动非常像。Flash里不只有一个App区而是有两个大小相同的App区A区和B区。Boot启动时根据参数区里保存的“启动目标”决定跳到A区还是B区。正常工作时比如当前在A区运行收到升级包后App把新固件写入B区。写入完成后做一次全量CRC校验确认没问题就把参数区的启动标志改成“下次启动B区”然后软复位。Boot看到标志跳到B区。如果B区跑起来正常整个升级就算完成了。如果B区跑起来有问题比如看门狗没喂上、系统反复重启Boot侧可以加一套看门狗检测逻辑发现目标分区连续复位超过N次就回滚到另一个分区。这样即使新固件在逻辑上是个炸弹也不会炸死设备老版本还能拉一把。这套逻辑放到F103上有个天然优势F103的Flash虽然不大但高容量型号有512KB分给Boot 32KBA区和B区各224KB对大多数应用固件来说完全够用。而且AB方案把升级链路中最复杂的“接收、校验、写Flash”逻辑放在App侧Boot只需要做跳转和仲裁Boot本身的体积可以做得非常小也降低了Boot自身出bug的概率。1.3 Flash资源盘点与分区规划动手写代码之前先把芯片型号确认清楚。F103家族里常见的STM32F103C8T6只有64KB Flash分成AB两区后会非常局促我一般不推荐在C8上做AB OTA除非你的固件压到20KB以内。真正适合AB方案的是256KB和512KB的大容量型号比如STM32F103RCT6、STM32F103ZET6。以STM32F103ZET6的512KB Flash为例分区规划可以这样定区段地址范围大小用途Bootloader0x08000000 - 0x08007FFF32KB上电跳转仲裁逻辑App A区0x08008000 - 0x0803FFFF224KB可运行固件区App B区0x08040000 - 0x08077FFF224KB可运行固件区参数区0x0807F800 - 0x0807FFFF2KB启动标志、版本号、升级状态这里有几个细节要特别说明。首先Boot区放在0x08000000这是STM32的默认启动地址上电后芯片从这里开始执行不需要任何额外配置。其次A区和B区大小必须一致因为两区存放的都是同一种固件只是地址不同。最后参数区单独留出2KB专门存启动标志和升级状态避免和固件区混在一起擦写逻辑也清晰。关于F103的Flash扇区结构大容量型号每个扇区是2KB前4个扇区是16KB后面有大扇区。但在代码里我们用标准外设库的FLASH_ErasePage函数函数内部会根据地址自动识别扇区不需要自己关心这个结构。不过参数区地址尽量选在2KB边界上也就是末尾的0x0807F800这样一次擦除正好一个扇区省心。2. 整体方案设计与关键原理2.1 Boot只做跳转仲裁下载逻辑放在App侧这是整个方案里最值得讲清楚的设计决策。很多人在设计OTA时习惯把升级协议也塞进Boot里让Boot直接接收固件、写Flash。这种思路对Boot的代码量、内存占用、协议处理能力要求都很高F103的Boot还承担着“救砖”的职责代码越复杂越容易出问题。而这个项目采用的做法是Boot永远不直接参与固件下载。Boot要做的事情只有三件——读取参数区的启动标志、校验目标分区的有效性、跳转过去。哪怕A区和B区都坏了Boot也不会自己写固件它只会停在原地等你用烧录器救。那固件下载谁来干由当前正在运行的App干。比如当前跑在A区A区收到升级指令就会把新固件写入B区校验通过后改标志、复位。当前跑在B区就写入A区。这个设计有个很妙的地方能运行App说明系统基本是健康的把复杂的协议解析、Flash擦写、CRC计算交给健康侧把风险集中在本来就要被替换的“非活动区”比在Boot里处理靠谱得多。用一句话总结就是Boot负责决策App负责干活。这也是为什么AB方案在做量产级OTA时特别稳因为它的逻辑链路非常短出错面小。App侧OTA模块的代码结构也相对独立不影响主业务。我习惯把OTA逻辑封装成一个独立模块提供三个接口初始化、周期轮询、收到升级命令时抢占执行。这样主业务和升级逻辑之间互不干扰升级完成后再复位切区。2.2 Boot跳转App的核心原理Boot跳App这件事原理上理解起来不难。STM32上电后CPU从0x08000000取出栈顶指针从0x08000004取出复位中断向量并跳转执行。App其实就是一段编译好的程序起始地址处也放着自己的栈顶指针和复位向量。Boot跳转要做的就是用软件模拟一次“重新上电”的过程。typedef void (*pFunction)(void); 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; } // 跳转前关闭全局中断、复位外设 __disable_irq(); RCC_DeInit(); SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; // 设置中断向量表偏移 SCB-VTOR app_addr; // 重设主栈指针 __set_MSP(app_sp); // 跳转到App复位向量 pFunction jump (pFunction)app_pc; jump(); }这里有几个必须注意的细节。第一跳转前一定要关闭全局中断并复位SysTick。否则跳过去之后如果还有一个SysTick中断挂着CPU会以旧向量表去取中断入口直接HardFault。第二SCB-VTOR是Cortex-M3内核里的中断向量表偏移寄存器在F103上地址是0xE000ED08设置成App的起始地址后后续所有中断都会从新向量表取地址。第三栈顶指针要手动设置App启动文件的第一件事就是设置SP但我们希望在jump()函数里完成类似“复位”的效果所以提前把MSP设好更保险。跳转函数写完之后Flash中的App还有一个硬性要求它的中断向量表必须放在它自己的起始地址处。也就是说如果App运行在0x08008000那么0x08008000处必须是栈顶指针0x08008004处必须是复位向量。这个要求由链接脚本保证后面会细说。2.3 中断向量表偏移与固件格式F103上App和Boot共用一套中断向量表机制但App要想跑在非0x08000000地址必须处理两件事一是链接时把镜像放到目标起始地址二是运行时设置SCB-VTOR。Keil MDK下设置App的起始地址靠的是分散加载文件。Boot工程通常不需要改默认0x08000000即可。App工程的分散加载文件里IROM1的起始地址要改成App区的起始地址比如0x08008000大小改成0x00038000。很多新手在这里踩坑只改了编译地址忘了在代码里设置SCB-VTOR结果跳过去就死。固件格式方面我强烈建议不要往App镜像前面硬塞自定义头部。网上有些方案会把魔数、版本号、CRC等字段直接放在固件开头这样固然方便但会破坏向量表布局App的栈顶指针就不是0x08008000了Boot跳转前还得把头部跳过去逻辑一复杂就容易出错。更稳妥的做法是固件文件就是纯编译产物版本号、固件长度、CRC32这些信息在升级开始时由上位机单独通过“升级启动帧”发给App侧。App接收完整个升级数据后再对写好的Flash区域重新算一次CRC和启动帧里携带的CRC比对。这样Boot侧只需要校验向量表里的栈顶指针是否合法整个链路最清晰。2.4 升级链路与帧协议设计F103本身不带以太网和无线功能所以这里说的OTA更准确地说是“通过任意可靠字节链路升级”。串口是最容易跑通的载体很多人项目里本来就有RS232或RS485总线直接复用即可。如果产品是CAN总线组网把传输层换成CAN帧也可以如果以后要接WiFi模组或2.4G模块只要把底层收发接口抽象出来协议层完全不用动。但不管用什么物理链路帧协议和应答机制必须设计好。我使用的帧结构是固定头部加数据体的形式放在App侧解析字段字节数说明帧头20xA5 0x5A命令字10x01查询版本、0x02开始升级、0x03数据帧、0x04结束分区号10表示写A区1表示写B区块序号2从0开始每块大小和Flash扇区匹配数据长度2本帧有效数据字节数数据N最大2KBCRC324对前面所有字节的CRC32校验为什么块大小选2KB因为F103大容量型号的Flash扇区正好是2KB。上位机每发一帧App侧攒够2KB就一次性擦写一个扇区逻辑最简单。串口接收缓冲区开4KB足够一帧数据最多2KB处理完才收下一帧不会溢出。应答机制做得也很简单App每写成功一块回一个ACK带上块序号如果CRC不对或者块序号不是期望的下一个回NAK。上位机收到NAK或者超时未收到ACK就重发当前块。这套机制在115200波特率下实测非常稳定。3. 从零复现代码实现与编译打包3.1 工程结构建议动手之前先把工程结构想清楚否则后面会乱。我建议整个项目分成三个工程目录Boot工程独立编译烧录到0x08000000App工程独立编译链接到0x08008000上位机工具Python脚本负责发送升级包Boot工程和App工程可以是两个独立的Keil工程也可以放在同一个工作区里。工程名建议带分区标识比如Boot_F103、App_ABOTA避免调试时下错镜像。我见过太多人把Boot镜像和App镜像下载反了芯片直接卡死那种问题排查起来很痛苦。App侧建议新建一个ota_ab.c和ota_ab.h把升级逻辑完整封装起来不干扰主业务代码。主函数里在一个周期任务中调用ota_ab_poll()即可收到完整升级命令后代码内部会暂存当前状态升级完成后不复位到正常业务流程而是直接软复位。3.2 Boot工程的完整实现Boot工程的代码量非常小核心流程可以用一个主函数说清楚。int main(void) { uint8_t target; // 系统时钟初始化和App保持一致 SystemInit(); // 读取参数区的启动标志 target read_boot_target(); // 根据启动标志选择启动分区 if ((target TARGET_A) check_app_valid(APP_A_BASE)) { jump_to_app(APP_A_BASE); } else if ((target TARGET_B) check_app_valid(APP_B_BASE)) { jump_to_app(APP_B_BASE); } else { // 标志异常或目标分区无效回退到A区 jump_to_app(APP_A_BASE); } while(1); }check_app_valid函数做的事很简单读目标分区起始地址的4字节检查里面存的栈顶指针是否落在RAM地址范围内。正常情况下一个有效的App镜像第一行一定是栈顶指针其值必定在0x20000000附近。这个判断虽然不严格但对Boot来说已经足够了相当于给跳转上了一道锁防止跳到空Flash区域。参数区的读取要做一下容错。read_boot_target读到的值不是预定义的TARGET_A比如0xA5也不是TARGET_B比如0x5A时直接当成A区处理不要往未知分区跳。Boot工程里不需要初始化串口不需要初始化任何外设保持最小系统状态即可。只有一个例外如果产品对启动时间有严格要求可以把看门狗开起来防止Boot卡死在未知状态。3.3 App侧OTA模块的完整实现App侧的OTA模块是这个项目的核心主要处理三件事帧解析、Flash写入、启动标志更新。帧解析用状态机实现这是嵌入式处理串口流式数据的标准做法。每来一个字节根据当前状态推进直到收完一整帧再统一处理。这里要注意串口中断函数里千万不要做Flash擦写等耗时操作只收数据帧处理放在主循环里。typedef enum { FRAME_IDLE, FRAME_HEAD1, FRAME_HEAD2, FRAME_CMD, FRAME_PART, FRAME_SEQ_H, FRAME_SEQ_L, FRAME_LEN_H, FRAME_LEN_L, FRAME_DATA, FRAME_CRC, } frame_state_t; void ota_ab_rx_byte(uint8_t byte) { static frame_state_t state FRAME_IDLE; static uint16_t data_len 0; static uint16_t data_cnt 0; switch (state) { case FRAME_IDLE: if (byte 0xA5) state FRAME_HEAD1; break; case FRAME_HEAD1: if (byte 0x5A) state FRAME_HEAD2; else state FRAME_IDLE; break; // 其余状态机步骤略按帧结构逐字节推进 default: break; } }收完一帧后验证CRC32通过再按命令字分发。如果是数据帧就写入Flash。写Flash的代码需要格外小心F103的Flash编程是以半字16位为单位操作的所以数据长度必须是偶数起始地址必须半字对齐。代码核心就是标准库的擦写接口。void flash_write_app_block(uint32_t dest_addr, uint8_t *data, uint16_t len) { uint16_t i; uint16_t *ptr (uint16_t *)data; FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); for (i 0; i len / 2; i) { FLASH_ProgramHalfWord(dest_addr i * 2, ptr[i]); } FLASH_Lock(); }实际项目中我不会每收到一帧就写一次因为擦写Flash时CPU取指会暂停对串口接收有影响。更稳的做法是上位机发完整块再等待ACKApp收到2KB整块后一次性擦写同时上位机那边做超时重发这样即使擦写时间长一点也不会丢数据。3.4 Flash分区标志的读写策略分区的启动标志存在参数区我用一个64字节的结构体来管理#define PARAM_BASE_ADDR 0x0807F800 typedef struct { uint32_t magic; // 固定为0xA5A55A5A用于判断标志有效性 uint8_t boot_target; // 0xA5写A区0x5A写B区 uint8_t version[4]; // 固件版本 uint32_t last_result; // 上次启动结果成功跑起来后App会更新 uint32_t reserved[10]; } boot_param_t;写入标志有一个避不开的问题Flash写之前必须先擦除而擦除是按扇区来的参数区所在的2KB扇区整个都会被擦掉。这样一来每次升级都要擦一遍参数区F103的Flash擦写寿命标称是1万次对经常升级的产品来说需要稍微注意下。我采用的缓解方案是“双槽位轮换”。参数区里准备两个槽位比如槽位0和槽位1每次写标志时轮流使用。写槽位0之前只擦槽位0对应的区域不行还是同一扇区。那就换成另一招用两个独立扇区各存一份标志交替写。但F103 Flash从扇区0到255最后两个扇区拿来做参数区每个扇区2KB完全够用。这次升级写扇区A下次升级写扇区B配合一个简单的计数器判断哪份有效。这个方案在量产里跑得很稳代码也不复杂。更简单的方案是接受1万次擦写寿命。对大多数设备即使每周升级一次也能用将近200年所以实际压力并不大。不过有一个原则必须坚持先擦参数区、再写参数区、再复位逻辑顺序不能乱。有些教程为了省事把改变启动标志放在升级数据校验通过之前一旦断电就会出现老固件被覆盖但标志已经指向新分区的局面那就真是灾难了。3.5 分散加载文件与固件打包App工程的分散加载文件是AB OTA能否跑起来的又一个关键点。Keil工程里直接修改Linker配置即可也可以用.sct文件精确控制。一个针对上述分区的App分散加载文件示例LR_IROM1 0x08008000 0x00038000 { ER_IROM1 0x08008000 0x00038000 { *.o (RESET, First) *(InRoot$$Sections) .ANY (RO) } RW_IRAM1 0x20000000 0x00010000 { .ANY (RW ZI) } }注意这里RAM大小按F103ZET6的64KB配置如果你的芯片RAM不一样要相应调整。修改分散加载文件后编译出的App镜像地址就从0x08008000开始了烧录时必须烧到App区对应地址不能直接在Debugger里按默认地址下载。固件打包用Python脚本完成脚本要做的事情是把编译生成的.axf转成.bin然后在上位机里按照前面定义的帧协议把版本号、长度、CRC32封装到升级启动帧里剩下的活就是分块发送。import serial import struct import zlib ser serial.Serial(COM3, 460800, timeout2) fw open(app.bin, rb).read() fw_len len(fw) fw_crc zlib.crc32(fw) 0xFFFFFFFF version b1.0.0 # 发送开始升级命令 head struct.pack(BBBBHI, 0xA5, 0x5A, 0x02, 1, fw_len, fw_crc) ser.write(head) # 分块发送数据帧块大小2048 block_size 2048 seq 0 for offset in range(0, fw_len, block_size): data fw[offset:offset block_size] frame struct.pack(BBBBHH, 0xA5, 0x5A, 0x03, 1, seq, len(data)) frame data frame struct.pack(I, zlib.crc32(frame) 0xFFFFFFFF) ser.write(frame) # 等待ACK超时重发 ack ser.read(4) while ack ! struct.pack(BBH, 0xA5, 0x5A, seq): ser.write(frame) ack ser.read(4) seq 1实际传输中还要加上进度打印和超时统计上面的代码只是核心逻辑。这里有个经验波特率我直接用了460800F103的USART完全扛得住配合2KB数据块下载速度比115200快4倍升级体验会好很多。4. 常见问题与排查技巧实录4.1 跳转后立刻HardFault这个几乎是新手必踩的坑。现象非常典型Boot里调用jump_to_app之后程序直接跑飞进HardFault中断屏幕上的调试输出全部消失。排查顺序要固定。第一步看App工程分散加载文件是不是改对了确认App镜像确实链接到了目标地址不是还搂在0x08000000。第二步看SCB-VTOR有没有在App的main函数最前面设置这一步经常被漏掉App的中断没起来只要一开中断就死。第三步看跳转前是否关闭了所有外设和中断有时候Boot初始化了串口或定时器跳过去旧外设还在产生中断App又没初始化这些外设中断一进来就找不到处理函数。还有个细节__disable_irq()只关了全局中断标志已经挂在NVIC里的pending中断不会消失。所以跳转前最好把NVIC清一下简单粗暴的办法是在RCC_DeInit()之后把所有外设时钟关掉。4.2 升级中途掉电后无法启动升级过程掉电是硬件现场无法避免的事AB方案对此必须给出明确应答。我的设计是升级启动帧里携带一块“升级进度”信息App每写成功一个扇区就在参数区更新一次进度。下一次Boot启动时看到升级没完成直接忽略不完整的区跳回老区继续运行。但这要求参数区数据在升级过程中一直保持“目标还没切换”的状态。前面提到的“先校验通过再改启动标志”就是核心保障哪怕整个B区写到一半断电参数区里的启动目标依然是A区。等App下一次恢复运行时它发现B区有半截数据可以让上位机从断点继续传或者干脆重新开始。另外要注意看门狗策略。如果升级过程中看门狗在跑每次写Flash耗时太长都可能触发复位。我踩过一次坑之后在升级期间果断把看门狗暂停了。如果产品有严格的安全要求不能关看门狗那就要把每两个数据帧之间的间隔压短保证看门狗能被及时喂到。4.3 串口传输总是丢块或CRC错误升级过程中上位机不停地报CRC错误重传率居高不下这个问题多半不是协议问题而是底层串口配置。先检查波特率误差。F103的USART波特率由PCLK分频而来如果系统时钟不是精确的72MHz或者外部晶振有偏差460800这种高波特率下误差会被放大。我用外部8MHz晶振时一切正常换成板载HSE配置不对就频繁出错。建议量产固件里把系统时钟配置和串口波特率做成编译期校验。再看串口缓冲区和中断处理。F103的USART硬件FIFO只有1字节如果中断响应不及时数据就丢了。升级时应使用DMA接收或者至少把串口中断优先级调到最高接收中断里只做存字节不做事数据处理全部放到主循环。这两个调整之后460800波特率下连续传几百KB数据基本零丢包。如果还丢那就查接地。串口线过长、USB转串口模块质量差、上位机电脑接地不良都会在高速率下引入误码。这时候把波特率降回115200通常能解决而协议层的NAK重发机制保证了即使降速也不会出错。4.4 常见问题速查表现象可能原因解决方案跳转后HardFaultApp的VTOR未设置/分散加载错误/外设中断未关闭按4.1顺序逐项排查升级后启动标志指向B区但Boot跳到A区参数区被擦除或标志值非法检查参数区擦写逻辑Boot默认回退A区升级过程中CRC大量报错波特率误差/接地不良/串口中断响应不及时降波特率开DMA检查硬件连接Flash擦写失败未调用FLASH_Unlock/Flash被写保护检查FLASH_Unlock和写保护状态升级完成后App反复重启新固件逻辑异常看门狗复位Boot侧增加连续复位次数检测回滚分区编译出的App镜像地址不是目标地址分散加载文件未生效重新配置Linker使用.sct文件Boot跳转后串口输出乱码Boot和App主频配置不一致统一两边的系统时钟宏定义4.5 这次复现后的几点体会整套AB OTA复现下来我最大的感受是F103做OTA不是不行而是要把方案做“小”。所谓小不是功能砍掉而是逻辑层次要极简——Boot小到不碰任何协议App侧的OTA模块独立成块上位机协议明确到每个字节都有唯一含义。任何一部分做大做复杂都会给排查带来成倍负担。如果后续想把这套方案用到自己的产品上建议第一步只做串口链路把AB分区跑通再考虑接CAN、RS485或者无线透传。传输层替换其实非常容易我在项目里把串口换成CAN总线时改动的地方只有底层收发函数帧协议和Flash策略一点没动。这也是AB OTA方案本身的价值它把“传输”和“升级策略”解耦了你只需要关心业务自身的通信方式升级可靠性由分区机制兜底。最后说一个容易被忽略的小细节App侧升级完成后复位前记得把串口发送缓冲区的数据清干净避免复位瞬间调试助手里还残留半截旧数据误导你判断Boot有没有正常启动。这个坑我排查了整整一个下午后来发现根本就是调试工具显示的问题。写出来提醒一下少走点弯路。