资讯动态

STM32F103 AB分区OTA升级实战:Bootloader设计与固件回滚机制

发布时间:2026/9/11 4:38:24 来源:尧图企业网站定制
1. 项目定位与整体设计1.1 为什么选STM32F103做AB方案OTASTM32F103这颗芯片放到今天来看性能不算突出但它在工业控制、智能硬件、物联网网关里出镜率依然极高。一方面是价格稳定、供货充足另一方面是生态太成熟标准外设库V3.50、HAL库、LL库随便挑网上参考设计一抓一大把。要说做OTA升级的入门芯片STM32F103绝对是最合适的那一批。再解释一下AB双分区方案。早期很多项目做OTA只有单个App区升级时直接往当前运行的程序区里写新固件。这个方案的痛处在于一旦写入中途断电、传输数据出错或者固件本身有问题设备当场变砖只能拆机用下载器恢复。AB分区就是给固件准备两个独立槽位一个作为当前运行区Active一个作为备份区Inactive。升级的时候往备份区写写完校验通过后通过Bootloader切换启动入口让设备从新固件启动。万一新固件启动异常还能自动回滚到旧固件安全性高很多。这套方案在手机和路由器上已经是标配思路在MCU上做起来也并不复杂。整个项目我做下来硬件成本不超过20块钱代码量控制在千行以内非常适合作为理解嵌入式OTA机制的入门案例。1.2 项目整体架构整个OTA系统的软件部分可以拆成三块Bootloader区、A区固件、B区固件。STM32F103C8T6的Flash是64KB地址从0x08000000开始。我把Flash规划成三个区域Bootloader区0x08000000 ~ 0x08001FFF8KB空间A区Active0x08002000 ~ 0x08003FFF8KB空间B区Standby0x08004000 ~ 0x08005FFF8KB空间这里要注意一个关键点虽然C8T6的Flash号称64KB但是在某些型号和批次上高地址区域可能不可靠。我的分区表只用到0x08005FFF也就是24KB以内的区域刻意留了大量余量避免踩到Flash末尾的坑。如果你的工程代码量比较大可以把Bootloader压缩到4KB给两个App区各留16KB这在F103上也能跑得动。升级流程是这样的设备上电后Bootloader先检查B区头部的升级标志字。如果标志字有效说明固件已经完整下载到B区就校验CRC通过后跳转到B区执行如果标志字无效或者校验失败就直接跳转到A区执行原有固件。应用运行过程中如果收到服务器下发的升级指令和固件包就由App自己把数据写入B区写完后在B区头部置上有效标志字然后软复位进入Bootloader完成切换。这个流程最大的好处是App不负责切换逻辑切换只由Bootloader决定降低了耦合度排查问题也容易很多。1.3 技术选型背后的思路在Bootloader和App之间我用的是标准外设库V3.50没有上HAL库。原因有两点第一标准库的函数接口直接对应寄存器操作看代码能清楚地知道每一条指令在做什么对理解启动过程非常有帮助第二F103的HAL库会做一些抽象封装在中断向量重映射、Flash操作时序这种细节上标准库反而更直观。串口通信用了USART1波特率1152008N1。没开DMA接收用中断逐字节缓存发送用阻塞方式。这个设计看起来不够“先进”但在OTA这种低频数据交互场景下稳定性比性能重要得多。等以后升级包做大或者交互频率上来了再优化成DMA也不迟。Flash写入使用的是标准库的FLASH_ProgramHalfWord函数按半字16位写入每次写之前必须先擦除整个扇区。Flash擦除是耗时操作在8MHz的HSE配置下擦除2KB扇区大约需要20~40ms这个时间在中断里绝对不能做所以我只在主循环里触发擦写操作。2. 硬件准备与开发环境搭建2.1 最小系统板的选择与接线核心板我用的是一块非常常见的STM32F103C8T6最小系统板也就是大家说的“蓝板”。这块板子上自带8MHz晶振、复位电路、LDO稳压和Type-C口不需要额外搭最小系统。对OTA项目来说板和USB转TTL模块之间的接线还有一个容易被忽略的细节要共地。USB转TTL的GND必须和板子的GND连在一起否则串口数据会出现乱码甚至完全不通。实际接线是这样的PA9USART1_TX→ USB转TTL的RXPA10USART1_RX→ USB转TTL的TXGND → GND板子3.3V输出给USB转TTL供电如果模块支持3.3V供电BOOT0引脚默认接地从主Flash启动不需要额外操作。如果你还需要用串口下载器也就是ISP方式烧录Bootloader那就要在烧录时把BOOT0拉高、BOOT1拉低烧完再恢复。为了方便操作我在板子上焊了一个小开关切换BOOT0的电平状态实测下来比短接跳线帽省事很多。选择F103C8T6而不是F103RCT6还有一层考虑C8T6在64引脚封装里是LQFP48手工焊接方便资料多各种问题一搜就有答案。等以后真要在产品上用再移植到RCT6或者更大Flash的型号逻辑基本不用改只改分区表就行。2.2 开发环境与工具链我是在Windows下用Keil MDK 5.27开发的选择老版本是因为它轻量编译速度快对新工程足够用。配合的标准外设库是STM32F10x_StdPeriph_Lib_V3.50这个版本的库在Keil里编译几乎零警告。工程结构上我建了三个分散文件sctbootloader.sctFlash起始地址0x08000000大小8KBapp_a.sctFlash起始地址0x08002000大小8KBapp_b.sctFlash起始地址0x08004000大小8KB为什么要拆成三个分散文件因为Bootloader和App本质上就是三个独立的工程编译出来的hex放到不同地址区域。如果不单独设置起始地址App默认会从0x08000000开始编译那跳转过去必挂。烧录工具用的是ST-Link V2配合STM32 ST-LINK Utility。第一版Bootloader通过ST-Link烧录之后的固件传输全走串口OTA就不再需要ST-Link了。另外Keil里我建议把Download功能配置成只擦除需要的扇区不要每次都全片擦除尤其是调试Bootloader时全片擦除会把已有固件也清掉比较麻烦。2.3 三个工程的公共代码三个工程虽然独立编译但底层驱动很多是可以共用的串口驱动、Flash驱动、CRC校验、延时函数。我专门建了一个common目录三个工程共享这些源文件只在不同工程里保留各自的main.c和启动逻辑。这里有个小技巧三个工程的system_stm32f10x.c会配置系统时钟默认都是72MHzHSE 8MHz经过PLL倍频。App区跳转后如果时钟和外设没有被Bootloader恢复默认值新固件可能会出问题。我的做法是在Bootloader里跳转前调用RCC_DeInit()把所有外设时钟复位到默认状态再进入App这样App这边不用额外处理时钟残留问题。3. Bootloader核心逻辑实现3.1 启动流程的详细设计Bootloader的main函数就是一个状态机我把它分成四个状态Check、Download、Jump和Error。Check阶段做的事情是读B区起始地址的前4个字节判断是不是设置好的升级标志字0xA5A5A5A5。如果是说明App已经写入了完整固件进入Jump阶段如果不是说明没有待升级的固件继续进Download阶段等待接收升级命令。Download阶段里Bootloader会一直等待串口命令。我定义了一套简单的帧协议帧头0xAA 0x55命令字0x01请求升级、0x02数据包、0x03升级完成数据长度2字节小端序数据区CRC16校验2字节每收到一帧完整数据Bootloader先算CRC16校验通过再判断命令类型。收到请求升级命令后Bootloader进入升级模式回复一个0xF1表示准备就绪收到数据包命令后把数据写入B区写完后回复0xF2表示收包成功收到升级完成命令后在B区头部写入标志字然后软复位。整个流程有一个核心原则任何一帧校验失败都不继续往下写直接丢弃并等待下一帧。这样可以避免把错误数据写进Flash。如果连续多次失败Bootloader会自动超时退出跳转到A区执行原固件。3.2 Flash分区实现技巧写Flash之前先要明确STM32F103的Flash是分扇区的C8T6的两页总计64KB每页大小1KB。标准库提供了FLASH_ErasePage函数但这个函数只能按页擦除不能按字节擦除。在设计B区的数据存储时我把升级包的接收缓存区放到了内部SRAM而不是Flash这样每包数据来的时候先写进RAM的buffer等攒到2KB一页的数据再一次性擦除并写入Flash对应页。这样做的原因很简单Flash擦写次数有限反复擦除同一区域会损耗寿命。如果下载一个20KB的固件按1KB一页来算也就擦除20次左右但在极端情况下比如断线重连可能反复擦除多次所以让擦除操作尽可能少是最稳妥的做法。还有个细节Bootloader本身也是放在Flash里的如果把B区地址和Bootloader区地址算错了升级时可能会把Bootloader自己擦掉。我专门在代码里加了边界保护任何一次Flash写入的地址范围如果落在0x08000000~0x08001FFF之间直接拒绝并返回错误。3.3 跳转执行的核心代码Jump阶段是整个Bootloader最关键的部分核心代码如下void jump_to_app(uint32_t app_addr) { uint32_t app_sp *(volatile uint32_t *)app_addr; // 取出栈顶地址 uint32_t app_pc *(volatile uint32_t *)(app_addr 4); // 取出复位向量 if ((app_sp 0xFFF00000) ! 0x20000000) { return; // 栈顶地址非法说明固件不完整 } __disable_irq(); RCC_DeInit(); SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; for (int i 0; i 8; i) { NVIC-ICER[i] 0xFFFFFFFF; NVIC-ICPR[i] 0xFFFFFFFF; } __enable_irq(); // 重新设置主栈指针 __set_MSP(app_sp); // 定义函数指针并跳转 void (*app_reset_handler)(void) (void (*)(void))app_pc; app_reset_handler(); }这段代码里比较关键的几个判断第一app_sp的合法性检查必须做。如果从Flash读出来的栈顶地址不在0x20000000~0x2001FFFF范围内说明这个地址要么是空Flash0xFFFFFFFF要么是乱数据跳过去必然HardFault会导致设备卡死。第二跳转前关闭中断、复位外设、清空NVIC挂起位这是很多人容易漏的。App启动时会重新初始化所有外设但如果跳转前外设还处于Bootloader配置的状态App初始化时可能会有竞态问题。比如串口DMA还在跑App却把DMA配置改掉了就会收到一串脏数据。第三__set_MSP要放在跳转函数的最后一步因为从这一刻起栈就是App的栈Bootloader的局部变量原则上都不能再用了。3.4 Bootloader的串口驱动Bootloader的串口收包驱动我没有用DMA原因是Bootloader和App是两个独立工程如果我在Bootloader里开了DMA跳转前必须彻底关停DMA否则DMA还是会继续往SRAM缓冲区写数据App起来后如果没重新配置DMA就可能被覆盖。用简单的字节中断接收跳转前只要把USART1的中断禁用就彻底干净了。具体实现是USART1的接收中断每收到一个字节就把它丢进环形缓冲区主循环里解析帧。解析逻辑按照帧头匹配的原则一旦匹配到帧头就开始把后续字节往临时数组里存直到收满一帧。如果中途发现数据长度不符或者CRC16不对就清空解析状态重新同步。实际测试下来115200波特率下每包256字节数据Bootloader整个下载流程的通量大约在110KB/s左右跑20KB的固件包也只需要不到0.2秒非常快。4. 应用层固件改造与升级包制作4.1 App工程启动文件和中断重映射App工程和普通工程的区别集中在两块启动文件和系统初始化。启动文件上我沿用了启动文件startup_stm32f10x_md.s但要注意启动文件会被放在0x08002000处复位向量表也会跟着搬过去。App的中断向量表不再从0x08000000开头所以需要在系统初始化时调用NVIC_SetVectorTable完成中断向量表重映射#define APP_BASE_ADDR 0x08002000 void SystemInit(void) { // ... 原有时钟初始化 // 重映射中断向量表到App区起始地址 NVIC_SetVectorTable(NVIC_VectTab_FLASH, APP_BASE_ADDR); }中断向量表重映射后所有中断回调函数才能正确触发。如果忘记这一步App的串口接收中断和定时器中断通通不会执行整个程序表现为完全无响应排查起来很迷惑。另外注意一点STM32F103没有像F4系列那样的VTOR寄存器向量表偏移寄存器它重映射向量表其实是通过把0x08000000处的向量表数据拷贝到SRAM起始地址再设置一个标志让芯片从SRAM取向量。标准库的NVIC_SetVectorTable虽然名字叫SetVectorTable但实际实现只改了映射配置不会帮你拷贝向量表。所以安全的做法是在跳转进App之前由Bootloader把编译好的App向量表从Flash拷贝到SRAM起始地址。我在Bootloader的jump_to_app函数里加了这段拷贝逻辑// 从Flash的App区拷贝向量表到SRAM起始位置 uint32_t *src (uint32_t *)app_addr; uint32_t *dst (uint32_t *)0x20000000; for (int i 0; i 128; i) { dst[i] src[i]; }其实在这套AB架构里App运行在B区时它也需要自己的向量表在SRAM里生效这个拷贝逻辑我会放在App启动的最前面。如果只是运行在A区由于A区起始地址恰好是0x08000000向量表位置天然正确不用拷贝。为了保险我还是统一在App的SystemInit里做了拷贝这样A区和B区都能跑。4.2 应用层触发升级的软指令App运行期间需要通过串口接收升级指令并触发Bootloader完成真正的切换。我给App加了一个字符串命令当收到UPGRADE_NOW\r\n时App做以下三件事在SRAM约定位置写入升级命令标记0x55AA55AA关闭所有外设至少要把串口、定时器关掉避免中断干扰调用NVIC_SystemReset()软复位软复位后芯片重新执行BootloaderBootloader检测到SRAM里的升级标记进入Download模式等待接收固件包。如果长时间没收到数据包Bootloader会超时清掉标记跳回A区。这个设计的好处是用户不用断电或者按按钮一条串口命令就能触发升级流程。在企业产品里这条命令通常由服务器通过远程下发。4.3 生成带校验信息的升级包说到升级包很多人以为把编译出来的bin文件直接发过去就行。其实不行。Bootloader需要校验固件完整性单纯发bin的话没法判断传输过程有没有出错。我在工程目录下放了一个Python脚本专门用来生成带文件头的升级包。升级包格式设计如下4字节魔数 0x4F544157OTAW4字节固件总长度4字节固件CRC324字节版本号从0x01开始递增固件数据区脚本用法很简单python make_ota_pkg.py app_b.bin ota_pkg.bin 1脚本做的事情就是读入bin文件计算CRC32把文件头拼接在前面。生成的ota_pkg.bin就是实际通过串口或者网络传给设备的升级包。这样设计带来的直接好处是Bootloader在接收数据时可以先校验文件头的魔数再根据长度字段知道应该接收多少字节最后收完所有数据再算一次CRC32和文件头里的CRC32对比完全一致才写入标志字。这个流程把数据传输的出错概率压到了极低基本杜绝了坏固件启动导致变砖的可能。5. 实操过程与问题排查5.1 完整操作流程实录我从零走一遍完整流程让你照着就能复现。第一步烧录Bootloader。用ST-Link把bootloader工程编译出来的hex烧进芯片。烧完后BOOT0保持低电平复位一下串口助手打开对应串口波特率115200。如果一切正常复位后串口会打印一行[BOOT] F103 AB OTA Ready。第二步烧录A区固件。先把app_a工程烧进0x08002000地址这个地址要在Keil里配置好或者用STM32 ST-LINK Utility手动指定地址。烧完后复位串口会打印[APP-A] Running。到这里A区固件已经在跑了。第三步准备B区固件。修改app_b工程的宏定义把APP_BASE_ADDR改成0x08004000重新编译出bin文件。注意分散文件里也要同步修改ROM起始地址。然后运行make_ota_pkg.py脚本生成ota_pkg.bin。第四步触发升级。在串口助手往设备发送UPGRADE_NOW\r\nApp收到后复位进入BootloaderBootloader打印[BOOT] Enter Upgrade Mode。紧接着用串口发送工具选择ota_pkg.bin按包发送。每收完一包数据Bootloader都会回一个ACK。第五步全部发完后Bootloader看到升级完成帧校验CRC通过后打印[BOOT] Upgrade OK, Jump to B然后跳转到B区执行app_b固件。串口会打印[APP-B] Running。如果一切顺利A区和B区固件都能正常运行。之后你再发一次升级命令Bootloader就会把新包写回到A区形成来回切换的闭环。5.2 高频踩坑记录与解决方案我把做这个项目过程中踩过的坑以及网上常被问到的几个问题整理成了一张表方便你对照排查问题现象根本原因解决方案跳转后程序跑飞、HardFaultApp编译地址和跳转地址不一致或向量表没有重映射检查分散文件起始地址确保App的SystemInit里重映射向量表串口接收数据全是乱码波特率配错或USB转TTL没有共地确认双方都是115200接线时共地Bootloader收不到完整帧没有按包发送一次性发了整个文件在串口工具里配置按256字节分包发送包间延时10ms升级写Flah时程序卡死擦除时间太长中断里不能做Flash操作擦写逻辑放主循环中断只置标志位开机直接进了Bootloader而没有进AppA区没有烧录固件或者Bootloader校验标志字出问题先用ST-Link烧录A区固件检查标志字读取地址是否正确升级完成后设备反复重启B区固件CRC校验通过但程序跑异常常见原因是B区App的分散文件没有改地址跳转到B区后固件仍然在A区代码的地址范围里执行其中跳转后程序跑飞这个问题我花了大半天才排查清楚。症状是Bootloader打印跳转信息后App一个字符都没输出用调试器看发现进了HardFault。后来定位到原因——App工程在Keil里忘了改分散文件的起始地址App还是被链接到了0x08000000但实际烧录却在0x08004000。跳转之后CPU去0x08000000取指令那里放着Bootloader的代码自然乱套。解决办法就是严格保证“编译地址”和“烧录地址”一致App-A编译到0x08002000App-B编译到0x08004000。5.3 升级失败后的自动回滚机制AB方案最重要的特性就是回滚。我在Bootloader里增加了这样一个机制B区固件第一次启动之后App需要在运行稳定后主动清除B区的升级标志字并把当前活跃分区标记设为B区。具体怎么判断“运行稳定”我的做法是App在初始化完成后启动一个看门狗定时器时间为10秒。如果10秒内主循环正常运行且没有触发复位就认为新固件工作正常App主动调用mark_current_partition_valid()函数把当前分区状态写入NVRAM这个项目里我用的是Flash末尾的一个扇区可擦写次数充足。如果新固件在10秒内崩溃或者看门狗超时复位Bootloader下次启动时发现B区的有效标志字没有被清除就会回滚到A区执行。这个10秒窗口期内设备可能重启多次但最终结果一定是能跑起来的固件。我实测过程中故意在B区固件里写了个无限循环让看门狗复位设备果然在两轮重启后回到了A区串口打印[BOOT] B Invalid, Rollback to A回滚机制符合预期。5.4 网络OTA的扩展思路串口OTA只适合本地调试和设备出厂阶段产品投放市场后肯定要支持远程升级。我在串口方案稳定后又给项目加了网络OTA能力这里说一下扩展思路。设备端增加一个ESP8266模块通过USART2与STM32F103通信。App收到服务器下发的升级指令后通过AT指令让ESP8266连接指定WiFi再向HTTP服务器发起GET请求请求ota_pkg.bin文件。ESP8266把收到的数据流通过串口发送给STM32STM32的App把这些数据重定向到OTA写入流程中和串口OTA共用同一套写入逻辑。服务器端我用nginx做了静态文件服务把ota_pkg.bin放到web目录下相当于一个极简的OTA文件服务器。设备访问http://服务器IP/ota_pkg.bin即可下载。为了支持断点续传我还让服务器响应Content-Length和Accept-Ranges头设备端记录已接收偏移量断线后从断点继续请求。这个扩展的好处是整个OTA协议栈不用大改Bootloader照样是接收数据包然后校验、切换那一套逻辑只是数据来源从直接串口变成了WiFi转串口。如果你想用4G Cat.1模块思路完全相同只是AT指令集略有差异。6. 复盘与项目延伸6.1 我用这套方案的真实体会一个项目做下来最大的感受是“AB分区本身不复杂复杂的是边界条件。”很多人一开始会把精力放在写跳转代码、处理Flash写入上这些反而是最顺的部分。真正折磨人的是各种异常场景传输中断了怎么办、新固件起不来怎么办、标志字被意外篡改怎么办。每一步都要考虑“如果这步失败了系统能不能自愈”这才是工业级OTA和Demo级OTA的本质区别。我在实际测试中专门做了一个故障注入脚本随机在网络传输过程中切断连接或者人为发送损坏的数据包观察设备能否自动恢复到可用状态。最开始的结果并不理想比如有一次升级包被截断了一半Bootloader停在下载状态不跳转也不回滚必须手动复位才能恢复。后来加了超时机制任何状态下超过5秒没有新数据Bootloader自动放弃升级流程跳回当前分区。如果你只做一次串口传输的完整流程演示那确实很快就能跑通。但只有把异常路径都补上这套东西才算真正可用。6.2 后续可以继续深入的方向这个项目里我还留了几个可以继续优化的方向各有各的坑想深入玩的话可以参考第一双Bootloader冗余。当前Bootloader如果自己损坏了芯片还是没法自恢复。工业级产品会再加一个只读的出厂引导区ROM Bootloader之外再加一层用于恢复主Bootloader。第二固件加密。OTA传输过程中如果设备和服务器之间没有加密通道固件包可以被抓包分析甚至重放篡改。我目前用的是AES-128-CBC对固件包做对称加密密钥预置在芯片内。这不会增加太多代码量但能显著提升安全性。第三多版本管理。当设备支持A/B两个分区后实际上只支持两个版本互为升降级。如果你需要回退到更早的版本就要引入一个版本链表或者云端的版本策略。这个属于运营层面的设计但代码上也要预留版本号字段。第四和ESP32的OTA方案做个对比。ESP32官方有完善的OTA框架底层自动管理分区表和App验证体验上确实比STM32省心很多。但从学习角度说STM32这种“裸写”方案反而更能让你理解OTA每一步背后的原理。做完这个项目再看ESP32那个框架的内部实现很多东西会豁然开朗。6.3 写给想复现这个项目的朋友最后分享几个小建议。第一别想着一次把Bootloader和App都写完一定要先把Bootloader单独跑通用ST-Link烧进去能打印启动信息、能跳转到你预先烧好的简单App再做后面的升级流程。第二串口调试时买一个好一点的USB转TTL模块我用过便宜的CH340模块在115200下偶尔丢字节排查了很长时间才定位后来换成FT232就再没出现过。第三每个版本的固件在OTA包里加上版本号这样排查线上问题时会省力很多。我至今还记得第一次通过OTA把B区固件跑起来的时候那种感觉跟当年第一次点亮LED完全不一样——这是一个完整的工程链路从分区规划、引导设计、传输协议到可靠性保障每一步都是自己亲手搭起来的。希望这篇教程也能让你复现成功并且在这个过程中真正搞懂OTA这套机制背后的原理。

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

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

免费获取报价