ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32F103 AB双分区OTA升级方案详解

STM32F103 AB双分区OTA升级方案详解 手里正好有一个量产项目要加远程升级功能我在选型阶段把常见方案都过了一遍最后在STM32F103上落地了一套基于AB双分区的OTA升级框架。前后设计和调试花了不少时间也踩了不少坑。这个方案的核心就一句话用双备份Flash分区把“升级过程”和“当前运行”彻底隔离任何异常都能回滚到上一个可用版本。这篇文章我会从方案选型、内存规划、Bootloader设计、固件打包、服务端配置、App流程实现到问题排查完整记录整个从零复现的过程。不管你是刚接触OTA的新手还是已经在做IAP想升级方案的开发者这套思路都值得参考。尤其是STM32F103这种没有硬件双Bank的芯片AB分区怎么做、跳转怎么写、校验怎么设计文章里都会给出可直接抄作业的方案。1. 为什么是AB分区方案背景与选型思路1.1 传统OTA方案的两个老大难问题传统IAP升级的核心链路是Bootloader接收固件写入App区然后跳转。这个方案在实验室里很好用真正到量产现场就暴露问题了。最典型的场景有两个。第一个是掉电变砖。升级过程中突然断电Flash里可能只写了一半App区既不是新程序也不是旧程序设备再也起不来只能返厂用烧录器救砖。对于几百上千台已经在客户现场运行的设备这是完全不可接受的风险。第二个是版本回退难。哪怕固件完整写入成功了新版本代码有Bug、设备启动就死机你还是回不到旧版本。传统IAP里想加回退逻辑就要额外做“备份区”整体复杂度一下就上去了。这两个痛点指向同一个解决方案不要在原地覆盖升级而是准备两个独立的App分区平时跑A区升级写B区写完后切换启动入口。这就是AB分区A/B Slot的核心思路。1.2 AB分区与“临时下载区”方案的对比有人可能会问那我单独划分一块“下载区”先把固件收完整再拷贝到App区不也能防掉电吗这确实是一种常见做法许多产品的“双备份升级”其实就是这么做的但它有几个短板。第一临时下载区方案多了一次“搬运”过程。数据先从下载区读到RAM再写入App区整个过程耗时翻倍而且Flash块数占用其实差不多。第二如果App区写入中途失败你确实还保留着下载区的数据可以重试但如果你同时只有一份运行固件可启动一旦App区被擦除过当前系统其实已经不能正常运行了。说白了就是这种方案防住了“下载中断”没防住“切换中断”。AB分区在这一点上要干净得多。任何时刻Flash里都存在至少一份完整可启动的固件。升级动作对当前运行系统零影响哪怕切换标志位写到一半掉电Bootloader也能根据标志位状态回退到另一个分区。可靠性的起点完全不同。1.3 方案能力边界与适用场景我需要提前说清楚这套方案的边界。本方案适用于内部Flash容量在256KB以上的STM32F103型号例如RCT6、RBT6等。如果用的是C8T6这类128KB的芯片跑完Bootloader加两个App分区会比较紧张除非你的App固件压缩后能压到40KB以内否则还是建议换大容量型号或走外部Flash方案。性能上AB分区不会给运行时带来额外开销启动时Bootloader只做了一个“判断跳转”动作几乎可以忽略不计。真正占资源的是升级过程中的下载与校验逻辑这部分全部放在App里执行Bootloader只负责最终裁决模块职责很清晰。2. 内存规划与Bootloader设计2.1 以STM32F103RCT6为例做Flash分区我使用的芯片是STM32F103RCT6256KB Flash48KB RAM资源在F1家族里算中等偏上。分区规划我建议在工程搭起来之前就先定死不然后期改地址牵扯到链接脚本、跳转逻辑、固件打包脚本工作量非常大。下面是我使用的分区表区间起始地址大小用途Bootloader0x0800000032KB启动引导、升级裁决App A0x0800800096KB默认运行分区App B0x0802000096KB升级目标分区Flag区0x080380004KB启动标志、升级状态保留0x0803900028KB预留选32KB给Bootloader是因为我们要在里面放串口驱动、Flash驱动和基础打印标准外设库编译下来大约20KB多一点32KB留了余量。96KB给每个App分区对绝大多数F103应用足够当然具体还要看你项目实际固件大小编译完固件后查看.map文件即可确认。Flag区非常关键。AB分区并不只是“两个App区来回跳”这么简单还需要一套可靠的标志位来记录当前状态。我用了一整页4KB的Flash空间存放这些标记虽然只用了几十个字节但单独划分是为了避免擦除操作误伤其他数据整页操作也更方便不用先读改写。2.2 Bootloader启动流程判断标志位与选择分区Bootloader的主流程可以简化成下面几条路径读取Flag区标志判断当前激活分区是A还是B。如果检测到“升级待确认”标志说明上一次升级没有完成确认流程此时启动回退策略直接跳转到上一次运行的分区。如果分区校验头部魔数、CRC失败同样回退到另一分区。正常情况跳转到激活分区对应的App入口地址。很多人会把“当前运行分区”这个概念搞混。AB分区里的激活分区不只是“上次写到的那个分区”而是“当前应该运行的分区”。我在Flag区里设计了三个核心字段当前激活分区0xAA表示A0xBB表示B、上次成功启动的分区、升级标志位。每次启动时Bootloader优先处理升级标志再根据标志合法性决定启动路径。设计上还有一个小细节启动数。如果在升级确认前系统反复重启说明新版本大概率有问题这会触发出厂保护机制连续失败N次后强制回退到旧版本。这样比单纯依赖CRC校验更保险因为有些Bug只在特定运行条件下才会触发单靠启动时静态校验根本查不出来。2.3 跳转App的关键代码与中断向量表处理跳转逻辑是Bootloader的核心。先看代码再解释为什么这么写。#define APP_A_ADDR 0x08008000 #define APP_B_ADDR 0x08020000 typedef void (*pFunction)(void); void Jump_To_App(uint32_t app_addr) { uint32_t app_stack *(volatile uint32_t *)app_addr; pFunction app_entry (pFunction)*(volatile uint32_t *)(app_addr 4); if (app_stack 0x20000000 || app_stack 0x20010000) { // 栈顶指针不合法禁止跳转 return; } __disable_irq(); // 清空中断标志 for (int i 0; i 8; i) { NVIC-ICER[i] 0xFFFFFFFF; NVIC-ICPR[i] 0xFFFFFFFF; } // 设置主栈指针跳转 __set_MSP(app_stack); app_entry(); while(1); }这里有几个核心点要展开说。第一跳转前要读两个数据地址处的栈顶指针和复位向量。App工程的启动文件开头就是初始栈指针紧接着是Reset_Handler入口地址这两项决定了App启动时从哪里取栈、从哪里进入。如果直接调用地址加4处的函数指针但栈指针还是Bootloader的App启动瞬间的全局变量初始化会把栈踩得面目全非。第二跳转前关闭所有中断并清空NVIC挂起位。这个坑比较隐蔽。如果Bootloader里用了串口中断或者定时器跳到App后中断响应函数地址可能还是Bootloader的一旦中断触发就会跑飞。NVIC里如果有挂起的中断App使能对应中断后也会立刻进入异常。清空NVIC状态是必须的。第三STM32F103的中断向量表问题。F103是Cortex-M3内核这个内核在F1这个型号上并没有像F4那样完整的SCB-VTOR重映射机制STM32F103设计时Cortex-M3还比较早期。这意味着App工程在编译时就要把中断向量表的偏移量写死不能靠Bootloader跳转前动态设置。对应的处理方式是在App工程中修改启动配置。如果是标准外设库在system_stm32f10x.c里找到VECT_TAB_OFFSET宏把它改成App的偏移地址。比如App跑在A区0x08008000那么偏移量就是0x8000App跑在B区时偏移量是0x20000。这也是为什么AB分区通常需要编译两份App固件或者使用带偏移参数的统一镜像压测时我就因为忘了改这个宏出现过App跳转后进HardFault的情况。3. 固件打包、镜像生成与服务端准备3.1 为固件增加头部信息校验字段的意义直接裸传一个.bin文件也能做OTA但生产环境里基本没人这么干因为你没法在单片机上判断这个文件是不是当前设备使用的、版本够不够新、传输过程有没有损坏。所以我在固件头里增加了元信息。我使用的固件头结构如下typedef struct __attribute__((packed)) { uint32_t magic; // 魔数固定为0x544F4142TOAB uint32_t version; // 版本号 0x0102 v1.2 uint32_t length; // 固件原始长度 uint32_t crc32; // 固件数据CRC32 uint32_t target; // 目标分区 0xAAA, 0xBBB uint8_t reserved[12]; // 保留字段 } firmware_header_t;魔数用来快速判断这是不是合法的固件头防止把随机数据当升级包解析。版本号用于App侧判断是否比当前版本新避免重复升级。CRC32是整个固件数据区的校验值下载完成后逐块计算比对确保数据完整。这里补充一个我个人强烈建议在头部添加target字段指明这个包要写入A区还是B区。这样服务端可以生成多个升级包精确控制设备升级路径而不需要设备自行计算下一个分区是哪个。虽然逻辑不复杂但明确写在包里面更稳妥日志排查时也一目了然。3.2 用Python脚本生成带头部信息的升级包Keil编译完成后生成的是裸.bin文件下一步是用脚本给这个bin文件加上头部信息。我用的Python脚本简化逻辑如下import struct import zlib import sys def build_firmware(bin_path, out_path, version, target): with open(bin_path, rb) as f: data f.read() crc32_val zlib.crc32(data) 0xFFFFFFFF header struct.pack(IIIII16s, 0x544F4142, # magic version, # version len(data), # length crc32_val, # crc32 target, # target 0xAA / 0xBB b\x00 * 16) # reserved with open(out_path, wb) as f: f.write(header) f.write(data) if __name__ __main__: build_firmware(app.bin, app_v1.2.bin, 0x0102, 0xAA)打包脚本主要注意两点。第一是版本号的规则我采用的是一个32位整数按字节拆分版本主版本占高字节依次类推比如0x0102表示v1.2。如果后续版本号超过255就要改结构体所以最初设计时最好预估够用不然兼容性会痛苦。第二是务必在编译完成后固定一个软件版本号最好能和git tag对应上避免现场升级时出现“版本号没变但内容变了”的尴尬情况。3.3 用nginx搭建简单的固件升级服务器升级服务器我用的nginx配置非常简单甚至比完整Web服务还轻量。核心就是一个静态文件服务把编译好的固件放到指定目录设备用HTTP GET就能下载。server { listen 8080; server_name _; root /data/firmware; autoindex off; location /ota { alias /data/firmware; } }这样设备端访问http://服务器IP:8080/ota/app_v1.2.bin就能下载固件。实测下来nginx做这个场景非常稳高并发几十台设备同时下载也没什么压力比写一个Python HTTP服务器靠谱得多。服务器端还可以按需求扩展一个简单版本管理接口。比如放一个latest.json文件内容是设备端查询版本所需的信息产品型号、最新版本号、固件下载URL。设备启动时先请求这个接口判断是否需要升级。这样版本发布和回滚不用改设备逻辑换一下服务端配置就行。服务端的重点其实不在nginx本身而在下载接口的幂等性设计。设备下载中断后重新请求服务器只需要继续返回原始文件即可不像大文件传输那种还需要断点续传支持。因为我们的固件包通常只有几十KB几秒钟内就能下载完断点续传的意义不大。4. App升级流程实现从检测到切换4.1 App侧升级状态机设计App侧是整个升级流程中代码量最大的模块。我认为最容易理解的做法是把它设计成一个有限状态机每个状态明确职责状态之间通过事件触发转换。状态定义如下状态含义触发条件IDLE空闲不执行升级逻辑启动时默认状态CHECK查询服务器版本用户触发或定时触发DOWNLOAD下载固件数据服务器有新版本VERIFY校验固件完整性下载完成REQUEST请求切换分区校验通过REBOOT重启系统写入标志位完成这里最关键的体会是不要把升级逻辑散落在业务代码里。用状态机管理之后每个状态内部的逻辑变得非常简单测试也容易覆盖。状态机的驱动我放在系统主循环里每次循环轮询一次当前状态。下载固件这种耗时操作放到状态机内部以块为单位分片处理每下载一块数据就写一块Flash写完成后返回继续主循环这样不会阻塞其他任务。如果用了RTOS可以开一个独立线程跑状态机道理一样。4.2 下载协议选择与分片写入策略下载和写入是整个流程里最需要仔细设计的两个环节。传输层我用的HTTP GET方式固件服务器支持Range请求头的话可以灵活分片拉取数据。但为了简化我直接做的是整体下载。因为F103的Flash是半字写入片擦除需要整页操作每页1KB或2KB所以写入策略是先擦除目标分区所有页然后按页写入新接收到的数据。写Flash的标准操作顺序解锁Flash等待BUSY标志清零按顺序擦除每一页FLASH_ErasePage检查擦除结果按半字方式写入数据FLASH_ProgramHalfWord锁定Flash有几个细节值得注意。写Flash时F103必须保持16位对齐也就是每次写入2字节。如果你的数据Buffer没有对齐写之前要做偏移修正否则会进入硬件错误。另一个经验是不要每收到一个块就擦一次页这样效率极低。我用PingPong双缓冲先填满RAM缓冲再一次性写入Flash对应页顺序和连续性都很好。擦除是整个升级过程最耗时也最怕掉电的操作。我的策略是每擦除一页就记录当前擦除进度到Flag区这样如果擦除中途断电重启后Bootloader可以检测到擦除未完成并自动重新执行擦除不会残留下半擦的页导致校验失败。这个细节让可靠性上了一个台阶。4.3 标志位切换与重启后Bootloader裁决固件下载校验完成后App需要把自己的“升级意图”告诉Bootloader。这个动作通过写Flag区完成。写入步骤分为两步。第一写“升级待确认”标志记录当前目标分区的标识。这时系统还没有切换当前App仍然正常运行。第二写“激活分区切换”标志把激活分区从当前分区指向新分区。写完这个标志后App调用NVIC_SystemReset()重启。这里有一个我在实际调试中发现的问题如果两次写标志之间没有做Flash缓存刷新或者编译器优化掉了冗余标志写操作重启后Bootloader可能读到半新半旧的状态。解决方法是每次写标志后至少等Flash操作完成并校验读回一致再做下一步。这个校验开销很小但能有效避免潜在异常。Bootloader启动时则按下面逻辑判断if (flag.upgrade_pending 1) { // 上次升级未确认回滚到旧分区 target flag.old_active; } else if (flag.active 0xAA) { target APP_A_ADDR; } else { target APP_B_ADDR; }新App启动后要做两件事正常运行业务的同时在启动3~5秒后向Flag区写“升级成功确认”标志清除upgrade_pending。这样下次重启Bootloader会认为升级流程已经完成不再执行回退。这个逻辑保证了如果新App根本起不来或者起来后立即崩溃Bootloader在下一次启动时会自动回退到旧分区用户感知是设备重启后回到旧版本而不是设备变砖。需要特别提醒的是App的“启动成功确认”不能写得太早。我在开发时就试过App刚初始化就发确认结果某次新版本在后续某个驱动初始化时死循环依赖的确认已经发送Bootloader就不会回退了。所以确认时机一定要放在业务启动完成且关键驱动初始化成功之后我给自己的标准是启动后30秒无重大错误才发确认宁可回退慢一点也要保证确认的可靠性。5. 移植与排查实录5.1 常见问题速查表这部分内容每一条都来自我的实际调试记录按出现频率排序整理成表格方便大家排查。现象可能原因解决办法跳转App后无反应仿真器看到PC跑飞栈顶指针/复位向量读取失败或App偏移配置错误检查App地址取值确认VECT_TAB_OFFSET已修改App启动后进HardFault跳转前未关闭中断/NVIC保持挂起跳转前执行__disable_irq()并清NVIC下载中掉电重启后Bootloader循环重启擦除进度未记录导致每次启动都擦一半每页擦除后写标记启动时检测擦除中断CRC校验总是失败固件包含头部后长度计算不对确认CRC计算范围、magic偏移和大小端串口打印正常但网络下载速度极慢TCP窗口设置过小、每次写入数据量太少增大RAM缓冲区单次下载和写入块尽量加大升级完成后重启死机新App存在初始化Bug且确认标志写入过早延迟确认建议至少30秒后Bootloader增加失败回退Flash写入时卡死FLASH忙等待死循环或地址未对齐检查Flash解锁状态和地址对齐增加超时保护5.2 踩过的三个“学费坑”写文章之前我想过要不要把一些“丢脸的失败经历”删掉后来想想这些才是最值钱的教训干脆放出来。第一个教训是关于链接脚本的。最初做AB分区时我把App A和App B编译成两份不同的工程结果出现了同一个BugApp A能正常跳转App B跳转后跑几秒就死机。反复查了一个下午才发现App B工程的VECT_TAB_OFFSET还是0x8000没有改成0x20000。中断向量表全指到了A区运行当然出问题。后来我把这个值做成编译期宏在打包脚本里检查地址和偏移量的对应关系从源头避免类似问题。第二个教训是关于Flash数据缓冲区的对齐。STM32F103写入Flash时要求半字对齐一开始我直接用了一个uint8_t数组然后把数组指针强转成uint16_t去写。结果某些优化等级下编译器给数组分配的地址没有对齐到偶数边界程序直接跑进HardFault。后来我把缓冲区定义改成__align(4) uint8_t buffer[1024];一次性解决。这种问题在仿真时不会发现因为仿真器环境下的内存布局可能不同所以我在代码里加了编译期断言确保缓冲区地址对齐。第三个教训是关于升级确认逻辑我上文提到过“确认过早导致回退失败”这里补充细节。当时我把确认逻辑放到了OS启动后第一个任务里距离复位只有几百毫秒。结果某次发布了一个在外部传感器通信初始化会卡死的版本设备升级后一直死循环但因为确认已经发出Bootloader认定升级成功不会回退。我当时的处理方式是临时用串口命令手动切回旧分区但现场没有串口只能返厂。后来我改成双重确认第一重是App启动后写入“临时确认”第二重是业务运行30秒后写入“最终确认”。Bootloader只有在“最终确认”之后才认为升级完成否则一律回退。5.3 给大家的移植建议有些同学可能是想把这套方案移植到自己的工程上我最后给几点实操性较强的建议。第一步先把Bootloader最小跑通不做任何升级逻辑只做固定跳转到A区。验证最小系统没问题后再逐步增加标志位判断和回退逻辑。Bootloader这块不要想着一步到位分阶段调试能省很多心力。第二步在App工程里实现Flash驱动和下载逻辑时先做串口传输的OTA版本验证擦除写入没问题再换成网络方式。把“下载通道”和“写入逻辑”两个变量分开调试遇到问题时能快速定位是网络问题还是Flash问题。第三步一定要搭一套自动化测试环境。我用的是三个STM32F103核心板连在一起服务器端同时模拟新版固件和旧版固件自动化脚本可以执行“升级成功”“升级失败”“中途断电”“重复升级”等场景。这套环境帮我发现了至少三四个偶现Bug靠人工手动测试根本复现不出来。还有个务实的建议给Bootloader和App各自加上版本号查询命令可以通过串口或日志上报当前运行版本。我在现场调试时遇到过设备反馈“升级失败还是老版本”其实情况是升级已经成功但因为回退逻辑又切回去了没有版本信息很难判断问题到底出在哪个环节。最后说一个好消息随着这套方案的稳定我在这个项目上的OTA升级支持成本已经降到了很低。现在的流程是编译固件、打版本号、传到服务器、远程点一次升级脚本几十台设备几分钟内全部完成更新以前这些工作都要人工现场烧录成本完全不是一个量级。如果你准备在F103上做OTA我的建议是直接上AB分区不要走“临时下载区再拷贝”的过渡路线。虽然前期Bootloader的代码量和调试成本确实会多一些但一旦跑通后续的可靠性和维护成本都是完全值得的。尤其是现场设备多、维护人员少、需要长期远程运维的项目这套方案的回报周期比你想象中短得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进