news 2026/9/11 6:23:00

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 Host,CH376才是

这是很多人理解错的地方。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的数据缓冲一般开2KB,FATFS读一个簇时走多次扇区读取即可。

2.3 CH376和CH378怎么选

很多开发板默认焊CH376,因为资料多、例程多。CH376内置USB Host全速收发器,能识别常见的U盘、读卡器,但面对USB 3.0 U盘或某些主控的老化盘,兼容性不够稳定。CH378定位更高一级,对U盘主控兼容性更好,并且支持更大容量的存储介质,代价是价格更高、外围要增加时钟和电源设计。

项目CH376CH378
典型接口SPI / UART / 并口SPI / UART
USB速度Full Speed 12Mbps更高速度,兼容性更强
文件级API支持FAT16/FAT32支持更多文件场景
典型成本中高
适用场景F103例程、U盘日志导出产品化、兼容性验证

提供例程包里的代码,绝大多数围绕CH376展开,因为SPI接口只占用F103的4个引脚,容易和现有板卡共存。如果项目要求U盘插拔几十次不失败,建议直接按CH378设计,F103侧的控制逻辑可以复用,只需改底层写扇区函数。

3. 用STM32F103控制CH376:SPI初始化与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数据输出到F103
PA7MOSIF103数据输出到CH376
PA8INT中断输出,低电平有效
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默认传输格式,如果改成LSB,CH376返回的数据全部是反位序,很多老例程栽在这里。

写入命令和数据的接口只需要两条短函数。命令阶段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. 对接FATFS:diskio层把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_readdisk_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盘卡住时,先用这三步定位

CH376+U盘方案不难,但坑集中在几个点上:供电不足、状态码没有打印、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的支持真的只适合“看上去能用”。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 6:22:19

2026毕业论文AI工具实测:8款神器从选题到答辩全程攻略

又是一年毕业季,我后台私信里关于“毕业论文怎么写”“查重怎么降”的提问肉眼可见地多了起来。今年和往年最大的不同是,问题里多了一类:“AI工具到底能不能用”“用哪款AI写论文靠谱”“降AI率是怎么回事”。作为从ChatGPT刚火就开始折腾各种…

作者头像 李华
网站建设 2026/9/11 6:22:01

树莓派Pico ADC温度采集实战:从API到定时中断避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 6:17:07

2026年9月西安装修门店AI同城拓客怎么做?获客渠道拆解

2026年9月,西安不少装修门店老板都在问同一句话:“西安装修门店AI同城拓客怎么做?”问的其实不是要不要继续发短视频、要不要继续投本地推广,而是为什么客户在豆包、文心一言、通义千问里问“西安哪家装修公司靠谱”时&#xff0c…

作者头像 李华
网站建设 2026/9/11 6:16:58

JavaWeb教师学生管理系统从零部署与调试指南

简介:本资源是一套完整的JavaWeb期末课程设计项目——教师学生信息管理系统,面向高校计算机类专业本科生,适用于《Java Web程序设计》《网页设计与制作》《Java设计模式》等课程的期末作业或综合实训。系统采用B/S架构,涵盖用户登…

作者头像 李华