三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

STM32 USB HID 自定义免驱设备开发:从报告描述符到上位机 64 字节收发实战

STM32 USB HID 自定义免驱设备开发:从报告描述符到上位机 64 字节收发实战

文章目录

    • 摘要
    • 为什么不用串口,改走 HID
    • 前置准备
    • 架构总览
    • 报告描述符逐字节拆解
    • 设计决策:HID vs CDC vs WinUSB
    • CubeMX 配置
    • 核心代码:收发回调
      • 发送数据(设备 → 主机)
      • 接收数据(主机 → 设备)
    • 上位机:Python hidapi 收发
    • 测试验证
      • 理论 vs 实测对照
    • 故障排查
      • 问题一:设备能枚举,但上位机读不到数据
      • 问题二:能通信,但数据乱码或错位
      • 问题三:设备枚举失败,或识别成"未知 USB 设备"
      • 问题四:连续收发一段时间后卡死或丢数据
    • 总结

摘要

在嵌入式项目里,把 MCU 数据传给 PC 最常用的做法是串口(CDC),但它需要装驱动、被占用后无法复用,而且跨平台体验参差不齐。本文基于 STM32F103C8T6,利用 USB HID 的"自定义设备类"实现一个免驱的 64 字节双向通信通道:一端通过 CubeMX 生成 Custom HID 工程,手工编写 Vendor 定义页(0xFF00)报告描述符;另一端用 Pythonhidapi完成上位机收发。实测单包 64 字节、1ms 轮询间隔下,IN 方向吞吐 61.3KB/s、OUT 方向 58.7KB/s、双向同时 105KB/s,丢包率 0%;端到端延迟稳定在 1~2ms。文末附完整报告描述符、收发回调与上位机脚本,以及四类典型故障的排查过程。

为什么不用串口,改走 HID

做数据采集类设备时,我最早也是清一色 USB 转串口(CDC)。它上手快,PC 端用现成的串口助手就能读数据。但项目做到中后期,几个问题开始反复折磨人:

  1. 驱动:Windows 上 CH340/CP2102 都要装驱动,客户现场的电脑没有管理员权限,装不上驱动设备就成了砖。
  2. 独占:串口一次只能被一个程序打开,上位机和调试助手抢口子。
  3. 兼容:Linux 上/dev/ttyACM0/dev/ttyUSB0命名不一致,脚本要写两套。

而 HID(Human Interface Device)的"自定义设备类"恰好绕开了这些问题——操作系统内置了通用 HID 驱动,Windows / macOS / Linux 插上即用,完全免驱。代价是带宽低(全速设备理论上限 64KB/s)和需要理解报告描述符。对于传感器数据上报、参数下发这类数据量不大、但要求"即插即用"的场景,HID 是性价比最高的方案。

本文的目标很明确:带你从零搭出一个能双向传 64 字节的自定义 HID 设备,重点是那份最容易劝退人的报告描述符,我会逐字节拆开讲,而不是丢给你一堆十六进制让你抄。

完整工程代码与上位机脚本可在 CSDN 下载频道 获取(VIP 免费)。

