做嵌入式开发,特别是工业现场的设备调试,最烦的一件事就是装驱动。以前用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/s | U盘、文件交换、固件升级 |
| 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 枚举过程:主机知道你的设备是什么
设备插上后,主机不认识你,它通过一套标准流程询问设备“你是谁”。这个过程叫枚举,大致分几步:
- 主机检测到设备接入,给D+/D-线上带来复位信号。
- 设备使用默认地址0响应主机的控制传输。
- 主机请求设备描述符,看设备支持的USB版本、VID/PID。
- 主机设置一个新的设备地址。
- 主机再次请求设备描述符、配置描述符、字符串描述符等。
- 主机发出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协议栈调试的变量太多,至少得像这样分步做:
- 保持CubeMX默认VID/PID,先让系统识别出一个标准HID设备。
- 改动报表描述符,改成8字节自定义报文,验证收发。
- 改动字符串描述符、VID/PID,做成产品规格。
- 再加应用协议层。
每一步都用逻辑分析仪或事件查看器确认,能节约大量联调时间。
我这里还有一个经验:把报表描述符的长度宏、端点最大包长宏、发送缓冲区长度统一用一个常量控制,比如一个#define HID_REPORT_SIZE 64,油文档和代码注释里写清楚这个量是报表描述符决定的,其他地方引用它就不会改漏。项目做久了你会发现,USB层出的很多问题根本不是协议难,而是同一份代码里多个长度数据不同步。
回想这个自定义HID项目,折腾最久的地方不是底层驱动,而是对上位机“报告模式”的理解。设备端把数据摆成64字节报告,主机端拿到的也是64字节报告,两端只要格式一致,通信就像在操作一个双向数据窗口,既干净又稳定。以后我再做类似的USB设备,大概率还是会选这条路线,至少在免驱这一项上,它解决的是实实在在的部署痛点。