
简介一份基于STM32F103的USB读卡器完整工程面向嵌入式开发学习者和电子工程师解决如何通过USB与计算机通信并读写SD卡的问题。工程涵盖SPI/SDIO接口设计、USB Mass Storage类实现、Bulk-Only传输协议处理以及FAT16/FAT32文件系统的移植与操作可帮助读者搭建一套可实际运行的USB读卡器。压缩包共122个文件以54个C源程序和59个头文件为主辅以2个汇编文件、uvprojx工程文件、可烧录hex文件和说明文档整体大小仅422KB便于快速下载与导入开发环境。已有1706人学习浏览适合具备C语言和单片机基础、希望深入理解USB存储设备的开发者参考。通过该工程可掌握STM32CubeMX外设配置、HAL库驱动编写、SD卡初始化流程、命令响应解析及Bulk传输数据调度等实践技巧结合完整代码和清晰目录结构便于二次开发与功能扩展。1. 为什么用STM32F103做USB读卡器一块几十块钱的stm32f103最小系统板一个SD卡槽一根USB线插上电脑就变成一个U盘。这个项目听起来像网上常见的例程但实际上它有明显的分水岭SD卡的SPI读写和FatFs文件系统的移植是任何嵌入式教程都会覆盖的基础内容而要把整个设备变成一个USB Mass Storage Device需要处理设备枚举、端点调度、SCSI命令响应、Bulk-Only传输中的CBW/CSW握手。任何一个字节的时序不对电脑就会报无法识别的USB设备而且调试起来无从下手。这篇内容从硬件引脚规划讲起覆盖USB描述符与SCSI命令分发、SD卡初始化时序与FatFs的对接最后给出用USB抓包和UART日志定位问题的具体方法。适合正在写USB设备固件、想把手头F103开发板变成实用工具的人。读完你不仅能复现一个读卡器还能理解为什么电脑会时不时弹出需要格式化磁盘的提示。2. 硬件接口与引脚规划SPI/SDIO选型与USB全速布线2.1 48脚和100脚的差距SDIO不是你想用就能用STM32F103的SDIO外设支持4位总线模式理论读写速度可以到几MB/s是SPI模式的数倍。但有一个容易被硬件选型忽视的封装限制SDIO在F103上需要PD2CMD、PA8CLK以及PC8~PC11DATA0~DATA3。如果是常见的STM32F103C8T6最小系统板48引脚封装只引出了PC13、PC14、PC15PC8~PC11根本没有物理引脚。也就是说即使芯片内部有SDIO外设也无法在C8T6上接出完整的数据线。这种情况下只能退而求其次使用SPI模式。而100引脚及以上的F103ZET6、F103VET6等型号才有完整SDIO信号引出。如果使用SDIO 4位模式还需要注意复用冲突。SDIO与SPI1、USART3存在引脚复用重叠例如PD2同时是USART3_RTS。如果电路里还挂了CAN总线PA11/PA12以及PD2的分配就要仔细规划。对于大部分自制读卡器和学习项目SPI模式完全够用。USB全速理论带宽只有12Mbps约1.5MB/sSPI模式实测读写速度在1~2MB/s左右正好卡在USB瓶颈上不会成为明显短板。我的建议是除非要做大容量高速U盘否则C8T6SPI是最省事的组合也意味着你不需要为了一个读卡器项目专门换用100脚的大芯片。2.2 SD卡原理图要点电源去耦、上拉和写保护SD卡插座电路本身不复杂但有三个地方容易踩坑这些会在原理图评审阶段被老手盯着看。第一是电源。SD卡在SPI模式下峰值电流可以达到几十毫安如果直接从STM32板载LDO取电而LDO余量不足卡初始化时会因电压跌落而无响应。常见做法是在卡座VCC引脚并联10uF钽电容和0.1uF陶瓷电容并尽量靠近卡座摆放。第二是上拉。SD卡的SPI模式中MISO、MOSI、SCLK和CS引脚一般可以依靠主机推挽IO驱动但加上10k上拉能改善长线下的信号边沿。第三是写保护检测。卡座的SW引脚会随卡侧面的拨片输出高或低电平如果固件忽略这个引脚或者原理图上让它悬空就可能出现SD卡没锁但是写保护这种看似没有逻辑的现象——插卡时弹片接触不良导致电平波动。SW引脚要经10k电阻上拉到3.3V再进MCU做GPIO检测。下面给一个典型的SPI模式连接表这个表直接决定原理图怎么画STM32F103引脚SD卡引脚方向说明PA5 (SPI1_SCK)CLK主机输出SPI时钟初始相位极性为模式0PA6 (SPI1_MISO)DO主机输入卡输出数据配为浮空输入PA7 (SPI1_MOSI)DI主机输出主机输出数据PB0CS主机输出片选低电平有效PA8WP输入写保护检测可选3.3VVCC-加10uF0.1uF去耦GNDGND-共地2.3 USB全速电路与PA11/PA12复用冲突F103的USB全速设备端需要一个1.5kΩ上拉电阻接到D线PA12用来告诉主机这是一个全速设备。很多最小系统板已经内置了这个电阻。如果自己画PCB注意这个电阻要接到3.3V不能接5V否则D电平会超过USB规范范围。PA11是D-PA12是D它们同时承担CAN_RX和CAN_TX的复用功能。网上常被讨论的stm32f103 pa11 bug多半是指把USB和CAN同时配置后PA11上的干扰会导致USB包检测出错。我在一个同时使用CAN和USB的项目里就遇到过两者共同初始化后USB枚举失败率明显上升禁用CAN后恢复正常。所以如果你的工程既要USB读卡器又要CAN通信要么分时使用要么把CAN映射到其他引脚实际上F103的CAN固定映射在PA11/PA12无法重映射只能换芯片或放弃CAN。USB信号的PCB走线也要注意。全速信号虽然只有12MHz但边沿很陡建议D/D-线等长、短而直中间不打过孔不要跨过晶振下方。VBUS感应电压通常通过电阻分压接到MCU的GPIO或ADC用于检测USB线是否插入。这里给出标准库里的USB引脚初始化参考GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); // PA11(D-), PA12(D) 配置为复用推挽输出交给USB外设控制 GPIO_InitStructure.GPIO_Pin GPIO_Pin_11 | GPIO_Pin_12; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_Init(GPIOA, GPIO_InitStructure); // PA8用于VBUS检测输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_8; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(GPIOA, GPIO_InitStructure);这段代码做了两件事先把PA11/PA12配置为复用推挽输出让USB外设直接驱动D/D-再把PA8配成带上拉的输入用于检测VBUS电平。这里的重点是GPIO_Mode_AF_PP如果误配成普通推挽输出USB外设无法控制引脚电平枚举必然失败。PA8同时是MCO引脚如果板子从PA8输出了系统时钟二者会冲突必须改用其他引脚或禁用MCO。3. USB Mass Storage类实现枚举、描述符与Bulk-Only传输3.1 设备类结构接口描述符定义读卡器身份USB读卡器属于Mass Storage类接口描述符中的bInterfaceClass为0x08bInterfaceSubClass为0x06bInterfaceProtocol为0x50。设备描述符中的bDeviceClass通常设置为0让类信息由接口描述符决定。端点配置上除了默认控制端点EP0外还需要Bulk IN和Bulk OUT两个端点。F103的USB外设有8个端点标准库示例普遍使用EP1 IN和EP1 OUT端点最大包长64字节。64字节是F103硬件缓冲区PMA在内存占用和吞吐率之间的一个折中。下面是一份可以直接跑通Windows和Linux MSC驱动的配置描述符数组static uint8_t MSC_ConfigDescriptor[] { 0x09, // bLength 0x02, // bDescriptorType Configuration 0x20, 0x00, // wTotalLength 32 0x01, // bNumInterfaces 1 0x01, // bConfigurationValue 1 0x00, // iConfiguration 0x80, // bmAttributes Bus Powered 0x32, // bMaxPower 100mA // Interface Descriptor 0x09, // bLength 0x04, // bDescriptorType Interface 0x00, // bInterfaceNumber 0x00, // bAlternateSetting 0x02, // bNumEndpoints 0x08, // bInterfaceClass Mass Storage 0x06, // bInterfaceSubClass SCSI transparent 0x50, // bInterfaceProtocol Bulk-Only Transport 0x00, // iInterface // Endpoint Descriptor (Bulk IN) 0x07, 0x05, 0x81, 0x02, 0x40, 0x00, 0x00, // Endpoint Descriptor (Bulk OUT) 0x07, 0x05, 0x01, 0x02, 0x40, 0x00, 0x00 };配置描述符总共32字节其中端点描述符部分0x81是EP1 IN0x01是EP1 OUT0x02表示Bulk传输0x40是端点最大包长64字节。注意F103内部把IN和OUT的同一个端点号视为一个端点主机看到的是地址0x81和0x01两个方向实际占用的PMA缓冲区可按两个64字节块分配。很多移植失败是因为PMA缓冲区首地址设置重叠导致收发的数据互相覆盖。3.2 CBW/CSW状态机的实现方式Bulk-Only传输协议规定主机先通过Bulk OUT端点发送31字节的CBWCommand Block Wrapper包括签名、标记、传输长度、方向、LUN、命令块长度和16字节的SCSI命令。设备执行命令后通过Bulk IN端点返回13字节的CSWCommand Status Wrapper。CSW中的bCSWStatus为0表示成功1表示失败2则表示命令阶段错误。许多初学者写出来的设备能在枚举阶段被识别但插上后Windows一直转圈就是因为设备没有在正确的时机发送CSW。下面是一个精简的SCSI命令分发骨架uint8_t MSC_ProcessCmd(uint8_t *cbw_cb, uint8_t lun) { uint32_t lba, cnt; switch (cbw_cb[0]) { case 0x00: // TEST UNIT READY return 0; case 0x25: // READ CAPACITY buf[0] (g_sd_capacity - 1) 24; buf[1] (g_sd_capacity - 1) 16; buf[2] (g_sd_capacity - 1) 8; buf[3] (g_sd_capacity - 1) 0xFF; buf[4] 0x00; buf[5] 0x00; buf[6] 0x02; buf[7] 0x00; // 块大小512 return 0; case 0x12: // INQUIRY memcpy(buf, STM32 MASS STORAGE , 24); buf[0] 0x00; // 直接访问设备 buf[1] 0x80; // 可移除介质 buf[2] 0x02; // SCSI-2 buf[3] 0x02; // 响应数据格式版本 return 0; case 0x28: // READ(10) lba (cbw_cb[2] 24) | (cbw_cb[3] 16) | (cbw_cb[4] 8) | cbw_cb[5]; cnt (cbw_cb[7] 8) | cbw_cb[8]; return SD_ReadBlocks(lun, lba, buf, cnt); case 0x2A: // WRITE(10) lba (cbw_cb[2] 24) | (cbw_cb[3] 16) | (cbw_cb[4] 8) | cbw_cb[5]; cnt (cbw_cb[7] 8) | cbw_cb[8]; return SD_WriteBlocks(lun, lba, buf, cnt); case 0x03: // REQUEST SENSE buf[0] 0x70; buf[2] 0x00; buf[7] 0x0A; return 0; default: return -1; // 不支持的命令由上层回应STALL } }这里READ(10)/WRITE(10)解析了5字节LBA和2字节扇区数然后直接调用SD卡底层读写函数。REQUESTSENSE被简化成无错误应答这在大多数Windows主机上够用。如果主机发出0x1EPREVENT ALLOW MEDIUM REMOVAL之类的命令也应该返回成功否则可移动磁盘弹不出。注意READ CAPACITY返回的是逻辑块地址最后一个扇区的编号不是扇区总数所以用(g_sd_capacity - 1)。g_sd_capacity来自SD卡CSD寄存器解析单位是扇区。3.3 枚举成功不等于能被识别为磁盘USB枚举成功只说明设备进入了地址状态主机还会发送一系列SCSI命令来确认它是磁盘。如果INQUIRY返回的数据不符合规范或者READ CAPACITY返回的容量小于某个阈值Windows可能把它识别为未知设备或0字节U盘。另一种常见问题是设备没有正确响应CBW导致主机用控制端点发送CLASS请求失败。这时你会看到设备管理器里的错误码43。要定位这类问题最快的办法是用USB抓包分析实际收发内容这一节的具体操作留到第5章。除了正确响应命令设备还必须处理CBW中的dCBWTransferLength。当这个长度与SCSI命令实际数据长度不一致时设备不能简单忽略必须读完或丢弃剩余数据再发送CSW。我的做法是在USB接收中断里维护一个全局剩余字节计数直到计数为0才允许发送CSW。如果你想更快验证协议正确性可以先用ST官方USB库中现成的usb_scsi.c它已经实现了完整的状态机之后按需求裁剪或改写。4. SD卡协议与FatFs文件系统初始化时序、SPI模式读写和FAT32挂载4.1 SD卡初始化流程与时序细节SD卡在SPI模式下初始化有严格时序上电后先发送至少74个时钟周期的高电平让卡完成内部上电过程并进入SPI模式。然后发送CMD00x40卡返回R1响应0x01表示进入空闲状态。接着发送CMD8检测卡是否支持SD v2.0再根据结果发送ACMD41。ACMD41必须先发CMD550x77作为前缀。初始化阶段SPI时钟必须低于400kHz完成后才能提高到高速模式。这里给出一个关键初始化序列static uint8_t SD_Init_SPI(void) { uint8_t resp; uint16_t i; // 发送至少80个时钟脉冲CS保持高 CS_HIGH(); for (i 0; i 10; i) { SPI_SendData(0xFF); // 每个字节产生8个时钟 } CS_LOW(); resp SD_SendCmd0(); // CMD0, 期望R10x01 if (resp ! 0x01) return 0xFF; resp SD_SendCmd8(); // CMD8, 检测SD v2.0 if (resp 0x01) { // 支持SD v2.0置HCS位重试ACMD41 do { resp SD_SendAcmd41(0x40000000); } while (resp ! 0x00); } else if (resp 0x05) { // 仅支持SD v1.x不置HCS do { resp SD_SendAcmd41(0x00000000); } while (resp ! 0x00); } else { return 0xFF; } resp SD_SendCmd58(); // 读OCR if (resp 0x00) { uint32_t ocr SD_ReadOCR(); if (ocr 0x40000000) { g_sd_type SD_TYPE_SDHC; } else { g_sd_type SD_TYPE_SD; } } SD_SendCmd16(512); // 设置块长度 SPI_SetSpeed(SPI_SPEED_HIGH); // 提高时钟 return 0x00; }这段代码有两个容易出错的地方。一是CMD8返回0x05表示0x40命令非法说明卡可能是MMC或老版本SD这时ACMD41不能置HCS位如果强制置位卡永远不会返回0x00。二是CMD58之后紧接着读取OCR的4字节数据必须在同一个片选周期内读完否则卡会结束响应。有些代码直接调用发送命令的函数但忽略后续数据读取导致OCR始终为空进而把SDHC卡识别成普通SD卡。4.2 FatFs在这里的定位裸机自检而不是读卡器固件的必要部分一个常见的认知误区是USB读卡器固件里必须同时有SD卡驱动和FAT文件系统。实际上USB MSC对主机暴露的是一个块设备主机上的操作系统会自己解析SD卡上的MBR和FAT32表设备端不需要也不应该感知文件系统。但我在调试项目时一定会先在STM32裸机上移植FatFs跑一轮文件读写自检。原因很简单把SPI驱动、SD卡协议和FatFs三层绑定在一起测试可以快速隔离问题。如果FatFs能在设备上创建和读取文件说明SD卡底层没问题随后调试USB MSC时出现故障问题就锁定在USB协议层而不是SD卡驱动层。FatFs源码只有ff.c、ff.h、diskio.c、diskio.h四个核心文件。移植需要实现disk_initialize、disk_read、disk_write、disk_status、disk_ioctl和get_fattime。下面是一个disk_read的常见实现DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { UINT i; if (pdrv) return RES_PARERR; // 单块读取方式如果count1建议使用CMD18多块读 for (i 0; i count; i) { if (SD_ReadSingleBlock(sector i, buff (i * 512)) ! 0) { return RES_ERROR; } } return RES_OK; }参数pdrv是物理驱动器编号单卡时固定为0。sector是逻辑块地址FatFs传入的偏移不包含任何分区信息因为读卡器场景下主机把整张卡当作块设备。count是要读的扇区数如果直接使用循环单块读性能会很差。我在自己的驱动里实现了CMD18多块读取一次读取可以连续传输任意块数。这样USB READ(10)按64KB批量传输时底层不会每秒产生上百次命令往返。4.3 与USB MSC对接的缓冲区和扇区映射F103的PMA缓冲区是512字节上限实际对于USB全速是多个端点共享512字节而SD卡一个扇区就是512字节这个巧合让缓冲区设计变得简单。常见的做法是在全局开辟一个512字节的块缓冲区USB外设把数据放到这个缓冲区然后SD卡驱动从同一块内存写出。但要注意如果USB中断正在写入缓冲区的后半部分而SD卡DMA开始读前半部分数据就会错位。这个问题在写入场景尤其明显因为USB接收是流式的SD卡写入是块式的。我的方案是给每个端点分配两个64字节USB缓冲区双缓冲当第一个64字节填满并转入SD卡预写区时USB外设立刻切到第二块64字节继续接收。这样可以连续接收而不丢数据同时避免用大块内存做双份拷贝。如果只是学习验证可以先只使用阻塞式传输每次收到完整512字节再放行USB端点。主机端USB驱动允许设备在写操作时短暂NAK但NAK次数过多会导致写入速度降到100KB/s以下所以双缓冲是必做项。关于写保护在disk_status和SCSI MODE SENSE命令里要正确返回。当SD卡SW引脚检测到写保护时WRITE(10)命令应返回错误或直接STALL。否则主机在写操作成功后会误认为数据已写入但卡实际拒绝写入最终导致文件系统损坏。5. 调试与排错USB抓包、UART日志和常见坑5.1 先隔离问题域UART打印初始化状态调试USB读卡器最怕电脑上完全找不到设备因为你无法只靠USB现象判断是SD卡初始化失败还是USB枚举失败。我的做法是复用USART1用一个FT231X usb uart模块把调试日志打印到电脑固件里每个关键节点都输出一行。例如CMD0_RESP、CMD8_RESP、ACMD41_RETRY、OCR_VALUE、USB_SUSPEND等。插上USB后如果日志里CMD8在0x01和0x00之间反复跳动说明卡座接触不良如果初始化流程根本没开始则说明USB供电或复位有问题。这比接个逻辑分析仪去戳SD卡引脚更高效。5.2 用USBPcap和Wireshark看SCSI流量usb抓包在Windows下的标准做法是安装USBPcap驱动然后在Wireshark里选择\\.\USBPcap1接口。抓包后会看到大段的URB_BULK_IN和URB_BULK_OUT。过滤条件可以直接写usb.transfer_type 0x03再配合usb.bmRequestType筛选MSC类请求。重点是看CBW的前四个字节是否为0x43425355这是USBC的字节序以及CSW的签名0x53425355。如果设备返回了CSW但状态字节为2说明设备驱动遇到了不支持的命令或者命令执行失败。Wireshark能直接解析MSC协议把每个SCSI命令展开成可读字段这个能力比任何printf都有用。5.3 常见坑速查表现象可能原因解决办法电脑提示无法识别的USB设备D上拉电阻缺失或USB时钟不对检查PA12到3.3V的1.5k电阻确认晶振为8MHzSWD下载正常但USB无响应USB时钟未使能或PA11/PA12被复用在RCC中使能USB时钟关闭CAN复用功能SD卡初始化卡死SPI时钟过快或CS未拉低初始化阶段SPI分频128CS低电平保持到命令结束识别后显示0字节磁盘READ CAPACITY扇区数错误检查CSD寄存器解析注意最后一个LBA要减1写入几分钟后文件损坏USB缓冲区与SD缓冲区重叠使用双缓冲避免同一内存被USB和SD同时访问卡没锁却提示写保护卡座SW引脚悬空或抖动SW加上拉电阻并在固件里做10ms软件消抖5.4 用FatFs当体检报告缩小问题范围这里分享一个能明显提高调试效率的技巧在写USB MSC代码之前先让FatFs在SPI上跑一遍完整流程。具体做法是在main里初始化SD卡后调用f_mount挂载然后用f_open创建test.txt并写入一段字符串再读出来比较。如果全部通过就说明SPI时序、SD卡协议、DMA缓存都没问题。之后遇到电脑上无法打开磁盘时可以先在固件里强制创建文件如果文件在PC上可见说明MSC层是通的问题可能出在主机侧驱动如果文件不可见则单独查MSC命令。这样把调试范围缩小一半远比对着抓包数据瞎猜更快。本文还有配套的精品资源点击获取