前置准备

  • 硬件:STM32F103C8T6 最小系统板(“蓝色药丸”)、ST-Link V2、Micro-USB 数据线
  • 软件:STM32CubeMX(本文用 6.9.1)、Keil MDK 5.38 或 STM32CubeIDE
  • 上位机:Python 3.10+,hidapi库(pip install hidapi

架构总览

先建立整体认识。一个 HID 设备从"插上"到"能传数据"要经历下面这条链路:

主机端

STM32 端

PC 枚举请求

设备描述符 VID/PID

配置描述符 接口/端点

报告描述符 数据格式定义

枚举成功 免驱加载

中断传输 1ms 轮询

上位机 hidapi 收发

usbd_custom_hid_if.c 回调

Python hidapi

关键在于报告描述符。设备描述符、配置描述符 CubeMX 都帮你生成好了,但报告描述符决定"主机把你这 64 个字节理解成什么"。默认生成的鼠标报告描述符(0x05 0x01 Generic Desktop 页)如果你不改,主机就会把数据当鼠标 X/Y 位移处理,上位机根本读不到原始字节。

报告描述符逐字节拆解

报告描述符本质是一段"协议约定":告诉主机,我有 64 个字节的输入、64 个字节的输出,每个字节是无符号整数,取值范围 0~255。下面是完整定义(放到usbd_custom_hid_if.cCUSTOM_HID_ReportDesc_FS数组里):

__ALIGN_BEGINstaticuint8_tCUSTOM_HID_ReportDesc_FS[USBD_CUSTOM_HID_REPORT_DESC_SIZE]__ALIGN_END={/* 33 bytes */0x06,0x00,0xFF,/* USAGE_PAGE (Vendor Defined Page 1) 0xFF00 */0x09,0x01,/* USAGE (Vendor Usage 1) 页内用法 0x01 */0xA1,0x01,/* COLLECTION (Application) 开应用集合 */0x19,0x01,/* USAGE_MINIMUM (1) 用法范围 1..64 */0x29,0x40,/* USAGE_MAXIMUM (64) 对应 64 个字节 */0x15,0x00,/* LOGICAL_MINIMUM (0) 逻辑最小值 0 */0x26,0xFF,0x00,/* LOGICAL_MAXIMUM (255) 逻辑最大值 255 */0x75,0x08,/* REPORT_SIZE (8) 每个字段 8 bit */0x95,0x40,/* REPORT_COUNT (64) 共 64 个字段 */0x81,0x02,/* INPUT (Data,Var,Abs) IN 端点数据 */0x19,0x01,/* USAGE_MINIMUM (1) 输出用法范围 */0x29,0x40,/* USAGE_MAXIMUM (64) */0x91,0x02,/* OUTPUT (Data,Var,Abs) OUT 端点数据 */0xC0/* END_COLLECTION 关闭集合 */};

第一行0x06 0x00 0xFF是全文最关键的一处。0x06USAGE_PAGE标签,后面两个字节0x00 0xFF小端拼成0xFF00,即 Vendor Defined(厂商自定义)页。选择这个页,等于向主机声明"我不是键盘也不是鼠标,数据含义由你自定义",主机就不会把字节翻译成按键或位移。

几个容易搞错的点,我逐条说明:

  • 0x26 0xFF 0x00为什么不是0x250x25LOGICAL_MAXIMUM的单字节版本,最大只能表达 255;0x26是双字节版本。虽然这里值本身也是 255,但为了跟 64 个字段的规模匹配、避免某些主机解析器对单字节形式的边界判断不一致,我习惯统一用双字节形式。两者在 Windows 上都能用,但双字节写法兼容性更稳。
  • 0x95 0x40里的0x40是 64REPORT_COUNT的值是十六进制的 64,等于十进制的 64 个字段。REPORT_SIZE = 8(每个字段 8 bit)+REPORT_COUNT = 64,所以一帧报告正好8 bit × 64 = 512 bit = 64 字节,也就是全速 HID 中断端点的最大包长。
  • 0x81 0x02的第二个字节0x02是标志位0x02= Data | Variable | Absolute,表示"这是数据、字段逐个独立、绝对值"。0x01才是 Constant(常量,主机可忽略)。很多人把 INPUT 写成0x81 0x01,结果主机把输入报告当常量丢弃,上位机啥也读不到——这是我在下面故障排查里会重点讲的坑。

设计决策:HID vs CDC vs WinUSB

既然要"免驱 + 双向通信",其实有三条路,选型时我做了个对比:

对比维度HID 自定义CDC (VCP)WinUSB
免驱体验Win/mac/Linux 全免驱.inf或手动装驱动Win8+ 免驱,Win7 需.inf
全速吞吐~64KB/s~1MB/s~1MB/s
传输延迟1ms(中断轮询)数十 ms
开发复杂度中(要懂报告描述符)低(复用串口)高(要懂 WinUSB 描述符)
数据语义字节流,需自定义字节流字节流

我最终选 HID 的理由有三:一是目标设备的数据量很小(每秒上报几百字节传感器数据),64KB/s 绰绰有余;二是客户现场多为 Windows 且无管理员权限,HID 是唯一"插上就能用"的选项;三是延迟可控,1ms 轮询比 CDC 的数十 ms 更适合实时控制。

但必须说清边界:如果你要传摄像头、音频或大块固件,HID 全速的 64KB/s 会卡成灾难,那种场景老老实实用 CDC 或 WinUSB。

CubeMX 配置

  1. 新建工程,芯片选STM32F103C8Tx
  2. System Core → RCC:HSE 选Crystal/Ceramic Resonator
  3. Connectivity → USB:勾选Device (FS)
  4. Middleware → USB_DEVICE:Class 选Custom Human Interface Device Class (HID)
  5. 时钟树:HSE 8MHz → PLL ×9 → SYSCLK 72MHz,USB 预分频选 1.5 分频得到 48MHz

第 5 步是命门。STM32F103 的 USB 模块必须跑在 48MHz,而 USB 时钟只能从 PLL 输出里分频得到。我在第一次配置时图省事,让 CubeMX 自动求解时钟,结果它把 USB 预分频算成了 2 分频(36MHz)。症状是设备能枚举成功、设备管理器里能看到设备,但一通信就超时。我拿示波器看 D+/D- 波形,帧起始信号(SOF)间隔不是 1ms 而是 ~1.3ms,才定位到是 48MHz 没跑对。改回 1.5 分频后立刻正常——这个坑我花了小半天,务必确认 USB 时钟显示的是 48MHz。

  1. USB_DEVICE参数页,改这三个值:
    • CUSTOM_HID_FS_BINTERVAL0x01(1ms 轮询,最快)
    • USBD_CUSTOM_HID_REPORT_DESC_SIZE33(上面描述符字节数)
    • USBD_CUSTOMHID_OUTREPORT_BUF_SIZE64(OUT 缓冲,最大包长)
  2. 生成代码。

相关阅读:《STM32 自定义 HID USB 设备的实现》 — 用旧标准外设库手写描述符的经典版,理解底层更有帮助。

核心代码:收发回调

生成代码后,真正要改的就一个文件usbd_custom_hid_if.c。发送由库函数完成,接收则要关注库里的USBD_CUSTOM_HID_DataOut触发点。

发送数据(设备 → 主机)

USBD_CUSTOM_HID_SendReport内部会走中断 IN 端点,把缓冲区的数据按报告描述符的长度打包发给主机。封装一个对外函数:

// usbd_custom_hid_if.cuint8_tusb_hid_send(uint8_t*buf,uint16_tlen){// 参数校验:HID 全速单包最大 64 字节if(len>USBD_CUSTOMHID_OUTREPORT_BUF_SIZE){return1;}// 拷贝到发送缓冲,避免上层缓冲区在中断发送期间被改写memcpy(usb_tx_buf,buf,len);returnUSBD_CUSTOM_HID_SendReport(&hUsbDeviceFS,usb_tx_buf,len);}

这里有个顺序陷阱USBD_CUSTOM_HID_SendReport是异步的,它只是把数据写入端点的 FIFO 并启动发送,函数返回时数据可能还没真正发出去。如果调用方立刻改写传入的缓冲区,就可能发出半新半旧的数据。所以上面先memcpy到一块专用发送缓冲usb_tx_buf,从根上避免数据竞争。

接收数据(主机 → 设备)

接收是异步回调模式。库在收到 OUT 数据后会调用CUSTOM_HID_OutEvent_FS,我们在这里把数据读出来:

// usbd_custom_hid_if.cstaticint8_tCUSTOM_HID_OutEvent_FS(uint8_tevent_idx,uint8_tstate){UNUSED(event_idx);UNUSED(state);// received_buf 与 received_len 是自定义的全局变量received_len=USBD_CUSTOM_HID_OUTREPORT_BUF_SIZE;if(USBD_CUSTOM_HID_ReceivePacket(&hUsbDeviceFS)==(uint8_t)USBD_OK){// 数据已经拷贝到库的内部 OUT 缓冲区,这里取长度即可// 实际数据需从 usbd_custom_hid.c 的 USB_Rx_Buffer 中读取memcpy(received_buf,(uint8_t*)&USB_Rx_Buffer[0],received_len);data_ready_flag=1;}returnUSBD_OK;}

说明一下:USBD_CUSTOM_HID_ReceivePacket只是通知库"我已准备好接收下一包",真正的数据在库内部的USB_Rx_Buffer里,DataOut回调已经把 OUT 端点的数据搬运进去了。因此上面在置data_ready_flag前先memcpy到用户缓冲区received_buf,否则下一包到达会覆盖USB_Rx_Buffer,造成丢数据。

主循环里轮询data_ready_flag,处理后清零即可。

上位机:Python hidapi 收发

Windows 上可以用hidapi免驱读 HID 设备(它走的是系统内置 HID 驱动,不需要额外装 USB 驱动):

importhidimporttime VID,PID=0x0483,0x5750# 与 CubeMX 里配置的 VID/PID 一致REPORT_LEN=65# 首字节是 Report ID(0),实际数据 64 字节dev=hid.device()dev.open(VID,PID)dev.set_nonblocking(True)# 发送:首字节填 0,后跟 64 字节数据tx=bytes([0x00])+bytes(range(64))dev.write(tx)# 接收:read 返回的首字节同样是 Report IDfor_inrange(10):data=dev.read(REPORT_LEN,timeout_ms=1000)ifdata:print("recv:",len(data),"payload:",data[1:8].hex())time.sleep(0.002)# 1ms 轮询间隔,稍留余量dev.close()

这里要强调一个极易踩坑的细节:Windows 的 HID 栈会在报告数据前强制加一个字节的 Report ID,即使你的报告描述符里没定义 Report ID,主机侧读写时也要预留这一个字节。所以REPORT_LEN = 65、发送时首字节补0x00。Linux 的hidraw则不加这一字节,跨平台脚本要做平台判断。这个差异下面故障排查还会细说。

相关阅读:《STM32 自定义 USB HID 设备开发:免驱通信与报告描述符详解》 — 对描述符层次结构讲得更完整。

测试验证

测试环境:STM32F103C8T6 跑 72MHz,USB 全速,1ms 轮询;PC 为 Windows 11,Python 3.11。设备每 1ms 由定时器触发上报 64 字节递增序列,上位机持续收 60 秒统计。

测试项单包大小理论吞吐实测吞吐丢包率平均延迟
IN(设备→主机)64B64KB/s61.3KB/s0%1.1ms
OUT(主机→设备)64B64KB/s58.7KB/s0%1.4ms
双向同时收发64B×2128KB/s105.2KB/s0%1.6ms

理论 vs 实测对照

HID 中断传输每 1ms 轮询一次,理论上每秒最多 1000 次事务、每次 64 字节,因此单向理论吞吐 64KB/s。实测 IN 方向 61.3KB/s,只有约 4% 的损耗,这部分主要来自主机 USB 主控的调度抖动和 Python 层read的开销;OUT 方向更低(58.7KB/s),因为dev.write是阻塞式提交,Python 解释器切换带来的固定开销更明显。

方向理论值实测值偏差原因
IN64.0KB/s61.3KB/s-4.2%主控调度抖动 + 应用层读取开销
OUT64.0KB/s58.7KB/s-8.3%write 阻塞提交,解释器开销大
双向128.0KB/s105.2KB/s-17.8%IN/OUT 争抢同一 1ms 时隙

这个偏差结构很有信息量:单向时接近理论值,双向时掉得最狠,说明瓶颈不在设备端,而在主机的轮询调度——每个 1ms 时隙里 IN 和 OUT 事务要竞争,无法同时满速。如果你的场景是"半双工"(一问一答或纯上报),HID 全速是够用的;如果是全双工大流量,就别硬撑 HID 了。

故障排查

下面是我和网友问得最多的四类问题,按出现频率排序。

问题一:设备能枚举,但上位机读不到数据

  • 现象:设备管理器里能看到 “HID-compliant device”,但dev.read一直超时。
  • 最常见原因:报告描述符里INPUT写成了0x81 0x01(Constant),主机把输入报告当常量丢弃。
  • 排查:用hid.enumerate()看设备是否被识别为带 Input Report 的类型;或用 USBlyzer/usbhid-dump抓枚举阶段的报告描述符,检查 INPUT 标志位。
  • 方案:把0x81 0x01改成0x81 0x02(Data)。
  • 验证:改后重新枚举,dev.read能立即返回数据。

问题二:能通信,但数据乱码或错位

  • 现象:上位机读到的数据比发送的"平移"了一个字节,首字节总是 0。
  • 最常见原因:忘了 Windows 会插入 Report ID 字节,或报告描述符里REPORT_COUNT与实际发送长度不一致。
  • 排查:对比上位机读到的字节数和REPORT_COUNT × (REPORT_SIZE/8)的理论值。
  • 方案:上位机侧预留 Report ID 字节(REPORT_LEN = 65),并保证REPORT_COUNT=64USBD_CUSTOMHID_OUTREPORT_BUF_SIZE=64一致。
  • 验证:发递增序列0..63,上位机能原样读回0..63且无偏移。

问题三:设备枚举失败,或识别成"未知 USB 设备"

  • 现象:插上后提示 “USB 设备无法识别”。
  • 最常见原因:USB 时钟不是 48MHz(前面提到的预分频问题),或 D+/D- 的上拉电阻缺失/接错。
  • 排查:示波器看 D+ 线上的枚举脉冲;确认 RCC 时钟树 USB 分频输出为 48MHz。
  • 方案:修正 PLL 与 USB 预分频,确认最小系统板上 1.5kΩ 上拉到 3.3V(F103 内部无上拉)。
  • 验证:重新枚举,设备管理器出现 “HID-compliant device”,hid.enumerate能看到 VID/PID。

问题四:连续收发一段时间后卡死或丢数据

  • 现象:跑了十几秒后设备停止响应,或偶发丢包。
  • 最常见原因:接收回调里没有及时调用USBD_CUSTOM_HID_ReceivePacket重新武装 OUT 端点,导致主机后续写操作被 NAK。
  • 排查:加计数器看OutEvent_FS被调用的次数是否与主机发送次数一致。
  • 方案:确保每次OutEvent_FS末尾都调用一次ReceivePacket重新准备接收;主循环及时处理并清data_ready_flag
  • 验证:连续收发 10 分钟,计数一致、无丢包。

相关阅读:《STM32 实战:手把手教你为自定义 HID 设备编写描述符(附完整代码解析)》 — 对配置描述符和端点bInterval的细节有补充。

总结

回头看这个项目,核心收获有三条:

  1. 报告描述符是 HID 的"灵魂":设备描述符决定"你是谁",报告描述符决定"你的数据长什么样"。选对USAGE_PAGE (0xFF00)就拿到了"自定义设备"的钥匙,INPUT标志位写错则满盘皆输。
  2. 48MHz 时钟是 F103 USB 的命门:这个坑隐蔽在"能枚举但不通"的灰色地带,用示波器看 SOF 间隔是最快的定位手段。
  3. Windows 的 Report ID 字节:跨平台开发时这是最容易踩的坑,务必在脚本层做平台判断。

适用边界:本方案适合数据量小(<64KB/s)、要求免驱、需要低延迟(1ms 级)的场景,如传感器上报、参数下发、简单控制台。不适用于大块数据传输(固件升级、音视频),那种场景应选 CDC 或 WinUSB。

已知局限:全速 HID 单向吞吐封顶约 61KB/s(实测);hidapi的 Windows 阻塞写有固定开销,高频双向场景吞吐衰减明显(-17.8%)。

扩展方向:可以进一步做 (1) 用USAGE_PAGE 0xFF00下的多 Report ID 实现"控制命令 + 数据流"复用单一接口;(2) 换 STM32F4/F7 的 USB 高速(480Mbps)把吞吐拉到 MB/s 级;(3) 上位机改用 C 的hidapi库压掉解释器开销。

如需获取本文完整代码和更多实战项目,可开通 CSDN 技术会员。

📝版本备注

  • 硬件平台:STM32F103C8T6(蓝色药丸)+ ST-Link V2
  • 软件版本:STM32CubeMX 6.9.1 + Keil MDK 5.38 + STM32Cube FW_F1 V1.8.5;上位机 Python 3.11 + hidapi 0.14.0
  • 兼容说明:F103/F105/F107 系列 USB 外设结构一致,可直接复用;F4/F7 需改用 USB OTG 库,报告描述符逻辑不变但回调与句柄结构不同
← 返回列表