1. 项目概述与核心价值最近在做一个嵌入式设备的小项目需要让设备在连接电脑时能自动安装驱动或者让用户能通过一个简单的配置文件来调整设备参数。常规思路是搞个外置的EEPROM或者SD卡但总觉得为了这点小事增加BOM成本和PCB面积有点“杀鸡用牛刀”。后来琢磨了一下STM32系列芯片不是都自带一块Flash吗这块Flash除了存代码剩下的空间能不能直接当个微型U盘来用这样一来电脑直接把设备识别成一个可移动磁盘用户拖个driver.inf或者config.ini文件进去设备上电后自己读取问题不就优雅地解决了吗这个想法听起来有点“野路子”但实践下来发现利用STM32的USB Mass Storage大容量存储设备类库配合内部Flash的剩余空间真的能在5分钟当然这是指理解原理和修改代码的时间编译烧录另算内搭建出一个超迷你的U盘功能。它虽然容量可能只有几十KB但用来存放驱动程序、许可证文件、小体积的固件升级包或者纯文本的配置参数那是绰绰有余。最关键的是它实现了硬件功能的“软件化”配置无需拆机、无需专用工具用户体验瞬间提升一个档次。对于从事STM32开发的工程师尤其是那些做需要与PC交互的工控设备、智能硬件、测试仪器的朋友掌握这个技巧非常实用。它把复杂的驱动安装、参数配置过程简化成了最直观的“复制粘贴”。下面我就结合ST官方库的修改把从原理到代码实现的每一步掰开揉碎讲清楚。2. 核心思路与方案选型解析2.1 为什么选择USB Mass Storage (MSC) 协议要让电脑把STM32认成U盘通信协议是关键。我们有几个备选虚拟串口CDC、自定义HID或者大容量存储设备MSC。虚拟串口需要主机端安装驱动不符合“即插即用”的初衷自定义HID虽然免驱但传输协议和文件系统都需要自己从头实现复杂度太高。而USB Mass Storage Class (MSC)协议是操作系统内核原生支持的Windows、macOS、Linux插上就能识别为磁盘无需额外驱动。操作系统负责了最复杂的FAT/FAT32/exFAT等文件系统解析设备端只需要响应最底层的“读扇区”、“写扇区”命令即可极大地简化了我们的开发工作。注意选择MSC协议意味着我们的设备在电脑上会表现为一个“可移动磁盘”。在文件操作期间如复制文件操作系统会独占访问这个磁盘此时设备CPU如果尝试读取同一块Flash区域可能会引发访问冲突或数据错误。因此在设计读写逻辑时需要做好状态管理或互斥保护。2.2 STM32内部Flash作为存储介质的可行性分析STM32的内部Flash主要用来存储程序代码但它也支持擦除和编程。我们可以把Flash的地址空间划分为两部分前半部分放我们的应用程序代码后半部分剩余的空间就划出来作为“U盘”的存储区。这里有几个关键点需要考虑擦写寿命Flash有擦写次数限制通常为1万到10万次。对于存放一次性写入的驱动或偶尔修改的配置文件这个寿命完全足够。但绝不能用来做频繁读写的日志存储。擦除单位STM32F1系列的Flash通常按“页”Page擦除每页大小1KB或2KB。这意味着即使你只想修改一个字节也必须先擦除整个页再重新写入该页的所有数据。这对我们的“写”操作实现有直接影响。代码保护我们必须确保应用程序代码和U盘数据区在物理地址上完全分开并且为数据区留出足够的起始地址偏移防止程序更新或运行时意外覆盖数据区。通常我们会将数据区起始地址设置为程序代码结束地址之后并向上对齐到页的整数倍地址。2.3 官方库的“宝藏”UM0424与MAL接口ST官方早就为我们铺好了路。在早期的USB设备库如版本2.x中有一个应用笔记UM0424里面提供了一个完整的USB Mass Storage例程。这个例程的精华在于它实现了一个清晰的架构USB层处理MSC协议文件系统由PC操作系统负责而设备端只需要实现一个名为MAL (Medium Access Layer)的介质访问层。这个MAL层就是我们需要修改的全部。它定义了四个标准函数我们的任务就是根据STM32内部Flash的特性重新实现这四个函数。这样一来我们就相当于给官方的USB大容量存储框架“换了个存储硬盘”从原来的SD卡/NAND Flash换成了自家的内部Flash。这是一种非常高效率的“嫁接”开发模式。3. 关键代码实现与深度解析3.1 工程环境与基础准备我当时的实验环境是IAR EWARM 4.4232K限制版但对于我们这个功能足够硬件是万利的STM3210B-LK1开发板主控STM32F103VBT664KB Flash。使用的USB库是相对老旧的V2.01版本但其核心架构非常清晰更适合学习原理。新版本的库如HAL库架构可能不同但MAL的思想是相通的。首先你需要一个能正常运行的USB MSC例程工程。可以从ST官网下载UM0424对应的老版本固件库或者从社区找到基于标准外设库的MSC例程。确保这个例程原本是支持SD卡或SPI Flash的这样它已经包含了完整的USB MSC协议栈和MAL框架。3.2 MAL层接口函数详解与改造MAL层在mass_mal.c文件中。我们的核心工作就是重写这个文件。先看它需要实现的四个函数原型及其职责u16 MAL_Init (u8 lun)初始化存储介质。lun逻辑单元号对于多存储设备如读卡器多个卡槽有用我们只有一个“盘”所以只处理lun0的情况。对于内部Flash初始化主要就是解锁Flash控制器使其允许擦写。u16 MAL_GetStatus (u8 lun)获取介质状态信息。这是最关键的函数之一。它需要告诉上层的文件系统层最终是操作系统三个核心参数总容量、块扇区大小、块总数。操作系统根据这些信息来格式化和识别磁盘。u16 MAL_Read(u8 lun, u32 Memory_Offset, u32 *Readbuff, u16 Transfer_Length)从介质的指定偏移地址Memory_Offset处读取Transfer_Length个字节的数据到Readbuff缓冲区。注意这里的偏移是字节偏移从数据区的起始地址0地址开始算。u16 MAL_Write(u8 lun, u32 Memory_Offset, u32 *Writebuff, u16 Transfer_Length)将Writebuff缓冲区中的Transfer_Length个字节数据写入到介质的指定偏移地址Memory_Offset处。这是最复杂的一个函数因为涉及Flash的擦除特性。3.3 定义Flash数据区参数在修改函数之前我们需要在文件开头定义好数据区的关键参数。这些参数需要和你芯片的具体型号、代码大小严格匹配。/* Private define -------------------------------------------------------------*/ /* 定义Mini U盘在Flash中的起始地址 */ #define FLASH_START_ADDR 0x08003000 // Flash start address for U-Disk /* 定义Mini U盘的总容量字节 */ #define FLASH_SIZE 0xD000 // 52KB (0x08003000 ~ 0x0800FFFF) /* 定义Flash的页大小字节 */ #define FLASH_PAGE_SIZE 0x400 // 1K per page for STM32F103 /* Flash操作超时等待 */ #define FLASH_WAIT_TIMEOUT 100000如何确定FLASH_START_ADDR这是最容易出错的一步。你不能拍脑袋随便写个地址。正确的做法是编译你的工程查看生成的.map文件或IDE的链接报告。找到你的程序代码.text段的结束地址。例如IAR编译后显示代码用到0x0800252B。在这个结束地址之后找一个页对齐的地址作为数据区开始。STM32F1的页是1KB0x400所以地址必须是0x400的整数倍。0x0800252B之后第一个对齐的地址是0x08002800但我为了留出更多余量选择了0x08003000。计算剩余空间芯片总Flash大小64KB 0x10000减去起始地址0x3000得到0xD00052KB。这就是我们U盘的最大可用容量。3.4 重写MAL_GetStatus函数这个函数必须准确无误否则电脑无法正确识别磁盘容量。u16 MAL_GetStatus (u8 lun) { if (lun 0){ /* 计算总块数 总容量 / 块大小 */ Mass_Block_Count[0] FLASH_SIZE / FLASH_PAGE_SIZE; // 52KB / 1KB 52块 /* 定义块大小这里我们让块大小等于Flash页大小 */ Mass_Block_Size[0] FLASH_PAGE_SIZE; // 1024 Bytes /* 定义总容量 */ Mass_Memory_Size[0] FLASH_SIZE; // 0xD000 Bytes // 可以在这里点亮一个LED指示U盘就绪 // GPIO_SetBits(USB_LED_PORT, GPIO_Pin_7); return MAL_OK; } // GPIO_ResetBits(USB_LED_PORT, GPIO_Pin_7); return MAL_FAIL; }关键点解析Mass_Block_Size[0]扇区大小设置为和Flash页大小一致1KB是最简单的做法。虽然FAT文件系统通常使用512字节扇区但MSC协议支持报告更大的扇区大小Windows等系统能自适应。设置为1KB可以简化我们的写操作因为每次写入的最小单位就是一整页。这三个全局数组Mass_Memory_Size、Mass_Block_Size、Mass_Block_Count是由USB库的上层代码定义的我们在这里赋值上层会读取这些值并反馈给电脑。3.5 重写MAL_Read函数读操作相对简单因为Flash可以随机读取。我们只需要把指定偏移地址的数据拷贝到提供的缓冲区即可。u16 MAL_Read(u8 lun, u32 Memory_Offset, u32 *Readbuff, u16 Transfer_Length) { u16 i; if (lun 0){ /* 将Flash数据区的数据拷贝到Readbuff */ /* Memory_Offset是字节偏移从数据区逻辑0地址开始 */ /* 我们将其转换为物理地址数据区基地址 偏移量 */ u32 read_address FLASH_START_ADDR Memory_Offset; /* 由于Readbuff是u32指针Transfer_Length是字节数所以循环步进为4字节 */ for (i 0; i Transfer_Length; i 4){ /* 使用指针直接读取Flash内存 */ Readbuff[i 2] *(vu32*)(read_address i); } return MAL_OK; } return MAL_FAIL; }关键点解析(vu32*)是Volatile Unsigned 32-bit Pointer的缩写用于指向可能被硬件改变的内存地址如Flash防止编译器做激进的优化。这里假设Transfer_Length总是4的倍数因为MSC协议传输和缓冲区常以字对齐但为了健壮性实际代码可能需要处理非对齐的情况。3.6 重写MAL_Write函数写操作是最复杂的必须遵循Flash“先擦后写”的规则且擦除以页为单位。u16 MAL_Write(u8 lun, u32 Memory_Offset, u32 *Writebuff, u16 Transfer_Length) { u16 i; FLASH_Status status; if (lun ! 0) { return MAL_FAIL; } /* 1. 计算写入操作影响的页范围 */ u32 start_addr FLASH_START_ADDR Memory_Offset; u32 end_addr start_addr Transfer_Length - 1; u32 start_page (start_addr - FLASH_START_ADDR) / FLASH_PAGE_SIZE; u32 end_page (end_addr - FLASH_START_ADDR) / FLASH_PAGE_SIZE; /* 2. 擦除所有受影响的页 */ for (u32 page start_page; page end_page; page) { u32 page_addr FLASH_START_ADDR page * FLASH_PAGE_SIZE; status FLASH_ErasePage(page_addr); if (status ! FLASH_COMPLETE) { FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); return MAL_FAIL; // 擦除失败 } } /* 3. 按字32位编程数据到已擦除的页 */ for (i 0; i Transfer_Length; i 4) { status FLASH_ProgramWord(start_addr i, Writebuff[i 2]); if (status ! FLASH_COMPLETE) { FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); return MAL_FAIL; // 编程失败 } } return MAL_OK; }关键点解析与避坑指南擦除范围计算这是最容易出bug的地方。如果用户只写一个扇区512字节而我们的页是1KB且这个扇区横跨了两个物理页那么这两个页都必须被擦除。上面的代码通过计算起始和结束地址所在的页号来处理这个问题。擦除前的状态确保Flash在擦除前已解锁MAL_Init中已做。每次擦除或编程操作后最好检查状态标志并清除为下一次操作做准备。数据对齐FLASH_ProgramWord要求写入的地址是4字节对齐的。MSC协议和我们的缓冲区通常能保证这一点。性能考量这种“写一扇区就擦除整页”的方式在频繁小数据写入时效率很低且会加速Flash磨损。因此这个方案极度不适合需要频繁保存数据的场景。它最适合“一次写入多次读取”的配置存储。3.7 调整数据缓冲区大小在官方例程的memory.c文件中定义了一个用于USB数据传输的缓冲区Data_Buffer。其默认大小通常是512字节一个标准扇区。由于我们的“扇区”大小被定义为1KBFLASH_PAGE_SIZE为了确保一次能传输一个完整的扇区数据必须将这个缓冲区扩大到至少1KB。// 在 memory.c 中找到类似定义 // 原可能为u8 Data_Buffer[512]; // 修改为 u32 Data_Buffer[BULK_MAX_PACKET_SIZE * 4]; /* 假设BULK_MAX_PACKET_SIZE64, 64*4*41024 bytes */ // 或者直接定义为 u8 Data_Buffer[1024]; // 1024字节缓冲区重要提示务必检查BULK_MAX_PACKET_SIZE的定义。USB全速设备的批量传输最大包长是64字节。缓冲区大小需要是最大包长的整数倍并且不小于我们定义的扇区大小1KB。64*161024所以一种常见的定义是u8 Data_Buffer[64*16]。4. 实操流程与现场记录4.1 步骤一获取并准备基础工程从ST官网或可靠资源库找到基于STM32F10x标准外设库的USB Mass Storage例程对应UM0424或类似文档。用IAR或Keil打开工程确保它原本是针对SD卡或外部Flash的并且能编译通过。先将工程烧录到板子上连接USB到电脑确认电脑能识别到一个无法访问的磁盘因为MAL层还未适配内部Flash。这一步是验证USB协议栈本身是正常的。4.2 步骤二修改MAL层代码找到工程中的mass_mal.c文件将其备份后用上一节提供的代码完全替换。根据你自己芯片的型号和程序大小修改FLASH_START_ADDR、FLASH_SIZE和FLASH_PAGE_SIZE这三个宏定义。务必通过.map文件确认起始地址。检查memory.c中的Data_Buffer大小将其调整为至少等于你的FLASH_PAGE_SIZE例如1024字节。4.3 步骤三编译、烧录与测试编译修改后的工程确保零错误零警告。将程序烧录到STM32开发板。通过USB线连接开发板的USB口注意是USB Device口不是串口到电脑。此时电脑应该会“叮咚”一声发现新硬件并很快在“我的电脑”里出现一个可移动磁盘但双击会提示“需要格式化”。4.4 步骤四格式化与使用右键点击这个新出现的磁盘选择“格式化”。在格式化窗口中文件系统可以选择FAT或FAT32对于52KB容量FAT12/16更合适但Windows可能只提供FAT选项。分配单元大小簇大小选择默认或512字节即使我们扇区是1KB文件系统簇大小可以更小。点击开始格式化过程会很快完成。如果失败请回到步骤二检查MAL_GetStatus函数返回的容量和块大小参数是否正确。格式化成功后你就可以像使用普通U盘一样向里面拖入文件了。例如放入一个config.ini文件里面写上DeviceID001、BaudRate115200等配置。4.5 步骤五设备端读取U盘文件U盘功能做好了设备怎么读取里面的文件呢这需要你在设备的主应用程序中添加读取内部Flash数据区的代码。注意此时USB MSC功能应处于未激活状态如未连接USB线否则Flash访问会冲突。确定文件系统由于我们格式化了FAT你需要一个嵌入式FAT文件系统库如FatFs。初始化FatFs并挂载将FatFs的底层磁盘读写接口指向我们Flash数据区的物理地址FLASH_START_ADDR。注意FatFs需要的扇区大小通常是512字节而我们的物理扇区是1KB。这里有两种处理方式方式A推荐在MAL层和FatFs层之间做一个转换层。MAL层给PC报告1KB扇区但对FatFs我们实现一个disk_read/disk_write函数内部处理1KB到512字节的地址映射和数据搬运。这样FatFs可以正常工作。方式B简单如果文件很简单如只有一个已知名的INI文件可以不用FatFs。直接根据FAT表结构比较复杂或干脆在固定扇区位置存放文件内容。更简单的做法是约定将配置文件内容直接以二进制形式写在Flash数据区的固定偏移处上电后直接去读那个地址。这就跳过了文件系统变成了“原始存储块”访问。读取配置挂载成功后就可以用FatFs的f_openf_read等函数打开config.ini并解析内容了。5. 常见问题、排查技巧与进阶优化5.1 问题排查速查表现象可能原因排查步骤电脑完全无反应不识别USB设备1. USB硬件连接错误DP/DM接反2. USB时钟未正确配置需48MHz3. 未启用USB设备时钟RCC_APB1PeriphClockCmd4. 程序未运行或卡死1. 检查原理图USB口是否连接正确。2. 检查系统时钟树配置确保PLL输出72MHzUSB预分频得到48MHz。3. 调试代码在USB初始化函数设置断点。4. 用LED或串口打印辅助调试确认程序运行到USB初始化。电脑识别为“未知设备”或提示驱动错误USB设备描述符PID/VID或配置描述符错误1. 检查usb_desc.c等文件中的设备描述符是否完整合规。2. 确认工程中USB库文件齐全中断服务函数已正确实现。电脑识别为“大容量存储设备”但提示“无法识别的设备”或“需要格式化”MAL_GetStatus返回的参数错误1. 单步调试MAL_GetStatus函数确认Mass_Block_Count等三个值计算正确。2. 检查FLASH_SIZE宏定义是否超出芯片实际剩余空间。3. 确认FLASH_PAGE_SIZE与芯片手册一致。格式化失败1. 存储介质Flash读写函数有bug2. 缓冲区大小不足3. 电脑系统问题1. 重点调试MAL_Write函数特别是擦除逻辑。可在擦除和编程后读取验证数据。2. 确认Data_Buffer大小 FLASH_PAGE_SIZE。3. 换一台电脑或USB口试试。可以格式化但复制文件进去后提示错误或文件损坏1.MAL_Write函数写入数据错误2. Flash地址计算错误写到了代码区3. 未处理跨页写入1. 在MAL_Write中写入数据后立即用MAL_Read读回比较验证写入正确性。2. 双重检查FLASH_START_ADDR确保其在代码区之后且页对齐。3. 确保MAL_Write中的擦除循环正确覆盖了所有受影响的页。设备运行时读取Flash数据区异常1. USB MSC功能激活时与主程序同时访问Flash冲突2. 读地址越界1. 确保在设备需要读取配置时USB MSC功能未启用如未连接USB线。或设计互斥机制。2. 检查主程序中读取Flash的地址是否在FLASH_START_ADDR到FLASH_START_ADDRFLASH_SIZE之间。5.2 进阶优化与安全考量写保护机制在产品化时你肯定不希望用户误格式化这个“U盘”导致配置丢失。可以在MAL_Write函数里加入判断如果尝试写入的地址是某个关键配置文件所在的扇区直接返回MAL_FAIL。或者更彻底的方法是在最终产品固件中完全移除MAL_Write函数的实现只保留MAL_Read。这样电脑上看到的就是一个“只读”U盘只能复制文件出来不能修改或格式化安全性大大提高。这就是原文提到的“将写入Flash的代码去掉”。磨损均衡简单版如果确实需要保存一些偶尔更新的数据如设备运行日志计数器可以考虑实现一个简单的磨损均衡。例如将数据区划分为多个“槽位”每次写入时轮流使用不同的槽位并在固定位置记录当前有效的槽位号。这能略微提升Flash寿命。容量扩展如果内部Flash空间紧张可以结合外部SPI Flash或QSPI Flash。MAL层可以同时管理多个lunlun0指向内部Flash存放关键配置lun1指向外部大容量Flash存放日志或其他文件。这样既保证了关键数据的可靠性又扩展了存储空间。结合USB复合设备可以让设备同时具备两种功能当需要配置时是MSC设备正常工作时是虚拟串口CDC或自定义HID设备。这需要实现USB复合设备描述符复杂度较高但用户体验最佳。这个“5分钟实现的超小U盘”方案其精髓在于巧妙利用了成熟的开源协议栈ST USB库和操作系统自带的功能文件系统通过修改最少的代码MAL层实现了硬件功能的快速原型验证和产品化。它成本几乎为零却极大地增强了设备的易用性和灵活性。下次当你的项目需要PC端交互配置时不妨先想想是不是能让芯片自己“变”出个U盘来