简介:面向STM32F407嵌入式开发者的USB Device HID Full Speed完整例程,演示了如何利用片上OTG_FS外设构建免驱人机交互设备,工程基于IAR 7.2搭建,适合正在学习USB协议栈或准备实现键鼠类外设的工程师。例程覆盖USB时钟树配置、设备描述符与HID报告描述符定义、端点管理及中断服务处理,并给出从12MHz外部晶振倍频至48MHz USB时钟的完整设置,可直接在Windows/Linux等系统下实现免驱通信。压缩包内共284个文件,核心源码以C文件与头文件为主,分布于标准STM32固件库工程中,同时保留IAR工程配置、调试批处理与编译中间文件,整体约13.53MB,目录结构清晰便于定位代码。目前已有635人学习,开发者可依据工程内的初始化流程、中断回调与描述符模板快速移植到自定义板卡,缩短USB外设开发周期;这些文件还保留了完整的调试信息,适合结合HAL库源码理解USB底层枚举与传输机制。 STM32F407的USB Device HID Full Speed例程,我在项目中实际用过不下十次,这个话题在嵌入式圈子里问的人特别多。很多人拿到F407开发板,照着例程点灯没问题,一到USB就卡住,要么设备管理器死活不认,要么枚举成功但数据收发不对。这篇就把这个例程从原理到实操拆开讲透,包括HID描述符怎么配、端点怎么选、CubeMX怎么设置,以及我这些年踩过的坑和排查思路。适合刚接触USB开发的入门者,也给已经在调USB但被各种玄学问题折磨的朋友一个参考。
1. 项目背景与整体设计思路
1.1 为什么选HID协议和Full Speed
STM32F407自带两个USB OTG外设,一个FS(Full Speed,全速12Mbps),一个HS(High Speed,高速480Mbps),并且HS可以配置为内置USB PHY工作在全速模式,也可以外接ULPI PHY跑高速。这个例程用的是Device模式下的HID类,走Full Speed,也就是常说的USB HID全速设备。
选HID有个很现实的好处:它天生自带驱动。Windows、Linux、macOS对标准HID设备都有系统级支持,只要你把描述符写对,插上电脑就能识别成“HID-compliant device”,不用自己写驱动,也不用安装第三方库。这在实际调试和产品交付阶段能省掉大麻烦。做一个自定义HID设备,端点用中断传输,带宽虽然只有64字节每帧(FS下),但对于键鼠、自定义指令交互、小规模数据上报这类场景完全够用。
这里要特别说明一个常见的理解误区:F407的HS接口同样可以配置成“全速模式”,因为内置PHY只支持FS,想跑HS必须外接ULPI PHY芯片(比如USB3300)。例程里如果用的是HS内核但跑FS模式,本质上是让HS控制器以12Mbps的速率工作,好处是HS控制器有更大的FIFO和更灵活的双缓冲配置,坏处是很多人搞不清楚为什么HS接口枚举出来是全速设备——这是正常的,不是bug。
1.2 开发方案选型:标准库、HAL库与USB中间件
STM32F407的USB开发主要有三条路线:标准外设库+ST官方USB库(旧版USB Library v2.x)、STM32CubeMX+HAL库+USB Device中间件、直接操作寄存器或基于LL库。现在新项目我建议直接用CubeMX+HAL,因为ST新一代的USB Device中间件从2020年前后开始重构,文档和结构比老库清晰很多,而且CubeMX能自动生成描述符框架和端点初始化代码,把最繁琐的部分包掉。
例程里核心的东西就三块:设备描述符、配置描述符(含HID描述符和报告描述符)、端点0的请求处理。老版本USB库是回调函数机制,新版本中间件改成了类驱动结构,HID类相关的实现在usbd_hid.c,接收和发送都围绕中断端点进行。刚开始不用纠结这两个框架的API差异,先理解:枚举阶段是控制端点在工作,传输阶段是中断端点在干活——无论哪个库,这条主线都不会变。
2. 核心细节解析:HID设备工作的底层逻辑
2.1 描述符体系:设备描述符、配置描述符与报告描述符
HID设备除了标准的USB设备/配置/接口/端点描述符之外,还多了一道报告描述符(Report Descriptor)。报告描述符不描述硬件参数,而是描述“数据长什么样、每个字节什么意思”,相当于给主机一份数据格式字典。主机解析完报告描述符,才能知道你的HID设备有几个按键、几个轴、按键位在哪个bit上。
报告描述符的字节流结构很像汇编指令,每一项是前缀 + 用途 + 用法的组合。最基础的鼠标报告描述符大概这样:
0x05, 0x01, // Usage Page (Generic Desktop) 0x09, 0x02, // Usage (Mouse) 0xA1, 0x01, // Collection (Application) 0x09, 0x01, // Usage (Pointer) 0xA1, 0x00, // Collection (Physical) 0x85, 0x01, // Report ID (1) 0x05, 0x09, // Usage Page (Button) 0x19, 0x01, // Usage Minimum (1) 0x29, 0x03, // Usage Maximum (3) 0x15, 0x00, // Logical Minimum (0) 0x25, 0x01, // Logical Maximum (1) 0x95, 0x03, // Report Count (3) 0x75, 0x01, // Report Size (1) 0x81, 0x02, // Input (Data, Variable, Absolute) 0x95, 0x01, // Report Count (1) 0x75, 0x05, // Report Size (5) 0x81, 0x03, // Input (Constant) // padding 0x05, 0x01, // Usage Page (Generic Desktop) 0x09, 0x30, // Usage (X) 0x09, 0x31, // Usage (Y) 0x15, 0x81, // Logical Minimum (-127) 0x25, 0x7F, // Logical Maximum (127) 0x75, 0x08, // Report Size (8) 0x95, 0x02, // Report Count (2) 0x81, 0x06, // Input (Data, Variable, Relative) 0xC0, // End Collection 0xC0 // End Collection这份描述符定义了一个带滚轮的三键鼠标:第一个字节是Report ID=1,第二字节是按键状态,后两个字节是X/Y相对位移。注意这里Report ID不是必须的,但它能让你在复合设备里区分不同接口的数据,建议一开始就加上,后面扩展省事。
很多新手栽在报告描述符上,最常见的问题是“Logical Minimum/Logical Maximum和Report Size不匹配”。比如你想发送一个0-1000范围的数值,Report Size给了8位,那逻辑最大值最多能表达255,主机端解析必然出错。所以务必先算清楚:Report Size * Report Count ≥ 表达范围所需的bit数。
2.2 中断端点与端点0:数据通路的分工逻辑
HID设备在枚举阶段完全依靠端点0,也就是控制端点,所有的描述符请求、配置请求、清除特性请求都走这里。枚举结束之后,HID中断端点开始工作。F407的USB OTG FS设备模式默认用端点1做HID中断IN端点,也可以自己配置,但例程里一般不做改动。
中断传输的机制要理解透:主机按帧周期(FS下每帧1ms)查询中断端点,设备有数据就响应,没数据就NAK。这个查询机制决定了中断传输的实时性上限——FS下中断端点最大包大小是64字节,单次事务最快每1ms一次。换句话说,你的应用无论如何不可能在单端点下突破“每毫秒最多64字节”这个天花板(可以做双缓冲提高效率,但那是另一个话题了)。
发送方向,数据从设备到主机,对应USBD_HID_SendReport或底层HID_Device_Transmit函数;接收方向,数据从主机到设备,对应回调函数。HID的中断OUT端点同样存在,用于主机给设备下发数据(比如键盘灯控制、命令配置)。例程里经常只做了IN方向,把按键状态发给PC,这是最简HID,但产品化的HID设备基本都要同时处理IN和OUT。
2.3 Full Speed和High Speed的选择对描述符的影响
F407的HS接口如果外接USB3300跑High Speed,配置描述符里**必须包含“设备限定符描述符”(Device Qualifier Descriptor)**和“其他速度配置描述符”(Other Speed Configuration Descriptor),并且端点的最大包大小、bInterval都要按照HS的规范填。
如果HS口跑FS模式,事情就简单了——中间件里不需要device qualifier,端点描述符的wMaxPacketSize按64字节以下填。但要注意HS控制器下FS模式的bInterval最小可以设到1,也就是1ms轮询一次,FS规范要求的。这个值设大了,主机轮询间隔拉长,数据传输延迟就跟着上去,做鼠标键盘这类交互设备会感觉“发飘”。
3. 实操过程:CubeMX配置HID全速设备的完整步骤
3.1 CubeMX图形化配置——别漏了这几步
打开STM32CubeMX,选择芯片(我以STM32F407VET6为例),在Connectivity菜单里找到USB_OTG_FS,勾选Device_Only模式。USB_DEVICE里Class for FS IP选Human Interface Device。
然后关键的一步:Parameter Settings里的Device Descriptor配置。这里有两个细节值得注意:VID和PID,例程默认VID是0x1155,PID是0x2232,量产产品最好改成自己申请的VID/PID,调试阶段可以不动;String相关描述符(厂商字符串、产品字符串、序列号)建议都填上,Windows和Linux在设备管理器里显示的名称全靠它们。
时钟树那里,USB必须跑48MHz。F407的USB OTG FS和HS(FS模式)都要求PLL48CLK为48MHz。我见过好多人配置完了不点时钟树,默认时钟下USB枚举随机失败——典型的玄学问题,查了半天最后发现是时钟没配好。CubeMX时钟树界面里把HCLK拉到168MHz后,确认48MHz CLOCK那一项显示为48MHz,然后生成代码。
先直接编译下载,不要改任何代码。此时插上USB线,设备管理器应该已经出现一个HID-compliant device或者USB输入设备。如果这一步就失败,大概率是硬件问题或者时钟配置问题,先解决基础问题再往下写应用。
3.2 例程代码结构与HID收发函数说明
CubeMX生成的项目结构分三层:应用层(usbd_hid_desc.c、usbd_hid.c)、中间件核心(usbd_core.c、usbd_ctlreq.c)、底层驱动(usb_drv.c)。HID相关的主要改三个文件:
| 文件 | 作用 | 需要改什么 |
|---|---|---|
usbd_hid_desc.c | 设备/配置/接口/端点描述符 | 配置描述符里HID描述符部分 |
usbd_hid.c | HID类驱动、报告描述符、收发回调 | 报告描述符数组、收发函数封装 |
usbd_hid_desc.c | HID报告描述符所在 | 按你的业务格式编写 |
HID发送数据,最核心的调用是这个:
uint8_t hid_report_buffer[4] = {0}; // 组装数据,比如一个鼠标:ReportID=1,按键=1,X偏移=5,Y偏移=0 hid_report_buffer[0] = 0x01; hid_report_buffer[1] = 0x01; hid_report_buffer[2] = 0x05; hid_report_buffer[3] = 0x00; // 发送,len不能超过端点最大包大小 USBD_HID_SendReport(&hUsbDeviceFS, hid_report_buffer, 4);接收方向和发送类似,在HID类驱动的HID_DataOut回调(或中间件对应回调)里拿到主机下发数据。注意一个坑:接收回调不是每次主机发数据都会立刻调用,它是按OUT端点事件触发的,因此你的接收缓冲区要有合理的深拷贝处理,不要直接用外部传入的指针做长期保存,因为中间件内部缓冲可能被覆盖。
3.3 动手写一个全速HID鼠标:从描述符到上报
拿一个最常见的需求举例:把开发板做成一个USB鼠标,用按键控制指针移动。CubeMX生成代码后,需要改的地方其实不多。
第一步,改报告描述符数组,把它换成前面我贴过的那份鼠标报告描述符。第二步,在主循环里判断按键是否按下,按下就组一包数据发送:
uint8_t report[4] = {0x01, 0x00, 0x00, 0x00}; while (1) { if (HAL_GPIO_ReadPin(BUTTON_GPIO_Port, BUTTON_Pin) == GPIO_PIN_RESET) { report[1] = 0x01; // 左键按下 report[2] = 10; // X方向移动10个像素 report[3] = 0; // Y方向不移动 USBD_HID_SendReport(&hUsbDeviceFS, report, 4); } else { report[1] = 0x00; report[2] = 0; report[3] = 0; USBD_HID_SendReport(&hUsbDeviceFS, report, 4); } HAL_Delay(10); }这里有个经验:鼠标的X/Y偏移是相对值,每次上报表示“相对于上一次位置移动了多少”,不是绝对坐标。所以系统里鼠标移动的距离取决于你每次上报的偏移量乘以上报频率。10ms上报一次、偏移量10,大致相当于鼠标以中等速度移动。
3.4 全速模式下HID的带宽极限实测与优化方向
我做过一个用F407 HID全速传输数据的项目,实测下来理想上限大概在7.5KB/s到8.5KB/s之间。这个数据比理论值低,原因是协议开销、调度延迟和主机轮询间隔叠加。你把这个例程改成高吞吐应用时,有几个优化方向:
一是把bInterval从默认的10ms改小(比如1ms),减少NAK空转;二是压缩单包数据格式,把标志位全部bit-packed,而不是每个标志占一个字节;三是考虑用High Speed配置(外接PHY),或者改走USB CDC——CDC的批量传输吞吐比中断传输高一个量级。
如果你对HID的实时性不满意,先别急着换协议,先抓包看看实际轮询间隔和NAK情况再决定。后面我会讲怎么抓包分析。
4. 常见问题与排查技巧实录
4.1 枚举失败:设备管理器里“未知设备”或“设备描述符请求失败”
这是问得最多的一个问题。拨出USB线再插入,如果设备管理器直接显示“未知USB设备(设备描述符请求失败)”,先把范围缩小:换USB口、换USB线(质量差的线会明显影响信号完整性)、换个电脑USB口试试。F407的USB DP/DM走线如果太长或者没做等长处理,全速模式下也可能出现枚举失败——全速虽然对PCB要求比高速低,但不是没有要求。
代码层面,先确认PLL48CLK确实是48MHz。HAL库工程里可以这样快速检查:
// 在main函数初始化后读取 uint32_t pll48ck = HAL_RCC_GetPCLK2Freq() / (RCC->CFGR & RCC_CFGR_PPRE1) // 更直接的方式:调试器里查看 RCC->CFGR 和 PLL配置另一种常见原因是电源。F407的USB外设需要3.3V供电,而且USB口的5V经过板载LDO后如果掉压明显,也可能导致枚举失败。检查VBUS检测引脚——OTG模式下OTG_FS_GPIO里的VBUS引脚如果没接或者电平不对,设备层会认为没有连接,枚举自然无从谈起。
如果枚举失败同时伴随USB中断一直触发HAL_HCD_IRQHandler异常,多半是描述符里某个长度字段算错了。重点检查配置描述符总长,Host向设备请求配置描述符时如果长度不匹配,主机端会拒绝加载设备。
排查这类问题,USB抓包分析工具几乎是必选项。我自己常用USBPcap配合Wireshark抓USB包,先看Setup事务里主机发了什么请求、设备回了什么,一眼就能定位问题出在“响应描述符数据错误”还是“设备没有响应”。抓包之前,记得在Windows属性里开启USB pcap驱动服务,手机或电脑上的抓包软件才能正常捕获。
4.2 枚举成功但HID收发不工作
设备管理器里已经有HID设备了,但发送数据主机收不到,或者主机下发的数据设备侧没反应。这个阶段的问题通常不在枚举,而在应用逻辑。
先确认端点方向配置。我在调试阶段习惯在发送函数里加一个IO翻转,比如发送前把PB1拉高,发送完成拉低,用逻辑分析仪看一眼波形,确认发送函数确实被执行了。如果需要抓数据层面,可以借助HID调试软件(像HID Explorer这类)直接读取原始数据包,它比自定义上位机更快定位问题。
接收方向不工作的经典场景是:主机通过HID API下发数据,但设备端OUT端点没有配置,或者接口描述符里没有声明OUT中断端点。标准HID规范中,IN和OUT端点都是可选的,例程默认可能只带IN端点。如果你需要接收,必须在配置描述符里补上OUT中断端点描述符,并在HID类初始化时正确注册。
另一个坑是报告描述符与实际数据长度不一致。比如报告描述符定义的是4字节Input报告,但你在发送时调用函数传了8字节。USB层会按照端点最大包长度发送,但主机解析HID数据依据的是报告描述符定义的长度,多余字节被丢弃或者导致解析错位。这就是为什么每次改报告描述符后,最好打印出来对照检查一遍。
4.3 设备枚举正常,但数据传输偶尔丢包或有较大延迟
丢包最直接的排查方向就是中断端点轮询间隔,也就是bInterval字段。FS中断端点的bInterval单位是1ms,如果描述符里被设置成了10,理论最大吞吐直接下降到原来的十分之一。这个值并不是越大越稳定,按需配置:键盘鼠标这种追求响应速度的,设1;需要稳定但数据量小的,3-4也可以。
HID全速设备丢包还有一种常见情况,是发送频率超过端点实际传输能力。你每5ms发一次64字节,看起来在理论上限内,但如果主机端一直在处理其他USB事务,你的端点实际轮询周期可能被拉长。此时NAK次数会显著变多,表现出来就是偶发延迟甚至丢包。不要盲目加大发送频率,先用USB抓包工具统计实际IN事务间隔和NAK比例,再反过来调发送节奏。
F407的FIFO配置也值得看一眼。USB OTG控制器里每个端点都有独立的发送FIFO和接收FIFO,如果发送FIFO开太小,软件一次SendReport进去的数据量超过FIFO容量,底层会把数据截断或者一直等待有空间。CubeMX的USB配置界面里可以设置端点的FIFO大小,默认值对HID这种小包够用,但如果同时开了CDC或者其他类,可能需要手动重新分配。
4.4 调试HID的惯用工具与快捷验证方法
调试HID设备,我平时的工作流是这三个工具配合:Wireshark抓USB枚举包、HID Explorer看原始报告、一个自写的极简Python脚本做自动化收发测试。
枚举问题用Wireshark,看设备响应描述符的每个字节。如果描述符返回数据在Wireshark里显示为STALL或错误状态,基本可以断定是代码里描述符数组发错了。此时用调试器在USBD_GetDescriptor的回调里打断点,逐字节检查请求类型和返回缓冲区的数据内容。
传输测试用Python做最方便,pyusb一行dev.find()就能枚举到设备:
import usb.core import usb.util dev = usb.core.find(idVendor=0x1155, idProduct=0x2232) if dev is None: raise ValueError("设备未找到") # 发送4字节报告 dev.write(0x01, [0x01, 0x00, 0x00, 0x00], timeout=1000) # 读取设备上报 data = dev.read(0x81, 64, timeout=1000) print(data)需要注意的是HID设备在大多数系统上默认被系统的HID驱动加载,用户态程序直接访问前可能需要释放内核驱动(对应Linux下cfg->set_alt_setting或者Windows下的HidD替换过滤),否则写操作会返回权限错误。
4.5 例程移植到其他STM32系列的核心经验总结
写完F407的例程,你想移植到F103、F429、G474或者其他M核MCU上,改动量其实比想象中小。通用原则:USB中间件换芯片时,只需要改底层usb_drv.c里的寄存器配置部分,中间件层和应用层几乎不用动。CubeMX干的就是这个活——不同芯片重新生成一次底层代码,中间件代码直接用。
真正要改的地方通常就三个:一是端点分配的优先级顺序(不同芯片支持的IN/OUT端点数量不同);二是FIFO总大小(F103是固定1.25KB的专用SRAM,F407是4KB的专用FIFO,G系列各有不同);三是时钟初始化函数不同(USB的48MHz来源和PLL配置关系要重新核对)。
F407到F103有个经典坑:F103的USB外设只有一个,没有OTG的VBUS检测概念,PA12是DP上拉控制脚,很多例程需要自己写USB_DISCONNECT引脚做重新枚举。如果你把F407的例程直接套到F103上,会出现“插上设备管理器识别不了,拔插一下才能识别”的诡异现象——本质就是没有正确处理初次上电的DP上拉时序。
最后分享一个尾注级别的经验:HID例程越简洁越好,能用标准报告描述符解决的,不要自己发明私有的报告格式。系统级驱动对标准的键鼠报告描述符支持最完善,用标准的Generic Desktop/Button/Consumer用法页,能让设备在Linux、Android、MacOS上免驱兼容。私有格式虽自由,但跨平台兼容性你要自己做大量测试和适配,得不偿失。
这个例程跑通之后,扩展方向很多:可以把F407做成组合HID设备(键盘+鼠标+自定义命令),可以通过修改报告描述符上报传感器数据,也可以配合F407的CCMRAM做高频数据采集——我最近就在做F407 DCMI采集图像通过HID上报的试验,测试下来全速带宽有点吃力,但如果控制图像分辨率和帧率,做低速图像协议栈也没问题。先把这个全速HID例程吃透,再做扩展会顺手很多。
本文还有配套的精品资源,点击获取