1. 项目缘起:为什么要在STM32上实现“小U盘”?
几年前,我在一个工业数据采集项目上遇到了一个头疼的问题。设备需要在现场运行数月,期间采集的数据量不小,但设备本身没有网络接口,也没有SD卡槽。每次维护人员去现场,都需要通过串口工具连接设备,用特定的上位机软件把数据一点点读出来,过程繁琐且容易出错。当时我就在想,如果这个设备能像U盘一样,插上电脑就能直接看到里面的数据文件,那该多方便。
这个想法,就是今天要聊的“STM32使用片内FLASH制作小U盘”项目的核心动机。它本质上是在STM32微控制器上,利用其内部的FLASH存储器,实现USB Mass Storage Class(MSC)设备功能。这样一来,你的STM32设备在电脑上就会被识别为一个标准的、可移动的U盘,你可以像操作普通U盘一样,直接拖拽文件进行读写。
听起来很酷,对吧?但这里面有几个关键点需要拆解清楚。首先,是“片内FLASH”。很多朋友一听到U盘,第一反应是外接一个SPI FLASH或者NAND FLASH芯片。这当然可以,而且容量更大。但使用片内FLASH的意义在于极致的集成度和成本控制。对于一些数据量不大(比如几KB到几百KB的配置文件、日志、校准参数)、但对PCB面积和BOM成本极其敏感的应用(比如消费电子、小型传感器节点),省掉一颗外部存储芯片,意义重大。
其次,是“MSC”。这是USB协议中定义的一个设备类,专门用于大容量存储设备,如U盘、移动硬盘。电脑的操作系统内置了MSC的驱动,所以你的设备一旦被识别为MSC设备,就获得了即插即用、无需安装额外驱动的巨大便利性。
最后,是文件系统。光有存储空间还不够,要让电脑能识别并管理文件,你需要在FLASH上创建一个文件系统,最常用的就是FATFS(一个开源、轻量级的FAT文件系统实现)。所以,整个技术栈可以概括为:STM32(硬件平台) + 内部FLASH(存储介质) + USB MSC(通信协议与设备描述) + FATFS(文件系统层)。
这个项目适合谁呢?如果你正在开发需要离线存储和便捷数据交换的设备,比如数据记录仪、配置参数存储器、固件更新载体(设备自己作为U盘来接收新固件),或者单纯想深入学习USB设备和嵌入式文件系统,那么这个实践会给你带来巨大的收获。接下来,我将带你从原理到实践,一步步实现这个“麻雀虽小,五脏俱全”的小U盘。
2. 核心原理拆解:FLASH、MSC与FATFS如何协同工作?
在动手写代码之前,我们必须把大脑里的电路图和信息流理清楚。这个过程不是简单的模块堆砌,而是一个精密的协作系统。理解了这个,后面调试时遇到问题,你才能知道该从哪里入手。
2.1 STM32内部FLASH的特性与约束
STM32的内部FLASH和我们电脑的内存、以及外部SPI FLASH都不同,它有自己独特的“脾气”。
首先,读写不对称。读操作可以按字节、半字(16位)、字(32位)随机进行,速度很快。但写操作(编程)和擦除操作就麻烦多了。
- 写操作:只能将数据位从‘1’写成‘0’,而不能从‘0’写成‘1’。要想把‘0’变回‘1’,必须执行擦除操作。写操作必须以“页”或“字”为单位进行。
- 擦除操作:只能按“扇区”进行。不同型号的STM32,扇区大小不同,常见的有1KB、2KB、16KB、128KB等。擦除会把整个扇区的所有位都变成‘1’。
这就引出了第一个关键约束:你必须以“擦除扇区->编程写入”的流程来管理数据。你不能像操作RAM那样,随意覆盖某个地址的一个字节。
其次,寿命限制。FLASH有擦写次数(Endurance)的限制,通常是10万次左右。这意味着如果你频繁地在同一个扇区擦写,这个扇区会先于芯片的其他部分损坏。因此,我们需要设计磨损均衡策略,虽然对于简单的U盘应用可能不需要复杂的均衡算法,但意识里必须有这根弦,避免在代码里死循环地擦写同一个地址。
最后,执行代码的禁区。当CPU从正在被擦写的FLASH扇区取指令时,会发生什么?答案是会总线错误或取到错误指令,导致程序跑飞。因此,绝对不能擦除或写入当前正在运行的程序所在的扇区。通常,我们会把U盘使用的数据存储区,规划在程序代码区之后、FLASH的末尾部分。在链接脚本(.ld文件或sct文件)里,你需要明确划分出代码区和数据区。
2.2 USB Mass Storage Class (MSC) 的工作机制
MSC设备与电脑的通信,是基于一套标准的命令集来完成的,这套命令集叫做SCSI透明命令集。别被名字吓到,你不需要完全理解SCSI,只需要知道电脑通过发送这些固定的命令来操控你的“U盘”。
当你的STM32被插入电脑USB口后,会发生以下对话:
- 枚举:电脑问:“你是谁?” STM32通过USB描述符回答:“我是一个MSC设备。”
- 查询:电脑接着问:“你有多大容量?块大小是多少?” STM32需要根据你分配给MSC的FLASH空间大小和扇区大小(通常是512字节,这是硬盘和U盘的标准扇区大小)来回答。
- 读写操作:当你在电脑上复制一个文件到“U盘”时,电脑并不是直接发送文件数据。而是发送SCSI读写命令,命令里包含了逻辑块地址(LBA,相当于扇区号)和要读写的扇区数量。你的STM32 MSC代码需要解析这些命令,然后调用底层的FLASH读写函数,去对应的物理地址读取或写入数据。
这里的关键抽象是:MSC层看到的是一块连续的、扇区大小为512字节的线性磁盘空间(逻辑空间)。而你需要实现一个“转换层”,将逻辑扇区号(LBA)映射到STM32 FLASH的实际物理地址上。这个映射过程,必须考虑FLASH的擦除特性。比如,你不能让电脑直接写LBA 0对应的物理地址,因为那里可能是你的程序代码。你需要将LBA 0映射到你规划的数据存储区的起始地址。
2.3 FATFS文件系统的角色
FATFS是一个中间层,它位于MSC和你的FLASH驱动之间。它的作用是管理文件。
当电脑格式化这个“U盘”为FAT12/FAT16/FAT32格式时,它会在磁盘(也就是你映射的FLASH空间)的起始部分写入一些特殊结构,包括引导扇区、FAT表、根目录区等。FATFS文件系统代码负责理解这些结构。
- 当电脑要创建一个新文件时,它会通过MSC发送写命令,将文件数据写入某些扇区,同时更新FAT表和目录项。FATFS的代码需要解析这些写操作,并调用你提供的底层磁盘读写接口(
disk_read,disk_write)。 - 当电脑读取文件列表时,FATFS会通过底层接口读取FAT表和目录区,整理出文件列表信息,再通过MSC返回给电脑。
所以,完整的调用链是:电脑文件操作 -> USB MSC驱动 -> SCSI命令 -> 你的MSC处理代码 -> 调用FATFS的f_read/f_write-> FATFS调用你实现的disk_read/disk_write-> 你的FLASH驱动函数 -> 操作STM32内部FLASH。
你的核心工作,就是实现最后两步:适配FATFS的磁盘接口,并写出稳健的FLASH驱动。下面我们就进入实战环节。
3. 工程搭建与底层驱动实现
理论铺垫足够后,我们开始动手。我以STM32F103C8T6(64KB FLASH)和HAL库为例,使用STM32CubeIDE进行开发。其他型号和开发环境思路相通。
3.1 存储空间规划与链接脚本修改
这是第一步,也是至关重要的一步,错了会导致程序自杀。
假设我们的程序代码大约占用了前40KB的FLASH。我们打算用最后的24KB作为U盘的数据区。STM32F103的FLASH扇区大小是1KB(页)和2KB(页),但整体擦除最小单位是1KB(对于小容量产品)。我们需要在链接脚本里明确告诉编译器:“代码放到前面,后面那块地儿留出来,别放任何代码或常量数据。”
在STM32CubeIDE中,链接脚本是.ld文件。你需要找到类似MEMORY定义的部分,并修改FLASH区域的定义和SECTIONS的分配。
一个简化的思路是(具体语法请参考GCC链接器手册):
MEMORY { RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 20K /* 将FLASH分为两部分:CODE和STORAGE */ FLASH_CODE (rx) : ORIGIN = 0x08000000, LENGTH = 40K FLASH_STORAGE (rx) : ORIGIN = 0x0800A000, LENGTH = 24K /* 40KB后的24KB */ } SECTIONS { /* .text, .data等所有程序段都放在FLASH_CODE区域 */ .text : { *(.text) ... } >FLASH_CODE /* 定义一个特殊的段,用于在代码中引用存储区的起始地址 */ .user_flash (NOLOAD) : { . = ALIGN(2048); /* 可选,按2KB对齐 */ _suser_flash = .; /* 提供起始地址符号 */ . = . + LENGTH(FLASH_STORAGE); _euser_flash = .; /* 提供结束地址符号 */ } >FLASH_STORAGE }这样,_suser_flash和_euser_flash就成了我们可以在C代码中使用的变量,分别指向数据存储区的起始和结束地址。(NOLOAD)关键字是关键,它告诉链接器不要在这里加载任何程序代码或初始化数据,这块区域是留给纯数据读写的。
注意:更简单的做法是在代码中直接使用宏定义存储区的起始地址,比如
#define FLASH_STORAGE_START_ADDR 0x0800A000。但修改链接脚本是更规范、更安全的方法,能绝对避免编译器将常量或代码误放到数据区。
3.2 实现FLASH的读写驱动
接下来,我们需要封装几个基础的FLASH操作函数。HAL库已经提供了HAL_FLASH_Unlock,HAL_FLASH_Lock,HAL_FLASHEx_Erase,HAL_FLASH_Program这些函数,我们要基于它们构建更易用的接口。
// flash_driver.h #ifndef __FLASH_DRIVER_H #define __FLASH_DRIVER_H #include “stm32f1xx_hal.h” #define FLASH_STORAGE_START 0x0800A000 #define FLASH_STORAGE_END 0x0800FFFF // 起始+24KB #define FLASH_SECTOR_SIZE 1024 // STM32F103C8T6 页大小1KB #define FLASH_TOTAL_SECTORS ((FLASH_STORAGE_END - FLASH_STORAGE_START) / FLASH_SECTOR_SIZE) // 擦除指定逻辑扇区 int32_t FLASH_EraseSector(uint32_t sector); // 将数据写入指定地址(调用前需确保地址已被擦除) int32_t FLASH_Write(uint32_t address, const uint8_t *data, uint32_t size); // 从指定地址读取数据 void FLASH_Read(uint32_t address, uint8_t *data, uint32_t size); #endif// flash_driver.c #include “flash_driver.h” // 计算逻辑扇区对应的物理起始地址 static uint32_t GetSectorPhysAddr(uint32_t sector) { if (sector >= FLASH_TOTAL_SECTORS) return 0; return FLASH_STORAGE_START + sector * FLASH_SECTOR_SIZE; } int32_t FLASH_EraseSector(uint32_t sector) { uint32_t sector_addr = GetSectorPhysAddr(sector); if (sector_addr == 0) return -1; FLASH_EraseInitTypeDef EraseInitStruct; uint32_t SectorError = 0; // 计算要擦除的页号(对于F1,地址直接对应页号) // 更通用的做法是根据起始地址计算对应的扇区编号,这里简化处理 // 注意:HAL_FLASHEx_Erase 的参数是 Page,对于F1,Page就是地址对应的那个页。 // 实际项目中,你需要根据芯片参考手册,正确填充 EraseInitStruct.TypeErase 和 EraseInitStruct.Banks 等字段。 // 以下为F1系列的简化示例: EraseInitStruct.TypeErase = FLASH_TYPEERASE_PAGES; EraseInitStruct.PageAddress = sector_addr; EraseInitStruct.NbPages = 1; // 一次擦除一个扇区(页) HAL_FLASH_Unlock(); if (HAL_FLASHEx_Erase(&EraseInitStruct, &SectorError) != HAL_OK) { HAL_FLASH_Lock(); return -2; // 擦除失败 } HAL_FLASH_Lock(); return 0; // 成功 } int32_t FLASH_Write(uint32_t address, const uint8_t *data, uint32_t size) { // 检查地址是否在存储区内,并且是字对齐的(F1要求字编程) if (address < FLASH_STORAGE_START || address + size > FLASH_STORAGE_END || (address & 0x3) != 0) { return -1; } HAL_FLASH_Unlock(); uint32_t *pData = (uint32_t*)data; uint32_t wordSize = (size + 3) / 4; // 计算需要写入的32位字数 for (uint32_t i = 0; i < wordSize; i++) { // 注意:这里假设data缓冲区大小足够,且地址是字对齐的。 // 实际编程时,如果size不是4的倍数,最后一个字需要特殊处理。 uint32_t targetAddr = address + i * 4; uint32_t dataToWrite = pData[i]; // 更健壮的写法应该检查目标地址是否已被擦除(全为0xFF),这里省略 if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, targetAddr, dataToWrite) != HAL_OK) { HAL_FLASH_Lock(); return -2; // 编程失败 } } HAL_FLASH_Lock(); return 0; } void FLASH_Read(uint32_t address, uint8_t *data, uint32_t size) { // 简单的内存拷贝,因为FLASH支持随机读取 memcpy(data, (uint8_t*)address, size); }重要提示:以上代码是高度简化的示例,特别是擦除函数。在实际项目中,你必须根据你所使用的具体STM32系列(F1, F4, H7等),仔细查阅对应HAL库的文档和芯片参考手册,来正确配置
FLASH_EraseInitTypeDef结构体。例如,F4系列是按扇区擦除,扇区大小从16KB到128KB不等,配置方式与F1完全不同。直接拷贝这段代码大概率无法运行,请务必根据你的芯片型号调整。
3.3 集成FATFS并实现磁盘IO接口
现在,我们将FATFS库添加到工程中。从FatFs官网下载源码,将ff.c,ff.h,ffconf.h,diskio.c,diskio.h等文件加入项目。
diskio.c是我们需要重点修改的文件,它定义了FATFS与底层存储介质之间的桥梁。我们需要实现以下几个函数:
// diskio.c #include “ff.h” #include “diskio.h” #include “flash_driver.h” // 引入我们刚写的FLASH驱动 // 获取磁盘状态(这里我们假设磁盘一直正常) DSTATUS disk_status (BYTE pdrv) { return RES_OK; } // 初始化磁盘 DSTATUS disk_initialize (BYTE pdrv) { // 对于内部FLASH,初始化可能什么都不用做,或者检查一下地址是否有效 return RES_OK; } // 读扇区 DRESULT disk_read (BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { // 将LBA扇区号转换为物理地址 uint32_t addr = FLASH_STORAGE_START + sector * FF_MAX_SS; // FF_MAX_SS通常是512 for (UINT i = 0; i < count; i++) { FLASH_Read(addr + i * FF_MAX_SS, buff + i * FF_MAX_SS, FF_MAX_SS); } return RES_OK; } // 写扇区(这是最复杂、最关键的函数) DRESULT disk_write (BYTE pdrv, const BYTE *buff, LBA_t sector, UINT count) { DRESULT res = RES_OK; uint32_t addr = FLASH_STORAGE_START + sector * FF_MAX_SS; for (UINT i = 0; i < count; i++) { uint32_t secAddr = addr + i * FF_MAX_SS; // 第一步:检查目标扇区所在的FLASH物理扇区是否需要擦除 // 我们需要一个逻辑扇区到物理扇区的映射管理。 // 简单策略:一个FLASH物理扇区(1KB)对应2个FATFS逻辑扇区(512B*2)。 // 当要写入任何一个逻辑扇区时,需要将其对应的整个物理扇区读入RAM缓存, // 修改缓存中对应的部分,然后擦除整个物理扇区,最后将整个缓存写回。 // 计算当前逻辑扇区属于哪个物理扇区(Flash Sector) uint32_t flashSectorIndex = (secAddr - FLASH_STORAGE_START) / FLASH_SECTOR_SIZE; uint32_t flashSectorAddr = FLASH_STORAGE_START + flashSectorIndex * FLASH_SECTOR_SIZE; // 由于FLASH_SECTOR_SIZE (1024) > FF_MAX_SS (512),我们需要缓存整个物理扇区 uint8_t sectorBuffer[FLASH_SECTOR_SIZE]; // 1. 读取整个物理扇区到缓存 FLASH_Read(flashSectorAddr, sectorBuffer, FLASH_SECTOR_SIZE); // 2. 将要写入的数据(一个逻辑扇区,512字节)复制到缓存的正确位置 uint32_t offsetInFlashSector = secAddr - flashSectorAddr; memcpy(sectorBuffer + offsetInFlashSector, buff + i * FF_MAX_SS, FF_MAX_SS); // 3. 擦除整个物理扇区 if (FLASH_EraseSector(flashSectorIndex) != 0) { res = RES_ERROR; break; } // 4. 将整个缓存写回物理扇区 // 注意:FLASH_Write要求地址字对齐,且数据大小是4的倍数。我们需要确保这一点。 // 这里假设FLASH_SECTOR_SIZE是4的倍数,且flashSectorAddr是字对齐的。 if (FLASH_Write(flashSectorAddr, sectorBuffer, FLASH_SECTOR_SIZE) != 0) { res = RES_ERROR; break; } } return res; } // 控制函数,用于同步、获取信息等 DRESULT disk_ioctl (BYTE pdrv, BYTE cmd, void *buff) { switch (cmd) { case CTRL_SYNC: // 对于FLASH,写入即持久化,无需额外同步操作 return RES_OK; case GET_SECTOR_COUNT: // 获取总扇区数 *(LBA_t*)buff = (FLASH_STORAGE_END - FLASH_STORAGE_START) / FF_MAX_SS; return RES_OK; case GET_SECTOR_SIZE: // 获取扇区大小(FATFS逻辑扇区大小) *(WORD*)buff = FF_MAX_SS; return RES_OK; case GET_BLOCK_SIZE: // 获取擦除块大小(对于FLASH,指一个擦除单元包含多少逻辑扇区) *(DWORD*)buff = FLASH_SECTOR_SIZE / FF_MAX_SS; // 例如 1024/512 = 2 return RES_OK; default: return RES_PARERR; } }这个disk_write函数是实现的核心难点。它清晰地展示了如何解决FLASH写前必须擦除,且擦除单位大于写入单位(512字节)的矛盾。这里的“缓存-修改-擦除-写回”策略,是此类项目的通用解决方案。你需要根据你的FLASH_SECTOR_SIZE和FF_MAX_SS来调整映射关系。
4. USB MSC设备配置与集成
底层存储驱动和FATFS接口准备好后,我们需要让STM32“变身”为USB大容量存储设备。
4.1 使用STM32CubeMX配置USB
- 打开CubeMX,选择你的STM32型号。
- 在
Connectivity中启用USB,模式选择Device Only。 - 在
Middleware中启用USB_DEVICE,Class选择Mass Storage Host Class (MSC)。 - 在
Project Manager中设置好工程名、路径和IDE,生成代码。
CubeMX会自动生成USB设备栈的代码,包括描述符、端点配置等。它会创建几个关键文件:
usbd_storage_if.c/.h: 这是USB MSC类与你的存储介质之间的接口文件,我们需要重点修改它。usbd_conf.c: USB底层配置。usb_device.c: USB设备核心初始化。
4.2 实现USB MSC存储接口
生成的usbd_storage_if.c里有两个关键的回调函数模板,我们需要用之前实现的FATFS功能来填充它们。
// usbd_storage_if.c #include “usbd_storage_if.h” #include “ff.h” // 引入FATFS #include “diskio.h” FATFS fs; // FATFS工作区 FIL file; // 文件对象(虽然MSC模式下我们不直接操作文件,但挂载需要) char fatfs_path[4] = “0:/”; // 逻辑驱动器路径 // 初始化存储单元 int8_t STORAGE_Init_FS(void) { // 挂载文件系统。驱动器号‘0’对应我们在diskio.c中实现的pdrv=0。 FRESULT res = f_mount(&fs, fatfs_path, 0); if (res != FR_OK) { // 如果挂载失败,可能是存储区还没有有效的FAT文件系统。 // 对于新芯片,第一次使用时需要格式化。 // 我们可以在这里尝试格式化,或者将初始化失败返回给上层。 // 对于MSC设备,通常由电脑端进行格式化。 return (int8_t)res; } return (int8_t)FR_OK; } // 获取存储容量信息 int8_t STORAGE_GetCapacity_FS(uint32_t *block_num, uint16_t *block_size) { DWORD free_clust, tot_sect, sect_per_clust; FATFS *fs_ptr = &fs; // 通过FATFS获取磁盘信息 FRESULT res = f_getfree(fatfs_path, &free_clust, &fs_ptr); if (res != FR_OK) { return (int8_t)res; } // 计算总扇区数 tot_sect = (fs_ptr->n_fatent - 2) * fs_ptr->csize; sect_per_clust = fs_ptr->csize; // block_size 是逻辑扇区大小,必须是512 *block_size = 512; // block_num 是总扇区数 *block_num = tot_sect; return (int8_t)FR_OK; } // 读扇区(USB MSC调用) int8_t STORAGE_Read_FS(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len) { // lun: 逻辑单元号,对于单存储设备就是0。 // 这个函数直接调用我们diskio.c中的disk_read DRESULT res = disk_read(lun, buf, blk_addr, blk_len); return (res == RES_OK) ? (int8_t)0 : (int8_t)1; } // 写扇区(USB MSC调用) int8_t STORAGE_Write_FS(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len) { // 这个函数直接调用我们diskio.c中的disk_write DRESULT res = disk_write(lun, buf, blk_addr, blk_len); return (res == RES_OK) ? (int8_t)0 : (int8_t)1; } // 判断存储介质是否就绪 int8_t STORAGE_IsReady_FS(uint8_t lun) { // 对于内部FLASH,可以认为一直就绪。 // 更严谨的做法可以检查FLASH初始化标志或进行简单的读写测试。 return (int8_t)0; } // 判断存储介质是否写保护 int8_t STORAGE_IsWriteProtected_FS(uint8_t lun) { // 返回0表示可写,1表示写保护。 // 我们可以通过一个GPIO引脚或软件标志来控制是否写保护。 return (int8_t)0; // 默认可写 } // 获取最大LUN(逻辑单元数) int8_t STORAGE_GetMaxLun_FS(void) { return (int8_t)0; // 我们只有一个存储单元 }修改完这些接口后,USB MSC的底层通信就和你实现的FATFS+FLASH驱动连接起来了。当电脑发送SCSI读写命令时,USB库会调用STORAGE_Read_FS和STORAGE_Write_FS,进而触发disk_read/disk_write,最终操作FLASH。
4.3 主程序逻辑与初始化流程
最后,我们需要在main.c中组织好初始化顺序。
// main.c #include “main.h” #include “usb_device.h” #include “fatfs.h” #include “usbd_storage_if.h” int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); // 其他外设初始化... // 1. 初始化FATFS(挂载磁盘) if (STORAGE_Init_FS() != 0) { // 初始化失败处理,比如LED闪烁报警 Error_Handler(); } // 2. 初始化USB设备库 MX_USB_DEVICE_Init(); while (1) { // 主循环不需要做太多事,USB中断会处理一切 // 可以添加LED心跳灯,指示系统运行状态 HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); HAL_Delay(500); } }编译、下载程序到STM32。第一次运行时,因为FLASH存储区是空的(全0xFF),没有有效的FAT文件系统,所以电脑可能会提示“需要格式化”。这是正常现象。你可以在电脑上格式化这个“U盘”。格式化过程,就是电脑通过USB MSC向你的设备写入引导扇区、FAT表等结构的过程,它会调用你实现的STORAGE_Write_FS函数。
5. 实测、优化与避坑指南
代码写完只是第一步,真正的挑战在调试和优化。下面分享几个我踩过的坑和对应的解决方案。
5.1 首次连接与格式化问题
现象:设备插入电脑,能识别到未知USB设备或无法识别的设备,或者识别为“U盘”但提示“无法访问,需要格式化”,点击格式化又失败。
排查思路:
- 检查USB硬件:DP/DM线是否接反?是否有上拉电阻(内部或外部)?USB端口供电是否充足?这是最基本也最容易出错的地方。
- 检查USB描述符:使用USB分析工具(如USBlyzer, Wireshark with USB capture)查看枚举过程。确保设备描述符、配置描述符、接口描述符、端点描述符都正确发送,特别是设备类(bDeviceClass)、接口类(bInterfaceClass)要设置为MSC(0x08)。
- 检查
STORAGE_GetCapacity_FS返回值:这个函数必须在电脑查询时返回正确的扇区数和扇区大小(512)。如果返回0或错误值,电脑会认为设备容量异常。确保你的disk_ioctl(GET_SECTOR_COUNT)返回的是基于FF_MAX_SS计算的值。 - 检查
STORAGE_IsReady_FS:这个函数要快速返回0(就绪)。如果在这里进行耗时的FLASH初始化或检查,可能导致电脑端超时。 - 格式化失败:这通常意味着
disk_write函数有bug。电脑格式化时会连续写入多个扇区,特别是前几个扇区(引导扇区、FAT表)。你需要:- 单步调试或添加日志:在
disk_write函数里,通过串口打印出每次写入的扇区号(LBA)和状态。观察是否在写入某个特定扇区时出错。 - 检查地址映射:确保LBA到物理地址的转换正确,没有写到程序代码区。
- 检查擦除-写入逻辑:特别是“缓存-修改-擦除-写回”策略,在连续写入相邻逻辑扇区时,是否会重复擦写同一个物理扇区?你的缓存管理逻辑是否能正确处理边界情况?一个常见的错误是:连续写LBA 0和LBA 1(属于同一个物理扇区),代码却执行了两次“读缓存->修改->擦除->写回”,第二次操作时,缓存里是旧数据(第一次写回后的),覆盖了第一次写入的结果。解决方案是:在
disk_write函数内部维护一个当前缓存物理扇区的索引和缓存数据,如果下次写入还在同一个物理扇区,就直接修改缓存,直到需要写入另一个物理扇区时,才执行一次“擦除->写回”。
- 单步调试或添加日志:在
5.2 文件操作缓慢与寿命担忧
现象:复制小文件很快,但复制大文件或频繁删改文件时,速度极慢。
原因分析:根本原因在于FLASH的擦除操作非常耗时(通常是毫秒级,而写和读是微秒级)。我们简单的“每写一个逻辑扇区就擦除一次物理扇区”的策略,在频繁小文件操作时,会引发大量的擦除操作。此外,FAT文件系统在修改文件时,会频繁更新FAT表和目录项,这些元数据通常集中在磁盘开头的几个扇区,导致那几个物理扇区被反复擦写,很快达到寿命极限。
优化策略:
- 增加RAM缓存:在
disk_write函数中,实现一个简单的写缓存。例如,开辟一个或多个物理扇区大小的RAM缓冲区。当收到写请求时,只写入RAM缓存,并标记该缓存为“脏”。当缓存满、或者收到同步命令(disk_ioctl(CTRL_SYNC))、或者一段时间没有写操作时,再将整个脏缓存一次性写回FLASH。这可以将多次小写操作合并为一次擦写,显著提升速度和寿命。 - 实现简单的磨损均衡:不要总是从存储区的起始地址开始使用。维护一个“写指针”,每次需要分配新空间时(比如FATFS创建新文件),从写指针处开始分配,写指针循环移动。这样可以将写操作均匀分布到整个FLASH存储区。这需要修改
disk_write的地址映射逻辑,变得更像一个FTL(Flash Translation Layer)层。 - 选择更合适的文件系统:对于频繁小文件读写的场景,FATFS可能不是最优选。可以考虑专为FLASH设计的文件系统,如LittleFS、SPIFFS等。它们内置了磨损均衡和掉电保护机制。但集成到USB MSC中会更复杂,因为需要让电脑也能识别其格式。
5.3 掉电保护与数据一致性
现象:在写入文件过程中突然断电,再次上电后,U盘可能无法识别,或者文件损坏。
原因:FLASH的写操作不是原子的。在“缓存-修改-擦除-写回”的过程中,任何一步断电都会导致数据丢失或不一致。特别是FAT表,如果只更新了一半,整个文件系统就可能崩溃。
应对措施:
- 确保写操作的原子性:尽量让一个物理扇区的写操作在最短时间内完成。使用芯片的掉电检测功能,在电压跌落时尽快完成当前操作并进入保护状态。
- 文件系统层面的保护:有些嵌入式文件系统(如LittleFS)使用copy-on-write和原子提交的机制来保证一致性。如果坚持用FATFS,一个土办法是:维护两份FAT表,交替使用。每次更新时,先写完备份FAT表和主数据,最后再更新一个“当前有效FAT表”的标记扇区。这个标记扇区的写入应该是单次、快速的。这样即使更新过程中断电,最多损失一份FAT表,可以根据标记恢复到最后一次一致的状态。但这会牺牲一部分存储空间和增加复杂度。
- 降低期望,明确用途:对于这个“小U盘”项目,最现实的定位是用于不频繁的、整文件替换的数据交换,比如定期上传一次日志文件、更新一次配置文件。避免用它来频繁进行文件创建、删除、追加写入等操作。向用户明确说明其使用限制。
5.4 资源消耗与性能评估
STM32F103C8T6只有20KB RAM和64KB FLASH。我们的工程消耗了多少资源?
- RAM:FATFS本身需要几个KB的工作缓冲区(在
ffconf.h中定义)。USB MSC栈需要缓冲区(通常是端点缓冲区)。再加上你的disk_write函数中的缓存(如1KB的sectorBuffer)。对于F103,RAM是紧张的,务必在CubeMX和ffconf.h中优化缓冲区大小。 - FLASH:程序代码、USB库、FATFS库加起来可能已经30-40KB。留给数据存储的空间可能只有十几KB。务必通过map文件查看实际占用,确保你的数据存储区地址不会覆盖程序代码。
- CPU:USB中断和FLASH擦写(尤其是擦除)会占用大量CPU时间。在擦除期间,如果中断过于频繁,可能导致USB通信超时。可以考虑在
FLASH_EraseSector期间临时提高任务优先级或进行简单保护。
最后,这个项目是一个绝佳的嵌入式综合实践,它串联了MCU底层存储、USB协议栈、文件系统三个核心知识点。实现过程中遇到的每一个问题,都会加深你对“计算机如何工作”的理解。当你第一次成功把txt文件拖进那个自己制作的“小U盘”时,那种成就感是无与伦比的。希望这篇长文能为你扫清障碍,祝你调试顺利!如果在实现过程中遇到具体问题,欢迎带着你的代码和现象来交流。