news 2026/9/12 7:51:24

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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分区

我使用的芯片是STM32F103RCT6,256KB Flash,48KB 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表示A,0xBB表示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,那么偏移量就是0x8000;App跑在B区时偏移量是0x20000。这也是为什么AB分区通常需要编译两份App固件,或者使用带偏移参数的统一镜像,压测时我就因为忘了改这个宏,出现过App跳转后进HardFault的情况。

3. 固件打包、镜像生成与服务端准备

3.1 为固件增加头部信息:校验字段的意义

直接裸传一个.bin文件也能做OTA,但生产环境里基本没人这么干,因为你没法在单片机上判断这个文件是不是当前设备使用的、版本够不够新、传输过程有没有损坏。所以我在固件头里增加了元信息。

我使用的固件头结构如下:

typedef struct __attribute__((packed)) { uint32_t magic; // 魔数,固定为0x544F4142("TOAB") uint32_t version; // 版本号 0x0102 => v1.2 uint32_t length; // 固件原始长度 uint32_t crc32; // 固件数据CRC32 uint32_t target; // 目标分区 0xAA=>A, 0xBB=>B 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的标准操作顺序:

  1. 解锁Flash
  2. 等待BUSY标志清零
  3. 按顺序擦除每一页(FLASH_ErasePage)
  4. 检查擦除结果
  5. 按半字方式写入数据(FLASH_ProgramHalfWord)
  6. 锁定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编译成两份不同的工程,结果出现了同一个Bug:App 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的代码量和调试成本确实会多一些,但一旦跑通,后续的可靠性和维护成本都是完全值得的。尤其是现场设备多、维护人员少、需要长期远程运维的项目,这套方案的回报周期比你想象中短得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 7:51:02

C++ 调用 OnnxRuntime 部署 YOLOv8:从 ONNX 导出到 NMS 后处理全流程

简介&#xff1a;面向需要在C工程中集成YOLOv8模型进行实时目标检测的开发者&#xff0c;该部署示例包提供了开箱即用的OnnxRuntime调用方案。压缩包共含3个文件&#xff0c;包括两个YOLOv8的ONNX权重文件&#xff08;分别适用于常规检测与分割任务&#xff09;以及一份C推理源…

作者头像 李华
网站建设 2026/9/12 7:49:21

四旋翼无人机制作以及强化学习控制

硬件准备&#xff1a;主控与通信&#xff1a; 遥控接收机&#xff0c; 飞控板&#xff0c; 用于与地面站发送信息的数传1&#xff0c; 用于接收ROS端角度指令的数传2&#xff0c;动力系统&#xff1a; 电调&#xff0c; 四个电机以及旋翼&#xff0c;结构&#xff1a; 机架设计…

作者头像 李华
网站建设 2026/9/12 7:48:00

SpringBoot音乐播放器系统开发与优化实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/12 7:47:43

DeepSeek Harness实战:从零搭建AI Agent完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华