STM32片内FLASH模拟U盘:MSC协议、FatFs与FLASH驱动实战
1. 项目概述:为什么要在STM32上实现U盘功能?
几年前,我在一个工业数据采集器的项目上遇到了一个头疼的问题:设备在现场运行,每隔一段时间就会产生几十兆的日志文件,维护人员需要带着笔记本电脑,用串口或者以太网去连接设备,再用上位机软件把数据导出来。整个过程繁琐不说,现场环境复杂,电脑不一定好带,网络也不一定稳定。当时我就在想,要是这个设备能直接“变”成一个U盘,插上电脑就能像访问普通文件夹一样拷贝数据,那该多方便。
这就是我们今天要聊的核心:基于STM32的片内FLASH,实现一个MSC(Mass Storage Class,大容量存储设备)类的USB设备,俗称“制作一个小U盘”。听起来很高大上,其实拆解开来,就是让我们的STM32芯片,通过USB接口,把自己内部的一块存储区域(FLASH)模拟成电脑操作系统能识别的可移动磁盘。
你可能会问,为什么不直接用SD卡或者外挂一个SPI FLASH芯片来做U盘?当然可以,而且方案更成熟。但使用片内FLASH有几个独特的优势:首先是成本与集成度,对于存储需求不大的应用(比如几KB到几百KB的配置参数、小批量日志、字库等),省去一颗外置芯片和对应的PCB空间,能显著降低BOM成本和板子尺寸。其次是可靠性,片内FLASH与MCU同寿命,焊接牢固,没有连接器接触不良的风险,适合振动、高低温等恶劣环境。最后是学习价值,这个项目能让你透彻理解USB MSC协议、FAT文件系统以及MCU内部FLASH的读写管理,是嵌入式开发中一次非常综合的实战演练。
简单来说,这个项目适合两类朋友:一是产品开发者,需要为设备添加一个极其简便的数据交换接口;二是嵌入式学习者,希望深入掌握USB和文件系统这两大核心技能。接下来,我会带你从原理到代码,一步步实现它。
2. 核心方案设计与技术栈选型
要实现“STM32变U盘”,我们需要打通三条关键链路:物理存储介质(FLASH)的驱动、逻辑文件系统(FAT)的支撑、以及与主机通信的协议(USB MSC)。这三者环环相扣,缺一不可。
2.1 技术栈拆解:MSC + FatFs + FLASH Driver
我们的方案核心由三个部分组成:
USB MSC(大容量存储类):这是USB协议中专门为硬盘、U盘等设备定义的一个标准类。STM32的USB外设(如USB FS-全速)在配置为Device模式后,我们可以编写代码实现MSC类所要求的接口描述符和请求处理。最关键的是,MSC类定义了一套基于SCSI透明命令集(如INQUIRY, READ CAPACITY, READ/WRITE)的通信方式。电脑(USB主机)会发送这些命令来询问设备信息、读写扇区,我们的STM32需要正确响应这些命令。
FatFs(文件系统模块):FatFs是一个为小型嵌入式系统设计的通用FAT文件系统模块。它独立于底层存储介质和硬件平台,我们只需要为它提供几个基础的磁盘I/O接口(如
disk_read,disk_write)。FatFs负责解析FAT表、目录项,管理文件和文件夹的创建、读写、删除等逻辑。这样,我们就不需要自己去处理复杂的FAT32/exFAT结构,大大降低了开发难度。片内FLASH驱动与抽象层:这是连接FatFs和物理硬件的桥梁。STM32的片内FLASH通常按页(Page)或扇区(Sector)组织,读写有特殊要求(如写前需擦除,只能按字/半字编程)。我们需要编写驱动,实现对指定地址的擦除和编程。更重要的是,我们需要建立一个虚拟的“磁盘”模型,将FLASH的物理空间划分成连续的、大小固定的逻辑扇区(例如512字节),并提供对应的读写函数给FatFs调用。
为什么选择这个组合?这是一个在嵌入式圈内经过无数次验证的“黄金组合”。FatFs模块成熟、稳定、资源占用可控;STM32的USB库和HAL库对MSC有较好的支持;而片内FLASH的驱动是标准操作。三者通过清晰的接口耦合,使得整个架构模块化,调试和维护都非常方便。市面上几乎所有的商业U盘主控和读卡器方案,其软件架构思想也与此类似。
2.2 硬件资源评估与规划
在动手写代码前,我们必须先算一笔“账”:我们的STM32片内FLASH到底能提供多大的“U盘”空间?又该如何规划这片空间?
以常见的STM32F103C8T6为例,它拥有64KB的片内FLASH。但请注意,FLASH的起始部分通常存放着我们的程序代码(Code)。我们不能让“U盘”的数据区覆盖掉程序本身,否则设备一运行就崩溃了。
空间规划实战:假设我们的程序编译后大小为40KB(0xA000字节)。我们可以从某个安全的地址之后开始划分U盘空间。STM32 FLASH通常以1KB或2KB为一页。为了对齐和管理方便,我们选择从0x0800A000这个地址开始,作为“U盘”的起始地址。
那么,可用的空间就是:0x08010000(64KB终点) -0x0800A000= 24KB。这24KB就是我们的“U盘”总容量。是不是觉得很小?没错,但这正是片内FLASH方案的典型应用场景——存储小数据。对于存储配置参数、少量日志、证书文件等,完全足够。
扇区大小设定:FatFs和USB MSC通常默认使用512字节的扇区。因此,我们需要将这24KB的物理空间,在逻辑上划分为48个扇区(24KB / 512B = 48)。后续所有的读写操作,FatFs和MSC驱动都会以“第N个扇区”为单位来请求数据。
注意:这个规划必须在项目初期就确定下来,并写入代码中作为常量。一旦产品量产,这个布局就绝对不能更改,否则之前设备上存储的数据将无法被识别。
3. 底层基石:片内FLASH的驱动与磁盘抽象
这是整个项目最底层、也是最关键的一环。如果FLASH读写不可靠,上层的一切都是空中楼阁。
3.1 STM32片内FLASH操作精要
与RAM随存随取不同,FLASH存储器有两大特性:写前必须擦除,且擦除的最小单位是一个扇区(或页);写入时只能将bit从1变为0,而不能从0变回1(擦除操作会将整个扇区所有bit置1)。
操作流程标准化:
- 解锁(Unlock):STM32的FLASH控制器有写保护,防止程序跑飞误擦写。在修改FLASH前,需要向特定的控制寄存器写入密钥序列。
HAL_FLASH_Unlock(); // 使用HAL库解锁 - 擦除(Erase):擦除一个或多个扇区。需要填充一个
FLASH_EraseInitTypeDef结构体,指定擦除类型和扇区编号。FLASH_EraseInitTypeDef EraseInitStruct; uint32_t SectorError = 0; EraseInitStruct.TypeErase = FLASH_TYPEERASE_PAGES; // 按页擦除 EraseInitStruct.PageAddress = FLASH_START_ADDR; // 要擦除的起始地址 EraseInitStruct.NbPages = TOTAL_PAGES; // 要擦除的页数 HAL_FLASHEx_Erase(&EraseInitStruct, &SectorError); - 编程(Program):以字(32位)、半字(16位)或字节(8位,取决于型号)为单位写入数据。必须确保目标地址已经擦除(全为0xFF)。
HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, address, data); - 上锁(Lock):操作完成后重新上锁,确保安全。
HAL_FLASH_Lock();
实操心得:
- 中断与耗时:擦除和编程操作是阻塞式的,并且耗时较长(擦除一页可能需要几十ms)。在此期间必须禁止所有中断,或者确保你的系统能容忍这段时间的延迟。通常HAL库的擦写函数内部会处理中断开关(
__disable_irq),但自己要清楚这个影响。 - 寿命问题:STM32的片内FLASH典型擦写次数是1万次。频繁地擦写同一个扇区会使其提前失效。切忌在循环或高频任务中直接擦写FLASH!对于需要频繁更新的数据(如日志),应采用“磨损均衡”策略,轮流使用不同扇区。
3.2 构建磁盘抽象层(diskio.c)
FatFs并不关心底层是SD卡、SPI FLASH还是片内FLASH,它只通过一个名为diskio.c的文件中定义的几个函数来交互。我们的核心工作就是实现这个文件。
diskio.c需要实现以下关键函数:
disk_status:获取磁盘状态(是否初始化、是否写保护等)。disk_initialize:初始化磁盘驱动,对于我们来说,就是检查FLASH相关硬件是否就绪。disk_read:从指定扇区读取数据到缓冲区。这是最常用的操作,电脑浏览U盘文件时,会频繁调用此函数。disk_write:将缓冲区数据写入指定扇区。这是最需要小心的操作,因为它内部必须包含擦除流程。disk_ioctl:提供控制命令,如获取扇区数量(GET_SECTOR_COUNT)、获取扇区大小(GET_SECTOR_SIZE)等。这些信息是FatFs和USB MSC初始化时必须的。
disk_write函数的实现要点:这是难度最高的部分,必须正确处理擦除。因为FLASH擦除以页为单位,而FatFs写入以扇区(512B)为单位。如果一页是2KB,那么它包含4个扇区。当FatFs请求写入这4个扇区中的任何一个时,我们不能直接擦除整页,否则会丢失其他3个扇区的数据。
解决方案:扇区缓冲管理我们需要在RAM中开辟一个页大小的缓冲区(如2KB)。写入流程如下:
- 当收到写入某个扇区的请求时,先判断该扇区所属的物理页。
- 将该物理页当前的全部数据(4个扇区)读取到RAM缓冲区。
- 将FatFs要写入的新数据,更新到缓冲区中对应的位置。
- 擦除整个物理页。
- 将整个RAM缓冲区(包含已更新的扇区和其他未变的扇区)的数据,重新编程回该物理页。
这个过程被称为“读-改-擦-写”,虽然效率不高,但对于小容量、低频写的场景是可靠且必要的。
踩坑记录:我曾因为忘记在
disk_write中实现这个缓冲机制,导致U盘每次写入后,其他文件全部损坏。调试时发现,电脑写入文件A后,文件B的内容变成了乱码。根本原因就是直接擦除了一整页,把其他扇区也清除了。这个坑务必避开。
4. 中间层:FatFs文件系统的集成与配置
FatFs模块的集成相对标准化,但配置选项决定了系统的能力和资源消耗。
4.1 FatFs模块裁剪与配置(ffconf.h)
ffconf.h是FatFs的配置文件,里面有大量的宏定义。对于资源紧张的STM32,合理的裁剪至关重要。
关键配置项解析:
_FS_READONLY:设置为0,因为我们肯定需要写功能。_FS_MINIMIZE:这个值可以设为1或2,以裁减掉不用的API,如f_opendir,f_stat等,节省代码空间。_USE_STRFUNC:如果不需要f_puts,f_gets等字符串操作,可以设为0。_USE_LFN:长文件名支持。这是一个“内存杀手”。如果设为0,只支持经典的8.3格式文件名(如LOGFILE.TXT)。如果设为1或2,则需要额外的缓冲区,会消耗较多RAM。对于小U盘,我建议暂时关闭长文件名支持,先让系统跑起来。_VOLUMES:卷的数量,我们只有1个“U盘”,设为1即可。_SECTOR_SIZE:必须设置为512,与我们的磁盘抽象层和USB MSC的设定一致。_MAX_SS和_MIN_SS:都设为512,表示我们只支持512字节扇区。
集成步骤:
- 从FatFs官网下载源码,将
ff.c,ff.h,diskio.c,diskio.h,ffconf.h等文件加入工程。 - 根据上述说明修改
ffconf.h。 - 实现好我们上一节讲的
diskio.c。 - 在应用程序中,包含
ff.h,然后调用f_mount函数来注册我们的磁盘。
FATFS fs; // 文件系统对象 FRESULT res; res = f_mount(&fs, "0:", 1); // 将磁盘0挂载到路径“0:” if (res != FR_OK) { // 挂载失败,可能是FLASH没有格式化或损坏 }4.2 格式化“磁盘”与创建文件
第一次使用这片FLASH空间时,它里面是空的,没有FAT文件系统结构。因此,我们需要先对其进行格式化。
格式化操作:格式化不能在STM32上电后自动进行,因为那会清空所有数据。通常有两种方式:
- 通过应用程序触发:在代码中设置一个“格式化标志”(比如检测某个GPIO引脚电平),首次烧录程序后,触发一次格式化流程。
if (need_format) { uint8_t work[_MAX_SS]; // 格式化需要的缓冲区 res = f_mkfs("0:", FM_FAT32, 0, work, sizeof(work)); // 格式化为FAT32 } - 使用PC端工具预先格式化:在烧录程序后,通过USB连接电脑。虽然此时电脑可能因无文件系统而报错,但我们可以借助一些底层扇区读写工具(或自己写个简单的USB通信程序),将标准的FAT32引导扇区、FAT表等数据写入FLASH的起始扇区。这种方式更专业,但门槛较高。
创建测试文件:格式化成功后,我们就可以像在PC上一样操作文件了。
FIL fil; UINT bw; // 创建并打开一个文件 res = f_open(&fil, "0:/test.txt", FA_CREATE_ALWAYS | FA_WRITE); if (res == FR_OK) { // 写入字符串 f_write(&fil, "Hello, STM32 U盘!\r\n", 20, &bw); // 关闭文件 f_close(&fil); }至此,只要diskio.c工作正常,文件操作就应该能成功。你可以通过调试器读取FLASH对应地址的内容,验证数据是否被正确写入。
5. 顶层协议:USB MSC设备类的实现
这是让电脑识别我们的STM32为U盘的最后一步。我们需要配置STM32的USB外设工作在Device模式,并初始化为MSC类。
5.1 USB MSC描述符与端点配置
USB设备通过一系列描述符来告诉主机“我是什么”。对于MSC设备,关键描述符包括:
- 设备描述符(Device Descriptor):指明这是一个USB设备,指定厂商ID(VID)、产品ID(PID)等。你可以使用公开的测试PID/VID,或者申请自己的。
- 配置描述符(Configuration Descriptor):包含接口描述符和端点描述符。
- 接口描述符(Interface Descriptor):指明这个接口属于MSC类(Class Code: 0x08)、SCSI透明命令集子类(SubClass: 0x06)、以及Bulk-Only传输协议(Protocol: 0x50)。
- 端点描述符(Endpoint Descriptor):MSC类需要两个Bulk端点,一个IN(设备到主机),一个OUT(主机到设备)。通常端点1作为Bulk IN,端点2作为Bulk OUT。
使用STM32CubeMX配置(强烈推荐):
- 在
Connectivity中使能USB (FS),模式选择Device_Only。 - 在
Middleware and Software Packs中,选择USB_DEVICE,Class选择Mass Storage Class (MSC)。 - 在
Project Manager的Code Generator里,选择“为外设生成独立的.c/.h文件”,这样USB的代码会单独放在USB_DEVICE文件夹里,结构清晰。 - 生成代码。CubeMX会自动生成所有USB描述符和MSC类框架代码。
5.2 SCSI命令处理与BOT协议
电脑通过Bulk-Only Transport (BOT)协议与MSC设备通信。其核心是CBW(Command Block Wrapper)和CSW(Command Status Wrapper)结构体。
通信流程:
- 主机发送一个31字节的
CBW,里面包含了具体的SCSI命令(如READ(10))以及数据传输长度和方向。 - 设备解析
CBW。如果是读请求(如READ(10)),设备就通过Bulk IN端点发送相应长度的数据;如果是写请求(如WRITE(10)),设备就从Bulk OUT端点接收数据。 - 数据传输完毕后,设备发送一个13字节的
CSW给主机,报告命令执行状态(成功、失败等)。
我们需要实现的核心回调函数:在生成的代码中,我们需要在usbd_storage_if.c文件中实现以下函数:
STORAGE_Init:初始化底层存储(其实就是调用我们disk_initialize)。STORAGE_GetCapacity:返回磁盘的扇区总数和扇区大小。这里返回的值必须与diskio.c中disk_ioctl函数返回的完全一致!否则电脑会识别出错误的容量。STORAGE_IsReady:报告磁盘是否就绪。STORAGE_IsWriteProtected:报告磁盘是否写保护。STORAGE_Read:处理读扇区请求。直接调用disk_read。STORAGE_Write:处理写扇区请求。直接调用disk_write。
关键连接点:STORAGE_Read和STORAGE_Write的参数是逻辑块地址(LBA)和扇区计数。这正是FatFs和MSC的通用语言。我们的disk_read/write函数接收的也是LBA地址。因此,这个对接非常直接。
重要提示:
STORAGE_GetCapacity返回的容量信息,是电脑识别U盘大小的唯一依据。务必确保计算准确:总扇区数 * 扇区大小(512)。例如,我们有48个扇区,总容量就是48 * 512 = 24576字节,电脑会显示为“24.0 KB”。如果这里算错,会导致电脑无法识别或识别容量错误。
6. 系统整合与调试实战
当三层架构(FLASH驱动、FatFs、USB MSC)都准备好后,我们需要将它们整合起来,并处理一些系统级的问题。
6.1 主程序流程与任务调度
一个典型的程序流程如下:
int main(void) { // 1. HAL初始化,系统时钟配置 HAL_Init(); SystemClock_Config(); // 2. 初始化底层硬件(GPIO, USB等) MX_GPIO_Init(); MX_USB_DEVICE_Init(); // 这会初始化USB并启动MSC设备 // 3. 初始化磁盘(FLASH)和文件系统 disk_initialize(0); f_mount(&fs, "0:", 0); // 先尝试挂载,不强制 // 4. 检查是否需要格式化(例如,首次运行标志位) if (is_first_time()) { format_disk(); create_default_files(); // 可创建一些默认文件或目录 } // 5. 主循环 while (1) { // USB的轮询处理通常由CubeMX生成的代码在中断中完成,主循环可以处理其他任务 // 例如:监测按键,触发文件操作 if (user_button_pressed()) { log_data_to_file(); } HAL_Delay(100); } }中断处理:USB通信严重依赖中断。CubeMX生成的代码已经为我们配置好了USB全局中断(USB_LP_CAN1_RX0_IRQn)和端点中断。我们切勿在中断服务程序中进行复杂的文件操作或FLASH擦写,这会导致中断阻塞,USB通信超时,进而被电脑断开。所有耗时的操作(如响应STORAGE_Write)都应放在主循环或低优先级任务中。
6.2 电脑端识别与文件操作
将STM32设备通过USB线连接到电脑。如果一切顺利,你应该能听到电脑发现新硬件的提示音,并在“设备管理器”的“磁盘驱动器”下看到一个新的设备。稍等片刻,它就会出现在“我的电脑”里,作为一个可移动磁盘。
首次使用的可能情况:
- 最佳情况:电脑直接识别出U盘并显示正确容量,可以正常打开、读写文件。
- 提示格式化:电脑识别到了磁盘,但认为其文件系统损坏或未格式化。这说明我们的FAT表等元数据可能不对,或者
STORAGE_GetCapacity返回的信息有误。此时千万不要在电脑上点格式化!这会导致电脑按照它的理解(可能是GB级)去格式化我们的FLASH,发送巨大的擦写命令,很可能导致程序跑飞或FLASH损坏。正确的做法是回到STM32代码中,检查格式化流程和容量报告函数。 - 无法识别:设备管理器里可能显示为“未知USB设备”或带感叹号。这说明USB枚举失败,问题出在USB描述符、端点配置或底层USB驱动上。需要检查CubeMX配置,并用USB分析仪(如Bus Hound)抓包分析。
安全移除硬件:和普通U盘一样,在拔掉USB线之前,最好在电脑上点击“安全弹出硬件”。这个操作会让电脑发送一个SCSI STOP命令,我们可以在这个命令的回调函数里,确保所有缓存数据都已写入FLASH(即调用f_sync),实现安全断电。
7. 常见问题排查与性能优化
即使按照步骤操作,也难免会遇到问题。这里汇总一些典型的坑和解决办法。
7.1 问题排查速查表
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 电脑完全不识别USB设备 | 1. USB硬件连接问题(DP/DM接反、没供电) 2. USB时钟配置错误(必须是48MHz) 3. 描述符错误 | 1. 检查硬件连接和电压。 2. 用示波器看USB数据线是否有信号。 3. 使用 STM32CubeProgrammer的USB DFU模式测试USB PHY是否正常。 |
| 电脑提示“无法识别的USB设备” | 1. 端点配置错误 2. 设备描述符/配置描述符响应错误 | 1. 核对usbd_conf.c中的端点Buffer大小和地址。2. 用Bus Hound等工具抓取枚举过程的数据包。 |
| 电脑提示“需要格式化” | 1. FLASH未格式化或FAT表损坏 2. STORAGE_GetCapacity返回值错误3. 扇区大小不是512字节 | 1. 确认已成功执行f_mkfs。2. 调试 STORAGE_GetCapacity,看其返回的块数量和块大小。3. 确保 _SECTOR_SIZE、disk_ioctl、STORAGE_GetCapacity三处的扇区大小定义一致。 |
| 可以识别,但写入文件失败/文件损坏 | 1.disk_write函数实现错误(未做读-改-擦-写)2. FLASH擦写期间发生中断,导致数据错乱 3. USB传输缓冲区溢出 | 1. 重点检查disk_write逻辑,特别是缓冲区管理。2. 在FLASH擦写操作前后加临界区保护( __disable_irq/__enable_irq)。3. 检查USB端点Buffer大小是否大于等于512字节。 |
| 读写速度极慢 | 1. FLASH擦写时间过长 2. 文件系统碎片化(在小容量下不明显) 3. 每次读写都重新挂载文件系统 | 1. 这是片内FLASH的物理限制,无法根本解决。 2. 避免频繁地打开关闭文件,可以长时间保持文件打开状态进行读写。 3. 确保 f_mount只在初始化时调用一次。 |
7.2 稳定性与性能优化技巧
写保护与掉电保护:在
disk_write函数开始时,可以检查一个全局的“写使能”标志。只有系统状态稳定(如电压正常)时才允许写入。在检测到即将掉电(通过电压监控电路中断)时,立即置位写保护标志,并尽快完成当前扇区的写入操作,防止文件系统结构被破坏。减少擦写次数:对于需要频繁记录的数据(如运行日志),不要每次写都立即保存到FLASH。可以在RAM中开辟一个环形缓冲区,积累到一定量(如攒够一个扇区)再一次性写入FLASH。这能大幅提升FLASH寿命。
使用RAM Disk进行测试:在开发初期,为了排除FLASH驱动的不稳定性,可以先用一片RAM区域模拟磁盘。只需修改
diskio.c中的读写函数,使其指向一个全局数组。这样可以快速验证FatFs和USB MSC层的逻辑是否正确,极大提高调试效率。添加状态指示:在板上增加一个LED,在
STORAGE_Read/Write函数被调用时闪烁。这样你可以直观地看到电脑在访问U盘,对于判断设备是否被正确识别非常有帮助。
实现STM32片内FLASH U盘的过程,是一次对嵌入式系统存储、文件系统和USB外设的深度整合。它没有太多高深的算法,但对细节的把握要求极高。每一个环节——从FLASH的页擦除,到FatFs的扇区映射,再到USB的SCSI命令解析——都必须严丝合缝。当你第一次在电脑上看到自己手写的代码所创造的“U盘”,并成功拷贝进一个文件时,那种成就感是对所有调试过程中抓耳挠腮的最好回报。这个项目获得的最大经验,就是模块化测试和重视数据手册。先把FLASH读写调通,再挂上FatFs测试文件操作,最后整合USB,每一步都确保稳固,最终的系统才会可靠。