☰
STM32自定义HID免驱方案:从描述符配置到USB收发实战
2026/10/21 6:45:47 网站建设 项目流程

做嵌入式开发,特别是工业现场的设备调试,最烦的一件事就是装驱动。以前用USB转串口芯片,换一台电脑就得装一次驱动,到了量产阶段,用户的系统一旦是精简版或者开启了驱动签名校验,虚拟串口经常变成黄色感叹号。后来我把一批设备改成了USB自定义HID方案,插上就能自动识别,上位机直接走系统HID接口,不用额外装任何驱动。这篇文章把STM32 HAL库下配置USB全速、自定义HID类的完整过程捋一遍,包括描述符怎么写、端点怎么配、收发代码怎么调,以及我踩过的几个坑。适合刚接触USB协议栈、想把设备从串口通信升级成USB原生通信的嵌入式开发者。

1. 为什么自定义HID类值得搞——先把需求和代价摆清楚

1.1 免驱这件事,省的不只是安装时间

USB自定义HID最吸引人的一点,就是操作系统对它的支持是“出厂自带”的。键盘、鼠标、游戏手柄都是HID设备,Windows、Linux、macOS为了让这些外设免驱运行,早就把HID驱动做成了系统组件。你只要把设备的描述符写得合法,系统就会在插入瞬间自动完成驱动匹配,用户完全不用管驱动文件的事情。

这个优势在量产设备上会被放大。USB虚拟串口方案虽然现在Win10以上系统也能免驱,但早期Windows 7、精简系统、某些国产系统环境里,串口设备经常要手动指定驱动路径,甚至因为数字签名问题直接装不上。HID方案没有这些场景外的变量,它天生就是“即插即用”的代表。

另外,HID还有一个不起眼但很实用的优点:设备热插拔的稳定性好。因为HID协议设计之初就要适应键鼠的随时插拔,所以在异常断开、主机睡眠唤醒、重新枚举这类场景下,HID驱动的恢复机制比串口更成熟。

1.2 和其他USB类的对比:没有银弹,只有取舍

先列个表格,把常见的STM32 USB方案摆在一起看:

方案驱动依赖通信方式典型吞吐适合场景
自定义HID系统自带中断传输几十KB/s量级键鼠、工业控制、附加命令面板、免驱场景
USB虚拟串口系统自带,但老系统可能出问题批量传输几百KB/s到数MB/s需要快速持续传输的数据通道
MSC存储设备系统自带批量传输数MB/sU盘、文件交换、固件升级
DFU升级系统自带批量/控制传输视实现而定固件升级专属场景
WINUSB需要安装WinUSB驱动批量/中断传输类型灵活高性能免驱需求,但要处理驱动安装

自定义HID的优势集中在“免驱+系统原生识别”,代价是传输带宽有限。全速USB理论带宽12Mbps,HID用的是中断传输,实际链路层还会吃掉一部分协议开销,所以不能拿HID当大流量数据管道用。

1.3 什么场景下别硬上HID

如果你的产品要持续传输音频、视频,或者一次要传几百KB的日志数据,HID就明显不合适了。中断传输每1ms一个事务,单事务最大64字节,即使每秒都发满,净数据也就几十KB,跟虚拟串口的批量传输没法比。

还有一种情况是上位机开发团队不熟悉HID API,之前都是用串口读写数据,HID的上位机编程模型是“报告”而不是“字节流”,要自己处理分包、组帧、报告ID这些概念。如果团队没时间熟悉这套,强行上HID反而会增加联调成本。

我的建议很直接:需要免驱、数据量不大、可靠性要求高的场景,自定义HID是优选;数据量大或需要持续字节流,还是老老实实做批量传输,同时接受驱动安装的成本。

2. 从总线视角看USB全速与HID枚举——协议都在“看不见的地方”

2.1 先建立几个必要概念:端点、管道、事务

USB通信不能照搬串口那套“开个串口、发字节、收字节”的模型。USB主机是绝对的主从关系,设备永远不能主动发数据,只能响应主机的请求或令牌。

