ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32模拟U盘实战:基于W25Q64和FATFS的虚拟U盘完整方案

STM32模拟U盘实战:基于W25Q64和FATFS的虚拟U盘完整方案 把STM32变成电脑上免驱的U盘插上USB线就能在资源管理器里拖动文件断点之后数据还在——这个效果听起来很玄实际用STM32F103加一颗W25Q64 SPI Flash再移植FATFS文件系统就能实现。我在调试这个项目时踩了不少坑从USB枚举失败到格式化不通过再到文件写入后拔插损坏基本把虚拟U盘这条路上的问题都趟了一遍。这篇博文就把整个方案完整拆开讲从架构设计、硬件接线、USB MSC协议栈、SPI Flash底层驱动到FATFS移植对接全部走一遍关键代码直接给出配好工程就能跑。1. 方案选型与整体架构思路1.1 为什么做虚拟U盘而不是读卡器市面上常见的USB存储方案有两种一种是单片机做USB Host去读U盘另一种是单片机模拟U盘让电脑来读写自己的存储介质。我们这里做的是第二种——将外部SPI Flash通过USB接口暴露给PCPC把它识别为一块普通U盘。为什么选这种方案因为虚拟U盘能带来一个真实U盘做不到的优点单片机自己也能访问这块存储区域。你可以让STM32在运行中记录温度、保存日志、存配置参数拔下USB线插到电脑上直接用资源管理器查看和拖拽文件数据不需要任何额外工具就能导出来。这个“一存储两用”的能力在数据采集、离线记录、嵌入式配置管理场景里非常实用。FATFS只负责文件系统的读写逻辑真正和USB协议栈对接的是diskio.c这一层。整个项目等于把“USB协议栈 FATFS文件系统 SPI Flash驱动”三块独立模块串在一起任何一个模块出问题都会导致电脑端表现异常调试时需要分层排查。1.2 数据链路从USB请求到Flash扇区先看一条完整的存储链路理解数据是怎么从电脑走到W25Q64里的PC资源管理器 → USB总线 SCSI协议层 → STM32的USB外设 → USB MSC类协议解析 → SCSI命令(READ/WRITE) → FATFS的disk_read/disk_write → fatfs挂载本地Flash → SPI总线命令(W25Q64 PageProgram/SectorErase) → W25Q64存储单元PC发出SCSI命令Windows/Linux底层都走这套STM32端USB MSC设备类解析后交给SCSI命令处理函数SCSI命令操作的对象是“块设备”也就是按扇区访问的存储介质。这里让FATFS参与进来是因为FATFS会帮我们管理文件系统的目录结构、文件分配表和文件碎片否则你要自己处理这些非常痛苦。关键点在于USB MSC层只认逻辑扇区号LBAFATFS只认物理扇区号也是LBA两者最终统一映射到W25Q64的字节地址上。逻辑扇区n对应的Flash物理地址就是n * 512扇区大小512字节。1.3 容量认知W25Q64的8MB是怎么算出来的W25Q64这个名字里“64”指的是64Mbit不是64MB。换算关系是64Mbit ÷ 8 8MB带Flash的字节容量 8MB × 1024 8192KB 8192 × 1024 8388608字节 以512字节为一个扇区8388608 ÷ 512 16384个扇区计算机对U盘格式化时会把整个设备视为16384个逻辑扇区最后一个逻辑块地址LBA就是16383。这个数字在SCSI命令的READ CAPACITY返回里会用到下面代码部分会详细说明。这个容量做FAT16文件系统刚好合适Windows格式化时会自动选择FAT16FAT32不支持这么小的卷。8MB能存多少东西大约能放2000首中等码率的MP3、几百张相机照片或者塞一个极简Linux内核进去。对演示和轻量存储场景完全够用成本还特别低。如果以后想做大容量把W25Q128换成W25Q64接口和驱动逻辑不变只改容量参数就行。2. 硬件准备与最小系统连接2.1 器件清单与接线映射我用的核心配置是STM32F103C8T6最小系统板“蓝板”加上一个W25Q64贴片转接模块板子自带3.3V稳压。如果你手头只有W25Q64裸片需要自己搭个外围电路但一般用模块更方便。硬件材料清单STM32F103C8T6最小系统板带陶瓷晶振8MHz或者外部晶振可选本项目用内部8MHz倍频到64MHz也够跑USBW25Q64 SPI Flash模块SOIC-8封装自带去耦电容USB连接线要能传输数据不是充电线杜邦线若干面包板用于快速验证W25Q64模块与STM32的SPI1接法固定W25Q64引脚功能STM32引脚CS片选低电平有效PA4CLKSPI时钟PA5MISO主机输入Flash输出PA6MOSI主机输出Flash输入PA7VCC3.3V电源3.3VGND地GNDUSB侧接法USB端引脚功能STM32引脚D-USB差分数据负线PA11DUSB差分数据正线PA12VBUS5V电源检测可选—GND地GND需要特别注意PA11和PA12是STM32内部的USB_DM和USB_DP引脚不能随意复用到别的引脚。虽然有些板子把PA12引到了其他位置但USB外设必须走这两个引脚所以硬件设计时这两个引脚不能做其他功能。2.2 USB部分的硬件细节USB物理层的两个差分信号线D/D-要求阻抗匹配90Ω但99%的DIY玩家不会专门做阻抗控制所以实际接线时保证以下三点基本能稳定枚举D/D-线尽量短并且从主控引脚到USB座之间不要拐弯太多。D线一般要接一个1.5kΩ上拉电阻到3.3VSTM32F103内部会处理上拉F103系列USB外设内置了上拉电阻不需要外部再加。D/D-线不要和SPI时钟线或者电源线并排走太长距离避免串扰。实际调试中如果USB枚举失败最常见的原因不是阻抗而是供电不稳。建议USB的VBUS和GND不要直接连到面包板电源轨从USB座出来的5V经过稳压芯片再给3.3VGND必须共地否则D/D-的参考电平会漂移。调试时推荐用一根带屏蔽的USB线并把杜邦线剪短到10厘米以内。我之前用了一根劣质USB延长线结果在电脑上死活识别不到设备换线后立刻正常——这种问题最容易让人误以为是代码写错了。2.3 硬件层面的几个大坑第一个坑W25Q64的WP和HOLD引脚千万别悬空。模块板上一般会把这两个引脚直接接高电平通过10k上拉到VCC如果是裸片必须手动把这两个引脚接到3.3V否则Flash可能进入保护状态或保持状态SPI操作会失败或锁死。用万用表量一下模块上WP和HOLD是否正常上拉一开始我用裸片焊接时忘了接结果擦除一直失败排查了大半天。第二个坑3.3V供电一定要稳。W25Q64工作电压2.7V到3.6VSTM32F103也是3.3V系统如果供电纹波大Flash读写偶发错误几乎不可避免。建议在VCC和GND之间再加一个10μF电解电容和0.1μF陶瓷电容做去耦AMS1117输出端不要只挂一片电容要大小配合。第三个坑USB供电与Flash供电的时序。STM32启动时需要几毫秒的稳定供电USB枚举是在上电后由PC发出复位信号开始的。如果Flash还没就绪USB就去访问Flash可能会读到乱数据导致设备枚举失败。一个保险的做法是在USB配置完成后再初始化文件系统而SPI Flash的初始化可以放在主循环里提前做但USB的MSC回调如果提前触发不能去访问未初始化的Flash。3. USB MSC协议栈实现细节3.1 USB设备描述符如何把自己“伪妆”成U盘USB世界有一套完整的“自我介绍”机制设备插入后PC会依次问设备各种描述符。作为U盘最关键的是接口描述符Interface Descriptor里的类、子类、协议三位一体设置字段值含义bInterfaceClass0x08Mass Storage Class大容量存储类bInterfaceSubClass0x06SCSI Transparent Command SetbInterfaceProtocol0x50Bulk-Only TransportBOT协议![USB MSC接口描述符示例]这个配置告诉PC这是一个大容量存储设备走SCSI命令用Bulk-Only传输方式。端点描述部分需要提供一对BULK端点IN端点和OUT端点最大包长设为64字节对应代码/* 端点1 IN用于设备→PC的数据传输 */ 0x07, /* bLength */ 0x05, /* bDescriptorType: Endpoint */ 0x81, /* bEndpointAddress: EP1 IN */ 0x02, /* bmAttributes: Bulk */ 0x40, 0x00, /* wMaxPacketSize: 64字节 */ 0x00, /* bInterval: Bulk端点无间隔 */ /* 端点2 OUT用于PC→设备的数据传输 */ 0x07, 0x05, 0x02, /* EP2 OUT */ 0x02, 0x40, 0x00, 0x00,有了这两个BULK端点PC和STM32之间就是简单的“你发我收”模式。对于U盘这种“高速之间还要带静默”的设备BULK端点最合适不像中断端点有轮询间隔限制也不像同步端点对时序敏感。3.2 BOT协议CBW、DATA与CSW的完整交互U盘工作流程不是直接发送数据而是遵循一条标准的“命令-数据-状态”协议叫BOTBulk-Only Transport。这三个阶段对应三个包CBWCommand Block WrapperUSB主机发给设备携带一个特定的命令块包括dCBWTag命令标签、dCBWLength命令长度、bmCBWFlags方向IN/OUT、和最多16字节的SCSI命令内容。DATA阶段根据命令方向传输数据可能是主机→设备写也可能是设备→主机读。CSWCommand Status Wrapper设备执行完命令后返回状态包告知主机命令是成功还是失败。PC的经典操作顺序是发出CBW请求读容量→ 设备返回数据容量信息→ 设备返回CSW命令成功。这个流程在每次文件读取时都会重复发生所以SCSI命令处理的正确性直接决定U盘能否工作。我见过很多人直接在USB中断函数里解析SCSI命令但这样很容易丢数据——因为DPIUSB端点中断会频繁触发处理不及时就会导致BOT超时。建议用一个FIFO缓冲USB端点收上来的数据主循环里从FIFO里取数据做解析和存储操作。3.3 SCSI命令表PC在背后问些什么格式化、复制文件、查看属性每一步操作系统都会发送一串SCSI命令。下面是PC对U盘最常发的命令全部需要正确处理哪怕只缺几个也会导致格式化失败或文件系统异常SCSI操作码命令名称用途关键返回/处理0x00TEST UNIT READY询问设备是否就绪返回状态成功即可0x12INQUIRY询问设备型号返回厂商信息字符串0x25READ CAPACITY (10)询问存储容量返回最后一个LBA和扇区大小0x28READ (10)读取指定扇区读取LBA指向的数据0x2AWRITE (10)写入指定扇区将数据写入LBA指向的介质0x03REQUEST SENSE查询设备错误信息返回Sense Key和数据长度0x1AMODE SENSE (6)读取设备模式参数至少返回一块固定参数0x1BSTART STOP UNIT启动/停止介质旋转对Flash无意义返回成功即可0xE5PREVENT ALLOW MEDIUM REMOVAL禁止/允许移除介质返回成功这些命令里真正需要下功夫的是READ(10)、WRITE(10)和READ CAPACITY(10)其余几个基本可以“敷衍”一下返回成功就行。但MODE SENSE这个命令容易被忽略Windows在格式化前会发MODE SENSE查询可写属性如果返回值错误格式化直接失败。我的做法是返回一页长度0x03的参数头后面参数都置零实测Windows和Linux都能正常格式化。3.4 关键SCSI命令的代码实现下面这段是SCSI层最核心的部分——处理READ CAPACITY(10)和READ/WRITE(10)。完整代码集成到一个scsi_handler()函数根据CBW里的操作码分发。void SCSI_Handler(uint8_t *cmd, uint8_t *buf, uint16_t *data_len) { uint32_t lba, count, i; uint32_t capacity_last_lba 16383; /* 16384个扇区最后一个LBA */ switch (cmd[0]) { case 0x00: /* TEST UNIT READY */ *data_len 0; break; case 0x12: /* INQUIRY */ buf[0] 0x00; buf[1] 0x80; /* 固定媒体 */ buf[2] 0x02; buf[3] 0x02; buf[4] 31; memcpy(buf[8], STM32 U-DISK, 12); /* 厂商信息 */ memcpy(buf[20], W25Q64, 6); /* 产品ID */ *data_len 36; break; case 0x25: /* READ CAPACITY (10) */ buf[0] (capacity_last_lba 24) 0xFF; buf[1] (capacity_last_lba 16) 0xFF; buf[2] (capacity_last_lba 8) 0xFF; buf[3] capacity_last_lba 0xFF; buf[4] 0x00; buf[5] 0x00; buf[6] 0x02; /* 扇区大小 512 */ buf[7] 0x00; *data_len 8; break; case 0x28: /* READ (10) */ lba (cmd[2] 24) | (cmd[3] 16) | (cmd[4] 8) | cmd[5]; count (cmd[7] 8) | cmd[8]; if (count 0) { *data_len 0; break; } for (i 0; i count; i) { disk_read(buf[i * 512], lba i, 1); } *data_len count * 512; break; case 0x2A: /* WRITE (10) */ lba (cmd[2] 24) | (cmd[3] 16) | (cmd[4] 8) | cmd[5]; count (cmd[7] 8) | cmd[8]; if (count 0) { *data_len 0; break; } /* 注意buf此时是PC发来的数据不能只有读还要写回介质 */ for (i 0; i count; i) { disk_write(buf[i * 512], lba i, 1); } *data_len 0; /* WRITE命令通常没有IN方向数据返回 */ break; case 0x1A: /* MODE SENSE (6) */ buf[0] 0x03; buf[1] 0x00; buf[2] 0x00; buf[3] 0x00; *data_len 4; break; default: *data_len 0; break; } }代码里有几个特别容易出错的地方强调一下大小端READ CAPACITY返回的LBA是大端序PC期望最高位在第一个字节。如果你把capacity_last_lba直接赋值要记得手动移位。这里我写的是显式移位可读性更好。WRITE(10)的数据方向PC发WRITE命令时数据阶段是OUT方向也就是说主机把要写入的数据通过BULK OUT端点发下来。SCSI处理函数里接收数据的缓冲区和传给disk_write的缓冲区是同一块。每接收完一个扇区就应该立刻通过disk_write写入Flash不要等整个CBW的count都收完才一次性写——因为Flash写入需要时间攒多了容易超时。READ(10)的数据长度读数据时要保证把所有扇区都通过USB端点依次发送出去不能一次性把大块数据塞进端点缓冲BULK端点最大64字节一包必须按包发。实际代码里我是在for循环里调USB_Send_Bulk()逐扇区发送。3.5 USB回调函数的设置之前提到USB设备类需要注册一批回调函数。在UFI和MSC类驱动里核心回调是这几个事件回调函数要做的事USB_RESETUSB_Event_Reset端点复位状态机复位GET_DESCRIPTORUSB_Event_Get_Descriptor返回设备/配置/字符串描述符SET_CONFIGURATIONUSB_Event_SetConfiguration激活端点DATA_INUSB_Event_DATA_IN数据发送完成准备下一次发送DATA_OUTUSB_Event_DATA_OUT接收完成通知SCSI处理这些回调在USB标准库中断里会频繁触发。项目中有个坑是USB中断优先级比SPI中断高导致在USB中断处理里直接调用disk_write会等Flash擦除完成而擦除一个4KB扇区要几百毫秒直接把USB中断阻塞死。我的解决方案是把SCSI处理移到主循环USB中断只负责把收到的数据/命令包塞到FIFOdisk_write在主循环执行USB中断可以正常响应下一个包。4. SPI Flash底层驱动与FATFS对接4.1 W25Q64驱动要点读、写、擦除W25Q64驱动是整个项目里“手感”最像单片机的地方。它的基本操作是通过SPI发送命令字、地址和数据。Flash内部是按块64KB、扇区4KB、页256B组织的擦除最小单位是扇区4KB写入最小单位是页256B。读取没有单位限制可以按任意长度连续读取。核心驱动接口uint8_t W25Q64_ReadID(void) { uint8_t id; W25Q64_CS_LOW(); SPI_SendByte(0x9F); /* JEDEC ID命令 */ SPI_SendByte(0x00); /* Dummy */ SPI_SendByte(0x00); /* Dummy */ id SPI_SendByte(0xFF); /* 读回第一个字节 */ W25Q64_CS_HIGH(); return id; } void W25Q64_Read(uint32_t addr, uint8_t *buf, uint32_t len) { W25Q64_CS_LOW(); SPI_SendByte(0x03); /* Read Data命令 */ SPI_SendByte((addr 16) 0xFF); SPI_SendByte((addr 8) 0xFF); SPI_SendByte(addr 0xFF); while (len--) { *buf SPI_SendByte(0xFF); } W25Q64_CS_HIGH(); } void W25Q64_WriteEnable(void) { W25Q64_CS_LOW(); SPI_SendByte(0x06); /* Write Enable */ W25Q64_CS_HIGH(); } void W25Q64_PageProgram(uint32_t addr, uint8_t *buf, uint16_t len) { W25Q64_WriteEnable(); W25Q64_CS_LOW(); SPI_SendByte(0x02); /* Page Program */ SPI_SendByte((addr 16) 0xFF); SPI_SendByte((addr 8) 0xFF); SPI_SendByte(addr 0xFF); while (len--) { SPI_SendByte(*buf); } W25Q64_CS_HIGH(); while (W25Q64_ReadStatus() 0x01); /* 等待忙 */ } void W25Q64_SectorErase(uint32_t addr) { W25Q64_WriteEnable(); W25Q64_CS_LOW(); SPI_SendByte(0x20); /* Sector Erase4KB */ SPI_SendByte((addr 16) 0xFF); SPI_SendByte((addr 8) 0xFF); SPI_SendByte(addr 0xFF); W25Q64_CS_HIGH(); while (W25Q64_ReadStatus() 0x01); }写入时有个必须注意的边界问题页编程命令不能跨页写。也就是说Page Program的起始地址加长度如果超过当前256字节页的边界数据会回卷写到页起始处导致数据错乱。所以任何一次写操作如果跨页必须拆成多次Page Program调用。4.2 逻辑扇区与物理地址的映射FATFS和USB层都把存储空间当作“连续的512字节扇区数组”。W25Q64内在组织不是这样它长这样共2048个4KB扇区每个扇区16个512B逻辑扇区。逻辑扇区n对应的物理地址是物理地址 n × 512字节 物理4KB扇区号 n ÷ 16取整 扇区内偏移 n % 160~15比如逻辑扇区0对应物理地址0逻辑扇区17对应物理地址87040x2200。这个映射很简单但写操作要考虑Flash物理擦除特性擦除以4KB为单位写入时如果只改了一个512字节的逻辑扇区不能把整个4KB物理扇区都擦掉重写否则同一物理扇区里的其他15个逻辑扇区数据就没了。常规做法是扇区缓存要写入逻辑扇区n时先读回n所在的整个4KB物理扇区修改其中第n % 16个512字节区段然后擦除这个4KB物理扇区再把修改后的数据整体写回。这样逻辑正确但代价是慢——每写一个逻辑扇区要读4KB擦除4KB写4KBFATFS更新一次FAT表可能要写好几个扇区Windows拷贝小文件时就会变慢。更高效的办法是用disk_write多扇区接口一次传入多个512字节扇区判断是否覆盖了完整物理扇区如果覆盖完整就直接擦旧写新不完整就做“读-改-写”。我实际做的时候做了个简化把disk_write里每次调用的count都拆成单扇区写入用缓存把同物理扇区里的多个逻辑扇区合并。Windows拷贝1MB的随机文件大约需要3秒左右作为低成本方案可以接受。4.3 diskio.c四件套的移植FATFS的底层块设备接口需要实现四个函数disk_status、disk_initialize、disk_read、disk_write、disk_ioctl严格说是五个。这是FATFS和W25Q64之间的桥梁。DSTATUS disk_initialize(BYTE pdrv) { if (W25Q64_ReadID() 0xEF14) /* 0xEF Winbond0x14 W25Q64 */ return 0; /* OK */ return STA_NOINIT; } DSTATUS disk_status(BYTE pdrv) { return 0; } DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { W25Q64_Read(sector * 512, buff, count * 512); return RES_OK; } DRESULT disk_write(BYTE pdrv, const BYTE *buff, LBA_t sector, UINT count) { uint32_t addr sector * 512; while (count--) { /* 写之前确保目标4KB物理扇区可写通常直接擦除 */ /* 实际生产代码需要用扇区缓存做读-改-写这里为简洁只做单扇区擦除写 */ W25Q64_SectorErase(addr ~0xFFF); /* 擦除所在4KB */ W25Q64_PageProgram(addr, (uint8_t *)buff, 256); if (count 0) { buff 256; addr 256; W25Q64_PageProgram(addr, (uint8_t *)buff, 256); buff 256; addr 256; } } return RES_OK; } DRESULT disk_ioctl(BYTE pdrv, BYTE cmd, void *buff) { switch (cmd) { case GET_SECTOR_COUNT: *(LBA_t *)buff 16384; return RES_OK; case GET_SECTOR_SIZE: *(WORD *)buff 512; return RES_OK; case GET_BLOCK_SIZE: *(DWORD *)buff 8; /* 擦除块大小单位扇区4KB8个512B扇区 */ return RES_OK; } return RES_PARERR; }disk_write这个写法是简化版实际工程需要缓存否则在格式化时速度会特别慢。另外注意GET_BLOCK_SIZE返回的是擦除块大小以扇区为单位FATFS会用这个参数优化簇结构W25Q64物理擦除块是4KB即8个扇区所以返回8。4.4 FATFS配置与格式化FATFS本身不需要改动太多但ffconf.h里几个配置项必须打开_USE_MKFS设为1允许格式化。_USE_WRITE设为1允许写入文件。_USE_LFN建议设为2打开长文件名支持Unicode辅助方便Windows下创建中文文件名。_FS_MINIMUM保持0完整功能。_FS_READONLY设0必须可写。_MAX_SS设为512因为扇区大小固定512字节。挂载和格式化的代码在主程序里一般是这样的流程FATFS fs; /* 挂载失败则格式化 */ FRESULT res f_mount(fs, 0:, 1); if (res FR_NO_FILESYSTEM) { MKFS_PARM opt; opt.fmt FM_FAT16; /* 8MB容量可以手动指定FAT16 */ opt.au_size 4096; /* 簇大小4KB和物理擦除块对齐 */ res f_mkfs(0:, opt, 0, 1); if (res ! FR_OK) { /* 格式化失败处理 */ } res f_mount(fs, 0:, 1); }特别注意opt.au_size这个参数。我把簇大小设为4KB因为W25Q64物理擦除块就是4KB。如果簇大小小于物理擦除块FATFS改一个簇就可能擦掉整个4KB物理扇区因为缓存未命中会频繁擦写性能和寿命都会受影响如果簇大小刚好等于物理擦除块写一个簇只需要一次擦除一次写比较划算。8MB卷格式化时选FAT16而不是FAT32因为FAT32从128MB起才有意义8MB用FAT12/FAT16即可Windows格式化时会自动判断但代码里显式指定能让移植到其他系统Linux下的mkfs.vfat时行为一致。5. 实机调试与常见问题排查5.1 我的调试流程拿到一个能跑的虚拟U盘工程我推荐的调试顺序是“先USB后文件系统再读写”每一步都有明确的验证标准第一步USB枚举验证。插上USB线看设备管理器Windows或者lsusbLinux确认出现“USB 大容量存储设备”如果出现“未知设备”或没有任何反应先检查D/D-接线和时钟配置。可以用逻辑分析仪抓USB包但更简单的方法是看ST-LINK串口打印的USB中断触发情况。第二步SCSI命令验证。在SCSI处理函数里加串口打印插上USB后串口应该会刷出一堆INQUIRY、READ CAPACITY、TEST UNIT READY等命令。如果一条命令都收不到说明USB枚举本身有问题先回第一步。第三步Windows磁盘管理验证。右键“此电脑”选择“管理”打开“磁盘管理”。正常情况下能看到一个8MB的磁盘未分配。这里会遇到两种情况如果根本无法初始化多半是READ CAPACITY返回值不对如果能初始化但格式化失败八成是MODE SENSE或WRITE(10)处理有问题。第四步格式化验证。在资源管理器里对磁盘执行格式化选择FAT16。格式化成功说明SCSI读写已基本打通文件系统也已正确写入。格式化完成后实际拷贝一个文本文件进去拔插后确认文件还在。这套流程每一步都能卡住问题比一上来就直接格式化要高效得多。5.2 常见问题速查表我把实际调试中遇到过的问题和排查方向整理成一个表格方便大家对照现象可能原因排查/解决设备插入后毫无反应D/D-接线错误或没有共地检查PA11/PA12接线确认GND已连设备管理器显示“未知设备”USB时钟不对确认STM32时钟配置USB需要48MHz时钟F103必须用PLL输出48MHz给USB外设设备显示“无法识别的USB设备”供电不足或USB线质量差换短线/屏蔽线检查3.3V电压去掉延长线设备能识别但磁盘管理器里磁盘为0MBREAD CAPACITY返回值错误检查容量字节序和大小端格式化到一半失败MODE SENSE返回错误或WRITE(10)数据方向不对检查MODE SENSE返回值确认WRITE时数据是从OUT端点收的写入文件后拔插文件损坏FATFS写缓存未正确刷回写入后立即f_mount卸载或调用f_sync检查disk_write是否真的把数据写进Flash了写文件时电脑卡死擦除时间太长导致USB超时优化disk_write使用扇区缓存减少擦除次数把SCSI处理移出USB中断格式化后容量只剩4MBGET_BLOCK_SIZE或簇配置错误检查disk_ioctl返回格式化时簇大小设为4KB无法格式化提示“请插入磁盘”TEST UNIT READY返回错误确认TEST UNIT READY返回状态成功不要返回media not present这里最花时间的一个问题就是“格式化倒计时到一半失败”最后定位到是MODE SENSE(6)返回的数据长度不对。Windows在格式化前会查询设备写缓存模式我原本只返回了一个0x00系统认为不支持写缓存导致格式化中途放弃。补上3字节参数头后问题解决。这个经验说明BOT协议里的每一个小细节都可能被Windows翻出来宁可多返回一些参数也不要返回空。5.3 排查技巧把SCSI命令打出来看虚拟U盘调试最大的困难是黑盒——你不知道系统到底发了什么命令也不知道自己哪里没返回对。我的习惯是在SCSI处理函数开头加一个串口打印把每次收到的操作码、LBA和长度打出来printf([SCSI] op0x%02X lba%lu cnt%u\r\n, cmd[0], lba, count);串口波特率用115200足够。插上USB后观察串口输出你能清楚看到系统在格式化时做了什么操作。Windows格式化时典型的输出顺序是INQUIRY询问设备信息TEST UNIT READY测试就绪READ CAPACITY(10)询问容量READ(10)多次读取引导区和FAT表WRITE(10)大量写入FAT表和文件数据MODE SENSE(10/6)查询写缓存和介质参数如果发现系统在重复发送READ(10)同一个LBA可能是你返回的数据不对导致系统反复读取失败如果系统频繁轮询TEST UNIT READY说明前一条命令的状态返回有问题设备被认为没就绪。5.4 性能实测与优化方向我用的配置是SPI1主模式时钟分频后SPI速率设为18MHzAPB2为72MHz分频4。实测读取速度约1.2MB/s写入速度约200KB/s到400KB/s这个写入速度明显慢于读取因为写操作背后有擦除动作。提升写入性能的方向有几个扇区缓存在disk_write里维护一个4KB缓存合并多个逻辑扇区再统一写大幅度减少无效擦除。实现后写入速度能提升2倍以上。分频调优SPI时钟调到36MHz分频2读速度能再快一些但要注意Flash支持的极限是104MHzSPI跨线长时高时钟容易出错。FATFS簇大小格式化时指定4KB簇已经是我推荐的最优值不建议设太小。改善擦除策略可以给每个物理4KB扇区加一个“脏标记”只在需要写入时才擦除并重写某些场景下能减少空转。对于8MB的容量来说读速度1.2MB/s已经足够日常文件查看写速度200KB/s拷贝小文件体验尚可拷贝整个镜像文件时明显吃力。但如果作为数据采集器的存储后盾这个速度完全够用。6. 代码工程的文件结构与移植提示6.1 工程文件划分整个项目我习惯按功能拆成独立文件方便调试和移植文件功能main.c主循环初始化USB、SPI、FATFS轮询SCSI处理usb_desc.cUSB设备描述符、配置描述符、字符串描述符usb_msc.cBOT协议底层收发CBW/CSW构造解析usb_scsi.cSCSI命令解析与响应调用disk_read/disk_writew25q64.cW25Q64 SPI驱动读、写、擦除、状态等待diskio.cFATFS底层磁盘接口对接W25Q64ff.c / ff.hFATFS文件系统核心官方源码ffconf.hFATFS配置文件这样的结构在Keil MDK里建立工程很直观。STM32固件库用标准外设库和HAL库都可以USB外围的初始化代码略有差异但SCSI处理这部分与库无关可以跨平台复用。6.2 从F103移植到其他芯片如果换到STM32F4系列USB外设从USB_FS Device变为USB_OTG_FS描述符和BOT协议基本不变但端点的接收发送函数API不同。换到F1系列其他型号如F103RCT6问题不大主要确认有USB外设和足够的Flash容量程序大约占20KB以内任意F103都够。换Flash芯片时比如换成W25Q12816MB需要改两处disk_ioctl的GET_SECTOR_COUNT从16384改为32768READ CAPACITY的最后一个LBA从16383改为32767。只有这两处是容量相关联的其余驱动完全一致。6.3 没有ST-Link调试器的调试思路如果手头只有USB线没有ST-Link或J-Link也可以把虚拟U盘和串口打印结合起来。用USB转TTL模块接PA9(TX)和PA10(RX)直接把SCSI命令打印出来。另外我在调试时发现如果程序里USB配置出错导致枚举不稳定可以先屏蔽SCSI处理里的文件系统挂载只让SCSI返回固定的几个命令结果看USB枚举和容量信息是否正常。如果这一步OK再逐步加入文件系统逻辑隔离问题点。这个思路对没有调试器的同学尤其重要——把问题层层剥开每次只验证一个环节。7. 写在最后的实操心得这个项目最大的价值不是“做出了一个U盘”而是把USB协议栈、块设备驱动、文件系统三套独立的知识体系串在一起。做完一遍你会对电脑和存储设备之间的底层对话有很直观的理解——电脑一操作背后SCSI命令和BOT协议就开始互相交换只是平时完全看不见。我个人实际做的时候最耗时间的不是代码而是USB和Flash之间“数据到了但没写进去”的排查。后来把SCSI处理从USB中断移出来、加了串口打印一下子就把问题锁定了。如果你也在做类似的项目强烈建议第一步就是加日志不要相信“应该没问题”。这个方案还能继续扩展可以给U盘加一个“写保护”开关用一个GPIO控制TEST UNIT READY的返回值实现只读模式或者把W25Q64换成W25Q128容量翻倍文件系统自动扩容再进一步可以加一个小型RTOS在后台同时跑文件记录任务和USB任务真正做到“边采集边导出”。手上没有虚拟U盘工程的可以直接按这个框架搭底层驱动和SCSI处理是通用组件换芯片换板子都能复用。写代码时把USB中断和Flash擦写分开后面会省掉很多头疼的时间。
返回列表