ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

STM32F103 A/B分区OTA远程升级教程:从零复现Bootloader与固件回滚

STM32F103 A/B分区OTA远程升级教程:从零复现Bootloader与固件回滚 1. 项目概述与整体思路如果你接触过带远程升级功能的嵌入式设备对“OTA”这个词一定不陌生。OTA全称Over The Air也就是空中下载技术最早在手机、车机上普及这几年在物联网设备、工业控制器、智能硬件里基本成了标配。这次的项目标题是“STM32F103_AB_OTA_从零复现教程”核心就三件事在STM32F103这颗经典的ARM Cortex-M3芯片上实现A/B双备份系统跑通远程升级流程并且把整个过程整理成一套可复现的工程方案。先说为什么要选STM32F103。这颗芯片发布于2007年前后距今十几年主频72MHzFlash从64KB到512KB不等RAM最大64KB在今天的芯片里算“老爷车”级别。但它有一个无可替代的优势资料极其丰富生态极其成熟固件库、参考手册、例程、量产工具随处可见而且价格低、供货稳定直到现在依然是无数产品开发者的入门首选。用这样一颗“老古董”做OTA能逼你在资源受限的条件下把方案做扎实将来换到更强芯片上这套思路依然适用。再说为什么要做A/B系统。网上搜“STM32 OTA”能找到一大堆例程但绝大多数是单备份方案——一个Bootloader加一个App升级时用新固件覆盖旧固件。这种方案的致命问题在于如果升级过程中断电、通信中断、或者新固件本身有bug设备直接变砖只能拆机接调试器救砖。而A/B方案相当于给设备装了“双系统”一个槽位运行当前固件另一个槽位留给新固件升级失败可以自动回滚从根上解决了“升级变砖”的痛点。汽车行业叫它A/B分区手机圈叫它“无缝升级”STM32上完全可以用软件方式实现。这篇教程适合谁如果你是刚接触STM32的初学者想搞懂固件是怎么“自己更新自己”的或者你已经做过单备份OTA想进一步弄清楚A/B备份、固件校验、回滚机制这些工程化细节再或者你要给公司的产品设计一套可靠的远程升级方案这篇文章都能给你一条完整的可落地的路子。我会把从Flash分区规划、Bootloader编写、固件生成、串口通信协议到上位机脚本、回滚逻辑、故障排查的全部过程拆开讲清楚附带可直接参考的代码思路和踩坑记录。先说结论这个项目的最终形态是——MCU上电后先跑BootloaderBootloader根据Flash里的标志位和固件校验结果决定从A槽还是B槽启动AppApp运行过程中收到新固件包写到非当前运行的备用分区写完校验通过后置位“待升级”标志并复位Bootloader在下次启动时切换槽位如果新固件启动失败或校验失败自动回退到旧固件。整条链路跑通后你手上这块STM32F103就具备了真正的“自恢复型远程升级能力”。2. 为什么选A/B方案对比单备份OTA的优劣2.1 单备份OTA的真实痛点先说单备份方案因为理解了它的痛点你才会真正认可A/B的价值。单备份方案的Flash布局通常是这样的Bootloader占一段App占一段App内部或尾部留一个临时存储区用来缓存下载的新固件。升级流程是Bootloader跳转到AppApp运行后接收新固件到缓存区完整性校验通过后把新固件从缓存区复制到App区覆盖旧固件然后复位。这个方案看起来没毛病但实际用起来问题不少。第一复制过程耗时STM32F103的Flash写入速度大约每字节几十微秒一个128KB的固件光擦写就要好几秒这期间一旦断电App区一半新一半旧直接变砖。第二没有回滚能力新固件就算校验通过、成功写入跑起来发现逻辑有bug设备已经回不去了只能人工干预。第三缓存区容量受限如果芯片Flash不够大缓存区放不下完整固件还得做分包边收边写复杂度更高。我见过不少产品在单备份方案上翻车。有次一个客户做智能电表升级程序写到80%的时候现场停电整批设备全部返厂工程师挨个拆壳连接调试器重刷那场面真是欲哭无泪。从那以后但凡涉及到远程升级的产品我第一建议都是上A/B方案除非芯片Flash实在小到装不下两个App才考虑别的路子。2.2 A/B方案的核心思想与Flash分区规划A/B方案的思想可以用一句话概括永远保证Flash里有一个“已知能用的固件”。具体做法是把App区拆成两个等大的分区A槽和B槽Bootloader负责决定从哪个槽启动。升级时新固件写入非当前使用的槽位写完校验通过后Bootloader在下一次启动时切换槽位。如果新固件启动失败Bootloader自动回滚到另一个槽位。以STM32F103RCT6为例256KB Flash48KB RAM我习惯的分区规划如下分区起始地址大小内容Bootloader0x0800000032KB启动引导、升级管理、回滚逻辑App A槽0x0800800096KBApp主程序当前运行App B槽0x0802000096KBApp备份/升级目标参数区0x080380004KB升级标志、启动计数、槽位状态预留区0x08039000剩余备用Bootloader为什么放32KB因为要承载串口驱动、Flash擦写、校验算法、跳转逻辑这些加起来20KB左右留点余量。App每个槽96KB对于常见的中小型应用足够用如果你用的芯片是512KB Flash可以把每个槽扩到160KB甚至更大。参数区至关重要里面存的是“下一个启动哪个槽”“当前App启动了几次”“固件是否有效”这些状态量相当于整个升级系统的“记账本”。2.3 A/B方案的额外收益启动计数与自动回滚A/B方案不只是“多一个备份”这么简单它还能配合启动计数实现更聪明的回滚策略。思路是这样的Bootloader跳转到App之前先把参数区里的启动计数器加1然后启动App。App正常运行起来后在初始化阶段主动调用一个接口告诉Bootloader“我起来了系统正常”这时Bootloader把计数器清零。如果App启动后因为bug不断崩溃、死机、看门狗超时计数器就会一直增长等它超过预设阈值比如3次Bootloader认为这个槽位的固件已经不可救药自动切回另一个槽位。这个机制在单备份方案里完全做不到但对于真实产品来说极其重要。你想OTA的目的是迭代功能、修复bug结果新固件引入一个更严重的bug设备一启动就复位如果没有自动回滚用户的设备就废了。有了启动计数相当于给升级过程买了一份“保险”虽然不是100%覆盖所有故障场景但能兜住绝大多数“新固件起不来”的情况。还有一个小众但很实用的场景用A/B方案做“灰度发布”。你可以让设备先升级到B槽跑一段时间观察稳定性确认没问题后再把所有设备切到B槽把A槽留作老版本兜底。这种方式在车机和工控领域非常流行手机上也是类似逻辑。3. 从零搭建工程工具链与环境准备3.1 硬件准备与最小系统注意点复现这个项目硬件上你需要一块STM32F103的开发板或者自己焊的最小系统板。网上搜“stm32f103最小系统”核心元件就那么几样STM32F103C8T6或RCT6主控、8MHz无源晶振、两个20pF起振电容、8个10K上拉电阻接BOOT0/BOOT1和复位电路、3.3V稳压芯片、若干104去耦电容。如果只是想跑通OTA逻辑直接用市面上几十块的“蓝丸”开发板也行板载ST-Link接口下载调试都方便。但有几个硬件细节必须提醒你。第一确认你的板子引出了串口1PA9/PA10OTA升级通常走串口YModem协议或自定义协议串口1是Bootloader和App共用的通信口省事。第二Flash容量一定要确认清楚C8T6是64KB Flash做A/B分区非常吃紧建议直接上RCT6或RBT6128KB否则两个96KB的App分区根本放不下。第三最好接一个外部看门狗或者用STM32内部的IWDG升级过程中万一程序跑飞看门狗能把系统拉回来不至于长期卡死。3.2 软件工具链选择标准库还是HAL库STM32的开发方式目前主要有两种标准外设库Standard Peripheral Library简称标准库和STM32CubeMXHAL库。网上搜“stm32f103库v3.50下载”大概率找到的是ST官方最后一代标准库V3.5这个版本发布于2011年前后之后ST就不再更新标准库全面转向HAL/LL库。这个项目我推荐用标准库V3.5。原因有三点第一标准库的代码直白寄存器操作逻辑清晰适合教学和二次开发第二网上绝大多数的STM32 OTA例程都是基于标准库写的你搜资料容易对上号第三HAL库虽然代码生成方便但抽象层太厚做Bootloader这种底层活反而要花时间绕开HAL的封装。当然如果你对HAL库更熟或者公司规范要求用CubeMX也完全能实现同样的功能只是代码风格不一样。这里插一句Keil MDK的版本建议用5.x以上支持AC5编译器即可。工程配置需要注意两个地方一是Target选项卡里的Flash容量设置要和芯片匹配二是C/C选项卡里的优化等级建议设置为-O0或-O1方便调试时看变量值Debug信息全一点。3.3 必读文档中文参考手册与勘误这块内容真的值得单独拿出来说。开发STM32F103有两份文档必须常备一份是《STM32F103中文参考手册》RM0008另一份是《Cortex-M3权威指南》。参考手册里最重要的是Flash编程章节和系统存储器章节Flash擦写时序、选项字节配置、读保护设置这些内容做OTA时天天都要翻。Cortex-M3权威指南则帮你理解向量表、中断优先级、MSP/PSP这些底层概念写Bootloader跳转逻辑时特别有用。我见过太多新手不看手册程序跑不起来就上网发帖问最后发现是Flash等待周期没配置、或者向量表偏移没设置导致的中断异常。做嵌入式就是这样手册就是“法律条文”代码只是“法律条文的具体实践”。你花两个小时翻一遍Flash章节和启动流程章节后面可能省下两个星期的调试时间。具体来说当你把App链接地址从0x08000000改到0x08008000后必须在App启动代码最开始的地方设置向量表偏移也就是把SCB-VTOR寄存器指向新地址否则中断一触发CPU还是去0x08000000找中断向量表找到的是Bootloader的向量表整个程序就乱套了。这个坑在下面实操章节还会再讲。4. 核心细节解析Bootloader设计与App侧改造4.1 Bootloader的职责边界写Bootloader之前先想清楚一个问题Bootloader到底管哪些事不管哪些事。我的原则是“Bootloader只做保证系统能启动的事不做业务功能”。具体来说Bootloader管三件事初始化必要外设时钟、串口、Flash控制器检查升级标志并决定跳转/升级执行固件校验和槽位切换。至于网络下载、加密解密、业务日志这些功能尽量放到App里做因为Bootloader的容量有限而且要尽可能保持精简出错的概率才低。如果Bootloader里塞太多功能比如TCP/IP协议栈、文件系统、复杂的加密算法那Bootloader本身就可能成为最大的不稳定因素。一旦Bootloader出问题整个设备的升级链路就彻底废了这是最糟糕的情况。我的经验是Bootloader能做到“最小可用”就够了能跳转、能升级、能回滚再多加一个字节都是浪费。4.2 Bootloader的启动流程与关键代码Bootloader的main函数逻辑可以拆成下面几个步骤初始化系统时钟配置外部8MHz晶振倍频到72MHz。初始化串口1波特率1152008N1。读取参数区获取槽位状态和升级标志。如果升级标志有效进入升级模式接收新固件并写入非当前槽位。校验新固件通过则更新槽位状态和启动计数清除升级标志。如果无升级请求直接跳转到当前活跃槽位。跳转App的核心代码只有一个函数类似这样typedef void (*pFunction)(void); void JumpToApp(uint32_t app_addr) { uint32_t app_sp *(volatile uint32_t *)app_addr; // 取栈顶地址 pFunction app_entry (pFunction)(*(volatile uint32_t *)(app_addr 4)); // 取复位向量 if ((app_sp 0xFFF00000) ! 0x20000000) // 检查栈顶地址是否在RAM区 { return; // 非法地址不跳转 } // 设置主栈指针 __set_MSP(app_sp); // 关闭所有中断防止跳转过程中断响应异常 __disable_irq(); // 可选将向量表偏移到App区 SCB-VTOR app_addr 0x1FFFFF80; // 跳转到App的复位向量 app_entry(); }跳转之前有个细节必须做关闭中断并且至少关掉SysTick、串口中断等所有可能触发的外设中断。否则App启动后如果某个外设中断触发但App还没完成初始化中断服务函数指针指向的还是Bootloader的地址而Bootloader的RAM可能已经被App清零了一进中断就跑飞。这是Bootloader跳转失败最常见的原因之一。4.3 App侧改造三大要点App侧要干的事情总结起来是三大改造链接地址修改、向量表偏移、升级服务接口。链接地址修改在Keil里Target选项卡的IROM1起始地址改为0x08008000以A槽为例大小改为0x1800096KB。如果你用GCC对应的就是链接脚本里的FLASH起始地址和长度。向量表偏移App启动代码里在进入main之前设置SCB-VTOR为0x08008000。使用标准库时通常在main函数的最开头加一行SCB-VTOR APP_A_ADDR;这里有个容易踩的坑如果你用了RTOS比如FreeRTOSRTOS可能会接管某些异常向量比如PendSV、SysTick向量表偏移必须在RTOS初始化之前设置否则RTOS的调度器会找不到正确的处理函数。升级服务接口App需要提供一个函数用来接收外部传入的新固件数据、写入备用槽位、校验固件。你可以把这个函数设计成模块化的比如uint8_t OTA_Process(uint8_t *data, uint32_t len, uint32_t offset);这个函数每次接收一个数据块写入备用分区对应位置同时维护一个CRC32校验值等到固件接收完再统一校验。写Flash的时候注意STM32的Flash必须按半字16位或字节写入且写之前必须先擦除整个扇区。所以你需要设计一个“分段擦写”策略擦一批、写一批、再擦下一批避免一次性擦除太多导致接收缓冲区溢出。4.4 升级标志与参数区的数据格式参数区是Bootloader和App沟通的“消息板”数据格式必须两边一致。我常用的数据结构类似这样typedef struct { uint32_t magic; // 魔数固定值0xA5A5A5A5用于识别参数区有效性 uint8_t active_slot; // 当前活跃槽位0A槽1B槽 uint8_t boot_count; // 当前槽位启动次数 uint8_t update_flag; // 升级请求标志0无1待升级 uint8_t reserved; // 保留字节 uint32_t new_fw_size; // 新固件大小 uint32_t new_fw_crc; // 新固件CRC32值 uint32_t update_time; // 升级时间戳如果RTC可用 } ota_param_t;这个结构体在Flash里存3份副本写入时按双字对齐每份单独做CRC校验读的时候先读第一份如果CRC不对就读第二份再不对读第三份三份都不对就用默认值。为什么要存3份因为Flash写入过程中如果突然断电正在写的那一份可能损坏但只要其他两份完好系统依然能从参数区读到有效状态。这就是工程上和“实验室原型”的差别多花4KB Flash换来的可靠性提升是实打实的。5. 固件格式与升级协议设计5.1 固件包格式不止是bin文件编译器生成的固件Keil里可以Output选项卡勾选“Create HEX File”得到hex文件也可以勾选“Create Batch File”用fromelf命令转出bin文件fromelf --bin --outputapp.bin .\build\app.axf但bin文件裸奔着用会有问题——没有版本号、没有目标芯片信息、没有CRC校验。所以工程化的做法是自定义一个固件包头把bin文件“包装”起来。我用的固件包格式如下偏移长度内容04魔数0x4F544131即“OTA1”44固件版本号如0x01000001表示V1.0.0.184固件长度bin文件字节数124固件CRC32164目标槽位0A1B202硬件平台ID0x0001表示STM32F103222保留24Nbin文件数据24N4整包CRC32包含包头和bin数据的校验固件包生成可以用Python脚本搞定脚本输入一个bin文件输出一个带包头的ota包文件。这一步看似多此一举但实际上给升级系统带来了四个好处版本管理清晰、目标平台校验防误刷、传输前就能识别包是否损坏、回滚时能精确判断新旧版本关系。5.2 传输协议设计串口YModem还是自定义STM32和上位机之间的固件传输常见的方案有两种YModem协议和自定义协议。YModem是一个历史悠久的文件传输协议支持128字节和1024字节包自带CRC校验网上有大量移植好的代码。它的优点是成熟稳定缺点是协议开销大、速度慢而且不支持断点续传。自定义协议的灵活性更高。我这里给一个参考协议框架帧头(2字节) 0xAA55 帧类型(1字节) 0x01握手 0x02数据 0x03结束 0x04确认 0x05错误 帧序号(2字节) 自增序号 数据长度(2字节) 最大1024 数据域(N字节) CRC16(2字节) 从帧类型到数据域的CRC握手阶段上位机发送“开始升级”帧包含固件总长度和CRC32Bootloader收到后回复确认帧并告知目标槽位。数据阶段上位机分包发送每包最大1024字节Bootloader收到后写入Flash并回复确认如果超时未收到确认上位机重传。结束阶段所有数据发送完上位机发送结束帧Bootloader做整包CRC校验校验通过则更新参数区复位启动新固件。串口波特率选115200是保守做法传输一个96KB的固件大约需要9秒左右。如果你对实时性要求不高这个速度完全可以接受。想要提速的话可以改成460800前提是两端硬件都支持而且线材质量别太差。5.3 上位机升级工具选型上位机工具有三个层次的方案。第一层直接用电脑串口助手配合手工指令测试适合验证协议正确性但效率低。第二层用Python写一个小工具用pyserial库实现自动分包发送、进度条显示、日志记录这个是我最推荐的灵活度最高代码量也不大一百多行就能搞定一个够用的工具。第三层如果有更多需求比如支持批量升级、远程管理、多设备并发可以考虑用Qt写上位机或者直接对接云平台的OTA服务。Python工具的骨架大致是import serial import struct import time ser serial.Serial(COM3, 115200, timeout2) def send_frame(frame_type, seq, data): frame struct.pack(HBHH, 0xAA55, frame_type, seq, len(data)) frame data crc crc16(frame[2:]) frame struct.pack(H, crc) ser.write(frame) def ota_upgrade(bin_path): # 1. 读取固件加包头 # 2. 发送握手帧 # 3. 分包发送数据帧每包1024字节 # 4. 发送结束帧等待确认 # 5. 设备复位后主动上报新版本号 pass加包头的脚本和上位机发送脚本可以是同一个工程也可以拆成两个。我建议拆开打包脚本在编译后自动执行生成ota包上位机升级工具单独维护方便复用。编译、打包、上传这一整套流程可以用批处理或Makefile串起来实现“一键生成可升级固件包”这对团队协作特别有用。6. 实操过程从编译到升级完整走一遍6.1 编译Bootloader和App的工程配置差异这一步是整个复现过程里最容易翻车的环节我把它写细一点。先建两个Keil工程Bootloader工程和App工程。Bootloader的IROM1地址从0x08000000开始大小32KB这没什么好说的。App工程的IROM1地址改成0x08008000大小0x1800096KB。关键点在于App工程和Bootloader工程必须使用不同的输出文件名防止生成物互相覆盖比如app_a.axf和boot.axf。另外App工程的C/C选项卡里Define那里需要加一个宏用来告诉代码“当前编译的是App”还是“当前编译的是Bootloader”。我习惯用#define APP_BUILD代码里就能这样写#ifdef APP_BUILD SCB-VTOR APP_A_ADDR; #endif这样同一个串口驱动源文件既能编译进Bootloader也能编译进App只是通过条件编译区分行为。代码复用率高维护成本低。链接脚本的区别也要处理。用Keil时不用手动改链接脚本图形化配置IROM就够。如果你用GCC/CMake需要把Flash起始地址和长度传给链接器比如FLASH (rx) : ORIGIN 0x08008000, LENGTH 96K RAM (xrw) : ORIGIN 0x20000000, LENGTH 48KRAM不用变因为Bootloader和App的RAM都是从头开始用的只要两边运行时不会同时存在RAM地址就不冲突。6.2 第一次升级实验的完整步骤第一次实验建议在开发板上进行核心目的是验证“Bootloader能升级App、App能启动”这条最小链路先别急着做复杂功能。具体步骤把Bootloader的hex通过ST-Link烧写到0x08000000。编译一个最简单的App比如LED闪烁通过ST-Link烧写到0x08008000验证Bootloader能否正常跳转启动。这个App的链接地址必须已经改到0x08008000否则跳转进去就是乱跑。把App固件打成ota包连接串口用上位机工具向Bootloader发起升级请求。观察升级过程日志确认数据分包、Flash写入、CRC校验全部通过。升级完成后设备自动复位新App启动观察LED闪烁状态确认运行正常。第2步是很多人跳过的“关键预检”。先手工烧一个App到目标地址验证跳转逻辑没问题再测试OTA升级流程。这样能隔离变量如果直接跑OTA升级失败你很难判断是Bootloader跳转的问题、还是Flash写入的问题、还是上位机协议的问题。先手工烧App把跳转链路打通后面再做OTA排查范围就小得多。6.3 编译脚本与固件打包的自动化每编译一次就要手动点一次Keil、再手动跑一次打包脚本太累了。我习惯用批处理把整条链路串起来echo off rem 1. 编译App工程 C:\Keil_v5\UV4\UV4.exe -b app.uvprojx -o build_app.log if errorlevel 1 (echo App build failed exit /b 1) rem 2. 转bin文件 C:\Keil_v5\ARM\ARMCC\bin\fromelf.exe --bin --outputoutput\app.bin .\build\app.axf rem 3. 打包ota固件 python make_ota_package.py output\app.bin output\app_ota.bin echo Build and package done.这样每次改完代码双击批处理几分钟后就能拿到一个可以直接上传的ota固件包。有同学会用Jenkins或者GitLab CI做持续集成道理一样无非是把这三步塞进流水线。嵌入式项目引入CI/CD的价值是每次提交代码自动编译并生成固件包版本可追溯人工误操作也少了。6.4 实际升级过程中的日志记录调试OTA升级时日志信息就是你的眼睛。Bootloader侧建议把每个关键节点的信息通过串口打出来比如[BOOT] Param valid, active slot A, boot count 0 [BOOT] No update request, jump to A (0x08008000)升级过程中[BOOT] Update request detected, target slot B [BOOT] Receiving packet 1/96, len 1024 [BOOT] Receiving packet 2/96, len 1024 ... [BOOT] Receive finish, total 98304 bytes [BOOT] CRC check pass, update slot status [BOOT] Reset to run new firmwareApp侧也会打日志比如[APP] Version 1.0.1, boot from slot B [APP] System init ok, clear boot counter把这些日志对齐看整个升级过程的状态迁移一目了然。日志的数据量不大115200波特率下完全够用。注意别把日志函数加进中断服务函数里否则实时性会受影响串口输出还会阻塞中断流程导致丢包。7. 常见问题与排查技巧实录7.1 升级后App不运行或反复复位现象OTA升级流程正常结束设备复位后却不停重启或者完全黑屏无反应。排查步骤先看日志如果Bootloader日志显示“Jump to A”之后没有任何输出说明App要么死机了要么根本没有执行。这时候优先检查三件事。第一App的链接地址和实际烧写地址是否一致。很多人改了IROM1地址但下载算法那边还是按0x08000000分配导致App被烧到了错误位置。用ST-Link读一下芯片的Flash内容对比App bin文件最直接。第二向量表偏移是否设置。如果App里没有加SCB-VTOR APP_A_ADDR;中断向量表指向0x08000000Bootloader的向量表被App栈顶覆盖一旦发生任何中断程序就飞。飞的表现就是“复位后没反应”或者“跑几步就死”。第三栈顶地址是否合法。跳转函数里我写了一个判断if ((app_sp 0xFFF00000) ! 0x20000000) return;。如果App的栈顶值不在RAM区说明bin文件本身有问题或者读取的偏移地址不对。这个判断看起来简单但能挡住一大半的非法跳转。7.2 Flash写入失败或校验不通过现象升级数据接收正常但写Flash时报错或者最后CRC校验不通过。这种问题多数出在Flash控制器配置上。STM32F103的Flash编程电压范围是2.0V到3.6V如果供电电压偏低Flash写入会随机失败。另外Flash编程前必须先擦除擦除的单位是扇区F103大容量芯片的扇区大小不完全一样前4个16KB扇区后面是64KB扇区。你写Flash前要确认擦除的范围覆盖了你写入的范围别只擦了一半另一半旧数据还在写出来的固件自然不对。还有一种隐蔽情况Bootloader和App共用串口中断但中断服务函数里用了同一个缓冲区App刚跳过去还没初始化串口Bootloader的串口中断还在跑数据覆盖了App的全局变量。解决方法是跳转前把串口中断关掉或者干脆把SRAM里的所有中断服务函数地址都失效掉。我在跳转代码里加了__disable_irq()就是为了防止这种情况。CRC校验不通过则要检查固件包头的CRC算法和Bootloader里实现的是不是同一个。CRC32有标准版、有变体初始化值、多项式、结果异或值都可能不同两边代码要完全对齐。推荐直接用ST官方库里的STM32 CRC外设或者用纯软件的查表法实现保证一致。7.3 升级中断电导致参数区损坏现象升级过程中人为断电重新上电后设备既无法启动新固件也无法进入升级模式甚至Bootloader也识别不了参数区。这个问题我在设计参数区时已经做了防护存3份副本交叉校验。但如果你只存了一份遭遇中途断电参数区数据就可能是半写状态魔数都对不上Bootloader只能走“无有效参数”的默认逻辑。一个干净的默认逻辑是如果参数区不可用Bootloader尝试从A槽启动如果A槽的固件校验通过就正常运行否则尝试B槽两个槽都不行才进入升级模式等待接收新固件。也就是说“参数区损坏”不能导致设备成砖而是让它退化为一种确定的、可恢复的状态。这个兜底策略才是A/B方案真正的灵魂。7.4 App侧接收升级数据时丢包现象大固件传输时收到的数据和发送的数据不一致或分段接收时中间丢了一部分。排查思路按这几步走第一确认串口波特率设置一致115200是最稳的区间第二确认Bootloader的串口接收缓冲区够大至少能缓存一个完整的数据包1024字节帧头CRC否则边收边写Flash容易丢数据第三确认上位机发送每帧之间有没有留时间间隔有些USB转串口在高速连续发送时会丢字节可以在每帧之间加5ms延时第四检查Bootloader的Flash擦写时间会不会阻塞串口中断如果擦写Flash期间中断被屏蔽而数据还在进来缓冲区溢出是必然的。解决丢包问题上最有效的方法是“流控”上位机发完一包必须等Bootloader回确认帧才发下一包。这种“停等协议”速度慢一点但可靠性极高。如果你对速度有要求可以改成“滑动窗口”一次连续发4包再等确认代码复杂度上来了但吞吐量能提升不少。第一版实现建议用停等链路跑通后再慢慢优化。7.5 一个容易忽略的坑Bootloader的代码优化等级Bootloader里的跳转函数、参数区操作这些代码如果编译器优化等级开太高比如-O3可能会出现“变量被优化掉导致判断失效”或者“函数内联导致指令顺序变化”的问题。尤其是跳转前的__set_MSP和SCB-VTOR这些操作必须严格按照顺序执行编译器乱序优化之后就可能出问题。我的做法是给关键函数加上__attribute__((optimize(O0)))强制这些函数不优化__attribute__((optimize(O0))) void JumpToApp(uint32_t app_addr) { // ... }虽然这会让代码体积变大一点点但换来的是行为的确定性非常值得。类似的还有参数区的写入函数也建议禁止优化防止写入顺序被编译器篡改。8. 进阶方向与实际项目落地建议8.1 从串口OTA到Wi-Fi/BLE/CAN OTA串口只是OTA传输层的一种载体。实际产品里常见载体还有Wi-Fi、蓝牙BLE、CAN总线、以太网、LoRa等。框架本身不用变——Bootloader只关心“怎么接收数据、怎么写入Flash、怎么校验和回滚”至于数据是从串口来、Wi-Fi来还是CAN来Bootloader根本不需要知道。换句话说你的OTA框架应该把“传输层”抽象成一个接口typedef struct { int32_t (*init)(void); int32_t (*receive)(uint8_t *buf, uint32_t len, uint32_t timeout_ms); int32_t (*send)(const uint8_t *buf, uint32_t len); } ota_transport_t;串口传的时候实现一个uart_transportWi-Fi传的时候实现一个wifi_transport。这样核心的槽位管理、回滚逻辑、Flash驱动一行都不用改换个transport就支持新的通道。这也是我在实际项目中反复使用的架构。8.2 固件加密与签名校验在车载、医疗、智能家居这些安全敏感领域OTA还必须考虑防篡改。光有CRC校验只能防“传输错误”防不了“恶意篡改”。业界常用的做法是“签名加密”两层底层用AES-CTR或AES-GCM对固件加密防止固件被提取分析上层用ECDSA或RSA做数字签名Bootloader验签通过后才允许写入。签名的作用是确保固件确实来自合法的开发方没被中间人替换。签名验证在资源受限的MCU上要做性能评估。STM32F103主频72MHz没有硬件加速跑一次RSA-2048验签大约是几十毫秒到几百毫秒不是不能接受但要在升级流程里规划好超时时间。更轻量的做法是用ECDSA-P256密钥更短验签速度也更快F103上大概在几百毫秒级别。需要“快”的地方还能用Ed25519但实现复杂度会高一些。现阶段先用CRC把链路跑通签名加密可以作为下一阶段的安全增强项。8.3 与云平台对接物模型与升级任务单台设备手动升级只是开始量产产品的OTA通常是“云端发起、设备端执行”。云平台无论是自建还是用公有云IoT套件一般会管理设备列表、固件版本、升级批次、灰度策略这些元数据然后通过MQTT等协议把升级指令下发给设备。设备App收到指令后下载固件包到备用槽位触发Bootloader完成升级。做云端OTA时App侧需要多实现几个功能定时上报当前固件版本号接收云端升级命令内含固件URL、版本、校验信息按URL下载固件HTTP或者MQTT下载过程中断点续传记录已经下载的偏移量。这些功能放在App里实现Bootloader保持“被动接收者”角色架构上更合理。8.4 A/B方案在资源受限芯片上的变通如果你的芯片只有64KB Flash装不下两个96KB的App怎么办有几个变通方案第一压缩固件。用LZ4或zlib在打包时压缩固件App侧解压后写入另一个分区。很多物联网模组都这么做缺点是升级时RAM占用高解压缓冲也要占RAMF103的48KB RAM有点吃紧但可以控制解压块大小来解决。第二缩小App分区。把App功能拆得精简一点两个App分区各自40KB一个Bootloader 8KB参数区4KB加起来92KB勉强塞进64KB是不行的所以64KB的C8T6做A/B确实很困难建议换RCT6。第三外部Flash方案。Bootloader和App都放在内部Flash但升级时先下载固件到外挂SPI Flash校验通过后再一次性搬进内部Flash。这相当于给内部Flash配了一个“暂存仓库”缺点是额外增加一颗SPI Flash的成本。实话说如果芯片Flash实在小到装不下两个App我建议你优先考虑换芯片或者重新审视产品功能边界——有些功能真的非做不可吗一个连A/B备份都塞不下的固件说明功能已经超出了这颗芯片的合理承载范围。硬往里面塞OTA最后只能是“带病上线”迟早要还债。9. 实操总结与我的经验体会做完这个项目回头再看你会发现A/B OTA最核心的价值不是“备份”而是“可回滚的信心”。有了这个机制团队在发布新固件时敢发、敢试、敢迭代因为出了事设备能自己退回老版本不用派人上門救砖。这种安全感对于产品长期演进的意义远大于省下来的那点售后成本。关于学习路径我建议你按这个顺序走先跑通Bootloader最小跳转再实现App侧改造然后用串口手动发几包数据做升级实验最后再写上位机工具、加参数区、做回滚。每一步都验证通过再进入下一步别一口气全做完才开始调试那样出了问题根本定位不了。有几个我自己反复踩过的坑值得再强调一遍跳转前一定要关中断、清中断标志否则有概率随机死机。Bootloader和App的串口驱动最好共用一套代码用条件编译区分省得两边行为不一致。固件包一定要带版本号和CRC别拿裸bin上生产血的教训。参数区写入要注意Flash写保护F103有默认的写保护设置不关掉的话写不进去。每次改Flash布局记得把Bootloader和App重新全部烧一遍别只烧App地址错位会导致诡异问题。最后再说一个小技巧把Bootloader的调试串口和App的业务串口分开。Bootloader用串口1做升级App用串口2做业务通信这样升级过程中业务数据也不会干扰升级流程日志也更容易区分。如果硬件上只有一个串口那就把业务通信在升级期间暂停用标志位控制别让两边抢同一个外设。这个项目做完之后建议你继续往前走两步第一步把固件签名加上哪怕先用简单的XOR校验熟悉流程后面再上正式加密算法第二步接一个真实的云平台做一次端到端的远程升级演示。跑通之后你收获的就不只是一段代码而是一整套从“开发-打包-发布-升级-回滚”的完整工程方法论。这套方法论用到哪个平台都不会过时。
RELATED READING

延伸阅读

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