1. 为什么是Custom HID:免驱不是唯一理由
做嵌入式USB设备的开发者,大概率都经历过这样一个过程:先玩一阵子CDC虚拟串口,用串口助手调数据,等产品要交付了才发现——用户电脑上插上设备没反应,原因是没装串口驱动,或者系统给设备分配的COM口对不上。然后你开始研究怎么免驱,搜了一圈发现HID类设备是最稳的选择,于是走进Custom HID这条路。
我最初接触Custom HID也是因为一个实际需求:做一个自定义按键面板,十几个按键加一个旋钮,要从PC上位机下发背光亮度、按键映射,还需要把旋钮的增量值实时上报。用串口方案其实能做,但客户环境是几百台办公电脑,IT部门不允许装驱动,串口方案直接被毙了。评估了几类USB类后,选了Custom HID。
这里顺手做个对比,帮助还没入坑的朋友理解路线差异:
| 对比项 | CDC虚拟串口 | 标准HID(键盘/鼠标) | Custom HID |
|---|---|---|---|
| 驱动依赖 | 多数系统自带,但部分环境受限 | 系统级免驱 | 系统级免驱 |
| 传输方式 | 批量传输,吞吐量大 | 中断传输,包小 | 中断传输,包小 |
| 双向通信 | 原生支持 | 一般只做单向输入 | 原生支持 |
| 实时性 | 一般 | 高 | 高 |
| 开发门槛 | 低 | 简单但约束多 | 中等,卡点在描述符 |
| 扩展性 | 一般 | 受HID用法页限制 | 自定义能力最强 |
Custom HID的核心价值不光是免驱。它走的是中断传输,时延低且带宽有保证,非常适合小数据量、高实时性的交互场景。键盘、鼠标这类标准HID设备虽然也免驱,但报告格式和用法被协议死死框住——你没法随便往报告里塞自己定义的字段,除非做一些“借用”操作,而且这种借用很容易产生兼容性问题。Custom HID用自己的Usage Page描述数据格式,主机只需要把它当HID设备加载,如何解释数据由你定。
所以这个方案的定位很明确:上位机由自己开发、通信数据量不大、要求低时延、需要系统级免驱的产品交互场景。工控面板、宏键盘、采集卡控制、医疗设备操作终端之类,都是典型适用场景。
2. CubeMX阶段最容易配错的三处地方
很多人觉得CubeMX图形化配置就是点点鼠标的事,结果卡了很久,问题往往不在代码而在前期的工程配置。我以STM32F103系列为例,把容易踩的点一个一个讲清楚。
2.1 时钟树:48MHz USB时钟不是自动配好的
STM32的USB全速外设要求48MHz时钟。F103的USB时钟来自PLL的Q分频输出,很多人生成工程后直接烧录,设备插上PC一点反应都没有,查了半天发现USB时钟源没配。
CubeMX里默认时钟树很可能是系统主频72MHz、USB关闭。正确的操作路径是:
- 在RCC设置里把HSE打开,设为Crystal/Ceramic Resonator。
- 进入Clock Configuration页面,配置PLL使系统时钟跑到72MHz。
- 在页面下方找到USB那一栏,把PLLQ或对应分频值配成48MHz。
F103的USB时钟必须严格是48MHz,偏差太大会导致枚举失败。有一种很隐蔽的情况是工程里开了LSE/RTC相关功能,时钟树优先级变化,USB时钟被连带改掉,这时候要回头检查USB时钟源是否还是48MHz。
2.2 USB设备库与类选择
进入Connectivity选项卡,打开USB_OTG_FS或USB Peripherals里的Device模式,然后去Middleware and Software Packs选择USB_DEVICE。这个环节有一个很关键的选项:Class for FS IP,里面有HID、CDC、Custom HID等选项,必须选Custom HID Class,而不是HID。
两个选项的区别在于:标准HID类生成的是固定报告描述符,一般是一个键盘或鼠标的报表结构;Custom HID类生成的是用户自定义的报表结构,端点配置也允许双向。选错的话后面改代码的工程量会翻倍,不如在这里就选对。
还有一个小细节:不同版本的CubeMX/F1固件包里,Custom HID选项的位置可能有差异,老版本可能在USB_DEVICE中间件配置页面里叫“Custom HID Class”,新版本统一归到Class for FS IP下拉菜单。如果找不到,检查一下固件包有没有装全。
2.3 描述符长度和端点的联动关系
CubeMX生成Custom HID工程时,默认的报告描述符会被编译器通过链接脚本嵌入到USB配置描述符里。默认报告描述符长度是34字节左右,端点配置里IN端点的最大包长默认是16字节,Buffer数组也是16字节。
如果你要改成64字节双向通信,需要动三个地方,而且必须同步改:
- 报告描述符里的Report Count要改成64。
- USBD_CUSTOM_HID_ReportDesc数组要改成你自定义的描述符字节序列。
- usbd_custom_hid_if.c里的USBD_CUSTOM_HID_OutReport_Buffer数组和USBD_CUSTOM_HID_OutBufferSize,以及端点描述符里的wMaxPacketSize,都要改成64。
端点和Buffer之间的联动关系是排查问题时最容易忽视的。曾经我同事A接手一个项目,改大了描述符但忘了改Buffer长度,数据上报正常,接收命令总是截断。排查了一下午,最后发现是Out Buffer还停留在16字节。这个坑在后面第4章详细说。
3. 报告描述符逐行拆解:设备与主机之间的“说明书”
HID报告描述符是Custom HID开发中最容易卡住的地方。它的本质是一份用USB HID协议语法写的数据说明书,告诉操作系统“这个设备会发什么格式的数据、能接收什么格式的数据、数据的取值范围是多少”。主机不需要理解你的业务逻辑,只要按这个说明书把数据包正确地送进驱动框架就行。
3.1 一个可用的双向64字节描述符
这里给一份我常用的双向64字节描述符,可以直接放到工程里:
const uint8_t USBD_CUSTOM_HID_ReportDesc[] = { 0x06, 0x00, 0xFF, // Usage Page (Vendor Defined Page 1) 0x09, 0x01, // Usage (Vendor Usage 1) 0xA1, 0x01, // Collection (Application) 0x85, 0x01, // Report ID (1) 0x09, 0x01, // Usage (Vendor Usage 1) 0x15, 0x00, // Logical Minimum (0) 0x26, 0xFF, 0x00, // Logical Maximum (255) 0x75, 0x08, // Report Size (8 bits) 0x95, 0x40, // Report Count (64) 0x81, 0x02, // Input (Data, Var, Abs) 0x09, 0x01, // Usage (Vendor Usage 1) 0x15, 0x00, // Logical Minimum (0) 0x26, 0xFF, 0x00, // Logical Maximum (255) 0x75, 0x08, // Report Size (8 bits) 0x95, 0x40, // Report Count (64) 0x91, 0x02, // Output (Data, Var, Abs) 0xC0 // End Collection };这份描述符的含义拆开看就几条:
- Usage Page选Vendor Defined Page 1,意思是数据格式是厂商自定义的,操作系统不做协议层解释。
- Collection Application表示这是应用集合,里面可以定义多个报告。
- Report ID设为1,让主机用这个ID来区分是哪个报告。
- 先用Report Size 8把64个字节定义成64个8位字段,Input表示设备到主机,Output表示主机到设备。
两份报告,一份Input,一份Output,各64字节,正好对应双向通信的需求。
3.2 为什么推荐Vendor Defined页
这里有个值得啰嗦的点。你完全可以在描述符里复用Generic Desktop页,甚至把每个字节定义为坐标轴、按钮等,但这种做法存在两个隐患:一是语义被系统解释成标准控件,可能出现系统层面的额外处理,比如某些平台会把你的数据当鼠标位移;二是当你想扩字段时,标准用法页很难定义“这个字节代表背光亮度”这种业务含义。
用Vendor Defined页的好处是把语义完全留给自己。上位机通过hidapi或Windows的HidD接口读取的是原始字节,怎么解释是应用层的事。这种方式最纯粹,也最不容易被系统“好心办坏事”。
3.3 描述符改错会有什么表现
描述符里一个数值写错,在现象上往往非常隐晦:
- Report Count与Buffer长度不匹配:主机收到的数据包长度对不上,出现截断、多余字节或读取失败。
- Logical Maximum设置过小:比如设成127而不是255,那你发0x80以上的字节时,系统上报的值可能被符号扩展成负数,收到的数据完全错乱。
- Input/Output顺序颠倒:主机端可能找不到输出报告,或者写入操作报参数错误。
- 遗漏End Collection:设备枚举阶段会直接失败,设备管理器报“描述符请求失败”。
如果你改完描述符出现这些现象,不要急着查业务代码,先把描述符逐字节对着协议说明走一遍,90%的问题都在这里。
4. HAL库数据通路:发送与接收的完整链路
描述符搞定,设备能被电脑识别,接下来就要把业务数据跑通了。HAL库把USB数据通路封装得比较友好,但封装带来的问题是什么都看不清楚,一旦出问题很难定位到具体环节。
4.1 上报数据:USBD_CUSTOM_HID_SendReport
设备主动往主机发数据,调用的是这个函数。它的原型大致是:
uint8_t USBD_CUSTOM_HID_SendReport(USBD_HandleTypeDef *pdev, uint8_t *report, uint16_t len);调用时机要看你业务。我做的按键面板是在检测到按键状态变化时上报,同时还有个定时器每20毫秒上报一次旋钮增量值。这里要记住:中断传输是有报告间隔限制的,枚举时配置的bInterval决定主机轮询间隔,F103的USB全速设备最小间隔是1毫秒。也就是说,你想要更高的上报频率也做不到,协议层面就限死了。
上报函数的调用要注意两个问题。第一,len不是随便填的,不能超过描述符里定义的Report Count,否则USB控制器可能出现溢出或数据被丢弃。第二,如果上一次上报还没完成(HAL状态还停在BUSY),你又调了一次,这次会返回失败,需要做重发或缓存机制。
4.2 接收命令:HID_Itf_DataOut回调
主机下发数据给设备,数据到达后HAL库会通过回调通知应用层。在usbd_custom_hid_if.c里,需要实现这个函数:
static int8_t HID_Itf_DataOut(USBD_HandleTypeDef *pdev, uint8_t *pbuf, uint32_t *length) { // pbuf 指向接收缓冲区,length 是收到的数据长度 // 在这里做数据解析、命令分发 return 0; }这个回调运行在USB中断上下文里,处理逻辑要快。我见过有人直接在回调里做字符串处理、查表、往EEPROM写配置,结果就是USB枚举不稳定、系统卡顿。正确做法是将数据拷贝到自己的接收队列,然后通过标志位或消息邮箱让主循环去处理。
另一个值得注意的点:HAL库的OutReport Buffer是一个环形或覆盖式的缓冲区,如果上一包数据还没被应用层取走,下一包又到了,新数据会把旧数据覆盖掉。所以回调里至少要把数据搬走,哪怕只是搬到应用层缓冲区。
4.3 Buffer尺寸不匹配的隐形炸弹
前面提到CubeMX默认Out Buffer是16字节,现在把这个坑展开讲。现象是这样的:上位机下发一包64字节的数据,设备端收到的长度始终只有16字节,或者前16字节正确、后面的数据丢失。很多人的第一反应是改上位机代码,或者怀疑通信协议有问题,折腾很久。
实际上问题就出在usbd_custom_hid_if.c这个文件的几个数组上:
#define USBD_CUSTOM_HID_OUTREPORT_BUF_SIZE 64 static uint8_t USBD_CUSTOM_HID_OutReport_Buffer[USBD_CUSTOM_HID_OUTREPORT_BUF_SIZE];如果这里还是16,即使描述符、端点和上位机都改成64字节,设备端也只会接收前16字节。这个参数有个更阴险的地方:CubeMX在生成代码时会把数组和宏放在不同文件里,宏定义在usbd_custom_hid.h,数组在usbd_custom_hid_if.c,改漏任何一个都可能出错。
**建议做法是先把所有的Buffer尺寸宏全部改成目标值,再从描述符到端点描述符全部过一遍,最后用Bus Hound或USB抓包工具确认实际传输长度。**不要靠肉眼代码审查,长度不对的Bug肉眼很难看出来。
5. 实测排查:设备识别失败与数据错位的完整链路
代码写完,上电实测,这是开发者最兴奋也最容易受挫的环节。归纳一下我在实际项目中遇到的几类问题,从现象到根因帮你梳理一遍。
5.1 设备管理器里的“未知设备”怎么查
插上设备,PC没有弹任何提示,设备管理器里出现一个带黄色感叹号的“未知USB设备(设备描述符请求失败)”。这种问题基本集中在枚举层面,可能原因优先级从高到低分别是:
- USB时钟不对,先回去确认48MHz。
- D+上拉电阻问题。F103的USB D+上拉是内部集成的,但如果CubeMX里没把USB的D+上拉配置打开,或者上拉电路异常,枚举会失败。
- 固件里中断优先级配置不当,USB中断被其他高优先级中断饿死。
- 供电不足,尤其是从USB口取电但板子上还带了显示屏、传感器等外设。
排查手法有个笨办法但非常有效:换一台不同年代的电脑试。某一台机器枚举失败、另一台正常,基本可以确认是时序问题而不是设备彻底坏了。此时可以用逻辑分析仪或USB分析仪抓取枚举阶段的数据包,确认设备有没有响应Set Address请求。
5.2 数据全0、丢包、首字节错位怎么查
设备能被识别,说明枚举阶段OK,问题就集中在应用层。最常见的情况是:设备上报数据但上位机读到的全是0;或者读到的首字节丢失;或者是数据对不上。
先说全0。排查顺序:确认上报函数有没有真正执行(加个IO口翻转或用调试器打断点);确认上报长度参数是否正确;最后用USB分析仪看原始包。有次我的项目里全0的原因是描述符里Report ID写了1,但SendReport时传入的数组第一个字节是0xFF,主机端按Report ID=1来解析,就丢掉了第一个字节,后面的数据就错位了。HID设备在带Report ID时,每个报告的第一个字节固定是Report ID,这个字节不会出现在你业务数据里。所以你的有效数据要么从Data[1]开始存,要么在应用层用一个偏移量规避。
再看数据对不上。要确认主机收到的真的是你发的那包数据,可以用一个简单的自测流程:设备端固定发一串0x00到0x3F的递增序列,上位机收到后检查序列值,哪一段断了错位了,就能反推是发送端问题还是接收端问题。
5.3 用Python脚本快速验证双向通路
上位机调试不一定非得先写完整的业务程序,推荐用Python加上hidapi库做最小验证。一个可用的测试脚本如下:
import hid # 这里填你设备枚举出来的VID和PID VENDOR_ID = 0x1234 PRODUCT_ID = 0x5678 device = hid.device() device.open(VENDOR_ID, PRODUCT_ID) device.set_nonblocking(1) # 发送一包给设备,注意首字节是Report ID out_data = [0x01, 0xAA, 0xBB, 0xCC] + [0x00] * 60 device.write(out_data) # 读取设备上报的数据 try: in_data = device.read(64, timeout_ms=2000) print(f"received {len(in_data)} bytes: {list(in_data)}") except Exception as e: print(f"read failed: {e}") device.close()这个脚本跑通后再去写正式的上位机逻辑,能省很多时间。
另外一个建议是准备一个USB抓包工具。Windows上可以用Bus Hound,Linux上用Wireshark加一个USB抓包模块,或者买一个USB分析仪。抓包工具能看到最底层的数据流,判断“设备到底有没有发出那包数据”几乎是唯一的权威手段。
最后说几句
HAL库的Custom HID开发走到这里基本完整了:工程配置、描述符编写、数据通路、问题排查。我的切身体会是,这类项目真正花时间的不是代码本身,而是对USB枚举和HID协议的理解。建议入门时用小步快跑的方式:先实现最小枚举,设备能被识别,再跑通一包双向数据,然后才去扩展业务功能。
最后再分享一个小技巧:设计产品时最好在项目早期就把Custom HID的数据帧格式和协议定下来,因为一旦设备交付、装有上位机的用户多了,描述符和Report ID就不能轻易改动,否则会造成一批无法识别设备的“砖头”。协议扩展时优先考虑在Payload里做版本号兼容,而不是改动描述符。祝各位一次点亮。