一个设备可以有好几个端点,端点地址由方向加编号组成,比如IN端点0x81表示端点1的输入方向。注意这里的IN、OUT都是以主机视角定义的:IN是设备往主机发,OUT是主机往设备发。写代码的时候稍不留神就会搞反。

HID通信使用的是中断端点,主机按固定时间间隔去设备里取一次数据,这个时间间隔由描述符里的bInterval字段决定。全速USB最低1ms一次,低速是10ms一次。

2.2 枚举过程:主机知道你的设备是什么

设备插上后,主机不认识你,它通过一套标准流程询问设备“你是谁”。这个过程叫枚举,大致分几步:

  1. 主机检测到设备接入,给D+/D-线上带来复位信号。
  2. 设备使用默认地址0响应主机的控制传输。
  3. 主机请求设备描述符,看设备支持的USB版本、VID/PID。
  4. 主机设置一个新的设备地址。
  5. 主机再次请求设备描述符、配置描述符、字符串描述符等。
  6. 主机发出SET_CONFIGURATION,设备配置生效,驱动开始工作。

自定义HID之所以看起来“免驱”,是因为主机在枚举时看到设备的接口类是HID,就直接加载系统自带HID驱动,不会去请求厂商驱动的安装包。如果枚举过程中描述符返回出错,主机就会判定设备不合法,表现为“未知设备”或设备不断重新连接。

2.3 中断传输的时间预算:别被理论带宽忽悠

全速USB的物理带宽是12Mbps,但这个带宽是共享的,其中有SOF帧起始包、控制传输、错误重传、握手包等协议开销。单个中断传输事务实际占用总线的时间,除了数据包本身,还包括令牌包、握手包、包间隔等。

以64字节的中断IN传输为例,轮询间隔设成1ms,那么理想情况下每秒可以传1000次,每次64字节,理论净吞吐就是64000字节/秒。但加上协议开销后,实际可用吞吐会低不少。我在STM32上用逻辑分析仪抓过,1ms轮询64字节的自定义HID设备,持续传输速率大约在50多KB/s,这个量级对命令交互是够用的,千万别拿它做文件传输。

3. CubeMX里把工程底子打好:时钟、外设、中间件

3.1 48MHz时钟是不可妥协的前提

USB全速外设对时钟要求非常苛刻,工作时钟必须是48MHz,不能多也不能少。STM32芯片内部的USB外设时钟来自系统时钟经过PLL分频或倍频后得到。如果你用外部晶振8MHz,PLL倍频后系统主频是72MHz,再经过USB预分频器得到48MHz,这套配置是正常的。

在CubeMX时钟树页面里,配置完系统时钟后,要专门看一眼USB时钟那一项是不是48MHz。如果显示48MHz,页面上一般是绿色或正常颜色;如果不是,会出现黄色警告,工程编译烧进去之后,大概率枚举失败。有一个特别隐蔽的坑:改过外置晶振频率后,忘记同步调整USB预分频,USB时钟变成52MHz之类的非标值,设备插上时好时坏。

3.2 中间件选HID类,但别急着写代码

在CubeMX的左侧Categories里,打开Connectivity,找到USB相关外设,比如USB_OTG_FS或USB外设,把它使能成Device模式。然后在Middleware and Software Packs里打开USB_DEVICE,选择Human Interface Device Class。

这时你会看到一些参数设置项:设备速度、VID、PID、厂商字符串、产品字符串等。VID/PID建议先在联盟申请或使用测试值,等量产前再换掉。字符串描述符能直观显示在系统设备管理器里,方便量产识别。

界面里那个HID类在CubeMX的作用是生成一套HID设备框架,包括usbd_hid.c、usbd_hid.h、usbd_desc.c这些文件,但它默认的设备行为偏向标准HID设备,不是我们最终要的“自定义HID”。所以生成代码之后,要自己动手改描述符和数据处理逻辑,CubeMX只负责搭架子。

