ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

STM32 USB OTA升级实战:Bootloader与Flash分区详解

STM32 USB OTA升级实战:Bootloader与Flash分区详解 简介针对STM32平台的USB接口OTA升级方案主要面向需要在线更新固件的嵌入式开发者帮助理解并实现自制Bootloader与应用程序协同工作。压缩包内包含两个独立完整工程Bootloader源码与App源码从代码层面展示了启动流程、USB协议收发、固件分包校验、Flash擦写以及跳转App等核心环节。资源总计504个文件以C源文件和H头文件居多另有工程配置文件、链接脚本、烧录用的bin/hex文件以及编译中间产物便于对照分析整个构建过程整体大小为7.57MB。已有6638人学习适合具备一定STM32基础、希望快速落地USB升级功能的读者配套博文可补充细节便于进阶掌握。 直接写正文不废话进入正文。做嵌入式开发的朋友应该都有过这种经历产品已经交付给客户了结果发现固件有bug或者需要加个小功能这时候怎么办按传统玩法要么发顺丰把板子寄回来要么派人带着ST-Link出差现场刷机成本高不说客户体验也糟糕。我去年做一个带USB接口的STM32便携设备时就遇到了这个问题最后干脆把OTA升级做进了USB协议栈里效果出奇地好——用户只需要一根USB线连上电脑运行一个小工具就能完成固件更新连壳都不用拆。这篇东西就把整个方案的思路、分区规划、Bootloader实现、App端配合、还有调试踩坑全过程都讲一遍。主要针对STM32F1/F4系列原理对其他系列同样适用适合手里有USB接口产品、想把升级流程做正规的工程师参考。1. 为什么选USB做OTA而不是UART或SD卡1.1 各升级方案优劣势对比很多产品默认用UART Bootloader做升级那也是OTA的一种实现方式。但UART有个天然短板不是所有设备都有调试串口引出量产产品的外壳上更不可能留一排杜邦线接口。SD卡升级倒是方便但需要用户手动把固件拷贝到TF卡、插拔卡槽对非技术用户来说门槛偏高。USB是成本最低、体验最好的选择。现在绝大多数STM32产品都有USB接口不管是做数据采集、HID设备还是虚拟串口USB物理层已经在那里了。用户操作逻辑极其简单设备进入升级模式插USB线电脑上点一下升级按钮完成。不需要驱动专业知识不需要打开外壳不需要额外硬件成本。我实测过几种方案的开发周期用USB CDC虚拟串口做数据传输通道配合自定的简单帧协议整个Bootloader从零到能跑大概三到五天比我想象中快很多。很多团队一听“USB协议栈”就觉得复杂实际上STM32的USB Device库已经把底层枚举、端点传输全封装好了你只需要关心收发缓冲区和数据解析。1.2 USB CDC虚拟串口的选型思路这里我推荐USB CDC类而不是HID类。CDC在主机端会虚拟出一个COM口用户可以用任何串口助手软件查看升级日志对调试极其友好。HID类虽然免驱但它的传输单元是报表单次传输数据量小、延迟高大固件传输效率不如CDC。CDC也有个代价Windows首次插上需要装驱动。不过STM32官网和各大IDE都自带驱动包况且如果是自有上位机还可以在驱动安装失败时自动提示用户手动指定驱动目录。如果产品对免驱有硬性要求那就用HID或者直接用自带WinUSB驱动的库这个后面有机会再单独写一篇。选USB CDC还有一个隐性好处升级协议和数据传输可以全部跑在同一个虚拟串口上不需要额外占用引脚或外设。Bootloader和App共用同一个USB硬件跳转前后USB重新枚举逻辑非常干净。2. 整体方案设计与Flash分区规划2.1 Bootloader App双分区架构USB OTA的本质是IAPIn-Application Programming核心思路是芯片上电先跑BootloaderBootloader判断是否需要升级如果需要就接收固件并写入App区不需要就直接跳转到App区执行。所以Flash必须划分成至少两个区域Bootloader区、App区。我常用的分区表如下以STM32F103RCT6、256KB Flash为例区域起始地址大小说明Bootloader0x0800000032KBUSB枚举、传输协议、Flash写入、跳转逻辑App参数区0x080080004KB存放升级标志、版本号、固件长度、CRC固件缓存区0x080090008KB存储待校验的固件头信息与升级状态备份App区0x0800B000剩余用户应用程序Bootloader 32KB其实非常充裕我实际编译出来才用了不到一半。参数区用来放升级标志和当前固件状态这样App可以通过读参数区判断是否需要触发升级Bootloader也可以根据参数区内容决定流程分支。2.2 为什么需要固件缓存区直接写App区不行吗这是很多初做OTA最容易忽略的问题。如果收到一帧写一帧中途USB断开或者数据出错App区就已经被写坏了变砖概率极高。所以我在App区前面单独划了一个8KB的固件缓存区用来暂存固件头信息、分段接收计数和校验和。注意这不是直接把整个固件存下来而是存“元数据”真正的固件数据还是边收边写App区只是加了一个“先校验后生效”的机制。流程是这样的App向Bootloader请求升级时先把固件总长度、版本号、CRC32摘要写入参数区和缓存区然后软复位进入Bootloader。Bootloader读取参数区发现升级标志为有效进入接收模式边收边写App区同时每收完一个包就把累计CRC更新到缓存区最后整包收完再校验整体CRC校验通过才把参数区升级标志改为“升级完成”否则改为“升级失败需回滚”。2.3 编译脚本和链接地址调整有了分区表工程配置也要同步改。用Keil时Bootloader工程的IROM1起始地址保持0x08000000不变大小设为0x8000。App工程的IROM1起始地址要改成0x0800B000大小填0x08015000这里的计算方式是256KB总大小减去Bootloader 32KB、参数区4KB和缓存区8KB。App工程的IRCCTarget地址也要同步修改中断向量表偏移由系统初始化代码设置。用STM32CubeMX的同学注意生成代码时在SystemInit()里或main函数最前面加一句SCB-VTOR APP_START_ADDR;跳转App前还必须关闭全局中断、把RCC外设时钟全部复位、将SysTick和所有外设中断清理干净否则App会跑飞。这是IAP的三座大山之一后面常见问题里会细说。3. Bootloader核心功能实现3.1 升级协议帧格式与传输机制协议层我设计得尽量精简总共三种帧类型握手帧、数据帧、结束帧。数据帧结构如下字段字节数说明帧头2固定0xAA55用于帧同步帧类型10x01握手0x02数据0x03结束包序号2从0自增用于检测丢包数据长度2数据域有效字节数最大256数据域N固件数据块CRC162对整个帧的CRC16校验握手帧由上位机发送包含固件总长度和CRC32高位摘要。Bootloader收到后核对剩余Flash空间是否足够、版本是否高于当前版本然后回应ACK或NACK。数据帧按512字节切块上位机一包一包发每包等待ACK后再发下一包超时300ms重发。这种停等协议效率虽然不算高但胜在实现简单、调试直观。实际升级一个128KB固件全流程大概20秒左右用户完全能接受。我不用YMODEM的原因很简单YMODEM的会话状态机和数据包格式处理起来复杂度高而且一部分串口助手软件对YMODEM实现得并不完整。自定协议只需要30行代码就能搞定收发出了问题自己完全可控。如果你喜欢YMODEM也可以用但排查协议Bug时大概率会怀念自定协议的简洁。3.2 Flash写入与擦除注意事项Flash操作有个铁律写之前必须先擦除而且擦除以扇区为单位。STM32F1的小容量扇区是1KB中容量是1KB前4个扇区加后面大扇区F4系列更麻烦扇区大小不统一从16KB到128KB都有。所以写Bootloader时建议用统一的FLASH_EraseInitTypeDef结构体根据芯片型号动态获取扇区大小而不是写死地址。写入时强烈建议用FLASH_Program逐半字写入虽然库函数也提供了一次写双字或256位F4系列的接口但考虑到协议分包粒度不同逐半字最稳妥。特别注意写入地址必须按半字对齐否则硬件错误。数据域里如果长度是奇数最后一个字节要单独处理不能把半个字塞进Flash否则会把相邻字节破坏掉。擦除App区前先把当前固件版本号和CRC备份到参数区——等等这个说法不严谨准确说是在擦除之前应该先把旧固件的关键信息整体搬到缓存区。但STM32同一时刻同一块Flash不能同时擦写所以我采用的办法是先把旧固件末尾几个扇区的内容整体读进RAM再执行擦除最后先写回这几个扇区再开始写入新固件。这样保证万一新固件写一半失败回滚时旧固件的末尾部分还在。实际做回滚设计时最简单的安全网其实是预留A/B备份区双Bank方案可以说是最容易实现的做法很多量产产品就是这么干的——当然代价是Flash占用翻倍。3.3 跳转App的关键代码跳转函数是整个Bootloader的核心代码如下typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t msp_value; pFunction jump_func; __disable_irq(); // 关闭全局中断 HAL_RCC_DeInit(); // 复位RCC时钟配置 SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; HAL_Delay(10); msp_value *(volatile uint32_t *)app_addr; jump_func (pFunction)(*(volatile uint32_t *)(app_addr 4)); __set_MSP(msp_value); // 设置主栈指针 jump_func(); // 跳转 }先读App区的首字作为MSP值第二个字作为复位向量地址再把MSP设置过去最后用函数指针跳转。注意__set_MSP之前一定要关闭所有中断否则一堆外设中断还在pending状态跳到App之后立刻触发中断但中断向量表还没来得及重定位直接就HardFault。3.4 Bootloader里的USB初始化时机我一开始犯过错误在Bootloader一上电就初始化USB并等待主机连接结果升级完成跳转到App后App又初始化一次USBWindows会听到两次设备插拔提示音设备管理器中虚拟串口的COM号也会变化用户就懵了。后来改成Bootloader上电只初始化GPIO和Flash读取参数区判断升级标志有效才初始化USB外设否则直接跳App整个过程不到500ms用户几乎感觉不到Bootloader的存在。如果升级标志有效再初始化USB并等待上位机连接。这种方式逻辑干净也不会干扰App的USB枚举。4. App端配合与交互设计4.1 App如何进入升级模式App端不需要集成完整的OTA协议栈只需要在收到上位机升级指令时做三件事保存当前状态、写入升级标志、软复位。核心代码如下void enter_bootloader(void) { FLASH_Unlock(); // 写升级标志到参数区值为0xA5A5A5A5 FLASH_Program(OTA_FLAG_ADDR, 0xA5A5A5A5); FLASH_Lock(); NVIC_SystemReset(); // 软复位 }关键点在于升级标志的写入必须是原子性的、可靠的。FLASH_Program操作虽然内部等待了操作完成但建议写入后再读出来校验一遍防止写入失败导致复位后Bootloader误判不进入升级模式。App还可以给自己加一个“强制升级”的触发方式比如开机后1秒内按住某个按键或者在配置页面里点击“进入升级模式”。这种后备手段在处理升级固件崩溃时非常有用——App跑飞了还能救回来。4.2 版本号与回滚策略版本号建议设计成主版本号、次版本号、修订号的三个uint8_t组成直接放在固件头部前8个字节里。Bootloader在握手阶段就要读取新固件的版本信息和当前App区已存固件的版本号做比较新版本必须严格大于旧版本才允许写入。这样能防止用户误操作把旧版本刷回去。回滚策略我用的是最简单的那种App区里预留一份上一次正常运行的固件副本放在高位地址段新固件写入时不动这个备份区。每次升级完成后第一次运行App时App会往参数区写入“本次运行正常”的心跳标志如果在设定的时间窗口内没有写入也就是新固件起不来下次上电Bootloader自动从备份区把旧固件恢复回来。这个机制实现成本极低但能把变砖概率降到最低。4.3 上位机交互细节上位机我用的PyQt5写了个小工具核心流程就四步选择固件文件 - 读取版本号 - 连接虚拟串口 - 发送升级命令并传输数据。这里有个非常实用的经验升级过程中上位机界面要显示进度条和当前包序号Bootloader在每收到1024字节数据时通过USB回传一个进度确认帧上位机据此更新进度条。不要在每包都回传ACK之外再额外回传进度帧两者合并就可以减少USB通信开销。用户拔线操作也要防护好。升级过程中如果用户中途拔出USB线Bootloader会一直等不到下一包超时后自动将参数区升级标志改为“升级失败”下次上电重新进入Bootloader并提示重新升级。不会导致变砖因为此时App区虽然写了一半但备份区的旧固件还在回滚机制的保护下。5. 常见问题与排查技巧实录5.1 USB枚举失败、设备管理器感叹号这是遇到最多的问题典型表现为设备插上后Windows提示“无法识别的USB设备”设备管理器里出现黄色感叹号描述是“STM32 Virtual COM Port”或“Unknown Device”。第一步检查USB硬件电路D和D-两根数据线是否接反VBUS检测脚是否连接。第二步检查软件配置用CubeMX生成工程时USB时钟源必须选择PLL1Q频率必须精确为48MHz。我遇到过一次把PLLQ配成47.88MHz的情况10次枚举有3次失败。第三步看驱动STM32的CDC驱动有时候会因为旧驱动残留导致枚举后装不上驱动重启电脑或者手动卸载设备后重装即可。5.2 跳转App后死机或HardFault跳转后死在启动阶段绝大多数原因就是中断向量表没重定位。我调试时打印PC指针发现跳转后首指令就进了Default_Handler。解决办法就是App工程确认在启动代码里设置了SCB-VTOR APP_START_ADDRBootloader跳转前确认关闭所有中断、复位系统时钟还有一个容易忽略的点如果你使用了FreeRTOS跳转前要确认所有任务环境都已经被销毁否则残留的中断和内存状态会互相污染。我通常的做法是直接用NVIC_SystemReset()复位后立即跳转不走到RTOS调度器。5.3 固件接收完成但校验失败CRC校验失败一般是两方面问题传输过程数据被破坏或者固件文件本身就不完整。先在PC端对固件文件做一次CRC32确认生成固件环节没问题。传输端的问题用USB抓包工具最直接Wireshark支持USBPcap抓包能看到每一帧USB传输的数据和端点号几乎能立刻定位是上位机发错数据还是Bootloader收错数据。如果抓包发现数据完全正确但Bootloader算出的CRC就是不对那要检查你的CRC实现是不是“半字对齐”问题——在Flash写入时因为奇数长度的包处理不当导致最后几个字节写错。我之前就在一个奇数字节分包上栽过跟头写了整整一个下午。5.4 反复擦写导致Flash寿命问题STM32F1系列Flash擦写寿命官方标称是10K次听起来很多但如果调试阶段反复做“擦除-写入-回滚”操作很快就能消耗掉几十上百次。调试阶段尽量用最小固件做测试一个大固件反复刷100次就是几十MB的擦写量。量产阶段一定要在Bootloader里加写次数统计超过阈值主动上报给上位机防患于未然。5.5 在线升级中途断电的保护不管是USB拔线还是用户直接断电情况都差不多。我在Bootloader里设计了一个“三段式升级状态机”接收中、已校验未生效、已生效跳转。断电发生在“接收中”阶段下次上电Bootloader发现升级标志不是最终态直接走旧固件回滚。这个策略需要搭配一个“新固件首跑自检”机制——App启动后向参数区写“启动成功”Bootloader在跳转前先读这个标志如果上次升级后的App从未写过“启动成功”说明App起不来执行回滚。我用一个1KB的备份区放置旧固件的CRC和启动标志整个回滚逻辑加起来不到100行代码却能把“升级变砖”这个最让人头疼的问题彻底解决。强烈建议所有做OTA的产品都必须有回滚方案没有回滚的OTA就是定时炸弹。5.6 常见问题速查表现象可能原因排查/解决枚举失败设备管理器感叹号USB时钟源配置错误检查PLL配置确认48MHz跳转App后HardFault中断向量表未重定位设置SCB-VTOR协议握手失败波特率不匹配或串口选择错误确认CDC波特率设置无实际影响检查打开的是不是虚拟串口接收过程丢包上位机发送超时或ACK未匹配查看协议实现中是否用包序号做匹配CRC校验失败奇数字节处理异常检查Flash写入是否按半字对齐升级后无法运行新固件初始化失败看备份区回滚标志是否被触发隔离新旧固件问题6. 工具链选型与调试建议6.1 开发环境Keil还是VS CodeKeil STM32CubeMX的组合基本是标配配置外设和生成初始化代码很爽。VS Code PlatformIO也很好用但STM32F1的PlatformIO支持不如Keil老道如果做USB这种依赖严格时钟配置的外设我建议还是用CubeMX生成后导入Keil少踩坑。代码编辑用VS Code编译维护用Keil两边通过脚本同步源码这是我的推荐模式。6.2 USB抓包工具的实战用法排查USB传输问题不能只靠printf。USBlyzer和WiresharkUSBPcap是两大利器。USBlyzer能看到设备描述符、配置描述符和每一笔USB请求对于枚举失败类问题几乎是秒杀。Wireshark的USBPcap更多用来分析批量传输的数据内容可以对比实际发出的固件是否与文件内容一致。还有一个土办法在Bootloader里把收到的数据再原样通过调试串口发出来用串口助手比对两边数据。这个方法虽然土但关键时刻比抓包工具更快定位问题尤其在USB和UART共存的环境里。6.3 模拟升级测试的流程写完代码别急着上真机。先在Proteus或QEMU里做USB CDC和Flash写入的仿真确认协议帧处理逻辑没有明显Bug。然后烧进真机做三轮测试第一轮用最小固件几KB验证链路通断和跳转第二轮用接近App容量上限的大固件验证擦写时间和缓冲逻辑第三轮做断电/拔线实验验证回滚机制在异常中断时不失效。三轮全过基本可以放心量产。这轮测试跑完USB OTA这套东西我就有底气打包交付了。最后再说一句OTA这东西看似简单里面的水其实很深尤其是分区规划、中断处理、回滚策略这三块任何一个细节没想清楚后面上线都会付出成倍的时间代价。我这套方案是在F103上打磨过的你在F4上做需要注意扇区大小差异但整体思路完全可以照搬。祝大家升级顺利少踩坑。本文还有配套的精品资源点击获取
返回列表