ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32F103通过CH376读取U盘:SPI驱动与FATFS移植实战

2026/9/11 6:23:03 拓冰建站 浏览量
STM32F103通过CH376读取U盘:SPI驱动与FATFS移植实战 简介基于STM32F103的USB读取U盘实战例程包面向嵌入式单片机开发人群适合首次接触USB主机应用或想通过标准库快速搭建外设驱动的学习者。整套例程均经实战验证代码采用KEIL标准库编写在演示U盘读写的同时也包含定时器、Flash、RCC时钟、ADC采样、I2C、CAN、USART等常用外设的驱动参考便于读者对照学习STM32的模块化开发与常见排错思路。压缩包共231个文件大小仅2.86MB其中含56个头文件、51个C源文件和30个汇编启动文件并配有uvproj工程、sct链接脚本、bat编译脚本、hex固件及说明文档文件类型覆盖从源码编写、编译链接到固件烧录的完整环节。代码中已定义各模块接线硬件不同时可根据自身情况调整同系列其他型号芯片仅需修改Keil芯片型号与Flash容量下载时还可按J-Link或ST-Link选择合适的调试配置。已有269人浏览学习。整体结构清晰内容简洁适合作为STM32单片机USB应用与嵌入式入门开发的实践参考资料。1. 在STM32F103上读U盘先绕过USB Host这个深坑很多工程师第一次在F103上做U盘导出数据第一反应是调F103内部USB外设的方向。翻遍参考手册会发现F103的USB是全速Device控制器没有真正的OTG Host能力。真正可靠的落地路线有两种更换带OTG的F105/F107或者保留F103通过SPI/UART控制一颗CH376/CH378这颗USB Host桥接芯片。老工程里“STM32F103实现USB读取U盘程序”绝大多数属于后者。这等于把最麻烦的USB总线分层外包F103专心跑FATFS和业务逻辑。本篇文章按这套常见做法展开。2. USB读取U盘的协议栈USB Host、Mass Storage和FATFS各管一段如果你上手就翻F103的寄存器会陷入枚举、端点、CBW/CSW的泥潭。把U盘读取拆成三条链路会清楚很多。第一条是USB总线层面负责让U盘“被认出”由USB Host控制器和枚举过程完成第二条是Mass Storage类协议负责把“文件读请求”翻译成SCSI指令再经过Bulk-Only传输发到U盘第三条是文件系统负责把扇区拼成文件名、目录和文件内容。STM32F103的算力和内存不足以轻松同时处理三条链路所以例程里通常只让F103处理第三条前两条交给CH376这一类USB Host桥接芯片。2.1 谁真正在“读U盘”F103不是USB HostCH376才是这是很多人理解错的地方。STM32F103的USB外设是Device控制器插到电脑上可以被识别为串口或HID但它缺少Host需要的能力比如产生SOF、分配地址、管理端口状态。所谓“F103读U盘”本质是F103通过SPI接口“指挥”一颗带USB Host功能的芯片这颗芯片直接驱动U盘然后把U盘封装成可读写的块设备。常见做法是使用CH376它会自动完成插入检测、枚举U盘、设置地址和配置成功之后F103发一条“挂载”命令就能拿到U盘容量和扇区大小。选择这种结构最主要的好处是把不稳定的物理层和时序交给专用芯片F103的代码可以保持简单。如果不用外部桥接芯片另一个可行方向是换用STM32F105/F107。它们的USB OTG外设支持Host模式需要配置VBUS、上拉电阻和ID引脚代码复杂度和调试成本会高一个数量级。对于原来已经做好F103主板的项目先加一颗CH376是改动最小、风险最低的方案。2.2 Mass Storage、SCSI和FATFS的职责划分U盘内部只有扇区没有文件名。USB Mass Storage类协议规定了主机和U盘之间传输数据的方式典型的是Bulk-Only Transport。主机发送一个CBW包里面携带SCSI READ(10)或WRITE(10)命令U盘执行完返回CSW状态。这一过程对CH376来说是透明的F103不需要关心CBW怎么组包。CH376提供的接口是“读扇区/写扇区”把扇区地址和缓冲区告诉它即可。FATFS正是建立在扇区读写接口之上它解析U盘的分区表、FAT表、目录表。因此要读取U盘里的文件不需要知道SCSI命令只需要给FATFS提供两个函数读扇区、写扇区。有一点要特别注意U盘返回的扇区大小通常是512字节但也有部分U盘用4KB物理扇区在协议层会屏蔽成512字节逻辑扇区。所以FATFS里_MIN_SS和_MAX_SS都填512不要为了“优化性能”改成4096否则挂载时会报FR_INVALID_PARAMETER。F103的RAM本来就不富裕CH376的数据缓冲一般开2KBFATFS读一个簇时走多次扇区读取即可。2.3 CH376和CH378怎么选很多开发板默认焊CH376因为资料多、例程多。CH376内置USB Host全速收发器能识别常见的U盘、读卡器但面对USB 3.0 U盘或某些主控的老化盘兼容性不够稳定。CH378定位更高一级对U盘主控兼容性更好并且支持更大容量的存储介质代价是价格更高、外围要增加时钟和电源设计。项目CH376CH378典型接口SPI / UART / 并口SPI / UARTUSB速度Full Speed 12Mbps更高速度兼容性更强文件级API支持FAT16/FAT32支持更多文件场景典型成本低中高适用场景F103例程、U盘日志导出产品化、兼容性验证提供例程包里的代码绝大多数围绕CH376展开因为SPI接口只占用F103的4个引脚容易和现有板卡共存。如果项目要求U盘插拔几十次不失败建议直接按CH378设计F103侧的控制逻辑可以复用只需改底层写扇区函数。3. 用STM32F103控制CH376SPI初始化与U盘检测这一章是“嵌入式单片机实战开发例程”里最常见的起点。F103作为SPI主机CH376作为SPI从机。要避免一上来就调文件系统先把SPI链路打通再用CH376自检命令验证通信最后检测U盘插拔。按这个顺序做即使后续出错也能明确故障在硬件还是软件。3.1 STM32F103最小系统上怎么接CH376以STM32F103C8T6为例最小系统只需要外部8MHz晶振、复位电路和BOOT0接地。CH376模块如果是5V供电版本注意SPI引脚电平要兼容3.3V必要时加电平转换3.3V版本则可以直接和F103相连。典型接线如下。F103引脚CH376引脚说明PA4CS片选低有效PA5SCKSPI时钟PA6MISOCH376数据输出到F103PA7MOSIF103数据输出到CH376PA8INT中断输出低电平有效GNDGND共地不要忘记在CH376的VCC与GND之间放一个10uF电解电容和104陶瓷电容。U盘插入瞬间电流可能到500mA如果F103开发板直接从USB口取电容易把电压拉到4.5V以下表现是CH376自检通过但U盘一挂载就失败。这种情况先把U盘识别问题解决再考虑文件系统。3.2 SPI参数和CH376写入命令CH376的SPI从机支持模式0和模式3通常例程使用模式0。F103的SPI1挂在APB2总线上时钟72MHz建议分频到9MHz以内也就是SPI_BaudRatePrescaler_8给CH376留足裕量。如果SPI线过长或杜邦线超过10cm继续降到4.5MHz。下面是标准外设库V3.50版本下的初始化代码注意RCC和GPIO配置缺一不可。void CH376_SPI_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; SPI_InitTypeDef SPI_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_4; // CS GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin GPIO_Pin_8; // INT GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin GPIO_Pin_5 | GPIO_Pin_7; // SCK MOSI GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin GPIO_Pin_6; // MISO GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); SPI_InitStructure.SPI_Direction SPI_Direction_2Lines_FullDuplex; SPI_InitStructure.SPI_Mode SPI_Mode_Master; SPI_InitStructure.SPI_DataSize SPI_DataSize_8b; SPI_InitStructure.SPI_CPOL SPI_CPOL_Low; SPI_InitStructure.SPI_CPHA SPI_CPHA_1Edge; SPI_InitStructure.SPI_NSS SPI_NSS_Soft; SPI_InitStructure.SPI_BaudRatePrescaler SPI_BaudRatePrescaler_8; SPI_InitStructure.SPI_FirstBit SPI_FirstBit_MSB; SPI_Init(SPI1, SPI_InitStructure); SPI_Cmd(SPI1, ENABLE); CS_HIGH(); }这段代码的关键点是PA4做普通推挽输出不要复用成SPI_NSS因为后续CS低电平时间需要自己控制PA8中断上拉CH376有事件时会把中断脚拉低。SPI_FirstBit_MSB对应CH376默认传输格式如果改成LSBCH376返回的数据全部是反位序很多老例程栽在这里。写入命令和数据的接口只需要两条短函数。命令阶段CS拉低发送命令码后释放数据阶段CS拉低连续发送或读取数据。void CH376_WriteCommand(uint8_t cmd) { CS_LOW(); while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) RESET); SPI_I2S_SendData(SPI1, cmd); while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_BSY) SET); CS_HIGH(); } uint8_t CH376_ReadData(void) { uint8_t val; CS_LOW(); while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) RESET); SPI_I2S_SendData(SPI1, 0xFF); while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) RESET); SPI_I2S_GetData(SPI1, val); CS_HIGH(); return val; }读数据时发送空帧0xFF同时接收CH376移位寄存器中的数据。注意BSY标志位要等否则CS释放太早会导致最后一个字节没发完。CH376的命令总线不像普通SPI Flash那么宽松时序不稳时优先检查这条函数。3.3 CH376自检和U盘插入检测SPI初始化完成后第一步执行CH376自检命令0x06。做法是发送0x55如果通信正常CH376会返回0xAA。这个测试不涉及U盘只验证F103和CH376之间的链路。uint8_t CH376_CheckExist(void) { uint16_t ret; CH376_WriteCommand(0x06); CS_LOW(); while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) RESET); SPI_I2S_SendData(SPI1, 0x55); while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) RESET); SPI_I2S_GetData(SPI1, ret); CS_HIGH(); return (ret 0xAA); }能得到0xAA说明SPI读写方向没错。如果返回0x55说明MISO/MOSI接反或CH376没进入SPI模式。接下来设置USB主机模式命令0x15数据0x06然后等待50ms让CH376完成内部复位。CH376_WriteCommand(0x15); CH376_WriteData(0x06); delay_ms(50);设置完成后CH376会不断探测USB端口。检测U盘插入使用0x30命令CH376返回USB_INT_SUCCESS代表U盘已经连接返回USB_INT_DISCONNECT代表没有设备。这个命令需要配合中断引脚使用F103先发命令然后等待PA8变低再读状态寄存器判断结果。uint8_t CH376_DiskConnect(void) { CH376_WriteCommand(0x30); CH376_WaitInterrupt(500); return CH376_GetStatus(); }CH376_GetStatus内部会读取命令0x22获取中断状态码。状态码的枚举定义在例程头文件里不要自己改映射关系。常见状态码有0x14表示连接成功0x16表示断开0x82表示命令错误。把状态码直接通过串口打印出来比看波形直观得多。4. 对接FATFSdiskio层把U盘扇区变成文件CH376本身提供文件级API可以直接建文件、写数据但它的文件系统实现比较老长文件名和子目录支持不完整。工程里更常见的是只用CH376的扇区读写能力上层挂FATFS。这样所有文件逻辑都跑在F103里调试手段也更通用。FATFS版本建议用R0.12以上因为它支持LBA和exFAT基础接口老版本在64GB以上U盘上会出错。4.1 为什么不在CH376里直接建文件CH376的文件级API适合“写一个TXT然后拔盘”的简单场景。一旦需要按日期建目录、覆盖旧日志、断电续写它的文件缓冲和底层状态机就不够灵活。FATFS跑在F103上占用的RAM大约2KB到4KB对ZET6来说完全可接受。F103读取U盘扇区时CH376只是块设备FATFS负责所有文件解析两者边界清晰遇到问题时可以用FATFS提供的FR_*错误码定位。另外FATFS对FAT16、FAT32和exFAT的处理模式统一CH376只关心“读扇区0x55、写扇区0x56”这类指令不参与文件表解析。U盘格式化从FAT32换成NTFS时CH376照样能读扇区但FATFS会因为无法识别文件系统返回FR_NO_FILESYSTEM这个行为比CH376内部的提示更明确。4.2 diskio接口和CH376的对应关系FATFS通过diskio.c调用底层块设备接口只有5个函数最关键。每个函数都要先判断盘号因为FATFS可能挂多个卷。diskio函数作用对应CH376操作disk_initialize初始化设备和确认连接执行0x31挂载获取扇区数disk_status返回设备状态检查CH376中断闭环disk_read读取一个或多个扇区调用0x55扇区读命令disk_write写入一个或多个扇区调用0x56扇区写命令disk_ioctl获取扇区大小、容量等返回固定512字节不要以为CH376挂载成功就万事大吉disk_status每次调用都要重新检查一次连接状态。U盘在写文件中被拔走如果状态仍返回正常FATFS会把错误数据写入缓冲区最终得到的是损坏文件。工程做法是每次写文件前调用f_chdrive之前先做一次连接检测成本不高但能避免很多隐蔽问题。4.3 最小可用的disk_read和disk_write实现先定义CH376的扇区操作API它的输入是起始扇区号、扇区数和数据缓冲区。下面这个disko_io是例程里的简化写法把命令发送和中断等待放在一起。uint8_t CH376_SectorRead(uint32_t sector, uint8_t count, uint8_t *buf) { CH376_WriteCommand(0x55); // DISK_READ CH376_WriteData((uint8_t)(sector 0xFF)); CH376_WriteData((uint8_t)((sector 8) 0xFF)); CH376_WriteData((uint8_t)((sector 16) 0xFF)); CH376_WriteData((uint8_t)((sector 24) 0xFF)); CH376_WriteData(count); if (CH376_WaitInterrupt(1000) ! USB_INT_SUCCESS) return 0; if (count 0) return 1; CH376_ReadUSBData(buf, count * 512); return 1; } uint8_t CH376_SectorWrite(uint32_t sector, uint8_t count, uint8_t *buf) { CH376_WriteCommand(0x56); // DISK_WRITE CH376_WriteData((uint8_t)(sector 0xFF)); CH376_WriteData((uint8_t)((sector 8) 0xFF)); CH376_WriteData((uint8_t)((sector 16) 0xFF)); CH376_WriteData((uint8_t)((sector 24) 0xFF)); CH376_WriteData(count); if (CH376_WaitInterrupt(1000) ! USB_INT_SUCCESS) return 0; CH376_WriteUSBData(buf, count * 512); return CH376_WaitInterrupt(1000) USB_INT_SUCCESS; }命令0x55和0x56的后续流程不同读操作是命令发完后CH376主动上传数据写操作是F103先把数据灌进CH376缓冲区再等待U盘写入完成。如果只发命令不等中断USB启动后CH376会一直卡在状态机里后续所有命令都返回超时。对应的disk_read和disk_write函数就是薄封装。DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { if (pdrv ! 0) return RES_PARERR; if (!CH376_SectorRead((uint32_t)sector, (uint8_t)count, buff)) return RES_ERROR; return RES_OK; } DRESULT disk_write(BYTE pdrv, const BYTE *buff, LBA_t sector, UINT count) { if (pdrv ! 0) return RES_PARERR; if (!CH376_SectorWrite((uint32_t)sector, (uint8_t)count, (uint8_t *)buff)) return RES_ERROR; return RES_OK; }注意LBA_t在64位系统下是32位类型不要强制转成16位。老例程里经常看到uint32_t sector在FATFS R0.15里改成LBA_t更规范。count超过255时要拆成多次调用因为CH376扇区计数字段只有8位。4.4 挂载、建目录和写文件底层就绪后文件操作和SD卡完全一样。挂载时第一参数传空字符串第二参数是立即挂载标志设为1会在f_mount内部自动调用find_volume。文件路径必须带卷号前缀0:否则FATFS会把它当作当前卷。U盘上没有卷号时挂载失败错误码是FR_INVALID_DRIVE。FATFS fs; FIL file; UINT bw; FRESULT fr; fr f_mount(fs, 0:, 1); if (fr FR_OK) { f_mkdir(0:/LOGS); fr f_open(file, 0:/LOGS/TEST.TXT, FA_CREATE_ALWAYS | FA_WRITE); if (fr FR_OK) { f_write(file, usb ok\n, 7, bw); f_close(file); } }f_mount的第二个参数是逻辑驱动器号字符串很多新手写/会挂载失败。挂载成功后打开文件路径分隔符用正斜杠FA_CREATE_ALWAYS会覆盖旧文件如果要做日志追加请用FA_OPEN_APPEND。写文件后必须f_close否则最后一簇数据可能还在FATFS缓冲区里拔盘后文件内容不完整。5. 读写U盘卡住时先用这三步定位CH376U盘方案不难但坑集中在几个点上供电不足、状态码没有打印、U盘格式不兼容。这里给出一套不会浪费太多时间的定位顺序。5.1 用USB转串口把状态码打出来将F103的USART1 TX接USB转串口模块波特率115200在每次CH376命令后把中断状态码打印出来。如果U盘插入后没有任何中断先查USB_DM/USB_DP是否接反CH376的D和D-和U盘座子必须一一对应反了只会在插入瞬间产生毛刺之后CH376永远报USB_INT_DISCONNECT。如果状态码是USB_INT_BUS_RESET说明物理连接正常问题在枚举或SCSI命令阶段这时再检查CH376供电。5.2 USB抓包在什么情况下有用USB抓包分析仪可以串在CH376和U盘之间直接看到枚举过程、CBW包和CSW状态。但这类设备价格不低除非做批量兼容性验证否则不推荐一开始就用。更实用的做法是把逻辑分析仪接在CH376的SPI CS和INT引脚上观察F103发命令到CH376产生中断之间的间隔。正常读一个扇区应该在几毫秒内完成如果看到命令发了但中断迟迟不来基本上是U盘进入异常状态需要重新执行0x31挂载命令恢复链路。5.3 库版本、Boot1和U盘格式老例程大多用标准外设库V3.50如果你的工程是HAL库SPI初始化函数名要对应改写否则编译报错找不到SPI_I2S_SendData。下载例程时关注网上的“STM32F103库V3.50”或“STM32 USB Library V2.2.1”关键词尽量找同一批外围代码混用库版本最容易出现GPIO配置遗漏。调试时还要注意启动模式Boot0接地运行Flash程序如果Boot0和Boot1都拉高而板子上又有DAP下载器会出现STM32F103 DAP下载失败不是U盘代码问题。串口无法打印时先确认USB转串口驱动属于FT231X还是ZTEK力特Windows下安装对应驱动后再看日志。最后把U盘格式化为FAT32分配单元大小保持默认不要用exFAT别问为什么CH376对exFAT的支持真的只适合“看上去能用”。本文还有配套的精品资源点击获取