3.3 引脚与最小系统检查

不同型号的USB引脚不一样。老的STM32F103系列用的是PA11和PA12,有些开发板上已经做了USB口,直接插就行。如果是自己做板子,D+和D-的走线要注意阻抗匹配,D+上一般要接1.5k上拉电阻,让主机识别为全速设备。没有上拉电阻或阻值不对,设备会一直无法被发现。

USB电源也要单独看,别和电机驱动器共用一路不稳的电源,USB枚举瞬间电流变化较大,电源抖动会导致设备枚举失败。

3.4 生成代码后,要确认的几个文件

工程生成后,不要急着写应用代码,先把下面几个文件打开看看:

  • usb_device.c:USB设备初始化入口,里面有MX_USB_DEVICE_Init,一般不需要大改。
  • usbd_desc.c:设备描述符、字符串描述符,要改VID/PID和产品名称。
  • usbd_customhid.c或usbd_hid.c:HID类实现,包括HID初始化、描述符、数据处理函数。
  • usbd_conf.c:底层配置,端点回调、中断优先级等。

我的习惯是先在debugger里单步跑一下MX_USB_DEVICE_Init,确认类注册成功,再继续后面的工作。

4. 描述符的每一字节都要心里有数

4.1 描述符的层级关系

一个HID设备对外暴露的配置信息,是一串嵌套的描述符:

  • 设备描述符:只有一个,包含VID、PID、设备类、端点0的最大包大小。
  • 配置描述符:描述这个设备支持的电源模式、接口数量。
  • 接口描述符:描述接口的类、子类、协议,对HID来说接口类固定是0x03。
  • HID描述符:描述HID版本、报表描述符长度等。
  • 端点描述符:描述IN/OUT端点的传输类型、最大包长、轮询间隔。

主机枚举时会一次把这些描述符读回去,任何一个字段返回非法值,设备就可能无法工作。比如说,接口描述符里的接口类是0x03,但报表描述符格式又是非法的,主机照样会拒绝加载HID驱动。

4.2 标准描述符里最常见的改法

在ST生成的HID代码里,USBD_HID_CfgDesc或USBD_HID_ConfigDesc数组保存了配置描述符、接口描述符、HID描述符和端点描述符。默认配置下,端点定义通常只包含一个IN端点。如果我们要实现双向通信,就必须在这里补一个OUT端点描述符,或者确认库的宏开关已经打开OUT端点。

配置描述符中的bInterval字段决定了主机轮询端点数据的周期。全速中断端点的最小值是1,我一般设置成1或5。对于需要较快响应的设备,设置1会好一点。注意全速HID的中断端点最大包长是64字节,不要写超。

HID描述符里有个wReportDescLength字段,它指向报表描述符的长度。改报表描述符后,这个长度一定要同步跟上,否则主机读取报表描述符时只读了一半,设备管理器中会显示无法识别的HID设备。

4.3 Report Descriptor:用字节描述你的报文

自定义HID和标准HID的差别,核心就在报表描述符。主机拿到报表描述符后,才知道你这个设备输入输出数据的结构。对我来说,报表描述符最难记的就是那些usage page、logical min/max、report size这些量化的“表意字节”,所以我习惯先写一个结构清晰的报表,再手工换算成字节数组。

以最常用的8字节自定义报文为例:4个按钮加4个8位模拟量,报表描述符可以写成这样:

