资讯动态

STM32F103 AB双分区OTA升级完整实现:Bootloader与App协作全解析

发布时间:2026/9/11 3:24:02 来源:尧图企业网站定制
STM32F103做AB双分区OTA升级这事我前后折腾了三个星期从零画最小系统板、移植Bootloader、写上位机到最终整条链路跑通踩的坑比代码行数还多。网上关于STM32 OTA的资料大多讲的是单分区方案AB分区的完整复现教程少之又少很多细节只能自己翻手册、看官方demo慢慢磨。这篇教程把我整个复现过程记录下来包括为什么选AB分区、Bootloader怎么写、App端怎么配合、双分区切换状态机怎么设计、还有我在实测中遇到的几个能把人气到摔键盘的问题全部摊开来讲。内容适合正在做STM32 OTA项目却找不到完整参考的朋友也适合刚接触Bootloader但想一步到位上双分区的新手看完至少能少走我一半的弯路。1. AB OTA方案的整体设计思路1.1 为什么是AB双分区而不是传统升级方案传统单分区OTA的逻辑很简单Bootloader接收新的固件直接覆盖写进App区写完校验通过就跳转整个过程App本身不能运行。这个方案在ST官方很多demo里都能看到简单可靠但有一个致命问题——升级过程中一旦断电或者固件校验不通过设备就直接变砖了只能重新拉串口烧录要是产品已经部署在现场返修成本直接起飞。AB双分区就是冲着这个痛点去的。A/B分区方案把Flash里的应用区一分为二分成两个完全独立的空间一个叫Slot A一个叫Slot B。当前运行的分区叫Active Slot另一个叫Inactive Slot。升级的时候只往Inactive Slot里写写完后通过一个标志位做原子切换最后重启由Bootloader根据标志位决定跳转到哪个分区。如果新固件校验失败或者运行异常还可以回滚到上一个正常的分区。升级过程全程不影响当前系统运行断电解千愁。举个例子帮助理解单分区方案像你住在一间屋子里要换地板必须先把家具全搬出去、拆掉旧地板、铺上新地板期间如果突然停电你就只能睡在毛坯房里AB方案则像是你有两间一模一样的屋子平时住A房B房空着想换装修就在B房里慢慢弄弄好了直接搬过去觉得不舒服还能再搬回A房住。实际项目里尤其在带电机控制、带通信链路、带关键数据采集的场合设备不允许长时间停机AB方案的体验几乎是无感的。1.2 内存分区与数据流设计以STM32F103C8T6为例这颗芯片的Flash为64KB实际地址范围0x08000000到0x0800FFFF。我在这片Flash上规划了4个区域分区数量看起来多但每个区域职责清晰互不干扰区域名称起始地址大小用途Bootloader0x0800000016KB上电引导、升级控制、串口接收App Slot A0x0800400020KB当前运行应用App Slot B0x0800900020KB备份/新固件写入区Flag区0x0800F8002KB升级标志、分区切换状态、固件校验信息这里Bootloader只给了16KB放在很多习惯用标准外设库写全套逻辑的人手里肯定紧张所以我建议Bootloader里除了启动代码、Flash驱动、串口接收、CRC校验和跳转逻辑之外什么都不要加协议栈、RTOS、看门狗这类东西一律不要进Bootloader保持它又小又稳这是双分区整套机制的安全基石。完整的数据流是这样走的PC端或者上位机准备好一份编译生成的BIN固件附加以CRC32校验值和固件长度组成的包头通过串口发送给BootloaderBootloader每收到一包数据就写入当前Inactive Slot对应的Flash地址全部写完做一次整体校验校验通过就把Flag区状态改成待切换然后复位重启后Bootloader读取Flag发现待切换标志有效就将Active指针切到另一个分区跳转执行App起来后主动上报自己运行正常Bootloader收到反馈后把Flag标记为稳定状态一次升级闭环完成。1.3 方案选型的几个关键考量第一点要考虑的是串口通信协议。AB分区本身和用什么物理链路传固件没有绑定关系你可以用UART、SPI、USB、以太网、CAN甚至LoRa都行。我最终选了串口做第一版原因很简单——STM32F103最成熟、调试最方便的就是USART几乎所有玩过这个芯片的人都有串口调试经验。而且串口协议做透之后把底层传输句柄换成其他总线只是工作量问题协议栈不用大改。第二点是固件校验算法。校验放在Bootloader里做不能太复杂也不能太弱。我选了CRC32STM32F103有硬件CRC外设计算一版20KB的固件只需要几毫秒占用资源少且错误检出能力够强。MD5和SHA256不是不能做但在Bootloader这种对代码体积敏感的环境里软件实现的开销太大除非你的固件安全性要求极高否则没必要。第三点是升级包格式。网上很多教程直接把BIN文件扔给BootloaderBootloader按固定地址死板地写完就跳转。这种方案对AB分区其实够用但缺少包头信息会导致Bootloader无法知道固件长度也不知道固件版本。我的做法是自定义一套简化包头——引导标记、固件版本、固件长度、固件CRC32加起来12个字节Bootloader校验头无误后再进入接收流程。这套包头在调试阶段能帮你快速排查问题在正式产品里也可以作为版本管理的依据。2. 硬件环境和工程结构准备2.1 硬件需求说明要做AB OTA复现首先把硬件环境准备好这部分看似简单实则很多细节没处理好后面调试时非常痛苦。我用的是一块自己画的最小系统板核心是STM32F103C8T68MHz晶振、复位电路、SWD下载口、一颗AMS1117稳压到3.3V、加一颗红色LED接到PC13作为运行状态指示再把USART1的TX/RX引出到CH340 USB转串口模块上。如果你手头有现成的STM32F103C8T6开发板完全可以省掉自己画板的步骤市面上的蓝色Pill板也可以跑这套方案注意看一下板子引出的串口是哪一个就行。供电部分需要多嘴一句OTA测试过程中涉及Flash擦写擦写瞬间电流会比正常运行高一些如果是用USB口直接给板子供电尽量用质量好一点的线劣质USB线压降大容易在擦Flash的时候触发掉电复位第一次复现的时候我在这上面莫名其妙翻过车换线之后一切正常。对于调试手段强烈建议准备两块板子。一块作为测试板跑整套OTA流程另一块通过SWD连上调试器烧录专用的测试固件并实时观察各分区状态。不要想着靠一块板子串口打印就能搞定全部调试AB分区涉及的交互点太多没有调试器辅助你会陷入盲人摸象的状态。2.2 Bootloader工程创建与配置我使用标准外设库V3.5版本配合Keil MDK5来做这个工程。之所以不用HAL库是因为Bootloader部分代码量小、且涉及Flash操作比较底层直接操作寄存器反而更直白清晰标准外设库已经把这个层面封装得恰到好处既不会像寄存器操作那样繁琐也不会像HAL那样引入一层不必要的抽象。工程结构上我分了5个模块system_stm32f10x.c系统时钟初始化为72MHzuart_boot.c串口初始化、单字节收发、协议解析flash_if.cFlash擦除、写入、读取操作crc_check.cCRC32计算和校验ota_core.c顶层状态机负责将上述模块串起来工程Options里需要关注几个核心配置。Target标签页把IROM1的起始地址改成0x08000000大小设为0x4000即16KB确保Bootloader链接时知道自己只占用前面这段空间C/C标签页的Optimization选择Level 2以减小代码体积Linker标签页勾选Use Memory Layout from Target Dialog让链接器从Target的地址配置生成分散加载文件。这些配置不到位编译出来的固件可能超出Bootloader分区边界后面写App的时候两个区就会互相打架。如果使用芯片Flash大于64KB的型号记得在Flash下载算法里选择对应的容量否则烧录时Keil会提示Flash Timeout。3. Bootloader核心实现3.1 升级标志区的设计与状态机Bootloader的一切逻辑都围绕Flag区来驱动。我用Flag区前128字节保存一个结构体里面包括当前激活分区编号、待切换分区编号、上次固件升级结果、还有一份Magic Number用于标识Flag区内容有效。Magic Number我取了0xA5A5A5A5什么含义都没有纯粹为了区分初始状态和已初始化状态。状态机包括5个状态等待命令、接收固件、校验固件、切换分区、执行跳转。Bootloader上电先读Flag区根据当前的激活分区号和待切换标志决定进入哪种流程。如果Flag显示有待切换任务就走升级确认流程如果没有直接跳转当前激活分区。这里有一个细节值得特别注意Flag区本身所在的Flash页在每次写入前都必须先整页擦除。Flash这种存储介质的物理特性决定了它只能把1写成0要想把0恢复成1只能整块擦除。所以Flag区的数据不管是更新分区号还是更新状态都要用先备份、再擦除、后写入的流程。我封装了一个函数专门处理这个逻辑void update_flag(ota_flag_t *new_flag) { ota_flag_t temp; // 先把当前Flag区内容读到内存 memcpy(temp, (uint32_t*)FLAG_ADDR, sizeof(ota_flag_t)); // 在内存中修改 temp *new_flag; // 擦除Flag区所在Flash页 FLASH_ErasePage(FLAG_ADDR); // 重新写入 uint32_t *p (uint32_t*)FLAG_ADDR; for (int i 0; i sizeof(ota_flag_t) / 4; i) { FLASH_ProgramWord((uint32_t)p[i], ((uint32_t*)temp)[i]); } }如果不做这个备份再擦除的流程直接写某个偏移的Flash会发生很奇怪的现象——数据像是写进去了但读出来是0xFF和已有数据的杂交体。3.2 串口接收与固件写入流程串口这块我用的配置是1152008N1开启接收中断。协议层自定义了一个非常轻量的帧格式每帧固定为帧头命令数据长度数据CRC160xAA 0x551字节2字节N字节2字节命令字定义了4类握手查询、开始升级、传输数据、结束升级。握手查询是上位机先发一个短帧Bootloader回应固件版本和已接收帧计数用来确认链路通畅开始升级携带了目标分区号、固件总长度、固件CRC32传输数据每一包固定携带256字节结束升级则是通知Bootloader进行整体校验。在接收数据写入Flash时我选用了每次写入一页的方式。一页为1KB而串口一帧只带256字节因此4帧数据攒够一页后调用一次Flash擦写。直接在每收到256字节后就擦一次页再写效率太低且磨损Flash。我的flash_if.c里写了这样一个缓冲写入函数bool flash_write_page(uint32_t page_addr, uint8_t *data, uint16_t len) { if (len ! FLASH_PAGE_SIZE) return false; FLASH_ErasePage(page_addr); uint32_t *p (uint32_t*)page_addr; for (int i 0; i len / 4; i) { if (FLASH_ProgramWord((uint32_t)p[i], *(uint32_t*)data[i*4]) ! FLASH_COMPLETE) { return false; } } return true; }写Flash之前必须先判断目标地址是否已经被擦除过。正常擦除后的Flash读出来全为0xFF如果发现某个地址不是0xFF说明该页之前写入过数据且没有擦除这种脏状态直接写入会导致位操作冲突最终数据是乱的。串口接收的数据需要防超时和防粘包。我在接收中断里维护了一个环形缓冲区主循环每毫秒检查一次缓冲区的空闲时间如果超过50ms没有新数据且缓冲区不为空就认为当前帧接收完成可以进入协议解析。这套处理方法在115200波特率下实测非常稳定哪怕上位机一次性把整个固件连续涌过来也不会出现丢帧错帧。3.3 固件校验与分区切换实现所有数据接收完成后Bootloader需要对刚写入的Inactive Slot做一次全量CRC32校验。注意这里的CRC32不仅包括固件内容本身还要和包头里携带的CRC32进行比对。我的crc_check.c里有一个Hex文件解析的替代方案——上位机在生成升级包时直接把BIN文件作为纯数据自己先跑一遍CRC32生成到包头里Bootloader再对收到的数据跑一遍同样的算法两边比对结果完全一致才认为校验通过。校验通过后就进入分区切换的关键步骤。分区切换的第一步是更新Flag区把待切换分区号记录下来并把状态标记为待切换第二步是复位系统。这里没有用软件复位直接跳转的原因是复位可以确保所有外设回到初始状态避免App启动时被Bootloader留下的外设配置干扰这种干扰在小概率情况下会导致莫名奇妙的问题例如UART中断残留、DMA配置悬挂等。复位后Bootloader重新走到主函数开头第一步读Flag发现有待切换标志且目标分区合法则将当前激活分区更新为目标分区清除待切换标志然后跳转执行目标分区的App。逻辑上用伪代码表示int main(void) { SystemInit(); uart_boot_init(); flash_if_init(); ota_flag_t flag read_flag(); if (flag.pending_switch 1) { if (validate_firmware(flag.target_slot)) { flag.active_slot flag.target_slot; flag.pending_switch 0; flag.last_result RESULT_SUCCESS; write_flag(flag); jump_to_app(flag.active_slot); } else { // 校验失败回滚到原分区 flag.pending_switch 0; flag.last_result RESULT_CRC_ERROR; write_flag(flag); jump_to_app(flag.active_slot); } } else { jump_to_app(flag.active_slot); } }3.4 跳转App的细节与中断向量表重定位从Bootloader跳转到App不是简单的一条goto命令能搞定的这里有两个必须处理的细节任何一步出错都会导致App跑飞或中断异常。第一个是检查栈顶地址合法性。App起始地址处即0x08004000存的是App初始化时的栈顶指针这个指针的值应该在SRAM范围内才说明App固件是完整的。SRAM的地址范围是0x20000000到0x20005000对于C8T6是20KB SRAM所以这个指针值应该落在0x20000000到0x20005000这个区间。如果读出来的栈顶值是个0xFFFFFFFF或者0x0800xxxx这类地址说明分区里根本没有有效固件直接跳转必死无疑。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); if ((app_stack 0x2FFE0000) ! 0x20000000) { // 栈顶地址不在SRAM范围固件非法 return; } // 设置MSP为主栈指针 __set_MSP(app_stack); // 关闭全局中断避免跳转瞬间被中断打扰 __disable_irq(); // 跳转前将所有已开启的外设时钟复位 RCC_DeInit(); pFunction jump (pFunction)app_reset; jump(); }第二个是中断向量表重定位。STM32F103上电后默认从Flash起始地址0x08000000开始取向量表Bootloader跳到App后如果App没有主动把向量表偏移到自己的起始地址中断一进来CPU仍然会去Bootloader区找向量找到的完全是错误的中断处理函数表现就是App主流程在跑但所有中断都不工作。这个问题的解决方案在App端做App初始化第一行就要写SCB-VTOR 0x08004000;。4. App端改造要点4.1 链接脚本与向量表设置App不是随便写个流水灯就能配合AB OTA的编译链接阶段就要做两处关键修改。第一处是Target配置。App工程里IROM1的起始地址要改成0x08004000大小改成0x5000即20KB。这和Bootloader工程里的设置方向相反——Bootloader是让他知道自己是前16KBApp是让它知道自己从16KB之后开始。如果App工程不改成这个地址编译器仍然会把代码链接到0x08000000一旦Bootloader跳转到0x08004000执行Flash里是空的或者是一堆0xFF程序直接死掉。第二处是启动代码里的向量表设置。标准库的system_stm32f10x.c里默认设置了VECT_TAB_OFFSET 0x0需要改成App的偏移量。我用的是在main函数第一行显式设置SCB-VTOR APP_ACTIVE_ADDR;这里APP_ACTIVE_ADDR不是固定写死的而是通过读取Flag区当前激活分区号来决定是0x08004000还是0x08009000。这样才能做到两个Slot的App共用一套代码运行在哪个分区就用哪个分区的地址不至于编译出两套固件。4.2 主动上报运行状态与心跳机制AB分区还有一个单分区没有的环节——新固件运行状态确认。Bootloader跳到新固件后它并不能立刻就认为升级成功万一新固件自己跑不起来、卡死在某个外设初始化或者死循环那下一次断电重启Bootloader又会跳到这个坏固件上。所以App起来后要主动上报运行健康状态。我实现的机制是这样的Flag区里有一个字段表示当前激活分区的健康状态App正常运行起来后在main函数的早期阶段就调用一个函数把这个字段标记为运行正常同时往后每隔1秒翻转一次PC13的LED状态作为肉眼可观察的心跳信号。Bootloader在跳转之前会先把这个健康状态字段清零如果App能正常工作它就会在启动后把字段设为正常如果App卡死这个字段就一直是零Bootloader在下一次复位后判断到这个字段不是正常值就知道当前分区运行异常自动回滚到另一个分区。这套机制保证了即使新固件有问题设备重启后也能自我恢复。考虑到串口通信既可能被App占用也可能被Bootloader占用我加了一个交接处理——App在跳转前要把串口完全清理干净Bootloader在跳转到App前同样要把自己用到的串口中断全部关掉。两边的外设互不干扰才不会有中断残留导致App初始化串口时直接卡住。5. 实测记录与问题排查5.1 联调过程中的典型问题整个链路第一次完整跑通前后花了不少时间重点排查了4个问题每个都有代表性。问题一Bootloader跳转App后程序立即跑飞。排查时先用调试器看到App确实被加载到了0x08009000但执行到第一条指令就进HardFault。后来检查出问题出在RCC_DeInit上——我在跳转前把整个时钟树复位了但App的SystemInit依赖外部晶振启动而时钟切换后晶振还没稳定就直接进入初始化流程导致HSE就绪标志无法置位卡死在启动代码里。解决方案是在跳转前保留时钟树不复位或者把RCC_DeInit放到App启动代码执行之后再调用。我这里选择去掉Bootloader的RCC_DeInit依赖App侧的SystemInit完成时钟重新初始化。问题二串口升级过程中偶发CRC校验失败。频率不高但随机出现烦人得很。通过逻辑分析仪抓串口数据发现上位机发完最后一帧后没有做延时立即发送了结束升级帧而 Bootloader 侧在串口接收中断里因为优先级处理问题丢掉了结束帧导致固件接收不完整。解决办法有两个一是在上位机发送完数据帧后加50ms延时再发结束帧二是在Bootloader侧收到超时后自动触发结束升级流程不依赖结束帧也能完成校验。我最终二者都做了双保险。问题三升级到固件B后运行没几秒就自动回滚到固件A。排查定位到是App健康状态上报得太晚。App里我在完成所有外设初始化、传感器校准、甚至等待了几秒网络重连之后才调用健康上报函数而Bootloader在跳转后设置了一个3秒的确认窗口窗口内App如果没有上报运行正常Bootloader就判定新固件运行失败并回滚。解决办法是把健康上报提前到App初始化最早期外设还没初始化完先上报等真正能跑业务后再做第二次上报确认业务也正常。问题四Flash擦写后串口打印乱码。原因是擦写Flash期间CPU被阻塞串口发送中断无法响应缓冲区里的数据被清掉后续发出的字符错位。这个严格来说不算Bug是我调试时用串口打印日志导致的现象。解决方法是所有Flash操作期间不要往串口写日志或者用DMA方式发送日志让CPU可以专心做Flash擦写。5.2 升级完整链路验证问题排查完后我按照以下步骤完整串了一遍升级链路每一步都确认无误才算真正跑通首次上电没有任何有效Flag记录Bootloader将A区设为默认激活分区跳转到A运行。PC端发送握手查询帧Bootloader回复版本号和当前分区号确认链路正常。PC端发送升级包目标为B区包含CRC32校验信息。Bootloader擦除B区逐页接收并写入固件数据全部接收完成后执行CRC32比对一致则认为接收成功。Bootloader将Flag区标记为待切换至B执行软件复位。重启后Bootloader读取Flag校验B区固件合法性确认栈顶和CRC正常切换激活分区为B跳转到B运行。B区App启动后立即标记健康状态正常PC13 LED闪烁链路跑通。再次触发升级这次升级目标是A区验证反向升级同样流畅。模拟新固件损坏场景往B区写入错误数据触发升级后Bootloader校验失败自动回滚到A区设备没有被锁死。整个过程走完后我对AB OTA这整套机制的认知才算真正落地。最后再分享一个小技巧调试阶段建议把Flag区的关键状态每个值都对应一段明显的LED模式比如升级中快闪、校验失败慢闪、正常双闪配合串口打印能瞬间定位当前状态。实际项目中这套方法比翻代码盯断点高效得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价