
做这类远程升级改造最怕的不是功能做不出来而是设备升级升到一半变砖。之前我帮客户把一批基于STM32F103的数据采集器加上OTA能力对方反复强调一句话升死了你负责。这话听着像开玩笑实际上覆盖式升级方案里断电、传输中断、固件包不完整任何一个环节出问题板子就真的变成废铁。为了把“升级失败”从一个灾难变成一次白忙活我把AB双分区OTA方案完整落地到了STM32F103上。这篇内容是整个从零复现过程的完整复盘从Flash分区规划、Bootloader的双分区逻辑到App工程改造、串口升级协议设计再到联调阶段踩过的那些坑一次讲清楚。如果你正在准备给STM32F103产品加远程升级能力或者刚接触bootloader双分区AB分区这个概念想找一份能直接照着做的案例这篇应该能帮你省掉不少弯路。1. 为什么别再用覆盖式OTA给F103做远程升级1.1 覆盖式方案一次断电就变砖覆盖式OTA的逻辑很简单芯片里有一个Bootloader和一个App分区升级时Bootloader把接收到的固件直接写入App所在Flash地址写完跳转启动。很多小项目一开始都是这么干的因为实现起来特别快串口收数据、写Flash、跳转三天就能跑通。但这个方案存在一个结构性缺陷它没有“升级前状态保存”的概念。新旧固件共用同一个Flash空间新固件写入的过程本质上就是覆盖旧固件的过程。一旦在擦除或写入一半的瞬间断电Flash里留下的是一份残缺的新固件旧的App已经被破坏了Bootloader只能尝试启动一份不完整的镜像结果就是设备变砖只能拿回来用烧录器重新刷。这是我见过最多的OTA翻车案例。有人会说“我们升级很少失败”“传输前先校验一下”。问题是现场环境没法控制。电池供电的野外设备可能会欠压复位工人可能误拔电源串口线可能有干扰导致误码率上升这些概率单独看都不高但乘上上百台设备、每台一年升几次固件“升级失败”几乎必然出现。覆盖式OTA最尴尬的地方是一旦失败连重试的机会都没有。1.2 AB OTA把“升级失败”变成“白忙一场”AB OTA的设计思想可以概括成升级不碰正在运行的系统先把新固件写入另一个完全独立的分区确认新固件可运行后再切换启动位置。也就是说Flash里同时存在两个App分区A区日常运行B区平时空闲。要升级时Bootloader把新固件写到B区写完通过校验后把启动标记从A切到B下次复位就从B启动。如果B区校验失败或者根本跑不起来Bootloader还能退回A区继续工作。这样做的好处很明显升级过程里不管怎么断电最多是B区写入失败A区的旧固件始终完好。等下次上电Bootloader发现B区不可用直接引导A区设备还是原来的系统只是“升级这一趟白忙了”。这个特性在远程升级场景里尤其重要因为它把用户体验从“设备变砖”拉低到“升级失败自动回滚”两者是完全不同的量级。顺便说一句经常有人搜“bootloader双分区ab分区”其实A/B的说法指的是App分区的双份镜像Bootloader本身始终只有一个分区。A/B两个分区互为备份Bootloader负责选择从哪一个启动千万不要理解成两个Bootloader互相切换那是另一个话题了。1.3 AB OTA的“AB”到底指什么A区是当前稳定运行的AppB区是升级目标或者备用App。Bootloader启动后根据Flash里记录的状态字段决定去哪个分区加载固件。升级时Bootloader把新固件写入当前不运行的那个分区写满、校验通过、更新状态再复位启动新分区。这套机制在汽车ECU、Android系统里已经是标配放到STM32F103这种资源相对有限的MCU上核心逻辑并不会改变需要重新设计的只是怎么把空间省出来、把流程做轻。我见过有人为了少占几十KB Flash简化掉回滚逻辑或者压缩分区状态管理最后都出了问题。AB OTA最精华的部分就是那套“启动-验证-回滚”闭环一旦为了省空间砍掉它就退化成一个复杂版覆盖式升级没有任何意义。2. F103的Flash分区表从选型到地址分配2.1 先选对芯片不是所有F103都适合AB OTASTM32F103系列从C8T6到ZET6都有Flash容量从64KB跨度到512KB页大小也不一样。这里要先搞清楚一个硬约束AB OTA至少需要Bootloader区、App A区、App B区、状态标志区四块空间每个App分区又必须留出足够余量否则固件膨胀一点就没法升了。我整理了一张常用的型号对比方便你判断手里的料能不能做AB OTA。型号FlashRAM页大小AB OTA可行性STM32F103C8T664KB20KB1KB很紧张只适合极简固件STM32F103RCT6256KB48KB2KB推荐起步型号STM32F103ZET6512KB64KB2KB空间充裕随便规划C8T6是市面上最常见的一款F103很多开发板都是它。但64KB Flash要拆出Bootloader加上两个App区每个App区可能只剩20KB左右如果固件体积超过20KB就会很痛苦。所以我的建议很直接新设计产品做AB OTA至少从RCT6起步ZET6最好。如果你的产品已经定死C8T6且只能用C8T6可以继续看后面的分区策略选择紧凑方案但要有心理准备后面每加一个功能都要反复算空间。还有一个细节需要关注不同型号的Flash页大小不同C8T6是每页1KBRCT6和ZET6是每页2KB。擦写Flash的单位是页这意味着擦除一次的最小粒度就不一样。后面写状态标志区和升级缓存区的时候这个差异会影响你写Flash的代码逻辑。2.2 256KB Flash的分区表与元数据设计以STM32F103RCT6的256KB Flash为例我的分区表长这样区域起始地址大小用途Bootloader0x0800000032KB引导、升级主逻辑App A0x0800800096KB常规主固件App B0x0802000096KB目标升级区状态与配置区0x0803800032KB分区状态、元数据、升级缓存Bootloader给32KB而不是更少是因为标准外设库加CRC32查表函数、串口接收状态机、Flash驱动代码加起来已经有一定体积再考虑以后扩展升级指令32KB比较从容。两个App区各96KB对大多数F103数据采集、控制类项目来说够用如果产品功能多可以换成ZET6并适当扩A、B区。状态区32KB看着大实际只是用它存少量元数据留下这么多空间是为了让一页或者几页Flash轮流承担擦写磨损。分区表里最关键的是一段叫分区元数据partition meta的存储结构它必须放在一个独立的Flash扇区里。我用的结构体大概长这样#define FLAG_AREA_BASE 0x08038000u #define META_MAGIC 0xA5A5A5A5u typedef enum { PART_STATUS_TRIAL 0x01, /* 试运行状态等待App自检通过 */ PART_STATUS_ACTIVE 0x02, /* 运行正常可以引导 */ PART_STATUS_FAILED 0x04 /* 校验失败或运行失败 */ } part_status_t; typedef struct { uint32_t magic; /* META_MAGIC */ uint32_t version; /* 固件版本号 */ uint32_t length; /* 固件有效长度 */ uint32_t crc32; /* 整包CRC32 */ uint32_t status; /* part_status_t */ } partition_meta_t;每次读写元数据时要注意F103的Flash写操作只能把1写成0不能把0写回1要更新一个字段必须整页擦除。虽然状态区划分了32KB实际上可能只用其中一页2KB但擦写次数多了会有Flash寿命问题。一个简单有效的方案是让32KB里的多页轮流承载元数据写坏了换下一页这样能大幅延长擦写寿命。对单个设备来说升级次数通常不多简单实现也能跑很久。2.3 Bootloader的启动流程约定分区表定下来之后整个系统的启动流程就固定了。我的Bootloader在每次上电复位后执行的逻辑是读取状态区元数据确定当前要引导的分区是A还是B校验目标分区的magic字段、栈顶地址范围、整包CRC32校验通过设置中断向量表地址跳转到该分区校验失败切换另一个分区重新校验两个分区都不可用原地进入串口升级模式等待接收固件。这套流程的本质是“多一次校验多一条退路”。在实际项目里目标分区校验通过不代表App一定能跑起来比如固件bug导致上电就死循环所以还需要App启动后的主动上报机制配合这部分放到后面章节展开。3. Bootloader核心实现状态判断、校验与跳转3.1 上电后Bootloader该做什么Bootloader是整个AB OTA的心脏它的初始化越精简越好。我见过有人把Bootloader写得很重初始化一堆外设、跑一个RTOS最后跳转前再反初始化结果问题频出。正确的做法是Bootloader只在需要时初始化必要外设跳转之前把所有外设状态恢复干净。以一个串口升级的Bootloader为例复位后的流程是初始化时钟树、系统滴答如果起来第一件事就要打印日志再初始化一个串口读取状态区元数据得到当前要引导的分区对目标分区进行合法性校验如果分区A/B都不可用初始化串口进入升级等待状态校验通过关闭全局中断跳转。这里有个容易踩的坑Bootloader里的延时函数如果依赖SysTick跳转前一定要把SysTick停掉。否则App启动后SysTick还是Bootloader配置的状态如果App自己也用SysTick做时基节拍完全错乱后面排查起来很头疼。3.2 分区状态机与回滚逻辑状态管理我按照“ATRIAL - ACTIVE - FAILED”的方式运转。新固件写完并校验通过后目标分区状态被写成ATRIAL表示“可以跑但还没自检通过”。Bootloader启动ATRial分区后App如果跑起来且自检正常就主动把元数据改成ACTIVE。这样一来终极判定权在App手里而不是Bootloader单方面相信CRC。如果App跑不到自检通过那一步系统看门狗会超时复位。复位后Bootloader再次读元数据发现刚才那个分区还是TRIAL状态没有变成ACTIVE于是切换到另一个分区启动完成回滚。我建议再加一个计数逻辑每次TRIAL分区启动失败就累加一次连续失败三次后把该分区标记为FAILED避免“每次启动都走一遍TRIAL然后再回滚”的死循环。这个计数可以放在状态区的另一个槽位里不要和分区元数据混在一起。3.3 App跳转函数与中断向量表重定向跳转是Bootloader的最后一公里实现起来不复杂但必须考虑清楚。Cortex-M3内核的CPU复位后从地址0x08000000读取初始栈顶和复位中断向量我们的App链接在0x08008000Bootloader要跳过去核心工作就是修改向量表和栈指针。下面是一个可以直接用的跳转函数typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t app_stack *(volatile uint32_t *)app_addr; uint32_t app_reset *(volatile uint32_t *)(app_addr 4); pFunction app_main (pFunction)app_reset; __disable_irq(); /* 恢复外设到初始状态 */ SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; /* 关闭Bootloader初始化过的外设和NVIC中断 */ deinit_all_peripherals(); NVIC_ICER0 0xFFFFFFFF; /* 清除所有使能的中断 */ NVIC_ICPR0 0xFFFFFFFF; /* 清除所有挂起的中断 */ /* 重定向中断向量表到App分区 */ SCB-VTOR app_addr; /* 用App向量表里的初始栈顶覆盖当前MSP */ __set_MSP(app_stack); /* 跳转到App的复位中断函数 */ app_main(); }关键点有三个。第一向量表第一项是初始栈顶第二项是复位中断入口所以app_addr处取出的栈顶会赋值给MSPapp_addr4处取出的是Reset_Handler地址。第二跳转前关闭全局中断并清除挂起中断是必须的否则跳转瞬间如果有中断正好触发它拿到的向量表可能还是Bootloader的或者外设状态混乱导致App启动异常。第三SCB-VTOR写的是新的中断向量表基地址也就是App分区起始地址这样跳转之后所有中断都会从新的向量表取入口。有人会问STM32F103的VTOR能不能直接用我实测是可以的。但老一批芯片勘误表里提到VTOR存在问题所以保守做法是跳转前用汇编方案手动设置向量表或者绕道SRAM。在实际项目里绝大多数F103用SCB-VTOR都没有问题如果你遇到了诡异的中断异常再考虑这种特殊兼容方案。4. App工程改造链接地址、向量表重定向、双固件编译4.1 让Keil、GCC、IAR把App编译到指定地址Bootloader写完只是第一步App端如果不做任何改造直接烧0x08000000地址的固件Bootloader跳转过去必死。App必须被链接到它自己所在的分区地址比如A区0x08008000B区0x08020000。在Keil MDK里操作路径是“Options for Target - Target”把IROM1的Start改成App分区地址Size改成0x18000。比如编A区固件就是Start 0x08008000、Size 0x18000编B区固件就是Start 0x08020000、Size 0x18000。注意改完一定要重新全量编译链接地址一变整个固件镜像的绝对跳转地址都会变。GCC环境则是改链接脚本里的ROM起始地址MEMORY { FLASH (rx) : ORIGIN 0x08008000, LENGTH 96K RAM (xrw) : ORIGIN 0x20000000, LENGTH 48K }IAR用户要去改ICF文件里的ROM起始地址。核心思想只有一个让链接器知道这段代码的绝对地址在哪编译器生成的跳转、常量访问都会自动适配。4.2 应用启动时的中断向量表处理App固件链接到0x08008000之后芯片上电默认从0x08000000取向量这显然不对。所以App在main函数最前面必须把向量表指到自己的分区地址#define APP_ADDR 0x08008000u int main(void) { /* 必须在使能任何中断之前设置VTOR */ SCB-VTOR APP_ADDR; /* 初始化时钟、外设、业务代码 */ SystemInit(); ... }如果你用的是ST的HAL库或者标准库启动文件system_stm32f1xx.c里通常会根据VECT_TAB_OFFSET宏设置VTOR。最常见的一个坑是VECT_TAB_OFFSET还保持默认0App启动后中断全部失效或者跳转后第一次进中断就HardFault。我的建议是直接在main最前面显式写SCB-VTOR不依赖system文件的宏这样每段代码的意图最清晰。另外要注意Bootloader跳转前已经关掉全局中断App刚启动时中断是关着的这是一个好的状态。App在做完基础初始化、设置好VTOR之后再按自己的需求开中断没有毛病。4.3 生成A/B两个分区的固件同一份源码要生成App_A.bin和App_B.bin最快的办法是维护两个编译配置分别指向A区和B区地址。Keil里可以建两个Target名字叫App_A和App_B各自IROM地址不同编译时选中哪个Target就产出哪个bin。两个Target共用源码只是在编译宏里可以区分当前分区方便代码里写日志或者上报当前运行分区。生成bin文件Keil在User页签After Build里加一句fromelf --bin --output .\build\App_A.bin .\build\App_A.axfGCC用arm-none-eabi-objcopy -O binary App_A.elf App_A.bin这里要提醒一个反直觉的误区有人以为把App_A.bin整体搬到偏移0x18000就能变成B区固件这是错的。因为固件内部的绝对跳转地址在链接时就固定了A固件内部的函数跳转都指向0x08008000附近直接搬过去虽然内容还在但跳转全错。所以A/B两个固件必须分别编译。5. 升级链路从固件下发到分区切换的完整流程5.1 串口升级帧协议设计Bootloader升级模式里最关键的任务是从上位机接收固件并写入目标分区。协议不用设计得太复杂但“帧边界、长度、校验、应答”这四样必须齐。我用的一个精简协议如下帧类型帧内容START帧帧头2字节 命令0x01 目标分区 固件总长4字节 版本号4字节 整包CRC32 4字节DATA帧帧头2字节 命令0x02 16位序号 数据块256字节 帧CRC32 4字节END帧帧头2字节 命令0x03 整包CRC32 4字节ACK帧Bootloader回复 0xAA 0x55 状态字节帧头我用0xA5 0x5A解析状态机是“收到0xA5再收到0x5A才算帧头成立”避免普通数据串扰触发错误解析。DATA帧固定256字节配合RCT6的2KB一页Flash正好8帧凑一整页方便按页擦写。每一帧都带CRC32接收到一帧立刻校验不对就丢弃并等待重传不阻塞整个流程。上位机用Python写最方便pyserial打开串口发START帧等ACK接着循环发DATA帧每收一帧ACK发下一帧。写入期间Bootloader返回ACK就是“这一帧已落Flash”这样上位机可以拿ACK当流控信号。5.2 Bootloader里的Flash写入实现F103的Flash编程有几个硬性规则必须先解锁再擦页再编程半字16位。页大小根据芯片不同是1KB或2KB。解锁操作是向KEYR寄存器写入两个固定值FLASH-KEYR 0x45670123u; FLASH-KEYR 0xCDEF89D9u;擦页调用FLASH_ErasePage编程调用FLASH_ProgramHalfWord。擦除和写入期间Flash控制器会暂停从Flash取指令所以这段代码必须放到RAM里执行否则很可能卡死在写入状态。Keil里用__ramfunc修饰函数GCC里用__attribute__((section(.ramfunc)))链接脚本需要把该段放到RAM地址。这一段是我认为整个Bootloader里最容易翻车的点因为很多人直接在Flash里调用Flash写函数小数据量碰巧没事但擦页或者连续写入时一旦卡死连HardFault都不一定进得去就是复位或者挂起。稳妥做法是把擦页、写半字这两个函数连同FLASH_BUSY等待全部扔进RAM执行。数据接收写入的伪流程收到START帧确认目标分区擦除目标分区所有页收到DATA帧校验通过后把256字节缓存起来攒满2048字节一页调用RAM版擦写函数写入Flash所有DATA帧发完后收到END帧对整包做CRC32校验校验通过把目标分区元数据写为TRIAL把另一个分区标记为回退分区软件复位Bootloader引导新分区。5.3 提交、回滚与看门狗配合升级完成后新分区是被Bootloader标记为TRIAL的Bootloader不能在新分区自检之前直接把它标记成ACTIVE。App侧的逻辑是主程序跑起来外设初始化完成业务主循环正常运行几秒之后把分区元数据改成ACTIVE表示“这个分区可以长期使用”。如果App一直没改状态看门狗就会不断复位。这里我给Bootloader加了一个启动计数器每次启动TRIAL分区失败就加一连续三次失败则把分区标记为FAILED然后强制切换到另一个分区。这样做的好处是避免某些故障固件每次都能卡在启动早期看门狗复位后又会再次选择它形成死循环。一个值得留意的设计点是App在把TRIAL改成ACTIVE时需要写状态区。写Flash操作发生在App运行阶段此时App可能正在Flash里执行代码如果写入和取指发生冲突同样要小心。我的处理是把状态写入函数也放到RAM执行或者在App启动后只允许一次状态升级状态升级完成后立刻重开看门狗。这块代码务必仔细。6. 从零复现过程中的高频踩坑记录6.1 症状跳进App就HardFault这个坑我第一次做AB OTA时浪费了整整一个晚上。现象是Bootloader打印“jump to app”后再输出就是HardFault。排查链路我建议按这个顺序走先看App起始地址的向量表内容。用调试器读0x08008000地址看第一个32位值是不是0x2000xxxx附近。如果不是说明App链接地址不对或者没写到目标Flash地址。再看第二个32位值它应该是指向App的Reset_Handler你可以通过map文件确认这个地址。然后检查跳转函数里有没有正确设置MSP。如果直接调用app_main而不是先__set_MSP(app_stack)栈指针还是Bootloader的栈一旦App压栈或者调用函数就会炸。我当时就是漏了这一步跳过去之后App初始化时用了错误的栈。还要检查跳转前是否把全局中断关干净。有一次我的代码在跳转前只调用__disable_irq()没清NVIC挂起位结果跳过去App刚开外设一个Bootloader阶段悬空的中断请求立刻触发但App的中断服务函数入口和Bootloader预期完全不同直接HardFault。跳转前把NVIC_ICPR清零这个是标配。6.2 症状Bootloader里初始化过外设App一开外设就异常我自己的Bootloader为了显示升级进度初始化了串口和GPIO。跳转前图省事没有关闭串口中断结果App初始化到串口时串口外设中断标志还残留着App一使能NVIC对应通道中断立刻进来而App的中断向量表刚设置完可能还没准备好全部外设直接跑飞。解决思路跳转函数里对Bootloader用过的外设逐个DeInit把相关NVIC通道Disable把PendSV、SVCall、SysTick这些系统异常也处理干净。简单粗暴的办法是调用一个peripherals_deinit函数把用到的外设寄存器全部复位再把NVIC所有通道清掉。这个函数要放在跳转前虽然麻烦一点但能省掉后面大量调试时间。6.3 症状CRC校验明明一样还是回滚升级过程中最诡异的现象是固件已经写完了Bootloader整包校验也返回成功但重启后却回滚到旧分区。查到最后发现是CRC算法不一致。上位机我用Python的zlib.crc32计算整包CRCBootloader端一开始图省事直接用了STM32F103片内硬件CRC外设结果两边算出来的值永远对不上。原因是STM32F103的硬件CRC实现和常见软件CRC32不是同一个多项式变体初始值、输出翻转、字节序处理都不同直接混用必然失败。解决方案很直接上位机和Bootloader统一用软件查表CRC32。在72MHz主频下对96KB固件跑一遍查表CRC32大概只需要几十毫秒完全可以在启动校验和整包校验里用没必要去折腾硬件CRC外设。如果你的代码想复用现有协议就坚持以“同一份算法、同一个初始值、同一字节序”为原则上位机和下位机保持一致。6.4 串口速率与Flash擦写时间打架如果不做流控升级速度根本不是串口理论带宽决定而是受Flash擦写速度限制。F103擦一页2KB Flash典型时间是20到40毫秒。如果串口用115200bps接收2KB数据大约要178毫秒远大于擦页时间可以一边收一边擦。如果用921600bps接收2KB只需约23毫秒擦页时间反而成了瓶颈上位机连续发帧Bootloader还没来得及擦完上一页下一帧又来了丢帧之后就很难收场。所以我建议Bootloader升级串口速率设在115200或者230400简单可靠。如果一定要高速率就在每帧CRC校验通过后先回ACK上位机收到ACK再发下一帧把ACK当流控信号。我在项目里用的就是ACK流控这样速率即使调到460800也能稳定跑不过综合考虑稳定性量产设备还是压到115200比较让人放心。另外Bootloader执行Flash擦写期间如果Flash代码区域正忙任何从Flash取中断向量表的操作都可能挂住。所以我在进入擦写流程时会临时关掉串口中断改用轮询方式收帧等擦写完成再开中断。这种做法虽然有一点丢帧风险但因为整个升级流程是线性的配合ACK流控和超时重传并不会影响最终成功率。DMA加双缓存是更优雅的方案但对于Bootloader这个场景轮询已经够用。做完这个STM32F103的AB OTA复现之后我最大的体会是AB OTA的难点不在于把固件从串口搬到Flash而在于构造一套“坏了能回来”的闭环机制。向量表重定向、链接脚本、RAM执行擦写函数这些都是基础功课真正决定这套系统能不能用在实际产品里的还是回滚逻辑、启动验证和异常兜底这些细节。如果你也开始动手做建议先把Bootloader的跳转和App的向量表重定向跑通再逐步加入双分区、校验、回滚。不要一上来就想把完整升级链路做完那样定位问题会很痛苦。F103上的AB OTA完全可行资源也没那么紧张关键是把每一步的边界条件想清楚。