uint8_t HID_ReportDesc[] = { 0x05, 0x01, // Usage Page (Generic Desktop) 0x09, 0x04, // Usage (Joystick) 0xA1, 0x01, // Collection (Application) 0x05, 0x09, // Usage Page (Button) 0x19, 0x01, // Usage Minimum (Button 1) 0x29, 0x08, // Usage Maximum (Button 8) 0x15, 0x00, // Logical Minimum (0) 0x25, 0x01, // Logical Maximum (1) 0x75, 0x01, // Report Size (1 bit) 0x95, 0x08, // Report Count (8 bits) 0x81, 0x02, // Input (Data, Variable, Absolute) 0x05, 0x01, // Usage Page (Generic Desktop) 0x09, 0x30, // Usage (X) 0x09, 0x31, // Usage (Y) 0x09, 0x32, // Usage (Z) 0x09, 0x33, // Usage (Rx) 0x16, 0x00, 0x00, // Logical Minimum (0) 0x26, 0xFF, 0x00, // Logical Maximum (255) 0x75, 0x08, // Report Size (8 bits) 0x95, 0x04, // Report Count (4 fields) 0x81, 0x02, // Input (Data, Variable, Absolute) 0xC0 // End Collection };

这套描述符的含义是:设备向主机上报4个按钮状态和4个数值,总共正好8个字节。按钮部分占一个字节,每个按钮一个bit,后面4个数值各占一个字节。上位机用系统HID接口读取时,收到的就是这8字节结构。

如果做真正“自定义”的HID,不想被理解成游戏手柄或键鼠,可以把Usage Page改成厂商自定义页。用0x06, 0x00, 0xFF设置Vendor Usage Page,再配合Vendor Defined用途,主机会把设备识别为通用的厂商自定义HID设备,不会自动绑到鼠标或键盘这类特殊设备上去。

4.4 报表描述符的验证技巧

报表描述符是最容易出错的字节流。写完以后,我强烈建议先用工具验证再烧录。可以找一些在线的HID报表描述符解析页面,把字节粘贴进去,看解析出来的结构是不是符合预期。解析结果里应该能清楚看到按钮字段、数值字段、输入输出方向。如果解析器直接报错,说明某个长度或最小值最大值字段写得不合理。

不要相信“写错了也能用”这种话。报表描述符错了,通常表现为设备被识别成HID但无法正确收发,或者设备管理中直接变未知设备。

5. 数据通路代码:从初始化到收发实战

5.1 初始化之后,类对象干了什么

CubeMX生成的代码会调用USBD_HID_Init,这个函数做的事情包括:

  • 注册HID设备的回调接口。
  • 打开HID的IN端点和OUT端点。
  • 准备好接收缓冲,用于存放主机发过来的OUT数据。
  • 保存报表描述符用于后续主机读取。

对于双向HID,必须确认OUT端点被打开。有的HAL库版本里,OUT端点默认不打开,需要你在USBD_HID_Init里补充USBD_LL_OpenEP调用,或者把某个宏打开。这块经常是“设备枚举成功但上位机写不进去”的根源。

5.2 发送Report的正确姿势与busy处理

设备往主机发数据,调用的是USBD_HID_SendReport。这个函数把数据拷贝到端点缓冲并触发USB传输,但它不是标准的阻塞函数,如果有上一次传输还没有完成,再次调用会失败。

ST库通常会提供一个状态查询函数,比如USBD_HID_GetState。发送前检查状态,如果上一个报告还没发完就返回空闲态,或者做简单的重试。

uint8_t tx_buf[64]; uint8_t USBD_HID_Busy = 0; void App_SendReport(uint8_t *data, uint16_t len) { if (USBD_HID_GetState(&hUsbDeviceFS) == USBD_HID_IDLE) { USBD_HID_SendReport(&hUsbDeviceFS, data, len); } else { // 上一次还没发完,要么丢弃,要么等下次再发 // 我一般会置一个标志,在状态变为IDLE后再补发 } }

这里有个容易被忽略的细节点:报告长度不要超过端点最大包长。如果报表描述符定义64字节,端点最大包长也是64,一次发送不能超过64字节。

5.3 OUT端点的接收:主机写进来的数据在哪

主机发数据给设备,实际是往OUT端点写。ST的HID类代码里,通常在HID_DataOut或类似回调里处理OUT数据,回调会拿到一个缓冲区指针和长度计数。最省事的做法是,在这个回调里把数据拷贝到自己的应用缓冲区,然后置一个“有新数据”的全局标志。

uint8_t usb_rx_buf[64]; volatile uint16_t usb_rx_len = 0; volatile uint8_t usb_rx_flag = 0; void HID_RxEvent(uint8_t *data, uint16_t len) { memcpy(usb_rx_buf, data, len); usb_rx_len = len; usb_rx_flag = 1; }

在HID_DataOut回调里调用这个HID_RxEvent,应用主循环检查到usb_rx_flag后,就拆包处理数据。回调运行在USB中断上下文里,不要在回调里做耗时操作,尤其不要做打印、计算、延时。

5.4 一个完整的收发回显例程

假设设备要做一个最简单的回显:上位机发来任意数据,设备原样返回。代码结构可以这样搭。

USB类模块:

/* usbd_hid_user.c 用户侧代码 */ uint8_t app_tx_buf[64]; void HID_RxEvent(uint8_t *data, uint16_t len) { // 原样拷贝到发送缓冲 memcpy(app_tx_buf, data, len); App_SendReport(app_tx_buf, len); }

应用主循环:

while (1) { if (usb_rx_flag) { usb_rx_flag = 0; // 处理协议逻辑,回显方式演示 HID_RxEvent(usb_rx_buf, usb_rx_len); } // 其他任务 }

这个例程看起来简单,但有几个细节值得思考:回显过程中,如果上一次发送还没完成,数据就会丢。真实项目中,我一般会维护一个环形缓冲区,把主机的数据先存起来,再按顺序发送。

5.5 中断里做还是主循环里做

USB的回调函数运行在USB中断上下文,主循环运行在后台线程上下文。两个上下文共享的数据,必须用标志位或环形队列缓冲。不要在中断里直接修改主循环正在使用的数组,也不要让主循环长期持有USB中断要使用的锁。

在裸机环境,我用一个循环缓冲区解决冲突;在RTOS环境,用消息队列把收到数据投递到任务,这是最稳的方式。USB中断优先级也要注意,一般不要设成最低优先级,否则高频率数据传输时可能被打断造成数据丢失。

6. 全速HID的带宽与实时性:数字说话

6.1 实际测量值,别迷信“12Mbps”

我在STM32F103上做过一个64字节、1ms轮询间隔的自定义HID回环测试。上位机连续发送64字节后,设备原样返回,用串口打印加逻辑分析仪统计持续吞吐。

实测结果稳定在每秒5万多字节,也就是50多KB/s。换算成bit是400多Kbps,而物理层明明有12Mbps的带宽,为什么不快?因为中断传输每个事务都要经过“令牌、数据、握手”三步双向传输,再加上帧间隔和总线仲裁,大量时间开销消耗在协议层了。

如果你想测试速度极限,可以用批量传输,但批量传输的设备类在系统里通常没有免驱HID接口,语义也不一样。

6.2 提高通信效率的几个参数选项

常见的优化方向有几个:

  • 减少轮询间隔:bInterval从2改到1,理论上限翻倍,但主机调度未必保证100%每帧都分配给你。
  • 增大报告长度:全速中断端点最大64字节,一次能带的数据就这么大。
  • 减少握手往返:HID协议中主机发来OUT数据,设备收到后一般不需要额外ACK,传输本身自带握手。

如果数据必须超过64字节,就得在软件里做分包和重组,但HID的单包事件语义不太适合连续传输大数据,这是协议设计决定的。

6.3 当HID撑不住的时候,怎么换路

真的遇到大流量需求,最直接的是把设备类改成CDC虚拟串口,使用批量端点,吞吐量可以到几百KB/s甚至更高。前提是驱动问题要提前和用户沟通好。微软系统自带的usbser.sys已经支持CDC,但老情况仍然存在。

还有一种做法是使用WINUSB接口类,配合系统的WinUSB驱动,既免驱又可以使用批量端点,但上位机开发要走WINUSB API,复杂度更高。我在实际项目里一般是这样决策:应用交互是低频命令、数据量小、要抗丢包,选自定义HID;应用需要连续传输日志或传感器原始数据,选CDC;对驱动有洁癖且数据量大,选WINUSB。

7. 实机调试中的典型坑和排查链路

7.1 枚举失败:从线缆到时钟按顺序排

最常看到的故障是设备插上后,USB设备管理器里反复刷新,最后变成“未知设备”或“设备描述符请求失败”。我会按顺序排查:

现象优先排查项实际经验
插上一点反应没有供电、D+/D-接线、晶振最常见的是USB座虚焊
有发现但设备反复断开时钟配置、48MHz是否正常CubeMX里改时钟后容易翻车
枚举卡住,描述符请求超时端点0最大包长、上拉电阻全速设备端点0一般发8或64字节
电脑提示无法识别HID报表描述符、接口描述符重点检查报表描述符长度

先看电源灯有没有稳,再用示波器或逻辑分析仪看D+/D-有无波形。如果波形一直跳但主机就是枚举不过,大概率是描述符字段越界。

7.2 主机不识别自定义报表

有一个很隐蔽的坑:报表描述符里的Report Count和实际代码发送的数据长度不一致。比如报表描述符定义了4个按钮加4个数字,共8字节,但代码里USBD_HID_SendReport发了16字节,主机读报表描述符打出来的数据就是乱的。

Windows系统自带的事件查看器里经常能看到“USB HID设备报告描述符无效”之类的信息。遇到这种问题,不要怀疑上位机,先去检查设备端的报表描述符。

7.3 发送卡死、端点STALL

如果程序执行到USBD_HID_SendReport后整个USB都卡死,多半是端点在发送过程中被主机认为出错,进入了STALL状态。发生STALL后,必须重新复位端点再继续传输。

我遇到过一种情况:应用层发数据太频繁,前一次还没发完就再次调用发送,导致端点缓冲冲突。后来统一改成“状态检查+发送完成回调”的模式,卡死问题就消失了。

7.4 用逻辑分析仪抓全速USB是否靠谱

全速USB的数据速率是12Mbps,普通几十块钱的逻辑分析仪采样率如果只有24M或48M,勉强能看个信号波形,解析出完整USB包就需要更高采样率和协议解析支持。我在项目里用的方案是,先看设备枚举阶段的描述符请求,确认传输过程有没有协议错误,再针对性检查端点。

没有逻辑分析仪时,可以在代码里用手动标记法排查:在枚举相关函数入口加GPIO翻转或串口打印,看代码走到哪一步停了。这样能快速定位是时钟问题、端点配置问题还是描述符问题。

一些工具和效率建议

如果你还没被HID折腾过,先用CubeMX生成一个最简工程,把系统自带的HID样板跑通,再用串口观察设备枚举状态。千万别一开始就堆业务代码,USB协议栈调试的变量太多,至少得像这样分步做:

  1. 保持CubeMX默认VID/PID,先让系统识别出一个标准HID设备。
  2. 改动报表描述符,改成8字节自定义报文,验证收发。
  3. 改动字符串描述符、VID/PID,做成产品规格。
  4. 再加应用协议层。

每一步都用逻辑分析仪或事件查看器确认,能节约大量联调时间。

我这里还有一个经验:把报表描述符的长度宏、端点最大包长宏、发送缓冲区长度统一用一个常量控制,比如一个#define HID_REPORT_SIZE 64,油文档和代码注释里写清楚这个量是报表描述符决定的,其他地方引用它就不会改漏。项目做久了你会发现,USB层出的很多问题根本不是协议难,而是同一份代码里多个长度数据不同步。

回想这个自定义HID项目,折腾最久的地方不是底层驱动,而是对上位机“报告模式”的理解。设备端把数据摆成64字节报告,主机端拿到的也是64字节报告,两端只要格式一致,通信就像在操作一个双向数据窗口,既干净又稳定。以后我再做类似的USB设备,大概率还是会选这条路线,至少在免驱这一项上,它解决的是实实在在的部署痛点。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询