STM32F103 USB模拟U盘升级方案:从Bootloader到固件校验的完整实践

发布时间:2026/10/21 17:59:57
STM32F103 USB模拟U盘升级方案:从Bootloader到固件校验的完整实践
前阵子做一款手持采集设备现场工程师反复跟我抱怨能不能让我插上U盘就升级固件客户不是专业嵌入式人员你让他按着BOOT0再上电十个人里有八个会操作错让他们拆壳接串口那更是灾难。所以我干脆做了一个USB模拟U盘升级方案——STM32F103插上USB线之后电脑上直接多出一个盘符把新的bin文件拖进去拔线重启设备就自己把固件换掉了。这个方案做完之后现场升级变成了一件小学生都会做的事复制、粘贴、等灯闪。整个过程稳定、直观也不需要额外安装任何驱动。这篇文章就围绕这套方案展开讲讲我为什么选模拟U盘而不是串口或网络升级、Flash存储分区怎么规划、USB Mass Storage类的代码路径怎么走、固件校验和跳转逻辑怎么做最后把实测过程中踩过的坑逐一列出来。如果你正准备给自己的F103项目加USB升级功能这些内容可以直接当参考。1. 为什么是模拟U盘这方案解决的问题比想象中大先明确一下模拟U盘到底是什么意思。STM32F103内部有一个USB全速设备控制器它的外设库或HAL库自带USB Mass Storage类MSC实现。我们只需要把内部Flash的一部分地址空间包装成一块块设备让操作系统把它识别成一块普通的U盘。这块U盘底层存储介质不是SD卡也不是SPI Flash而是单片机的内部Flash。用户往里拖文件时数据实际上被USB协议接管经过Bulk Only TransportBOT传输协议最终落到内部Flash的指定扇区里。为什么选这个方案我把它和另外两种常见升级方式做了个对比升级方式对操作者的要求是否需要额外硬件传输速度主要风险串口ISP升级会操作烧录软件、会设置BOOT跳线需要USB转串口模块慢115200bps下几十KB要几十秒接线错误、误擦Bootloader网络升级需要懂IP配置、会Web或客户端操作需要网口/WiFi模块快网络不稳定、协议复杂度高USB模拟U盘升级会复制粘贴文件即可只需一根USB线全速USB约8-15KB/s实际写入断电中断、文件系统损坏实测下来模拟U盘方案对用户最友好。串口升级你还要教对方装驱动、选端口、设置波特率网络升级则要处理一大堆协议细节。而模拟U盘把升级行为简化成了拷贝文件这几乎不用培训。更重要的是USB线本身就同时承担了取电和通信现场只要带一根普通的Micro-USB线就能干活。当然这个方案的代价是Bootloader要做得比较厚。你必须处理文件系统、SCSI命令、Flash擦写调度。好在STM32F103功能足够官方也提供了MSC加SD卡的基础例程我们只需要把存储介质从SD卡换成内部Flash再做一套可靠的升级标志和校验机制。整体工作量不算恐怖但坑确实不少。2. 先把Flash地图画清楚分区、文件系统与FAT的配合2.1 分区设计不要让Bootloader和App打架任何升级方案第一步都是把Flash地址空间规划好。我用的是STM32F103RET6512KB主Flash。如果芯片空间不够这套方案也可以裁剪但建议至少256KB以上否则固件区和文件区都会很紧张。我的Flash分区如下区域起始地址大小用途Bootloader区0x0800000032KB引导程序、U盘枚举、升级逻辑App区0x08008000320KB用户应用程序文件系统区0x08058000约160KB模拟U盘空间存放升级固件为什么Bootloader要留32KB很多人觉得一个引导程序几KB就够了但当你往里面塞了USB协议栈、FatFs文件系统、SCSI命令处理、Flash擦写驱动之后空间消耗很快。标准外设库加MSC类大概就要十几KB再算上FatFs和升级逻辑保守32KB才不会抠抠搜搜。另外Bootloader必须放在0x08000000起始位置这是芯片复位后的默认取指地址。App区从0x08008000开始也就是偏移32KB处。注意这里有一个最重要的原则Bootloader所在扇区永远不能被擦除。App的启动地址和中断向量表偏移都基于这个基址计算。文件系统区放最后160KB这里就是计算机看到的U盘。容量虽然不大但装一个几十上百KB的固件文件绰绰有余。FAT16文件系统在这么小的卷上完全没有问题。2.2 FatFs的接入disk_read和disk_write是命门文件系统我直接用了FatFs这是STM32圈子里最成熟的方案。FatFs本身和硬件无关它只调用你提供的底层接口。最核心的就是disk_read、disk_write、disk_status、disk_initialize这几个函数。我的做法是让diskio.c直接操作内部Flash寄存器而不是再包一层虚拟块设备。这里有个关键点内部Flash的读操作就像读内存一样直接但写操作必须先擦后写。而擦除的最小单位不是字节是扇区对F103大容量型号来说Flash页大小一般是2KB但也有型号是1KB要以芯片手册为准。所以disk_write里收到一个512字节的逻辑扇区时不能直接memcpy过去要先判断Flash页是否需要擦除再按半字或字编程。FatFs在写入文件时往往不会老老实实按顺序写。比如创建文件时会先写FAT表再写目录项最后写数据区修改文件时还可能要写好几处。如果每次write都触发一次页擦除速度会非常难看。我的处理是加了一个512字节的RAM扇区缓存只有缓存里的数据和Flash里的数据不一致时才触发擦写。// diskio.c 核心写函数示意 DRESULT disk_write( BYTE pdrv, const BYTE *buff, LBA_t sector, UINT count) { uint32_t flash_addr; uint8_t need_erase; if (pdrv ! 0) return RES_PARERR; for (UINT i 0; i count; i) { flash_addr FLASH_FS_BASE (sector i) * 512; need_erase check_page_dirty(flash_addr, buff); if (need_erase) { FLASH_Unlock(); FLASH_ErasePage(flash_addr ~(FLASH_PAGE_SIZE - 1)); program_flash_buffer(flash_addr, buff i * 512); FLASH_Lock(); } buff 512; } return RES_OK; }2.3 为什么不用文件区的存在即升级有人会设计成U盘里只要出现xxx.bin重启就升级。这个思路听起来简单但实际不靠谱。因为用户拖进文件之后FAT文件系统的元数据不一定立刻落盘。如果用户拖完文件没安全弹出就直接拔线FAT表可能还没写完整Bootloader就根本找不到这个文件。我更推荐用升级标志 固定文件名的组合卸载U盘时在文件系统区的末尾写入一个十六字节的升级请求标志魔数加版本号同时把bin文件内容完整落盘。Bootloader下次上电时先检查标志位再去找固定文件名读文件头校验通过才执行升级。3. 从USB枚举到文件拖拽MSC设备的关键代码路径3.1 CubeMX生成工程后别急着改现在做F103的USB设备大多数人是先用STM32CubeMX把工程框架拉起来。在Middleware里选择USB_DeviceClass选Mass Storage Class然后让CubeMX生成描述符和传输回调。生成之后你面对的是一堆看起来很复杂的USB库代码但真正需要改的只有两个地方一个是存储介质参数一个是底层读写函数。很多人的误区是去改USB协议处理部分实际上完全不必。CubeMX生成的 usbd_storage.c 文件里函数已经被做成了清晰的回调接口我只需要实现下面这些内容// usbd_storage.c 中最关键的三个回调 int8_t STORAGE_Read(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len); int8_t STORAGE_Write(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len); int8_t STORAGE_GetMaxLun(uint16_t *lun);这三个回调就是USB协议栈和Flash之间的桥。操作系统发起SCSI READ命令时最终会调STORAGE_Read发起SCSI WRITE时最终会调STORAGE_Write。这里面的blk_addr和blk_len单位都是扇区每个扇区固定512字节这是USB Mass Storage规范和FAT文件系统的公共约定。3.2 你不需要理解全部的BOT协议但要知道这几个命令BOTBulk-Only Transport是MSC设备默认的传输协议。协议本身有CBW、CSW、状态阶段这些概念但实际操作中你只需要知道Windows和Linux在访问U盘时会发这么几类SCSI命令INQUIRY询问设备信息返回厂商字符串、产品名等READ CAPACITY询问总扇区数决定盘符容量TEST UNIT READY查询设备是否就绪READ(10)/WRITE(10)真正的读写扇区数据START STOP UNIT弹出/停止设备这里能捕获安全弹出的动作MODE SENSE返回写保护状态、介质参数库代码里已经把这些命令的响应框架搭好了。我唯一额外做的是在START STOP UNIT里加了一个钩子当收到弹出指令时先刷干净闪存缓存再写升级标志。3.3 U盘容量和扇区大小的坑虚拟U盘的容量不是随便填的。内部Flash可用的文件区大小是160KB那么READ CAPACITY返回的逻辑块地址就是320个扇区160 * 1024 / 512。这里务必注意如果你把容量返回成你希望的数值而不是实际可用的数值用户向U盘拷文件的时候Windows会报磁盘已满更糟的是会写超出Flash地址范围的扇区把App区搞坏。我自己习惯的配置是#define STORAGE_BLK_NBR ((uint32_t)(160 * 1024 / 512)) // 320 #define STORAGE_BLK_SIZ 512另外USB全速设备每个批量包最大64字节F103内部还要通过PMA缓冲区搬运数据。如果你用的是标准外设库的老例子大概率已经开了双缓冲不用特意调。但如果你自己写驱动有一个容易漏的地方双缓冲的PMA地址必须按64字节对齐否则DMA搬运会错位。4. 正确搬砖升级流程、校验机制与跳转逻辑4.1 一份固件文件应该长什么样用户拖进来的bin文件不能是裸的固件Bootloader没法判断这个文件是不是给本设备用的、版本是多少、数据完整不完整。我在真实项目中会做一个简单的文件头放在bin文件最前面占48字节#pragma pack(1) typedef struct { uint32_t magic; // 固定魔数 0x5A5AF103 uint32_t version; // 固件版本号如 0x01020003 uint32_t length; // 固件数据长度不含文件头 uint32_t crc32; // 固件数据 CRC32 校验 uint32_t reserved[7]; } fw_header_t; #pragma pack()固件数据紧跟文件头。电脑上打包时用一个小脚本把keil或IAR生成的axf转换成文件头 bin数据的合包文件。注意CRC32只覆盖固件数据部分不要把文件头自己算进去。在Bootloader侧升级流程是这样的上电读升级标志如果不存在就正常跳转App如果存在挂载FAT文件系统打开固定路径FW.BIN读取文件头检查魔数和版本号计算CRC32和文件头里的比对全部通过后擦除App区逐扇区写入新固件写入完成后再次校验App区的前4KB清除升级标志软复位整个流程中第6步很容易被忽略。写入Flash之后强烈建议再读回一段数据做二次比对不然万一某个半字没写进去产品就是运行一个损坏的固件比不升级更麻烦。4.2 App区防砖检查栈指针合法性在跳转App之前有一个很简单的检查能避免设备变砖读App区首地址处的四个字节这应该是初始栈顶值检查它是否落在RAM地址范围内。对STM32F103来说合法RAM地址是0x20000000到0x2001000064KB RAM的话就是到0x2000FFFF。如果这个值不在合法RAM区间说明App区压根没有完整的可运行镜像此时直接跳转必然HardFault。我见过太多人只做CRC校验就跳转结果因为擦写过程中断电重启App区写了一半CRC校验在写入前是过的但写入后的断电瞬间把Flash内容破坏了。加上栈指针合法性检查虽然不能完全避免损坏但至少能在启动时兜底。#define APP_BASE_ADDR (0x08008000) void jump_to_app(void) { uint32_t stack_addr *(volatile uint32_t *)APP_BASE_ADDR; uint32_t reset_addr *(volatile uint32_t *)(APP_BASE_ADDR 4); if ((stack_addr 0xFFF00000) ! 0x20000000) // 栈指针必须在RAM区 { return; // 停在Bootloader等待再次升级 } __disable_irq(); SCB-VTOR APP_BASE_ADDR; __set_MSP(stack_addr); ((void (*)(void))reset_addr)(); }4.3 升级失败怎么办停在Bootloader等重试升级失败是常态不失败才反常。我的Bootloader做了这样一个策略如果升级过程中发生CRC错误、文件不存在、擦写失败等任何异常都不把当前运行的App擦掉。只有全部校验通过之后才真正动App区。但如果在擦写App区的过程中断电了呢此时App区确实损坏了。处理方法是App区损坏没关系Bootloader跳到App前的栈指针检查会判定失败然后系统会回到Bootloader的升级循环等用户再次插入USB重新拖文件升级。你只是需要重新升级一次不会出现连Bootloader都进不去的情况。这一点是模拟U盘升级方案最让人安心的地方——Bootloader永远完整设备永远不会变成砖。5. 实测踩掉的坑以及怎么处理理论说完了聊聊实战中我踩过和修过的坑。这些坑如果没人提醒你大概率也会踩一遍。5.1 读保护开了之后U盘里全是0xFF产品要想上线几乎都得开读保护RDP。STM32F103的读保护级别一打开后任何通过调试接口或者非系统Bootloader读取Flash内容的操作都会受限。但USB模拟U盘本质上是CPU正常取指、正常跑代码时读取Flash按理说不受影响。可问题出在某些库函数上标准外设库的FLASH读函数在开RDP后有些接口行为会变。更常见的是如果同时开了RDP Level 1用户把U盘插上电脑后Windows显示盘符正常但里面文件全是空的、格式化为0xFF这通常不是USB协议问题而是读Flash的路径走了什么奇怪的缓存。我的建议不开RDP做开发调试点等固件稳定了再烧写RDP字节。如果你必须开着RDP测试模拟U盘优先查一下是不是某些官方例子里的FLASH_Read函数返回了保护错误而你的disk_read里没有处理这个错误码。5.2 安全弹出与直接拔线的差距用户不是永远会点安全弹出。实测中我发现Windows在用户拖完文件后并不会立刻把所有FAT缓存写进设备它的写入时机很随意。如果用户看到复制进度条走完就拔线FAT目录项很可能没落盘Bootloader上电后找不到FW.BIN。解法是把升级动作的触发点从文件复制完成改成安全弹出动作。设备收到START STOP UNIT命令后先强制把缓存里的脏扇区全部刷到Flash再写升级标志然后用LED或蜂鸣器提示用户可以拔线了。这个流程要在FAT层面做了同步之后才可靠。而如果用户不管提示直接拔线怎么办也有办法设备启动后Bootloader去检查固定文件是否存在。之前我们不是担心FAT没落盘吗所以我在STORAGE_Write回调里做了边收边存——FAT表、目录项、数据扇区只要进来就立刻刷到Flash。代价是写Flash次数上升但换来的是任何时刻拔线最后Flash里总有一个相对完整的文件系统。事实证明这样组合后用户怎么拔线都不会丢文件。5.3 FAT的写放大和Flash寿命内部Flash的擦写寿命大概一万次FAT文件系统每更新一个文件可能要写多次扇区写放大倍数不低。模拟U盘频繁插拔升级如果天天擦同一个页寿命确实会成问题。我的经验是两个手段一起上一是在disk_write里加脏页缓存尽量只在Flash页对齐边界上擦除减少无谓擦写二是文件区里常驻一个几百字节的空文本文件不删除它这样文件系统相对稳定FAT表的改动频率会低很多。对于升级频次很低的产品一周升级一次一年也就50次这个磨损完全可以接受。5.4 App里怎么主动进入U盘模式有些需求是设备运行过程中用户通过某个菜单触发进入升级模式。实现方法很简单App里把升级标志写入RAM然后直接软复位。Bootloader上电后先读这个标志如果是升级模式就留在Bootloader并枚举为U盘。要做到这一点Bootloader和App必须约定好一块RAM地址比如最后4KB RAM里的固定偏移。F103的RAM在软复位后内容不会被清零所以这个方案可行。但要注意如果App里开了看门狗软复位前要确保看门狗也复位并重新初始化否则Bootloader里卡在枚举阶段看门狗可能会把芯片再次复位。5.5 主板没有外部晶振的USB枚举问题最后说一个容易被忽视的硬件坑。STM32F103可以内部RC振荡器运行但USB外设要求48MHz的精确时钟。内部RC振荡器在全温度范围下的精度不一定满足USB规范要求插上电脑后可能出现设备无法识别、反复枚举失败的问题。我的项目主板上有一颗8MHz晶振作为HSE通过PLL倍频到72MHz再通过USBPLL分频产生48MHz时钟。如果你发现自己的板子枚举困难最优先检查的是晶振和USB上拉电阻其次是检查时钟树里USB时钟源有没有正确配置到PLLCLK。最后分享一点个人的落地经验这套模拟U盘升级在真实产品里跑了大半年现场反馈最好的一点不是技术先进而是用户不需要任何培训。只要有人会把文件从电脑A复制到电脑B就会给设备升级。关于移植我建议第一版不要追求花哨功能先把BootloaderApp升级标志的最小闭环跑通再考虑什么双备份区、OTA差分升级。最小闭环虽然只覆盖U盘拖文件升级这一件事但它把USB协议栈、FatFs和Flash擦写之间的数学关系全都理顺了。等这个闭环稳定了你后续加网络升级、加双备份都是在一个可靠地基上做增量而不是在深渊边缘反复试探。