1. 先聊聊这个标题:做外设方案时绕不开的“豁免金牌”
看到“STM32 HAL Custom HID”这个标题,老嵌入式玩家心里基本有数了。它说的是:用 STM32 的标准库(准确说是 HAL 库 + USB Device 中间件)实现一个“自定义 HID 类”的 USB 设备。翻译成大白话就是——让单片机插上 USB 线之后,能被电脑认成一个“免驱动”的玩意儿,而且这个玩意儿能收发自定义格式的数据,不是键盘、鼠标那种被系统死死规定好协议的“标准件”。
这几年不管是做自定义键盘、音量旋钮、工业控制面板、精简版游戏外设,还是做调试工具、数据采集盒子,Custom HID 几乎成了默认首选。原因很现实:它免驱,插上就能在设备管理器里看到;它不挑系统,Windows、Linux、macOS 都能直接访问;它还支持双向通信,不像标准键鼠 HID 那样要么只管输入、要么只管输出。
这篇文章我会把 Custom HID 从方案选型到描述符定义,再到收发数据、踩坑排查,按我自己实际做过的流程捋一遍。适合刚接触 STM32 USB 的爱好者,也适合想把手头标准 HID 改成自定义协议的工程师。你要是有过“标准 HID 用着难受、虚拟串口又嫌麻烦”的体验,看这份内容基本能把路走通。
2. 整体方案设计:为什么偏偏选 Custom HID
2.1 先把同类方案拉出来对比一遍
动手之前我先做了方案比对,因为 USB 设备的产品形态决定了协议选择,这一步跳过了后面全是泪。我把当时纠结的几条路放一起对比过:
| 方案 | 免驱 | 双向通信 | 传输速率 | 适合场景 |
|---|---|---|---|---|
| 标准 HID(键盘/鼠标) | 是 | 受限 | 低 | 键鼠、消费类输入设备 |
| 虚拟串口(CDC) | 是 | 强 | 一般 | 数据透传、日志输出 |
| WinUSB | Windows 免驱 | 强 | 高 | Windows 专属工具 |
| Custom HID | 几乎所有系统免驱 | 强 | 64 KB/s 左右 | 通用交互设备 |
标准 HID 的问题是协议被系统锁死了,想传自定义数据得套用 Report ID、Usage 各种边角料,比如媒体键、消费者控制页,结果就是上位机解析起来十分别扭。虚拟串口虽说免驱,但 Windows 下首次插拔要装一个“通用串行总线设备”驱动,虽然也是系统自带,但在一些精简系统、国产化环境里会遇到识别异常。WinUSB 性能最好,可一旦你不想绑定 Windows 平台,基本等于自废武功。
Custom HID 的核心优势在于“系统认识它,但不管它”:USB 核心层知道这是一个 HID 设备,会加载对应驱动,但不像标准键鼠那样由系统直接消费数据,而是把收发权全部留给你。
2.2 先认清 HID 这条“窄道”的边界
我也得先泼盆冷水:Custom HID 不是万能的速率之王。HID 全速设备默认走中断传输,单个事务最大 64 字节,最快 1ms 一次,理论极限就是 64 KB/s。实际跑下来,考虑到系统调度和总线开销,稳定能到 40~50 KB/s 已经很不错了。
所以如果项目要传音频流、视频流、大文件,趁早换 UAC / UVC / Bulk 方案,别在 HID 上死磕。但如果只是传指令、状态量、参数配置、几百字节的传感器数据,HID 的中断传输反而更舒服:它在总线拥塞时有固定带宽保障,而且主机侧访问延迟低。
2.3 什么样的场景最适合 Custom HID
我自己归纳下来,Custom HID 适合两类典型项目:
第一类是桌面交互设备。比如自定义旋钮面板、宏键盘、控制台、模拟飞行操纵面板。设备需要把编码器转动、按键按下、LED 状态传给电脑,电脑再回传动态配置。这类设备数据量小,但要求响应及时,而且用户可能随时换电脑用,免驱是硬指标。
第二类是专用的采集/配置工具。比如某个上位机软件需要和设备握手,读取设备 ID、校准参数、固件版本,写入阈值配置。这类设备如果做虚拟串口,用户装驱动时容易卡壳;做成标准 HID 又没法结构化管理数据。Custom HID 在这里就很自然:Windows 下用 HIDAPI、Linux 下用 hidraw,都是直接 open 就能访问,不用碰驱动层。
3. 关键配置与环境准备:CubeMX 里那几处必须抠清楚的选项
3.1 选中和时钟配置:800 块 MCU 也能干这活
实现 Custom HID 对芯片的要求低到令人发指。我最初是在某老款 8×8 封装的入门级 MCU 上验证的,内置全速 USB 设备控制器,无需外部 PHY 芯片。关键的约束只有一个:USB 外设必须工作在 48 MHz 的时钟下。
在 CubeMX 里配置时钟树时,要确保 USB 外设的时钟源是 48 MHz。不同系列写法有差异,有的用 PLLQ 分频输出,有的有专门的 USB prescaler,建议直接查对应系列的参考手册时钟树。
注意:如果芯片没有独立的 USB 时钟分频器,CPU 主频调整会影响 USB 时序,最常见的故障就是设备枚举不稳定、时好时坏。
3.2 CubeMX 勾选路径:别把中间件选错了
在 CubeMX 中,勾选 USB_DEVICE 后选择 Communication Device Class (CDC) 和 Custom HID Class 时,只要选 Custom HID 即可。千万注意别同时勾选多个 USB 类,否则生成的中间件会相互冲突。
另外强烈建议把生成的中断优先级看清楚:USB 全局中断优先级要高于一般外设中断,但低于系统滴答定时器。我在 F1 系列上习惯把 USB 中断优先级设为一个较高抢占值,避免 USB 中断被其他外设长时间打断导致丢包。
操作路径参考:
- 勾选 USB_DEVICE
- 在 Class for FS IP 下拉框里选 Custom HID Class
- 生成单独的 C 文件:建议每个外设生成独立文件,方便后续介入调试
3.3 工程文件结构:生成完毕先认识几个关键文件
用 CubeMX 生成工程后,USB 中间件相关的核心文件长这样:
USB_DEVICE/ ├─ usbd_desc.c # 设备描述符、字符串描述符配置 ├─ usbd_conf.c # USB 底层配置(FIFO 大小、中断处理挂钩) ├─ usbd_customhid.c # Custom HID 类的主体逻辑 └─ usbd_custom_hid_if.c # HID 描述符、报告描述符、收发回调真正需要我们“动手改造”的主要是usbd_custom_hid_if.c和usbd_desc.c。前者里定义了 HID 报告描述符和收发函数,后者定义了 VID/PID、厂商字符串、产品字符串。
实操提醒:不要一上来就猛改
usbd_customhid.c,那是类驱动的骨架,改错了容易把整个枚举流程破坏。先在配置界面把描述符定义改好,优先级更高。
4. 描述符拆解:Custom HID 的灵魂就在这一串字节里
4.1 设备描述符和配置描述符:基础信息决定系统如何认识你
设备描述符中两个最重要的数值是 VID 和 PID。自定义 HID 一般使用自定义 VID/PID,测试阶段可以选一个随手编的,但量产后记得要通过正规渠道申请。
字符串描述符里的产品名称,比如“Custom HID Panel”,会直接显示在设备管理器中,建议改成有意义的名字,方便调试时一眼识别。
配置描述符中通常还会包含 HID 描述符和端点描述符。这一部分 HAL 库已经自动组装好了,我们重点关注的是报告描述符,因为它决定了上位机能读到什么结构的数据。
4.2 报告描述符:用一堆字节描述“数据长什么样”
HID 报告描述符的机制是:设备告诉主机“我发出去的数据是什么含义”,尽管 Custom HID 场景下这套含义大部分时间没人去解析,但主机层会用它来判断数据长度和方向。描述符写得不对,系统可能直接拒绝枚举或者把数据截断。
下面这段是我在自定义控制面板里用的报告描述符:
__ALIGN_BEGIN static uint8_t CUSTOM_HID_ReportDesc[] __ALIGN_END = { 0x06, 0x00, 0xFF, // Usage Page (Vendor Defined 0xFF00) 0x09, 0x01, // Usage (0x01) 0xA1, 0x01, // Collection (Application) // 输入方向:64 个字节 0x19, 0x01, // Usage Minimum 0x29, 0x01, // Usage Maximum 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) // 输出方向:64 个字节 0x19, 0x01, // Usage Minimum 0x29, 0x01, // Usage Maximum 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 };这段描述符定义了一个 Vendor Defined 的 HID 设备,输入报告和输出报告都是 64 字节。
关于 Report Count 这里有一个容易踩的坑:有些教程会把 Report Count 定义成 0x10(16 字节),然后传输时又发 64 字节缓冲区,结果上位机读到的数据只有 16 字节是有效的。建议业务数据量明确后,把 Report Count 和收发缓冲区大小对齐,不要随意增大或减小。
4.3 报告 ID:要不要加,主要看上位机工具链
如果设备定义了多个报告 ID,需要在所有 Input/Output 项前加上0x85, 0x01这类字节。一旦加了报告 ID,传输时缓冲区第一个字节必须是报告 ID,后面才是真实数据,上位机读取时也会多出一个字节。
我建议桌面交互设备不加报告 ID,原因是 libusb 的 HIDAPI 在有些版本中对报告 ID = 0 的兼容性更好,直接 read/write 就行,不用额外处理偏移。
5. 核心代码实现:把数据通路跑通的完整套路
5.1 数据上行:从单片机到上位机
工程初始化阶段,USB 外设枚举成功后会自动进入配置状态。我们要做的核心工作就是调用收发接口。
上行的标准代码路径是:
// 申请发送缓冲区,放入业务数据 uint8_t tx_buf[64]; for (int i = 0; i < 64; i++) { tx_buf[i] = i + 1; } // 调用发送函数 USBD_CUSTOM_HID_SendReport(&hUsbDeviceFS, tx_buf, 64);注意不要在主循环里高频轮询调用这个函数。HID 中断传输本质是“等下一个帧开始才处理”,上一次还没发完又调一次,会被 HAL 库内部的 busy 状态卡住。我个人的经验是:维护一个发送状态标志,在发送完成回调里清除标志,主循环里只在标志为 0 时启动新的发送。
发送完成回调一般位于usbd_customhid.c中的USBD_CUSTOM_HID_SendReport_Handle,但更推荐在应用层通过检查USBD_CUSTOM_HID_GetState()判断发送状态,避免改库文件导致代码难以升级。
5.2 数据下行:从上位机到单片机(最容易出问题的地方)
下行接收是 Custom HID 例程里最容易让人懵的地方。HAL 库的 Custom HID 中间件处理下行数据时,默认会调用一个回调函数:
static int8_t CUSTOM_HID_OutEvent(uint8_t *data, uint32_t len) { // 在这里处理上位机发来的数据 // 注意:data 指向接收缓冲区,len 为数据长度 // 处理完毕后,必须再次启动下一次接收! USBD_CUSTOM_HID_Receive(&hUsbDeviceFS, data, len); return 0; }如果只处理一次数据而不重新调用USBD_CUSTOM_HID_Receive,就会遇到一个典型现象:上位机第一次发数据设备能收到,第二次、第三次就完全没反应了。这是因为 USB OUT 传输完成后,如果设备侧没有再安排接收缓冲区,主机发来的数据就无处可去。
另外一个常见坑点是:在USBD_CUSTOM_HID_EPOUT_Decoder或者回调函数中做耗时操作,比如把收到的数据用串口打印、把数据写 Flash。USB 中断回调环境下不允许做阻塞操作,正确姿势是只在回调里拷贝数据到应用缓冲区和置事件标志,真正的业务逻辑放到主循环里跑。
5.3 计算和验证一帧数据的理论时间
全速 USB 帧周期是 1ms,Custom HID 一个最大包 64 字节,双向各占一个中断事务。一帧数据从调用发送函数到主机读到,需要经过硬件排队、协议层发送、主机 USB 栈轮询等环节,理论最短时间可以估算为:
1ms(等下一个帧开始) + 传输时间 + 主机 HID 驱动读取周期实际测量下来,从设备发起到上位机读到,普遍在 2~3ms。对于按键类、旋钮类交互任务完全够用。如果延迟要求更苛刻,可以尝试把轮询间隔从默认的 10ms 改成 1ms,在报告描述符中加 bInterval 字段,不过对多数场景提升感知不强。
6. 调试全过程实录:枚举、收发、抓包一整套方法
6.1 第一步:确认设备枚举是否成功
代码烧录后,插上 USB 线,首选看设备管理器:如果出现一个没有感叹号的 HID 设备,恭喜,枚举已经过了。这一步如果失败,先用排除法:
| 现象 | 可能原因 |
|---|---|
| 电脑完全没反应 | USB 时钟没配好、供电不足、DP/DM 接反 |
| 有反应但显示未知设备 | 描述符长度错、报告描述符非法、电源配置超限 |
| 设备反复枚举/断开 | 端点配置和描述符不一致、中断优先级配置异常 |
最省事的枚举验证方法是用 USB 树状图之类的工具,它能直接展示设备请求的描述符内容。如果发现设备和主机请求的字符串内容对不上,多半是描述符数组写错或长度计算错误。
6.2 第二步:上位机读写验证
枚举成功后,用个简单的 Python 上位机脚本验证数据通路两条方向是否都通。我习惯用 HIDAPI 库:
import hid dev = hid.device() dev.open(0x1234, 0x5678) # 改成你的 VID / PID # 读取数据(阻塞模式) data = dev.read(64) print("recv:", bytes(data)) # 发送数据 payload = bytes([0x00] + list(range(16))) # 第一个字节 0x00 代表 Report ID 占位 dev.write(payload)这里有一个非常容易踩的坑:发送时很多人直接dev.write(b'\x01\x02\x03...'),导致上位机写数据的第一个字节被当成 Report ID,设备收到的数据整体错位一个字节。HIDAPI 的 write 接口要求第一字节必须是 Report ID,如果设备没有启用 Report ID,填0x00就可以了。
6.3 第三步:抓总线数据,看事务是否正常
数据通路验证 OK 之后,如果遇到数据偶发丢失,建议直接用总线抓包工具,比如 Bus Hound 看 USB 总线事务。重点关注这几项:
- 是否有 STALL 握手
- 主机是否频繁重发同一包
- 设备端是否出现 NAK 过多的情况
NAK 多是好事——说明设备不忙,只是端点暂时没准备好;STALL 出现则说明设备协议处理出了毛病。另外如果发现设备发送的数据和上位机读到的不一致,优先检查发送缓冲区是否被主循环覆盖了,这是个典型时序 bug。
7. 常见问题速查与独家避坑心得
7.1 这个问题专题:枚举失败十有八九是时钟和描述符
枚举失败是新手遇到最多的坎。我曾在一颗某系列芯片上用内部 RC 振荡器跑 USB,结果十次有八次枚举失败,最后查时钟树才发现 USB 需要 48MHz 精确时钟,必须用 PLL 从外部晶振分频。这个细节如果没注意,代码写得再完美也白搭。
描述符方面最容易犯的错是报告描述符长度没对齐系统要求的那一栏。HAL 库在usbd_desc.c里会用一个宏定义报告描述符长度,修改描述符后如果不更新长度字段,主机收到的数据就是错位和残缺的。
7.2 数据丢包和错位的经典案例
有次我做一个双方向控制面板,设备端同步采集 8 路 ADC 数值和按键状态,上位机偶尔会出现某个数据跳变。排查到最后发现,问题不在 USB 传输,而是发送缓冲区在中断回调里被下一次业务线程提前覆盖了。说白了就是:底层已经把这块内存发出去了,上层还在往里面写新数据。
后来我把收发缓冲区改成双缓冲:一个用于 USB 栈发送、一个用于应用层写入,完成回调里交换角色。从此再没出现过数据错乱。
7.3 低功耗场景下的特殊招数
带电池的设备一般都会做低功耗。STM32 进入 STOP 模式前,一定要先停掉 USB 外设并把 USB 时钟关掉,同时把 DP 下拉,否则 USB 线没拔的情况下进入 STOP,系统会被 USB 唤醒事件频繁打断,功耗怎么也降不下来。
唤醒回正常模式后,需要重新执行 USB 外设初始化。需要注意的是,Custom HID 在重新初始化后需要重新枚举,Windows 一般会自己处理,但有些 Linux 系统的 hidraw 节点会失效,需要重新打开设备文件。
7.4 跨平台兼容性的一点体会
Custom HID 在三大系统上表现确实认人省心,但 Linux 下访问 hidraw 节点需要权限,通常要写一个 udev 规则。macOS 上 HIDAPI 走 IOHIDManager 的路径,注意把hid.enumerate()的 VID/PID 过滤条件写对。
配套测试时,我一般用一个桌面工具 + 一个命令行工具两套方案。桌面工具给小白用,命令行工具给自己快速定位问题。千万别只依赖一个工具,因为如果工具本身对 HID 报告做了转换,你会把工具的行为当成设备的行为来调试,非常浪费时间。
8. 一些升级思路和奇技淫巧
Custom HID 跑通之后,我顺手做过几个小扩展,实战中效果不错:
- 把厂商字符串写成包含设备名和固件版本,这样上位机可以直接通过读取字符串来识别设备属性,不用先握手再说。
- 用 Feature 报告做参数读写。Feature 报告不走 Input/Output 套路,而是通过单独的通道读取和配置参数。同一个设备上,Input 走高频数据流、Feature 走低频配置流,很适合做设备参数管理。
- 在设备端实现简单的分包协议,比如 4 字节头 + 2 字节长度 + N 字节数据 + 2 字节 CRC,应用层不直接裸传 64 字节缓冲。这个习惯能帮你免掉后续做协议升级时几乎全部的数据错位问题。
我个人后来的项目基本都固定用一套模板:Custom HID 底层负责可靠双向传输,上层跑自定义小协议,加上 udev 规则和 HIDAPI 封装库,整套开发周期可以压缩到两个工作日以内。
这个方案后续还能往双向多端点扩展,比如增加一个中断 IN 端点专门用来上报实时事件,原端点用来收发流式数据,理论上可以把吞吐再挤一挤。如果你正好在搞自定义 USB 外设,建议先把基础版跑通,再按实际需求逐步加功能。