1. 项目概述:为什么非要用 Custom HID
做嵌入式 USB 设备的时候,凡是需要和 PC 交换数据,大家第一反应就是用串口,或者偷懒直接上 CDC 虚拟串口。串口确实简单,但一遇到“免驱”两个字就头疼——要么让用户装驱动,要么就是驱动签名问题在 Win10、Win11 上折腾半天。后来我开始用 STM32 HAL 做 Custom HID,这个方案解决了我不少实际项目里的痛点,今天就把整个思路和踩过的坑完整梳理一遍。
先说清楚 Custom HID 是什么。标准 HID 设备大家很熟了,鼠标键盘都是,操作系统自带驱动,拿到报告描述符以后自动解析成标准的输入事件。但 Custom HID 指的是厂商自定义用途的 HID 设备,它的报告描述符里用的是厂商自定义的用法页(Usage Page 0xFF00 之类),操作系统不“理解”这些数据的业务含义,也不做解释,只是给你一个合法的 HID 接口,让应用层软件直接读写原始数据。换句话说,系统把它当成一个“允许存在的 USB 设备”来处理,免驱,数据则原封不动地透传给你。
这就有意思了,它集齐了几个我很看重的特性:
- 免驱。Windows、Linux、macOS 都内置了 HID 框架,不需要额外安装内核驱动。
- 双向通信。HID 的 interrupt IN 端点负责设备上报数据,OUT 端点负责接收主机下发数据,足够覆盖绝大多数控制类的应用场景。
- 不需要申请 VID/PID。自己画板子做原型验证的时候,用 STM32 默认的 VID 0x0483 就能跑起来,等到量产再换成自己的厂商标识。
- 延迟可控。USB 全速 12Mbps 下,中断端点轮询间隔可以设到 1ms,对于控制命令、传感器数据上报这类任务绰绰有余。
这文章适合谁看?正在做 STM32 USB 相关项目的人,尤其是被 CDC 驱动签名折磨过、或者需要在无驱动环境下和主机交换自定义协议数据的开发者。硬件上你需要一块带 USB 功能的 STM32(F103、F407、F746 之类都可以),软件上会用 CubeMX 和 HAL 库就够了,基础的 USB 概念比如端点、描述符这些,我会在后文逐步解释。
2. 整体设计思路:先搞清楚 Custom HID 的数据流
2.1 和标准 HID、CDC 的本质区别
我在很多群里看到有人把 Custom HID 和 CDC 搞混,其实这俩的数据通路完全不同。CDC 虚拟串口本质上是一个“串口桥”,主机端没有内置通用驱动,Windows 上需要 usbser.sys 配合 INF 文件才能枚举成功,这就要过驱动签名那一关。Custom HID 走的是操作系统内置的 HID 驱动(Windows 上是 hidusb.sys),只要你的描述符合规,系统就认它,剩下的数据怎么解释完全由你的应用层说了算。
和标准 HID(鼠标键盘)的区别更关键。标准 HID 设备说到底是“输入设备”,主机拿到数据以后会先去解析报告描述符,然后把数据翻译成鼠标移动、按键事件。Custom HID 不一样,报告描述符里声明的是厂商自定义用法,系统拿到数据就直接丢给应用层,不做任何语义翻译。这相当于你借了 HID 这个壳,干的是自定义协议的活。
区别说清楚了,做一个表方便对比:
| 对比维度 | 标准 HID(键鼠) | Custom HID | CDC 虚拟串口 |
|---|---|---|---|
| 驱动支持 | 系统内置 | 系统内置 | 需要系统驱动/INF |
| 数据语义 | 系统解析 | 应用层解析 | 纯透传 |
| 双向通信 | 一般单方向为主 | 支持 IN/OUT | 支持收发 |
| 实现复杂度 | 中等 | 中等 | 较低 |
| 免驱跨平台 | 好 | 好 | 一般,依赖系统 |
| 典型场景 | 键鼠、游戏手柄 | 自定义数据交换、配置工具 | 串口调试、工业通信 |
2.2 数据通路的完整流程
弄明白 Custom HID 的数据流非常关键,很多初学者把描述符改完发现上位机收不到数据,就是因为没搞清楚整个链路。数据从设备到 PC,要经过这几层:
- 应用层代码:调用 HAL_HID_ReportSend() 把自定义格式的数据提交到 HID 驱动。
- USB 协议层:数据按照中断传输方式打包,通过 IN 端点发送。
- 主机 HID 驱动:系统收到后根据报告描述符确认这是一个合法的 HID 报告,然后交给应用层。
反向路径则是应用层写数据,系统通过 OUT 端点下发,设备端在 HID 的 OUT 事件回调里接收。整个过程里面,真正需要你操心的就是两头:设备端的报告内容怎么组织,主机端怎么读写。
2.3 为什么选用 STM32 HAL 而不是标准库或 LL 库
个人意见,做 USB 这类协议栈复杂的外设,必须用 HAL,理由非常直接。标准库虽然代码看起来轻量,但 USB 设备协议栈要自己移植、要自己管理状态机,工作量很大,而且 ST 官方都已经停止维护标准库了。LL 库偏底层,适合做外设驱动的研究,但 USB 协议栈直接用 LL 写,回调、中断、端点管理的代码量会非常可观。
HAL 的 USB 设备库(STM32 USB Device Library)已经帮你把状态机、描述符请求、端点处理都封装好了。你只需要做三件事:配置 USB 外设、编写报告描述符、实现收发回调。剩下那些控制传输、复位、枚举相关的底层逻辑,HAL 全帮你扛了。这套体系在 F1、F4、F7、H7 上都通用,换芯片平台的时候移植成本极低,我后来从 F407 迁到 F746 基本就改了引脚配置和时钟树。
2.4 项目整体架构规划
做一个 Custom HID 设备,完整的工作量分配大致是这样的:CubeMX 配置占三成、报告描述符设计占两成、代码逻辑占三成、主机端测试工具占两成。很多人把精力全放在写代码上,结果枚举都过不了,就是因为前两步没做扎实。
所以在动手之前,先想清楚几个问题:
- 你的数据帧格式是什么?定长还是变长?最大长度多少?
- 数据方向是哪几种?只上报、只下发,还是双向都有?
- 实时性要求多高?轮询间隔定多少毫秒合适?
- 包长度够不够用?64 字节的端点容量能容纳你的最大帧吗?
这些问题想清楚了,后面不管是描述符还是端点配置都有据可依。我见过最典型的反面案例是:需求变化导致帧结构变长,结果端点容量不够,整个协议推倒重来。所以设计阶段宁可把包长放宽一点。
3. USB HID 协议解析:报告描述符才是灵魂
3.1 四类描述符的关系
Custom HID 设备在枚举阶段需要向主机上报四类关键描述符:设备描述符、配置描述符、接口描述符、HID 描述符。其中接口描述符里的bInterfaceClass必须是 0x03(HID 类),HID 描述符里必须指明报告描述符的长度和版本号。
很多教程只让你改报告描述符,不解释整个描述符树的逻辑,导致一错就不知道错在哪。这里我用一个生活化的类比:设备描述符是“车子的品牌型号”,配置描述符是“配置单”,接口描述符是“这个配置里有几个座位、每个座位是干嘛的”,HID 描述符是“座位的说明书目录”,报告描述符才是“说明书正文”。主机枚举设备时,就是按照“品牌 → 配置 → 接口 → 说明书目录 → 说明书正文”的顺序一层一层问的。
3.2 报告描述符逐字节拆解
报告描述符是 Custom HID 项目里最核心也最容易写错的部分。它是一串字节流,主机通过解析这串字节流来知道设备有几组数据、每组的长度和方向是什么。标准键鼠的报告描述符非常复杂,因为要描述鼠标按键、X/Y 轴、滚轮这些元素。而 Custom HID 就简单粗暴:我们直接声明一个自定义用法页,然后塞一大堆Report Count就完了。
以我最常用的一个四字节 Custom HID 报告描述符为例:
0x06, 0x00, 0xFF, // Usage Page (Vendor Defined 0xFF00) 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 fields) 0x81, 0x02, // Input (Data, Var, Abs) 0x85, 0x02, // Report ID (2) 0x09, 0x02, // Usage (Vendor Usage 2) 0x15, 0x00, // Logical Minimum (0) 0x26, 0xFF, 0x00, // Logical Maximum (255) 0x75, 0x08, // Report Size (8 bits) 0x95, 0x40, // Report Count (64 fields) 0x91, 0x02, // Output (Data, Var, Abs) 0xC0 // End Collection关键点逐条说。首先Usage Page必须是厂商自定义页,一般用 0xFF00 到 0xFFFF 之间的值,主机看到这个就知道这设备的用法不是标准的键鼠,不套用标准解析。然后Report ID是个很有用的东西,如果同一个设备想区分不同的数据帧格式,可以用多个 Report ID 来标识,我在传感器上报和命令下发用不同 ID 的场景下就靠它区分。
再往下是Report Size和Report Count。Report Size指的是每个字段多少位,这里设 8 位就是一个字节;Report Count是字段个数,64 个字段正好凑成 64 字节,和 USB 全速中断端点的最大包长一致。最后Input和Output的 tag 分别声明这组数据是设备上报方向还是主机下发方向。细心的人会发现数据方向不是随便写的,设备和主机刚好相反:设备上报对应主机的 Input,主机下发对应设备的 Output。
3.3 端点机制与轮询间隔
Custom HID 的数据传输用的是中断传输方式。全速 USB 下,中断传输的最大包长是 64 字节,最小轮询间隔是 1ms。HAL 库的 USB HID 类默认把中断 IN 端点设为端点 1,大小 64 字节,轮询间隔 10ms。我看过很多人改了半天代码没效果,结果发现是轮询间隔太长,数据上报延迟居高不下。
把轮询间隔改成 1ms 的方法是在usbd_hid.c里改HID_EPIN_INTERVAL这个宏。需要双向通信的,还要把 OUT 端点也配出来。HAL 的 USB HID 类默认只配了 IN 端点,OUT 端点需要自己加,具体做法是在HID_Data结构体里增加一个接收缓冲区,然后在HID_EPOUT_SIZE相关的配置里把 OUT 端点地址、大小配好,同时在初始化的时候把 OUT 端点打开。
这一步经常有人漏,因为 CubeMX 勾选了 HID 类后默认生成的工程不包含 OUT 端点。你要在MX_USB_DEVICE_Init之后的初始化流程里,手动调用USBD_LL_OpenEP打开 OUT 端点,并且把接收缓冲区的地址告诉底层驱动,同时打开端点中断。很多初学者卡在这一步,发现主机写数据过来设备毫无反应,就是因为 OUT 端点压根没有打开。
4. 实操全流程:基于 CubeMX 一步步配置
4.1 时钟和 USB 外设配置
先用 CubeMX 新建一个工程,芯片选你手上的型号,我这边以 STM32F407 为例。USB 全速设备外设需要 48MHz 的时钟,这个很多人一开始就忽略,导致枚举完全不工作。在时钟树配置页面,把 USB 的时钟源选为 PLL48QCLK,并且确认最终给到 USB 外设的时钟是精确的 48MHz。F407 的常规配置是主频 168MHz,PLLQ 设为 7 分频正好得到 48MHz,这个是最稳妥的一组参数。
USB 的引脚不用配置,因为 FS 设备模式用的是 PA11(DM)和 PA12(DP)这两个专用引脚。但要注意的是,如果你的板子上 PA11、PA12 还被别的功能占用了,比如接了个 LED 或者串口,那 USB 一定起不来。我帮人排查过好几块板子,最后发现是引脚冲突这种低级问题。
接着在 Connectivity 里选择 USB_OTG_FS,模式选 Device Only,然后在 USB_DEVICE 中间件里选 Communication Device Class(Virtual Port Com)下面的 Human Interface Device。注意 HID 类选项不需要选成鼠标键盘那种具体设备,直接选 HID 类即可。
4.2 中间件参数调整
在 USB_DEVICE 的配置页面里,HAL 库的 HID 类有几个参数可以调,但真正需要动的不多。设备描述符里的 VID 和 PID 在usbd_desc.c里改,默认是 0x0483 和 0x5740,这个组合对应 ST 的虚拟串口设备,虽然能枚举成功,但不专业。自己做产品的话,量少的可以先用默认值验证,要发布的话去申请自己的 VID/PID 再做替换。
重点说一下端点参数。HAL 库默认把 HID 输入端点设在端点 1 的 IN 方向,最大包长 64 字节。你可以在usbd_hid.h里改HID_EPIN_SIZE和HID_EPIN_INTERVAL。我一般把间隔直接改成 1,这样数据延迟最低。OUT 端点需要手动加,这一步别指望 CubeMX 自动生成,虽然新版 CubeMX 生成的 HID 类代码已经带了 OUT 端点,但老版本还是需要手动补,我下面会给出完整的修改顺序。
4.3 报告描述符替换步骤
CubeMX 生成代码后,报告描述符在usbd_hid.c的HID_ReportDesc数组里,默认是鼠标的描述符。这里有一个非常关键的细节:HID_ReportDesc数组里面除了报告描述符本身,前面还带了一个HID_REPORT_DESC_SIZE宏控制长度。如果你修改了描述符的内容但忘了同步更新这个长度宏,主机枚举的时候会因为长度不匹配而出错。
我在实际项目里写的是 64 字节输入加 64 字节输出的双向报告描述符,也就是上面 3.2 节贴的那段。替换的时候把整个HID_ReportDesc数组内容换掉,然后把HID_REPORT_DESC_SIZE从默认值改成新的数组长度。写完以后建议用 USB 描述符检查工具(比如网上常见的 HID Descriptor Tool 或者 USB Descriptor Editor)验证一遍,这类工具能解析出每个 item 的含义,比肉眼强太多了。
4.4 收发函数的正确用法
HAL 库的 HID 类向外暴露了两个核心函数:HID_ReportSend和USBD_HID_GetRxBuffer。
发送方向的完整调用链是这样的:
uint8_t tx_buffer[64] = {0}; // 填充你的数据帧 tx_buffer[0] = 0x01; // Report ID // ... 填充有效载荷 USBD_HID_ReportSend(&hUsbDeviceFS, tx_buffer, 64);注意底层实际调的是USBD_LL_Transmit,发送完成会有回调通知。如果你在中断里和高优先级任务里同时调用发送函数,要做好互斥,否则可能出现数据交错。我经历过一次数据帧错乱的问题,排查了半天发现是发送函数的并发调用,后来加了个标志位做排队就好了。
接收方向,设备端要先准备一个接收缓冲区,然后注册 IN 传输完成回调来重新开启接收,这是 HID OUT 接收的关键机制:
static uint8_t hid_rx_buffer[64]; // 初始化时挂一次接收 USBD_HID_RegisterOps(&hUsbDeviceFS, &HID_Ops); // 在 USER 的 OUT 事件回调里接收数据 static int8_t HID_OutEventHid(USBD_HandleTypeDef *pdev, uint8_t epnum) { uint16_t len = USBD_LL_GetRxDataSize(pdev, epnum); if (len > 0) { // 处理 hid_rx_buffer 里的数据 ProcessReceivedData(hid_rx_buffer, len); // 必须重新打开 OUT 端点,否则后续数据再也进不来 USBD_LL_OpenEP(pdev, HID_EPOUT_ADDR, USBD_EP_TYPE_INTR, HID_EPOUT_SIZE); USBD_LL_PrepareReceive(pdev, HID_EPOUT_ADDR, hid_rx_buffer, HID_EPOUT_SIZE); } return USBD_OK; }那个“必须重新打开 OUT 端点”的操作极容易被忽略。USB 的接收机制是一次性的,你要不断地重新挂上缓冲区它才会继续接收下一包。我见过有人只写了一次 PrepareReceive,结果只能收一包数据,后面再来数据设备直接没反应,主机那边是什么都发不过去。
4.5 主机端如何验证数据通路
设备端做好了,主机端测试同样重要。Windows 上最简单的验证方法是打开设备管理器,看“人体学输入设备”下面有没有多出一个 HID 设备,并且没有黄色感叹号。有感叹号说明枚举没过,驱动有问题;没感叹号但收不到数据,那问题多半在报告描述符或者应用层的读写。
真正要收发数据,建议直接用 Python 的hidapi库做验证,代码量极小,调试效率极高。下面是一段实测可用的收发脚本:
import hid import time # 打开设备,VID/PID 按你的实际修改 dev = hid.device() dev.open(0x0483, 0x5740) # 读取设备信息 print(dev.get_manufacturer_string()) print(dev.get_product_string()) # 下发一帧命令,注意第一字节是 Report ID data = [0x01, 0xAA, 0xBB, 0xCC] + [0x00] * 60 dev.write(data) # 读取一帧上报数据 report = dev.read(64) print(report)这里有个细节容易踩:hidapi的write函数要求第一字节必须是 Report ID。如果你的描述符里定义了 Report ID 为 0x01,那么第一字节就写 0x01,后面的才是数据。如果你的描述符里完全没有 Report ID,那么第一字节会被当作缓冲区的第一个数据字节,但很多 HID API 会在底层自动补一个 ID 字节,不同库行为不一样,导致同样的代码在有的库能跑通、有的库一直收不到。我的经验是:自定义报告描述符里不要省略 Report ID,加上它跨平台兼容性更好。
Linux 下可以用hidapi同样跑,也可以用lsusb -v查看设备描述符和报告描述符是否加载正确。macOS 下同样支持hidapi,只是权限弹窗要处理一下。
5. 常见问题与排查技巧实录
5.1 枚举失败:设备管理器出现未知设备
这是最让人头疼的问题之一,也是新手最容易卡的环节。遇到未知设备,先不要慌,按这个顺序排查:
第一步查时钟。用调试器读出 RCC 的时钟配置,确认 USB 外设时钟是不是 48MHz。这一步因为报错信息不明显,十个人里八个会忽略。F407 上要是时钟树没调好,USB 外设根本不会工作。
第二步查硬件。用示波器或者逻辑分析仪看 USB DP 引脚有没有 1.5kΩ 上拉电阻动作。很多开发板上这个上拉是可控的(比如通过一个引脚控制),如果你的代码没把那个引脚拉高,主机根本检测不到设备插入。市售的 STM32 开发板一般默认处理好了,但自己画的板子容易漏这个。
第三步查描述符。设备枚举失败绝大多数是描述符哪里不对。用 USB 分析仪抓包是最直接的,没有分析仪就开调试器在 USB 中断里打断点,看看主机发的控制请求设备有没有正常响应。我在一个项目里碰到过设备描述符的bMaxPacketSize0写错,导致控制传输阶段就过不去,怎么修都枚举不了。
5.2 能枚举但收不到数据
设备管理器里看到 HID 设备正常,但上位机读不到任何数据,这种情况比枚举失败更常见。排查方向有三个:
方向一:OUT 端点没打开。上面 4.4 节说的那种一次性接收问题,只开了一次 IN 端点,主机怎么发都不回。检查你的初始化代码里有没有把 OUT 端点打开,并且收到数据后有没有重新 PrepareReceive。
方向二:报告描述符不对。很多人改了描述符,但长度宏没同步修改,或者描述符里 Report Count 和实际发送的缓冲区长度不一致。主机读数据时如果发现实际包长和描述符声明的长度对不上,就直接丢弃。所以我每次都强调:描述符内容和代码里的缓冲区一定要严格对应。
方向三:上层 API 被系统过滤。Windows 上 HID 设备如果声明了集合(Collection),应用层要匹配正确的 Usage Page 和 Usage 才能打开设备。如果你在报告描述符里用了标准 Usage Page 的某些值,可能会被系统键盘过滤器拦截,数据根本到不了用户态。解决办法就是老老实实用厂商自定义的 Usage Page。
5.3 数据延迟过高
默认配置下 HID 的轮询间隔是 10ms,这意味着设备上报一帧数据到主机,最坏情况要等 10ms。如果你做的是实时控制类的应用,这个延迟可能不够。把HID_EPIN_INTERVAL改成 1,轮询间隔就降到 1ms。另外还有一种情况是上位机开了阻塞读取,底层缓冲还没来得及刷新,应用层拿到的就是旧数据,这种要从主机侧解决,关闭系统对 HID 的输入缓冲或者提高读取频率。
全速 USB 的理论带宽是 12Mbps,但中断传输模式下实际可用带宽受轮询间隔和包长限制。1ms 间隔、64 字节包长,每秒最多 64KB,这个带宽做一般的传感器采集、命令控制绰绰有余,但要是传音频流或者大文件就不行了,那种场景还是考虑批量传输类或者 MTP、UVC 协议。
5.4 跨平台兼容性问题
同一个设备,Windows 上跑得好好地,Linux 上收不到数据,或者 macOS 上设备就是不枚举。这类问题十有八九出在报告描述符的严谨性上。Windows 的 HID 解析器相对宽松,有些描述符写得模棱两可它也能接受,Linux 内核里的 HID 解析器校验更严格,一个字段不对就直接拒绝。我遇到过的最典型例子是Logical Minimum和Logical Maximum没声明,Windows 上一切正常,Linux 上直接把设备踢掉了。
解决办法是严格按 HID 规范写描述符,每个字段都写全,别偷懒。报错信息在 Linux 下可以在dmesg里看到具体的拒绝原因,比如hid-generic: invalid report id之类,改成 Windows 上跑一遍同样的描述符,对比一下两边行为,基本能定位问题。
6. 工程实战心得:从验证到量产的几个建议
6.1 数据帧协议设计建议
Custom HID 的应用层协议设计,我总结了几条经过多次教训换来的建议。
第一,一定要定长帧。HID 的传输天然是定长的端点包,虽然你可以通过 Report ID 或者帧头区分有效长度,但定长帧让主机端的解析逻辑简单太多。比如你定了 64 字节的帧,哪怕实际只有 4 字节数据,也把剩下的字节填 0,主机端永远按 64 字节解析,边界情况少一大半。
第二,帧头加魔数。在帧的开头固定放两个字节的魔数(比如 0xAA 0x55),主机端解析的时候先检查魔数,不匹配就丢弃。别小看这一步,HID 数据在传输过程中偶尔会有错位,加了魔数能把错误帧挡在解析逻辑之外。我在一个无线模块协同工作的项目里,就是靠魔数过滤掉噪声帧的。
第三,充分考虑字节序。如果你的数据帧里有多字节的数值(比如 16 位 ADC 采样值、32 位传感器数据),一定要在协议文档里明确是小端还是大端。USB 协议本身倾向于小端,但很多传感器驱动默认给大端数据,两边的工程师各自按自己的习惯来,最后联调的时候数据对不上,排查极其浪费时间。
第四,预留版本号字段。帧里加一个协议版本号,升级协议的时候主机端可以兼容旧版本设备,不用强制用户刷固件。我做过一个批量部署的项目,设备分布在现场,协议升级后老设备必须继续能用,就是靠版本号字段做的分流。
6.2 端点缓冲区与 DMA 注意事项
STM32 USB 外设使用专用的数据包缓冲区(PMA),HAL 库在初始化的时候会帮你做好地址分配,但你自己写接收逻辑时要注意缓冲区对齐问题。USB 外设对 PMA 地址有对齐要求(通常是 4 字节对齐),如果你用了 DMA 或者自定义缓冲区,地址不对齐会导致数据错乱。
另外我强烈建议把 USB 相关的缓冲区都定义成全局变量,不要用栈上的局部数组。原因是 USB 中断处理是异步的,传输还没完成时缓冲区就出栈了,会造成不可预知的数据损坏。这类 bug 最恶心,因为它不是必现的,每次出现就像闹鬼。我从一个惨痛教训里学到的原则是:USB 缓冲区一律用全局数组或者静态数组。
中断优先级也要注意。USB 中断如果被其他高优先级中断长期阻塞,主机的数据会超时。但反过来,如果你在 USB 中断回调里做了耗时过长的处理,又会阻塞其他外设。我的建议是 USB 中断回调里只做数据搬运,把实际处理丢给主循环或者 RTOS 任务,保持回调轻量。实测下来这个原则能避免大量裸机架构下的优先级死锁问题。
6.3 低功耗场景的坑
如果你的设备是电池供电,想用 HID 设备做低功耗,有一个坑必须注意:USB 挂起机制。主机长时间没有数据交互,会向设备发送挂起请求,设备进入挂起状态后可以关掉部分外设。但 HID 设备的唤醒要靠远程唤醒功能,默认是不开启的。
在 STM32 HAL 里启用远程唤醒需要在描述符里配置bmAttributes唤醒位,同时在代码里处理挂起中断和恢复逻辑。这个功能看起来简单,做起来细节很多,而且很多开发板的 USB 外设和 MCU 的电源域是绑定的,挂起恢复的时序如果处理不好,设备会直接死机。我的个人建议是:如果低功耗不是刚需,先把挂起处理 disable 掉,把功能跑通后再慢慢加低功耗优化。
6.4 从验证到产品的路径
原型验证阶段用默认 VID/PID 没问题,但别忘记后面还有个重要步骤:量产前的描述符调整。至少需要做这些事:
- 申请或者购买针对自己产品的 VID/PID。
- 更新厂商字符串和产品字符串,注意字符串描述符用的是 UTF-16LE 编码,直接填 ASCII 也能工作但专业度不够。
- 检查批量固件烧录时 USB 描述符是否一致,避免烧错固件导致一堆设备枚举失败。
- 准备量产测试工具,用 hidapi 写一个简单的出厂测试程序,验证收发通路和报告描述符匹配。
还有一点关于 USB 线材和电源的教训。USB 全速虽然速率不高,但对信号质量还是有一定要求的。我见过设备在短线上一切正常,换了一根劣质长线以后开始频繁枚举失败,用示波器看才发现信号完整性已经崩了。产品化的 PCB 布局上,DP/DM 差分对的阻抗匹配和走线长度也要尽量按规范做,不然批量生产的时候会有各种玄学问题。
7. 扩展方向:向复合设备和高速设备演进
7.1 复合 HID 设备
单接口 Custom HID 用顺手之后,可以往复合设备方向走。复合设备的意思是同一个 USB 物理接口下挂多个 HID 逻辑接口,比如一个接口做厂商自定义数据通信,另一个接口模拟键盘做快捷键控制。这类设备通过接口描述符的bInterfaceNumber和报告描述符的多个集合来区分。
在 STM32 HAL 上实现复合 HID 的路径比较曲折,因为 HAL 库的 USB 类框架默认是一个类一个设备,要同时挂两个 HID 集合需要自己改描述符列表,或者直接把两个集合写进同一个报告描述符里。后者实现起来更简单,在同一个报告描述符里写两个 Application Collection,每个集合有自己的 Report ID,主机端通过 Report ID 区分数据是哪一个逻辑接口的。这个方法我在一个既做数据通信又要模拟媒体按键的项目里实际用过,比改 HAL 类结构省事很多。
7.2 高速 HID 设备
如果数据吞吐量要求更高,可以换带高速 USB 外设的芯片(比如 STM32F746 的 OTG_HS),把 HID 跑在高速模式。高速模式下中断传输的最大包长可达 1024 字节,带宽提升非常明显。不过高速模式需要额外的 PHY 芯片,板上要多几个器件,软件上还要配置 UTMI 接口,复杂度会上升不少。
从我的经验看,大部分嵌入式 HID 应用其实不需要高速,全速 64 字节包长在 1ms 轮询下已经能满足绝大多数控制类和中等速率的数据传输需求。真要追求高吞吐,与其在 HID 上死磕,不如考虑换成 USB 批量传输类设备,或者直接用带内置 USB 高速 PHY 的新一代芯片。
7.3 与上位机框架的集成
Custom HID 的方案做出来后,上位机可以直接走 WebHID。WebHID 是浏览器提供的标准 API,Chrome 和 Edge 都支持,这意味着你的 HID 设备可以在网页上直接被读写,完全不需要安装任何原生驱动和上位机软件。这个方案特别适合做配置工具、演示工具、量产测试工具,我最近一个项目的参数配置就是用 WebHID 做成网页的,部署成本比写一个完整的桌面应用低太多了。
WebHID 的代码也不复杂,核心流程是请求设备、打开设备、发送报告、监听输入报告。下面是一段可以直接跑通的简化示例:
// 请求设备 const device = await navigator.hid.requestDevice({ filters: [{ vendorId: 0x0483, productId: 0x5740 }] }); await device.open(); // 监听上报数据 device.addEventListener('inputreport', (event) => { const data = event.data; console.log('received:', new Uint8Array(data.buffer)); }); // 下发数据,第一字节是 Report ID const payload = new Uint8Array([0x01, 0x11, 0x22, 0x33, ...]); device.sendReport(0x01, payload.subarray(1));这个方向的潜力很大,因为浏览器跨平台、免安装、可分享链接,配合 Custom HID 的免驱特性,一套设备配合一个网页就能完成从演示到生产工具的完整闭环。
说了这么多,最后分享一点个人经验。做 STM32 的 USB Custom HID,最难的不是代码本身,而是对 USB 协议的理解。描述符错一位、端点配置丢一个、轮询间隔没调,这些问题本身都不复杂,但没有经过一次完整的排查过程,很难真正记住。我自己做了好几个版本的 HID 设备之后才慢慢形成一套固定的排查套路,现在再遇到问题基本都是十分钟内能定位。所以如果你第一次调不通,不要灰心,按着枚举、传输、数据解析的顺序一层层查,每个问题排掉后,后面的路会顺很多。最后再提醒一句:USB 相关的缓冲区用全局变量,中断回调保持轻量,报告描述符严格写完整,这三点做到位,你的 Custom HID 项目基本就成功了一大半。