STM32F103 AB双分区OTA完整实现:从Bootloader到App的可靠固件升级
最近又把STM32F103这颗“老古董”翻出来折腾了一遍AB双分区OTA。AB方案在Android和Linux设备上已经很成熟但在F103这种资源紧张的Cortex-M3上从零复现一遍并不像网上很多教程写的那么轻松。这篇文章就是把我完整踩过的坑、最终跑通的代码结构、分区表设计和切换逻辑全部摊开讲清楚适合想真正搞懂OTA核心原理的人。先说清楚这不是一个能直接抄完收工的项目模板而是一套从Bootloader到App端怎么配合的完整思路。如果你手里正好有一块STM32F103最小系统板想给它加上可靠的AB分区OTA升级能力这篇教程能带你从头到尾复现一遍。1. 为什么要给STM32F103做AB双分区OTA1.1 传统单分区OTA的痛点传统MCU OTA方案通常是Bootloader加App单分区模式Bootloader负责接收固件、擦写App区、跳转AppApp区只有一份固件。听起来很简单但升级过程非常脆弱。我在复现时初始用的就是这种单分区IAP测试时故意在写入50%时断电结果板子直接变砖只能通过BOOT0拉高重新进ISP烧录。对个人玩家来说这只是一次重新下载但对量产设备来说远程升级一旦变砖售后成本立刻拉满。单分区的致命问题在于“原地覆写”。不管你是用YModem串口接收还是通过ESP8266/WiFi模块下载HTTP固件写Flash总是要先把旧固件对应的扇区擦掉然后再逐字节写入新数据。只要擦写过程被打断App区就处于一个残缺状态既不是旧版本也不是新版本Bootloader跳转进去大概率直接HardFault。很多产品宁可放弃OTA也不愿意承担这种“升级即变砖”的风险这就是痛点所在。如果你只做本地开发的Bootloader单分区其实也够用毕竟人就在板子旁边随时能用下载器救回来。但一旦设备部署到现场没有维护人员不能开外壳不能连调试器单分区的不可靠就会被无限放大。我自己的项目里需要给一批F103设备做远程固件更新单分区方案在实验室里测试没问题放到真实网络环境里却不敢用所以后来才下定决心换成AB双分区。1.2 AB双分区到底解决了什么AB双分区从名字就能看出来核心思想是把Flash里原本一份App的存储空间分成两个独立的App槽Slot A和Slot B。Bootloader负责维护一个元数据区域记录当前应该启动哪个槽。升级时正在运行的App把新固件写入“非当前槽”写完并校验通过后修改元数据把启动槽切换到新固件那一侧然后复位重启。这样做最大的好处是写入新固件的过程中当前槽没有被触碰。哪怕新固件写了一半断电或者新固件本身有Bug启动不了Bootloader依靠元数据中的尝试次数和回滚逻辑还能回来启动旧固件。对用户来说升级失败了设备还是能正常用体验和可靠性都完全不一样。你可以把AB理解成手机系统的A/B无缝更新在单片机上的精简实现。Android设备常见的“当前系统”和“另一个系统”分区就是同一个套路。MCU上虽然资源有限但F103的内部Flash只要容量够大完全可以把这套逻辑跑起来。它本质上是用空间换可靠性一次搞定断电、写飞、启动崩溃这些OTA的经典问题。1.3 适用场景与硬件条件不是所有STM32F103型号都适合AB。F103C8T6这种64KB Flash、20KB RAM的中容量芯片可以做但App区会被压缩得很小F103RCT6这种256KB Flash的大容量芯片就舒服得多。我在复现时先在一颗C8T6最小系统板上跑通精简版最后代码量其实不大真正被约束的是Flash空间。如果你手里只有C8T6建议先做一个“最小AB演示工程”Bootloader占16KBSlot A和Slot B各占20KB元数据区4KB剩余4KB保留。这样App只能塞进去一个简单的LED闪烁加串口打印程序但AB逻辑是完整的。如果App业务代码体积已经很大直接换RCT6或者F103ZET6分区就可以做到Bootloader 32KBA槽和B槽各64KB剩余空间还能存点参数和日志。RAM方面不用太担心。F103C8T6有20KB RAM做Flash写入时一次读一页数据到RAM缓冲区完全够用。OTA传输链路可以用串口YModem也可以外挂ESP8266走HTTP下载。我在复现过程中两条路线都试了最终线上项目用的是HTTP加分包ACK但排查问题时串口YModem调试起来更快。2. AB OTA 整体架构与Flash分区设计2.1 四个关键角色分工一套完整的F103 AB OTA方案里最少要有四个角色Bootloader、Slot A、Slot B、元数据区。Bootloader放在0x08000000起始地址上电最先运行负责读元数据、选槽、校验固件头部、跳转App同时还要提供一版完整的Flash擦写入口方便在无App时手动升级。Slot A和Slot B各存放一份完整的App固件。App里既包含正常的业务逻辑也包含OTA升级处理代码。运行时App要知道自己当前在哪个槽升级时把新固件写到对侧槽而不是写自己。这个过程听起来复杂实际上只是地址切换的问题。元数据区是整个AB方案的中枢。它里面记录了当前启动槽是A还是B、新固件的尝试次数、固件是否已经确认成功、还有CRC校验值。Bootloader和App都能读写这个区域所以它必须放在Flash末尾的独立位置既不能被Bootloader覆盖也不能被Slot A或B的固件覆盖。我在复现初期就是因为元数据放的位置不对Bootloader一升级就把元数据冲掉了调试了很久才发现。通信链路的角色划分也很重要。我的设计是让App负责收包、写对侧槽和更新元数据Bootloader只负责启动和回滚。这样做的好处是Bootloader不需要集成HTTP协议栈或者YModem解析代码量小逻辑简单出错的概率低。如果你让Bootloader也参与固件分包接收那Bootloader本身会变得很大反而不利于稳定性。2.2 针对F103的分区表设计分区表是AB OTA方案里最重要的文档没有之一。我在项目里用Excel维护了一份地址规划表每个宏的地址都必须与链接脚本严格对应。下面给两个常用型号的参考分区表。方案一STM32F103C8T664KB Flash页大小1KB起始地址结束地址大小用途0x080000000x08003FFF16KBBootloader0x080040000x08008FFF20KBApp Slot A0x080090000x0800DFFF20KBApp Slot B0x0800E0000x0800EFFF4KB元数据区0x0800F0000x0800FFFF4KB保留方案二STM32F103RCT6256KB Flash页大小2KB起始地址结束地址大小用途0x080000000x08007FFF32KBBootloader0x080080000x08017FFF64KBApp Slot A0x080180000x08027FFF64KBApp Slot B0x080280000x08028FFF4KB元数据区0x080290000x0803FFFF92KB日志/参数/用户数据注意F103中容量与大容量Flash的页大小不一样C8T6按1KB页擦除RCT6按2KB页擦除。写Flash函数应该以页为最小单位起始地址必须页对齐。我在使用HAL库时直接设置FLASH_EraseInitTypeDef的PageAddress为对应页首地址NbPages设为1就能避免越页问题。2.3 为什么元数据要单独放元数据区非常容易被人忽略但它才是AB方案能不能“自我恢复”的关键。元数据必须能被Bootloader和App共同读写如果放在某个槽内部那更新这个槽的时候就会把它破坏。放在Flash末尾独立分区是最稳妥的选择。我还额外在元数据区做了双备份设计避免元数据自身写坏。具体做法是把4KB元数据区拆成两个2KB备份区域。每次写元数据时先擦写备份1再擦写备份2。Bootloader读取时优先找标记为“完成”且CRC正确的那份作为有效数据。万一写入过程中掉电两个备份里至少有一个是旧的有效值Bootloader不会因为元数据丢失而不知道往哪跳。这一步在工业产品里非常有用。可能有人会问既然F103页擦除粒度最小是1KB为什么用4KB做元数据而不是1KB一是为了双备份二是为了方便后续扩展字段比如固件版本号、硬件兼容性标记、签名长度等。升级策略升级时往往要加字段预留空间能少改一次地址规划。3. Bootloader从零实现启动、跳转与校验3.1 最小启动流程设计Bootloader的代码结构不复杂但顺序不能乱。我的启动流程是关看门狗、初始化时钟和串口、关闭全局中断、读取元数据、根据元数据决定启动槽、校验固件头部、跳转。如果检测到有“强制升级请求”比如上电按住按键就留在Bootloader里等待升级包不跳转App。Bootloader里的串口打印很有用建议加上一个简单的格式化输出函数。调试的时候LED可以不加串口一定要加。打印当前读取到的元数据、目标槽地址、CRC校验结果能省下大量排查时间。我在第一版Bootloader里没打印每次都是猜问题后来加上打印之后很多异常一眼就能看出来。还要考虑一种边界情况如果Slot A和Slot B都没有有效固件Bootloader不能直接跳转死机。这时候应该进入下载模式等待上位机或服务器发送固件。判断条件就是目标地址前16字节的栈顶指针和复位向量是否落在合理范围内这两个值不合法就认为固件不存在不能跳转。3.2 跳转函数与中断向量表重定位跳转函数是Bootloader里最核心的代码。这里给出一个最常用的实现先用地址解析出App的栈顶指针和复位向量检查栈顶指针是否落在RAM范围内再关闭中断、设置主栈指针、跳转#define FLASH_APP_A_ADDR 0x08004000UL #define FLASH_APP_B_ADDR 0x08009000UL typedef void (*pFunction)(void); static void ota_jump_to_app(uint32_t app_addr) { uint32_t msp_addr *(volatile uint32_t *)app_addr; pFunction jump_func (pFunction)(*(volatile uint32_t *)(app_addr 4)); // 检查栈顶指针是否在STM32F103 RAM范围 if ((msp_addr 0x2FFE0000) ! 0x20000000) { return; } __disable_irq(); __set_MSP(msp_addr); jump_func(); while (1); }跳转之前一定要检查MSP地址。如果App工程链接脚本没有改对或者Flash里没有App读出来的MSP地址很可能落在Flash区域或者0地址跳进去必死。我在复现时就因为App的链接地址还是0x08000000结果Bootloader跳过去直接HardFault排查了半天才意识到是链接脚本的问题。跳转前必须关全局中断。因为Bootloader可能开着串口中断、SysTick这些中断向量仍然指向Bootloader的中断处理函数。App刚启动时如果SysTick或串口中断触发MCU会跳到Bootloader向量表而不是App的向量表导致乱跑。除了__disable_irq()之外建议在跳转前把用到的外设时钟关闭或复位使外设处于干净的状态。App这边还必须在初始化早期重新设置中断向量表偏移。F103没有VTOR寄存器额外的重定位硬件机制但标准Cortex-M3的SCB-VTOR仍然是可用的。在App的main函数最开始执行#define APP_BASE_ADDR 0x08004000UL void SystemInit_App(void) { SCB-VTOR APP_BASE_ADDR; }只要App的第一条指令执行前设好VTOR后续中断都能正确进到App的中断服务函数。3.3 页面擦写与CRC校验Bootloader和App里都需要Flash擦写代码。F103的Flash写操作很简单解锁、擦页、编程、加锁。用HAL库实现如下static void ota_flash_write_page(uint32_t dst_addr, uint32_t *data, uint32_t words) { FLASH_EraseInitTypeDef erase_cfg {0}; uint32_t page_error 0; HAL_FLASH_Unlock(); erase_cfg.TypeErase FLASH_TYPEERASE_PAGES; erase_cfg.PageAddress dst_addr; erase_cfg.NbPages 1; HAL_FLASHEx_Erase(erase_cfg, page_error); for (uint32_t i 0; i words; i) { HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, dst_addr i * 4, data[i]); } HAL_FLASH_Lock(); }注意HAL_FLASH_Program一次只能写一个32位字。如果你从串口收到的是一整个缓冲区必须按字为单位拆开写入。开发中最常见的错误是直接传byte数组导致后续的地址错位。我做了一个统一缓冲区每收到512字节就先拷贝到RAM里再由这个函数写入对侧槽。校验部分我是用STM32的硬件CRC外设做的。Bootloader和App都使用相同的CRC32算法升级包头部里保存整包bin的CRC值。接收完固件后计算实际CRC与头部CRC比对不一致就放弃切换。有条件的项目建议在CRC之上再加一层签名验证防止固件被篡改不过那不是AB方案的核心这里先不做展开。3.4 编译与下载BootloaderBootloader工程我用CubeMX生成芯片选择STM32F103C8T6Flash起始地址保持0x08000000长度设为16KB在链接脚本里体现为FLASH ORIGIN 0x08000000, LENGTH 16K。编译后生成bin文件用DAP-Link或者STM32CubeProgrammer烧录到0x08000000。下载Bootloader时BOOT0引脚必须为低电平否则芯片会进入系统存储器的ISP模式DAP连接不上。我第一次给F103烧Bootloader时BOOT0跳线帽没拔干净DAP提示“Cannot access target”折腾了半小时才发现是跳线问题。Bootloader写完后不要急着做完整OTA先用一个最简单的App测试跳转。App直接编译到Slot A地址Bootloader固定跳到A槽App点亮一颗LED。确认跳转成功、中断正常再继续做B槽和元数据逻辑。分步验证是整个复现过程中最省时间的策略。4. App端改造链接脚本、启动配置与升级包服务4.1 改链接脚本和VTORApp工程的起始地址不能再用0x08000000必须改成对应Slot的地址。Slot A和Slot B是两个不同的地址所以App需要分别编译两份bin或者用宏切换。我的做法是在工程里定义APP_SLOT_INDEX根据它选择链接脚本和VTOR偏移一次编译出A版和B版。在STM32CubeMX生成的链接脚本里主要改这一行FLASH (rx) : ORIGIN 0x08004000, LENGTH 20K RAM (xrw) : ORIGIN 0x20000000, LENGTH 20K如果是Slot B就改成ORIGIN 0x08009000LENGTH 20K。App工程编译完成后用Keil的fromelf或者GCC的objcopy导出bin文件它就是可以直接写到对应槽的固件。App的main函数里必须马上设置SCB-VTOR否则中断向量表还在0x08000000。很多人只改了链接脚本忘了设VTOR结果串口中断一来就跑飞。一个判断是否设置成功的简单办法在App里开头翻转一次LED如果翻转正常但按键中断或定时器中断一开就死机多半就是VTOR的问题。4.2 升级包格式与生成为了统一对接Bootloader和App的校验逻辑我定义了一套极简升级包格式偏移字段长度说明0magic4字节固定0xA5A5A5A54version4字节固件版本号8length4字节bin有效数据长度12crc324字节对bin数据计算的CRC3216dataN字节实际App程序生成升级包我直接写了一个Python脚本读取编译产物app.bin在文件头拼上magic、版本号、长度和CRC32最终输出app_ota.bin。这样上位机下载、MCU接收时都只处理这一个固定格式包不用再在设备端做复杂解析。import struct, zlib def make_package(bin_path, output_path, version1): data open(bin_path, rb).read() length len(data) crc zlib.crc32(data) 0xFFFFFFFF header struct.pack(III I, 0xA5A5A5A5, version, length, crc) with open(output_path, wb) as f: f.write(header data)4.3 下载到对侧分区的完整流程升级流程我把它拆成六个步骤App接收升级指令、确定目标槽、逐包写入对侧槽、整包CRC校验、修改元数据、复位。目标槽的计算很简单。如果当前正在运行A槽目标槽就是B槽地址如果当前运行B槽目标槽就是A槽地址。不能直接写当前槽否则擦写Flash就等于删除正在运行的程序这是AB方案的铁律。接收链路我用过两种。一种是串口YModem适合本地调试简单稳定另一种是ESP8266透传HTTP下载适合远程更新。HTTP下载时MCU解析服务器返回的Content-Length按1KB分块接收每收满一页就调用Flash擦写函数写入目标槽。写入完成后一定要回读几个关键位置确认数据不是全0xFF再继续下一包。整个下载过程中最需要注意的是不要一次擦除整个槽再慢慢写入。万一中断目标槽虽然废了但当前槽还完好AB方案能回滚但如果中途有人误操作把整个Flash擦了那问题就大了。我习惯“先擦一页、写一页、回读校验一页”这样风险最可控。4.4 网络下载与Nginx简要配置如果你和我一样用HTTP方式托管升级包Nginx做一个静态文件服务器就够了。但有几个坑值得提前说第一MIME类型要设置成application/octet-stream避免Nginx按照文本方式解析.bin文件时破坏数据第二MCU端必须解析Content-Length否则不知道下载什么时候结束第三Nginx的limit_rate参数单位要看清我之前误配成100k一个20KB的升级包下载得奇慢无比后来改成100m才正常。server { listen 8080; server_name _; location /ota/ { default_type application/octet-stream; limit_rate 100m; root /var/www; } }不过这不代表Nginx配置好了就能稳定下载。ESP8266这类串口透传模块在传输大量二进制数据时如果速度过快会顶不住串口缓冲区丢包几乎必然发生。我在实际测试中给每一块固件数据加了序号和ACK确认MCU每写入一块就回一个ACKESP8266收到ACK后才继续发送下一块。这样下载速度虽然降低但成功率大幅提升这也是我后面项目能稳定跑OTA的关键。5. 元数据与AB切换决策如何保证不砖5.1 元数据结构设计元数据虽然看着简单但它决定了回滚逻辑能不能成立。我定义的结构体如下typedef struct { uint32_t magic; // 固定魔数防止空Flash被误判 uint32_t boot_slot; // 0表示Slot A1表示Slot B uint32_t try_count; // 当前槽已尝试启动次数 uint32_t success_flag; // 1表示当前槽已自检成功 uint32_t crc32; // 元数据自身CRC } ota_meta_t;magic字段非常重要。Flash默认值是0xFFFFFFFF如果直接读meta而不校验magic就可能把空Flash当成有效数据导致跳到一个不存在的槽。我在Bootloader里统一先判断magic和CRC不通过就使用默认值启动Slot Atry_count清零success_flag置1。5.2 Bootloader决策逻辑Bootloader的上电决策逻辑实际上就是一段简单的条件判断。我写成了类似下面的伪代码ota_meta_t meta read_valid_meta(); if (!meta_valid || meta.success_flag 1) { // 没有元数据或者当前槽已经标记成功 boot_slot (meta_valid ? meta.boot_slot : 0); } else { if (meta.try_count MAX_TRY_COUNT) { // 继续尝试当前槽 boot_slot meta.boot_slot; meta.try_count; save_meta(meta); } else { // 当前槽连续多次启动失败回滚到另一个槽 boot_slot 1 - meta.boot_slot; meta.boot_slot boot_slot; meta.try_count 0; meta.success_flag 0; save_meta(meta); } }MAX_TRY_COUNT我一般设3。当新固件第一次启动失败后下一次上电还会继续尝试同一个新槽直到尝试次数超过3才回滚。这个设计可以容忍一些偶然的启动抖动。如果新槽一失败就立刻回滚可能因为一次瞬态故障就把好不容易升级的固件弄回旧版体验不好。尝试次数的更新写入要放在实际跳转之前。也就是说Bootloader决定要试B槽就先让try_count加1并保存再去跳转B。这样即使B槽启动后立刻死机复位后Bootloader读到的是已经1的计数回滚逻辑才能生效。5.3 App自检与成功标记App在正常启动后不能马上把success_flag置1。正确的做法是等关键业务初始化完成、RFC通信或自检任务跑通后再调用ota_mark_success()。比如在main里创建了一个FreeRTOS任务任务循环中通过串口发心跳包如果设备能正常发一段时间就认为是升级成功。成功标记一旦写入元数据下次Bootloader启动就直接使用当前槽不再计算try_count。等到下一次升级时成功标记会被OTA流程重新清零进入试运行状态。这个流程能保证“升级完成并确认好用后设备才不会轻易回滚”。如果你在App里用了FreeRTOS可以在任务创建成功后延迟几秒再写成功标记给系统一个稳定观察窗口。我实际测试时发现有些外设初始化失败并不会导致HardFault但业务逻辑跑不起来如果过早置成功回滚功能就失效了。所以“确认成功”的时机比代码细节更加重要。5.4 失败回滚流程回滚的场景我完整测过当前运行A槽A版本正常OTA下载B槽新固件写入成功元数据boot_slot改为B。重启后Bootloader启动B但B程序在初始化到一半时HardFault死机。复位后Bootloader读取元数据发现success_flag为0try_count1并继续尝试B。连续3次失败后Bootloader将boot_slot改回A重启进入A设备回到升级前状态。这套流程看起来很简单但有一个细节要注意回滚后要不要把B槽标记成“坏固件”我的实现里回滚后仍保留B槽的数据但下一次OTA会选择覆盖B槽。因为AB方案里旧槽和新槽是对称的如果升级B失败下次还可以再往B写新的修复版本不需要先擦除A。只有反复失败且确认B槽地址损坏时才考虑强制擦除整个B槽。6. 从零复现中遇到过的问题与排坑实录6.1 BOOT0/BOOT1导致下载器连不上这个是新手最容易踩的坑我自己也在上面浪费过时间。STM32F103在上电时根据BOOT0和BOOT1引脚的电平决定从Flash、系统存储器还是RAM启动。正常下载Bootloader和App时BOOT0必须为低电平如果BOOT0为高电平DAP下载器会提示“Cannot access target”或“No target connected”看起来就像芯片坏了。解决办法是先把BOOT0跳到1上电进入ISP系统存储器模式此时DAP可以连接芯片擦除Flash然后再把BOOT0跳回0重新上电恢复正常下载。BOOT1一般直接接地不要和BOOT0同时为高否则芯片会尝试从RAM启动调试更混乱。6.2 跳转后进了HardFault跳转成功后App进HardFault我在复现过程中至少遇到过三次。第一次是App链接脚本起始地址没改Bootloader从0x08000000读到的是Bootloader自己的向量表跳过去直接乱套。第二次是跳转前没有关中断SysTick中断先触发跑到Bootloader的中断向量表里。第三次是忘了设置SCB-VTOR导致App能启动但外设中断一开就死。如果你的设备也是跳转后马上HardFault建议按这个顺序排查先确认App链接地址确实是对应Slot再确认跳转函数里检查了MSP地址并调用了__set_MSP最后在App的main最开始设置SCB-VTOR。如果还不行在App的第一行翻转一个LED看到底有没有进到App。6.3 Flash写入一半死机Flash写入过程中死机原因通常有三个。一是没先擦除直接编程Flash编程操作要求目标地址必须是0xFFFFFFFF否则写入会失败。二是地址没有页对齐F103的页擦除要求地址是页边界对齐。三是供电电压不稳Flash编程时需要稳定的电压如果板子用的是劣质USB供电大电流波动容易导致写Flash失败。我的习惯是每次写入前先用JLink/DAP读一下目标地址的内容确认是0xFFFFFFFF再写。如果反复出现写入错误用HAL_FLASH_Program的返回值定位错误码常见的HAL_ERROR多半是地址或解锁问题。6.4 HTTP下载超时和校验失败HTTP下载时最容易遇到两个问题下载到一半断连或者下载完成后CRC校验失败。断连多半是服务器端超时时间太短或者MCU接收速度跟不上Nginx默认的send_timeout只有60秒如果升级包较大需要手动调大。要么就缩小单包大小提高ACK频率保证链路不空闲。CRC校验失败我最初也头疼后来发现是ESP8266透传模式下把二进制数据按文本处理0x00被当成字符串结束符丢掉。换成二进制透传模式后数据就正常了。另外一定要在传输完成后用整个bin计算CRC而不是按接收包累计算避免包头和数据混淆。6.5 问题速查表现象可能原因处理办法DAP连接不上芯片BOOT0跳线为高或程序跳飞BOOT0拉高进ISP擦除Flash后再拉低复位Bootloader不跳转AppApp地址无有效向量表或MSP非法检查App链接脚本检查跳转前SP检查跳转后串口中断死机SCB-VTOR未设置App初始化最前面设置SCB-VTORFlash写入返回错误未擦除、地址不对齐、电压不稳确认页对齐先擦后写换稳定电源升级完重启后反复回滚success_flag未置位或置位太早在App自检成功后调用ota_mark_success()下载半路断开服务器超时、串口缓冲区溢出调大Nginx send_timeout使用分包ACKCRC校验失败ESP8266透传丢0x00使用二进制透传模式收完统一算CRC最后说一点实际测试中的体会AB OTA真正难的不是跳转代码而是让你相信“升级失败也能恢复”。我在复现过程中故意做了十几次断电测试每次写入不同阶段的Flash最终Bootloader都能回到旧固件。那种安心感是单分区方案完全给不了的。如果你也在折腾F103的AB分区OTA建议先从串口YModem版本跑通再切换成HTTP下载每一步单独验证。分区地址和元数据逻辑一定要写到纸上错一个地址就会让你怀疑人生。