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

我们的方案核心由三个部分组成:

  1. USB MSC(大容量存储类):这是USB协议中专门为硬盘、U盘等设备定义的一个标准类。STM32的USB外设(如USB FS-全速)在配置为Device模式后,我们可以编写代码实现MSC类所要求的接口描述符和请求处理。最关键的是,MSC类定义了一套基于SCSI透明命令集(如INQUIRY, READ CAPACITY, READ/WRITE)的通信方式。电脑(USB主机)会发送这些命令来询问设备信息、读写扇区,我们的STM32需要正确响应这些命令。

  2. FatFs(文件系统模块):FatFs是一个为小型嵌入式系统设计的通用FAT文件系统模块。它独立于底层存储介质和硬件平台,我们只需要为它提供几个基础的磁盘I/O接口(如disk_read,disk_write)。FatFs负责解析FAT表、目录项,管理文件和文件夹的创建、读写、删除等逻辑。这样,我们就不需要自己去处理复杂的FAT32/exFAT结构,大大降低了开发难度。

  3. 片内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)。

操作流程标准化:

  1. 解锁(Unlock):STM32的FLASH控制器有写保护,防止程序跑飞误擦写。在修改FLASH前,需要向特定的控制寄存器写入密钥序列。
    HAL_FLASH_Unlock(); // 使用HAL库解锁
  2. 擦除(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);
  3. 编程(Program):以字(32位)、半字(16位)或字节(8位,取决于型号)为单位写入数据。必须确保目标地址已经擦除(全为0xFF)。
    HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, address, data);
  4. 上锁(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)。写入流程如下:

  1. 当收到写入某个扇区的请求时,先判断该扇区所属的物理页。
  2. 将该物理页当前的全部数据(4个扇区)读取到RAM缓冲区。
  3. 将FatFs要写入的新数据,更新到缓冲区中对应的位置。
  4. 擦除整个物理页。
  5. 将整个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字节扇区。

集成步骤:

  1. 从FatFs官网下载源码,将ff.c,ff.h,diskio.c,diskio.h,ffconf.h等文件加入工程。
  2. 根据上述说明修改ffconf.h
  3. 实现好我们上一节讲的diskio.c
  4. 在应用程序中,包含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上电后自动进行,因为那会清空所有数据。通常有两种方式:

  1. 通过应用程序触发:在代码中设置一个“格式化标志”(比如检测某个GPIO引脚电平),首次烧录程序后,触发一次格式化流程。
    if (need_format) { uint8_t work[_MAX_SS]; // 格式化需要的缓冲区 res = f_mkfs("0:", FM_FAT32, 0, work, sizeof(work)); // 格式化为FAT32 }
  2. 使用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配置(强烈推荐):

  1. Connectivity中使能USB (FS),模式选择Device_Only
  2. Middleware and Software Packs中,选择USB_DEVICE,Class选择Mass Storage Class (MSC)
  3. Project ManagerCode Generator里,选择“为外设生成独立的.c/.h文件”,这样USB的代码会单独放在USB_DEVICE文件夹里,结构清晰。
  4. 生成代码。CubeMX会自动生成所有USB描述符和MSC类框架代码。

5.2 SCSI命令处理与BOT协议

电脑通过Bulk-Only Transport (BOT)协议与MSC设备通信。其核心是CBW(Command Block Wrapper)CSW(Command Status Wrapper)结构体。

通信流程:

  1. 主机发送一个31字节的CBW,里面包含了具体的SCSI命令(如READ(10))以及数据传输长度和方向。
  2. 设备解析CBW。如果是读请求(如READ(10)),设备就通过Bulk IN端点发送相应长度的数据;如果是写请求(如WRITE(10)),设备就从Bulk OUT端点接收数据。
  3. 数据传输完毕后,设备发送一个13字节的CSW给主机,报告命令执行状态(成功、失败等)。

我们需要实现的核心回调函数:在生成的代码中,我们需要在usbd_storage_if.c文件中实现以下函数:

  • STORAGE_Init:初始化底层存储(其实就是调用我们disk_initialize)。
  • STORAGE_GetCapacity:返回磁盘的扇区总数和扇区大小。这里返回的值必须与diskio.cdisk_ioctl函数返回的完全一致!否则电脑会识别出错误的容量。
  • STORAGE_IsReady:报告磁盘是否就绪。
  • STORAGE_IsWriteProtected:报告磁盘是否写保护。
  • STORAGE_Read:处理读扇区请求。直接调用disk_read
  • STORAGE_Write:处理写扇区请求。直接调用disk_write

关键连接点:STORAGE_ReadSTORAGE_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线连接到电脑。如果一切顺利,你应该能听到电脑发现新硬件的提示音,并在“设备管理器”的“磁盘驱动器”下看到一个新的设备。稍等片刻,它就会出现在“我的电脑”里,作为一个可移动磁盘。

首次使用的可能情况:

  1. 最佳情况:电脑直接识别出U盘并显示正确容量,可以正常打开、读写文件。
  2. 提示格式化:电脑识别到了磁盘,但认为其文件系统损坏或未格式化。这说明我们的FAT表等元数据可能不对,或者STORAGE_GetCapacity返回的信息有误。此时千万不要在电脑上点格式化!这会导致电脑按照它的理解(可能是GB级)去格式化我们的FLASH,发送巨大的擦写命令,很可能导致程序跑飞或FLASH损坏。正确的做法是回到STM32代码中,检查格式化流程和容量报告函数。
  3. 无法识别:设备管理器里可能显示为“未知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_SIZEdisk_ioctlSTORAGE_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 稳定性与性能优化技巧

  1. 写保护与掉电保护:在disk_write函数开始时,可以检查一个全局的“写使能”标志。只有系统状态稳定(如电压正常)时才允许写入。在检测到即将掉电(通过电压监控电路中断)时,立即置位写保护标志,并尽快完成当前扇区的写入操作,防止文件系统结构被破坏。

  2. 减少擦写次数:对于需要频繁记录的数据(如运行日志),不要每次写都立即保存到FLASH。可以在RAM中开辟一个环形缓冲区,积累到一定量(如攒够一个扇区)再一次性写入FLASH。这能大幅提升FLASH寿命。

  3. 使用RAM Disk进行测试:在开发初期,为了排除FLASH驱动的不稳定性,可以先用一片RAM区域模拟磁盘。只需修改diskio.c中的读写函数,使其指向一个全局数组。这样可以快速验证FatFs和USB MSC层的逻辑是否正确,极大提高调试效率。

  4. 添加状态指示:在板上增加一个LED,在STORAGE_Read/Write函数被调用时闪烁。这样你可以直观地看到电脑在访问U盘,对于判断设备是否被正确识别非常有帮助。

实现STM32片内FLASH U盘的过程,是一次对嵌入式系统存储、文件系统和USB外设的深度整合。它没有太多高深的算法,但对细节的把握要求极高。每一个环节——从FLASH的页擦除,到FatFs的扇区映射,再到USB的SCSI命令解析——都必须严丝合缝。当你第一次在电脑上看到自己手写的代码所创造的“U盘”,并成功拷贝进一个文件时,那种成就感是对所有调试过程中抓耳挠腮的最好回报。这个项目获得的最大经验,就是模块化测试重视数据手册。先把FLASH读写调通,再挂上FatFs测试文件操作,最后整合USB,每一步都确保稳固,最终的系统才会可靠。