简介:基于STM32F107的USB主机读写U盘完整工程,面向嵌入式开发者,解决在Cortex-M3平台上通过USB Host模式与U盘交互的需求。工程基于ST官方库与FatFS文件系统,涵盖USB主机初始化、设备枚举、挂载及文件读写等关键环节。压缩包共298个文件,约8.18MB,以h头文件、c源码为主,辅以o目标文件、crf交叉引用及uvprojx工程文件,便于直接编译学习,另有hex可烧录文件与map映射文件辅助调试。内容包含驱动配置、USB主机配置、FatFS移植及读写示例,目录结构清晰,可快速定位模块。已有354人学习下载,适合正在学习STM32 USB主机通信或需要参考FatFS移植方案的开发者。通过分析源码和配置,可掌握USB枚举流程与文件系统调用方法,为后续项目提供可复用的代码基础。
1. 一块开发板、一个U盘,嵌入式读写就这么被卡住了
手头有一块STM32F107的开发板,主控芯片自带USB OTG硬件,当时要在设备端直接插U盘读取配置文件。说起来很简单——U盘插上去,板子把里面的参数读进来。真做起来才发现,USB Host整个链路远比想象的长:枚举、地址分配、Mass Storage类协商、BOT协议传输、CBW/CSW命令块,再到文件系统挂载,任何一环出错都只有一个结果,读写失败。而这块板子之所以选它,正是因为F107内部集成了USB OTG FS控制器,不用外挂USB Host芯片,一个USB口既能做Device又能做Host。这份基于STM32F107的USB Host读写U盘源码包,我把它拆开重新跑了一遍,整个过程走下来,收获很大。它对两类人最有用:一类是刚接触USB Host、想找个完整工程做参考的新手,另一类是做产品选型、需要在F107平台上落地文件读写功能的老手。
2. 技术选型思路:为什么是STM32F107,USB Host整个链路怎么搭
2.1 STM32F107的USB OTG硬件特点与选型逻辑
STM32F107是意法半导体互联型系列的一员,最大的亮点就是内置了USB OTG FS控制器,同时支持Host和Device两种模式。做USB Host读U盘这个场景,F107天然合适,因为它的OTG控制器内部集成了收发器,外部只需要很少的被动元件就能工作。相比之下,如果用不带OTG的F103系列,要么用软件模拟USB Host,稳定性堪忧,要么外挂CH374之类的Host芯片,多一颗物料、多一路调试。F107这个方案把硬件成本压到了最低。
另一个选型理由是F107的主频最高到72MHz,虽然不算高,但应对USB全速12Mbps的传输绰绰有余。U盘读写的瓶颈在USB总线和U盘本身的Flash速度,不在MCU主频。实际跑下来,小文件读写基本感觉不到延迟。当然,F107没有专用的USB HS高速控制器,只能跑Full Speed,大文件读写时会感受到速度上限,但绝大多数嵌入式场景——读配置、存日志、升级固件——这个速率完全够用。
协议栈方面,这份源码用的是ST官方USB OTG库配合FATFS文件系统,整体架构分三层:底层是寄存器操作和中断处理,中间是USB核心枚举和类驱动,上层是FATFS对存储介质的抽象。三层各司其职,后面拆代码的时候会逐层看。
2.2 源码目录结构:每个文件夹里是什么
拿到源码包,我先按目录结构梳理了一遍,这样后续定位问题会快很多:
USB HOST/ ├── Libraries/ │ ├── STM32F10x_StdPeriph_Driver/ # 标准外设库,寄存器封装 │ └── STM32_USB_HOST_Library/ # USB Host协议栈 │ ├── Core/ # USB核心层:枚举、控制传输、HUB │ └── Class/ # 类驱动 │ ├── MSC/ # Mass Storage类,U盘走这个 │ └── HID/ # 人机接口设备类(可选) ├── FATFS/ │ ├── FATFS_TINY/ # 精简版FATFS │ └── FATFS/ # 完整版FATFS ├── Project/ │ └── USB_HOST/ │ ├── inc/ # 应用层头文件 │ └── src/ # 应用层源码 └── Utilities/ └── STM32_EVAL/ # 评估板驱动,含LCD、按键等核心要关注的是STM32_USB_HOST_Library/Core和Class/MSC这两个目录。Core负责USB协议栈最底层的枚举过程,包括设备连接检测、复位、地址分配、配置描述符解析。Class/MSC则是在枚举完成之后,专门处理和U盘之间的命令交互,SCSI指令通过BOT协议封装成CBW包发出去,再收CSW包确认状态。
FATFS被放在了独立目录,没有和USB库混在一起,这个设计很良心。因为FATFS只依赖底层disk_read和disk_write两个函数,只要把这两个函数映射到MSC的读写接口上,文件系统和USB协议栈就被彻底解耦了。后期想换成LittleFS或者自研文件系统,只需要替换上层。
2.3 一个完整的U盘读取链路:从插上到读出第一个字节
我在调试的时候习惯把整个链路在脑子里过一遍,哪一步出了问题就能快速缩小范围。一次完整的读U盘操作,大致走这样一条链路:
U盘插入 → OTG_FS中断触发 → 检测到设备连接 → 复位总线 → 分配地址 → 读取设备描述符 → 读取配置描述符 → 解析出Mass Storage接口 → 发送BOT复位 → 发送GET MAX LUN → 发送SCSI READ CAPACITY → 获取U盘总扇区数 → 调用FATFS f_mount挂载 → 拿到根目录 → f_open打开文件 → f_read读取数据每一步都有对应的代码和寄存器操作。前六步属于Core层,BOT复位和GET MAX LUN属于MSC类驱动,READ CAPACITY之后才算真正进入了SCSI命令阶段,最后FATFS将扇区访问抽象成文件操作。
这个链路里最容易出问题的是枚举阶段。因为枚举是USB协议最复杂的部分,设备描述符、配置描述符、接口描述符、端点描述符,一环扣一环,任何一环解析失败整个枚举就失败了。而这套源码把枚举过程封装成了状态机,放在USBH_Process里,通过轮询方式推进,这在嵌入式里是非常典型的设计。我在实际移植时发现,只要状态机的时钟节拍稳定,枚举成功率就很高。
3. 核心代码拆解:枚举、MSC驱动和FATFS如何对接
3.1 枚举状态机的核心实现
枚举是所有USB通信的前提。设备插上之后,主机需要完成地址分配、读取描述符、选择配置等一串动作,才能和U盘进行真正的数据通信。这份源码把整个枚举过程做成一个状态机,每个状态对应一个枚举步骤:
/* USBH_Process - USB Host枚举状态机 */ static USBH_Status USBH_Process(USB_OTG_CORE_HANDLE *pdev, USBH_HOST *phost) { /* 根据当前状态执行对应操作 */ switch (phost->gState) { case HOST_IDLE: /* 空闲状态,等待设备插入 */ if (USBH_IsDeviceConnected(pdev)) /* 检测到D+线上有设备 */ { phost->gState = HOST_RESET; /* 进入总线复位 */ } break; case HOST_RESET: /* 拉低D+至少10ms,让设备地址复位为0 */ USB_OTG_ResetPort(pdev); phost->gState = HOST_ADDRESS; break; case HOST_ADDRESS: /* 分配地址1,然后读取设备描述符前8字节 */ USBH_SetAddress(pdev, phost, 0x01); phost->gState = HOST_GET_DEV_DESC; break; case HOST_GET_DEV_DESC: /* 完整读取18字节的设备描述符 */ USBH_Get_DevDesc(pdev, phost, 8); phost->gState = HOST_GET_CFG_DESC; break; case HOST_GET_CFG_DESC: /* 读取配置描述符,注意要先读前4字节拿到总长度 */ USBH_Get_CfgDesc(pdev, phost, 4); phost->gState = HOST_SET_CONFIG; break; case HOST_SET_CONFIG: /* 设置配置值,激活设备 */ USBH_SetConfig(pdev, phost, phost->device_prop.configuration); /* 枚举完成,进入类驱动处理阶段 */ phost->gState = HOST_CLASS_REQUEST; break; } }这个状态机的逻辑很直白:每个case做完自己的事,就把gState改成下一个状态,主循环下次进来继续推进。我拆这部分的经验是要注意HOST_GET_DEV_DESC先读了8字节,这是因为设备描述符的前8字节已经包含了bMaxPacketSize0,主机需要它来决定控制传输的最大包长,后续才能真正传完整个描述符。这个细节很容易被忽略,但实际上它对兼容性影响很大。
phost->gState是整个枚举过程的核心变量,调试时打印它就能看到当前卡在哪一步。我试过在传输失败时把gState和USBH_Status一起打出来,能非常快地定位问题出在哪个阶段。
3.2 Mass Storage类驱动:CBW和CSW的收发逻辑
枚举完成之后,U盘的读写就由MSC类驱动接管。U盘遵循Bulk-Only Transport协议,通过批量传输端点收发数据。主机要把SCSI命令封装成CBW包发送给U盘,U盘执行完命令后返回CSW包表示状态。
/* 读取U盘扇区 - 组装SCSI READ_10命令并发送 */ static int8_t USBH_MSC_Read(CORE_HANDLE *pdev, uint8_t lun, uint32_t sector, uint8_t *buf, uint32_t count) { MSC_BOT_CBW cbw; /* 命令块包裹 */ uint32_t timeout; /* 填充CBW包各字段 */ cbw.signature = CBW_SIGNATURE; /* 0x43425355,固定标识 */ cbw.tag = MSC_CB_TAG; /* 命令标签,用来匹配CSW */ cbw.data_len = count * BLOCK_SIZE; /* 数据阶段字节数 */ cbw.flags = 0x80; /* 方向:设备到主机(D2H) */ cbw.lun = lun; /* 逻辑单元号,通常为0 */ cbw.cb_len = 10; /* SCSI命令长度 */ /* 填充SCSI READ_10命令块 */ cbw.cb[0] = 0x28; /* READ_10操作码 */ cbw.cb[2] = (sector >> 24) & 0xFF; /* 起始扇区高字节 */ cbw.cb[3] = (sector >> 16) & 0xFF; cbw.cb[4] = (sector >> 8) & 0xFF; cbw.cb[5] = sector & 0xFF; cbw.cb[8] = (count >> 8) & 0xFF; /* 传输扇区数 */ cbw.cb[9] = count & 0xFF; /* 发送CBW包到OUT端点 */ USBH_MSC_SendCBW(pdev, &cbw); /* 等待数据阶段完成,读取指定长度数据 */ timeout = MSC_MAX_RETRY; while (timeout--) { /* 轮询OUT端点,接收U盘返回的扇区数据 */ if (USBH_MSC_ReadData(pdev, buf, count * BLOCK_SIZE) == USBH_OK) { break; } } /* 接收CSW包,确认U盘执行成功 */ timeout = MSC_MAX_RETRY; while (timeout--) { if (USBH_MSC_GetCSW(pdev, &csw) == USBH_OK) { /* 检查CSW状态字段是否为成功 */ if (csw.status == 0) { return 0; } } } return -1; }这段代码的关键在两处。一是cbw.signature必须是固定的0x43425355,这是USB组织规定的BOT协议签名,任何偏离都会导致U盘直接拒绝执行命令。我在这上面栽过跟头,当时用了大小端不同的赋值方式,结果U盘完全无响应,排查了半天才发现签名反了。
二是cbw.flags的方向位,0x80表示设备到主机,对应读操作;如果做写操作要改成0x00。这个位如果设置反了,U盘返回的CSW状态会一直报错。在这个函数里,读扇区命令的整个生命周期是:发送CBW包 → 等待数据阶段完成 → 接收CSW包确认。三者缺一不可。
3.3 FATFS对接层:让文件系统跑在USB驱动之上
有了扇区读写的能力,接下来就是FATFS的对接。FATFS本身和存储介质完全无关,它只定义了底层接口函数,只要实现这几个函数,文件系统就能工作了。
/* FATFS底层接口 - 对接USB MSC扇区读写 */ /* 读扇区接口 */ DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { /* pdrv是物理驱动器编号,单U盘场景固定为0 */ if (pdrv != 0) return RES_PARERR; /* 调用MSC层读函数,把FATFS的扇区请求直接转成SCSI命令 */ if (USBH_MSC_Read(&USB_OTG_Core, 0, sector, buff, count) == 0) { return RES_OK; } return RES_ERROR; } /* 写扇区接口 */ DRESULT disk_write(BYTE pdrv, const BYTE *buff, LBA_t sector, UINT count) { if (pdrv != 0) return RES_PARERR; /* 调用MSC层写函数 */ if (USBH_MSC_Write(&USB_OTG_Core, 0, sector, (BYTE *)buff, count) == 0) { return RES_OK; } return RES_ERROR; } /* 磁盘状态查询 */ DRESULT disk_status(BYTE pdrv) { /* 检查USB连接状态和MSC类设备是否就绪 */ if (USBH_MSC_IsReady(&USB_OTG_Core, 0)) { return RES_OK; } return RES_NOTRDY; } /* 磁盘初始化 - 在f_mount时被调用 */ DRESULT disk_initialize(BYTE pdrv) { /* 等待USB枚举和MSC就绪完成 */ while (USBH_MSC_IsReady(&USB_OTG_Core, 0) != 0) { /* 这里可以加超时机制,避免死循环 */ } return RES_OK; }FATFS的对接接口不多,核心就是上面的读写和状态查询。disk_read和disk_write里的LBA_t sector参数要特别说明——在旧版FATFS里这是一个32位无符号整数,直接对应U盘的逻辑块地址。U盘扇区大小一般都是512字节,但有些U盘会报告4096字节的物理扇区,如果FATFS编译选项里的扇区大小宏没配对,文件系统会挂载失败。
我一般会在disk_read里加打印,把第一批读到的数据打出来看一下。如果能读到EB 3C 90开头的引导扇区数据,说明MSC通路完全没问题,问题基本就在FATFS配置。反过来如果扇区读出来全是0xFF或者0x00,那大概率USB传输层就出了问题,跟文件系统无关了。
4. 工程落地:从新建项目到实际跑通读写U盘
4.1 搭建编译环境与工程目录
这份源码是基于ST标准外设库的。用Keil MDK可以直接打开Project/USB_HOST下的工程文件,编译工具链版本建议用5.x,我实测过4.74以下的旧版本对标准外设库支持不完整,编译会报一些奇怪的错。
# 编译前的三个确认项: # 1. 芯片型号选择 STM32F107VC # 2. C/C++宏定义里加上 USE_STDPERIPH_DRIVER、STM32F10X_CL # 3. FATFS版本选择完整版(带长文件名支持),必须打开 FF_USE_LFN第二个宏STM32F10X_CL特别关键,F107属于Connectivity Line,和F103的外设定义不完全一致。如果没加这个宏,外设库的寄存器映射会错乱,编译能过但跑起来时钟全是乱的。
4.2 时钟配置:一切USB问题的根源
USB Host要正常工作,USB模块必须有48MHz的时钟。F107没有专用的48MHz晶振引脚,这个48MHz是内部PLL分频出来的。在SystemInit里要确保PLL配置正确:
/* F107时钟树配置 - 系统时钟72MHz,USB 48MHz */ /* 外部晶振25MHz,PLL倍频x9 = 72MHz */ RCC_PLLConfig(RCC_PLLSource_PREDIV1, RCC_PLLMul_9); /* 分频器配置:48MHz USB时钟 = PLL时钟(72MHz) / PLLUSB_DIV2 */ RCC_USBCLKConfig(RCC_PLLCLK_Div2); /* 打开USB OTG时钟和GPIO时钟 */ RCC_AHBPeriphClockCmd(RCC_AHBPeriph_OTG_FS, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE);RCC_PLLConfig的第一个参数是分频链选择,F107支持外部晶振先经过预分频再进入PLL。这个预分频值要和外部晶振频率匹配,如果板子上是8MHz晶振,预分频要设1,倍频设9;如果是25MHz晶振,预分频设5,倍频仍设9。配错之后最常见的情况是USB枚举时设备描述符读回来全是乱码。
USB时钟频率严格要48MHz,偏差超过1%就可能导致枚举不稳定。我在调试时用定时器引脚输出了PLL时钟的测量频率,实测48.03MHz,属于正常范围。如果偏差太大,要检查是不是晶振负载电容选得不对,这不是代码问题,是硬件问题。
4.3 首次跑通:调试步骤与预期现象
工程编译通过之后,烧录前先做一次硬件检查。USB Host模式下,开发板的USB口要能对外提供5V电源,U盘插入后D+线上会有一个上拉。然后用调试器全速运行,打开USB Host库里的调试宏,会在串口打印出枚举过程:
/* 使能调试打印,观察枚举过程 */ #define USBH_DEBUG_ENABLE /* 预期串口输出顺序: USB Host Library Started Device Connected Enumeration Done Mass Storage Device Ready Sector Size: 512 Total Space: 7825918 KB */看到Enumeration Done就说明枚举阶段完成了,后面Mass Storage Device Ready是MSC类驱动完成了BOT复位和GET MAX LUN。到了这一步,U盘已经在逻辑上挂载到系统里了。
接下来用FATFS挂载文件系统,在USBH_USR_Application里加测试代码:
/* 应用层测试代码 - 挂载U盘并读取根目录 */ FATFS fs; DIR dir; FILINFO fno; FRESULT res; /* 挂载文件系统,立刻挂载 */ res = f_mount(&fs, "0:", 1); if (res == FR_OK) { /* 打开根目录 */ if (f_opendir(&dir, "0:/") == FR_OK) { /* 遍历打印所有文件名 */ while (f_readdir(&dir, &fno) == FR_OK && fno.fname[0]) { printf("%s %lu\r\n", fno.fname, fno.fsize); } } }这里有个容易踩的小坑:f_mount的第三个参数1表示立即挂载,FATFS会在挂载时调用disk_read读取引导扇区。如果挂载返回FR_NOT_READY而不是FR_OK,先检查disk_status是否返回了就绪状态。
首次跑通之后,读写文件的操作就很简单了:f_open打开文件,f_read读数据,f_write写数据,f_close关闭文件。整个链路从FATFS到MSC到USB驱动,最后经过OTG控制器打到U盘上。
5. USB Host避坑指南:这些问题的排查记录
5.1 现象:U盘插上无反应,枚举失败
根源在于D+上拉电阻。USB设备靠D+线上的上拉电阻告知主机有设备插入。F107的OTG控制器内部虽然有上拉,但有些板子没有引出来,或者电阻值不对。我当时这个板子的D+上拉电阻是悬空的,加了一颗1.5kΩ电阻到3.3V才解决。排查方法很简单,插上U盘后用万用表量D+电平,如果枚举时D+没有任何电平跳变,先查上拉电阻。另外,部分U盘枚举失败是枚举超时导致的,可以把USBH_Process的轮询周期调快一点,让它别等到超时才开始处理。
5.2 现象:小文件读写正常,大文件读写到一半卡死或报错
根源是USB电源带载能力不足。U盘读写的瞬时电流波动远大于枚举阶段,如果板子的5V电源是从USB转串口模块取的,负载稍微大一点电压就会跌落,导致U盘内部Flash读写出错甚至掉线。解决方法是给U盘供电加一颗220μF的电解电容,并在USB口的VBUS和GND之间并联一颗0.1μF的陶瓷电容,滤掉高频噪声。实测加上电容之后,连续读写几百兆字节的大文件没有再出过问题。
5.3 现象:FATFS挂载失败,返回FR_NOT_READY
根源是扇区大小不匹配。FATFS默认假设扇区大小是512字节,但部分U盘报告的逻辑扇区大小可能是1024或4096。排查方式是在disk_read里打断点,看FATFS发出的读取请求是多大,再对比U盘通过READ CAPACITY返回的扇区大小。解决方法是修改FATFS配置头里的FF_MIN_SS和FF_MAX_SS宏,把扇区范围放宽到512到4096,然后实现disk_ioctl里的GET_SECTOR_SIZE命令,返回实际扇区大小。
5.4 现象:开发板死活枚举不到U盘,但把U盘插电脑上完全正常
根源是调试器的D+/D-接线干扰。有些调试器(比如J-Link的某些版本)在连接目标板时,会在D+/D-线上引入额外的电容负载,导致USB信号质量下降。排查方法是把调试器断开,改用串口打印日志来观察枚举过程。或者先插U盘再上电,避开工装带来的时序影响。这个问题属于硬件调试环境的坑,跟代码关系不大,但花了我不少时间才定位到。
5.5 现象:第一次读写正常,热插拔之后设备无响应
根源是软件复位流程不完整。USB Host需要在设备断开后正确清理状态,否则新插入的设备会按照旧的状态继续跑。解决方法是实现完整的断开回调,在USBH_USR_DeviceDisconnected里重置MSC状态机、FATFS卸载文件系统,然后重新初始化内核状态机。我现在的做法是设备断开后延迟500ms再强制走一次USBH_Init,实测热插拔几十次都没出问题。
6. 改造思路:从固定读U盘到灵活的文件系统操作
6.1 把读写逻辑抽象成独立模块,方便业务调用
源码包里最需要改造的地方是应用层。原始的USBH_USR_Application把测试逻辑写在了回调函数里,二开时要把文件操作封装成接口。我的做法是新建一个usb_storage.c模块:
/* 封装文件操作接口,业务代码只需要依赖这个模块 */ int storage_read_file(const char *path, uint8_t *buf, uint32_t size); int storage_write_file(const char *path, const uint8_t *buf, uint32_t size); int storage_delete_file(const char *path);每个函数内部做三件事:检查MSC设备就绪状态 → 挂载文件系统 → 执行文件操作。业务模块不再需要关心USB协议栈的细节。封装之后,读写U盘文件就像调用一个普通库函数一样简单,真正做到了业务和驱动的解耦。
6.2 改造案例:把U盘升级固件的流程做成BootLoader + APP双区方案
实际产品里最常用的改造方向是U盘升级固件。思路是把Flash分成BootLoader区和APP区,BootLoader负责检测U盘是否有升级文件,有则写入APP区,然后跳转执行。关键代码是跳转前要确保USB Host和FATFS正确关闭:
/* BootLoader检测到升级文件后,写入APP区并跳转 */ void jump_to_app(uint32_t app_addr) { /* 1. 关闭USB Host,释放中断和DMA通道 */ USBH_DeInit(&USB_OTG_Core); /* 2. 关闭全局中断 */ __disable_irq(); /* 3. 检查APP区首字是否为合法栈顶 */ if (*(volatile uint32_t *)app_addr & 0xFFF00000 == 0x20000000) { /* 设置MSP为APP的栈顶地址 */ __set_MSP(*(volatile uint32_t *)app_addr); /* 取出APP的复位向量并跳转 */ void (*app_reset)(void) = *(void **)(app_addr + 4); app_reset(); } }这个改造的验证方法很直观:把固件写到U盘里,插上U盘,板子上电后BootLoader自动检测到文件并开始升级,升级完成后自动跳转到新固件。我最初做这个功能时犯过一个低级错误——跳转之前忘了关串口中断,进了APP之后串口中断还在跑,导致设备直接硬件错误。从那以后我每次跳转都强制走一遍完整的设备反初始化流程,从USB到外设中断全部关干净,确认无误再跳。这个习惯帮我避免了好几次现场升级翻车的情况。
希望这篇拆解对你有帮助。
本文还有配套的精品资源,点击获取