文章目录
-
- 摘要
- [为什么不用串口,改走 HID](#为什么不用串口,改走 HID)
- 前置准备
- 架构总览
- 报告描述符逐字节拆解
- [设计决策:HID vs CDC vs WinUSB](#设计决策:HID vs CDC vs WinUSB)
- [CubeMX 配置](#CubeMX 配置)
- 核心代码:收发回调
-
- [发送数据(设备 → 主机)](#发送数据(设备 → 主机))
- [接收数据(主机 → 设备)](#接收数据(主机 → 设备))
- [上位机:Python hidapi 收发](#上位机:Python hidapi 收发)
- 测试验证
-
- [理论 vs 实测对照](#理论 vs 实测对照)
- 故障排查
-
- 问题一:设备能枚举,但上位机读不到数据
- 问题二:能通信,但数据乱码或错位
- [问题三:设备枚举失败,或识别成"未知 USB 设备"](#问题三:设备枚举失败,或识别成"未知 USB 设备")
- 问题四:连续收发一段时间后卡死或丢数据
- 总结
摘要
在嵌入式项目里,把 MCU 数据传给 PC 最常用的做法是串口(CDC),但它需要装驱动、被占用后无法复用,而且跨平台体验参差不齐。本文基于 STM32F103C8T6,利用 USB HID 的"自定义设备类"实现一个免驱 的 64 字节双向通信通道:一端通过 CubeMX 生成 Custom HID 工程,手工编写 Vendor 定义页(0xFF00)报告描述符;另一端用 Python hidapi 完成上位机收发。实测单包 64 字节、1ms 轮询间隔下,IN 方向吞吐 61.3KB/s、OUT 方向 58.7KB/s、双向同时 105KB/s,丢包率 0%;端到端延迟稳定在 1~2ms。文末附完整报告描述符、收发回调与上位机脚本,以及四类典型故障的排查过程。
为什么不用串口,改走 HID
做数据采集类设备时,我最早也是清一色 USB 转串口(CDC)。它上手快,PC 端用现成的串口助手就能读数据。但项目做到中后期,几个问题开始反复折磨人:
- 驱动:Windows 上 CH340/CP2102 都要装驱动,客户现场的电脑没有管理员权限,装不上驱动设备就成了砖。
- 独占:串口一次只能被一个程序打开,上位机和调试助手抢口子。
- 兼容 :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 设备从"插上"到"能传数据"要经历下面这条链路:
#mermaid-svg-iWIa5hOtfMQABJLS{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-iWIa5hOtfMQABJLS .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-iWIa5hOtfMQABJLS .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-iWIa5hOtfMQABJLS .error-icon{fill:#552222;}#mermaid-svg-iWIa5hOtfMQABJLS .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-iWIa5hOtfMQABJLS .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-iWIa5hOtfMQABJLS .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-iWIa5hOtfMQABJLS .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-iWIa5hOtfMQABJLS .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-iWIa5hOtfMQABJLS .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-iWIa5hOtfMQABJLS .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-iWIa5hOtfMQABJLS .marker{fill:#333333;stroke:#333333;}#mermaid-svg-iWIa5hOtfMQABJLS .marker.cross{stroke:#333333;}#mermaid-svg-iWIa5hOtfMQABJLS svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-iWIa5hOtfMQABJLS p{margin:0;}#mermaid-svg-iWIa5hOtfMQABJLS .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-iWIa5hOtfMQABJLS .cluster-label text{fill:#333;}#mermaid-svg-iWIa5hOtfMQABJLS .cluster-label span{color:#333;}#mermaid-svg-iWIa5hOtfMQABJLS .cluster-label span p{background-color:transparent;}#mermaid-svg-iWIa5hOtfMQABJLS .label text,#mermaid-svg-iWIa5hOtfMQABJLS span{fill:#333;color:#333;}#mermaid-svg-iWIa5hOtfMQABJLS .node rect,#mermaid-svg-iWIa5hOtfMQABJLS .node circle,#mermaid-svg-iWIa5hOtfMQABJLS .node ellipse,#mermaid-svg-iWIa5hOtfMQABJLS .node polygon,#mermaid-svg-iWIa5hOtfMQABJLS .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-iWIa5hOtfMQABJLS .rough-node .label text,#mermaid-svg-iWIa5hOtfMQABJLS .node .label text,#mermaid-svg-iWIa5hOtfMQABJLS .image-shape .label,#mermaid-svg-iWIa5hOtfMQABJLS .icon-shape .label{text-anchor:middle;}#mermaid-svg-iWIa5hOtfMQABJLS .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-iWIa5hOtfMQABJLS .rough-node .label,#mermaid-svg-iWIa5hOtfMQABJLS .node .label,#mermaid-svg-iWIa5hOtfMQABJLS .image-shape .label,#mermaid-svg-iWIa5hOtfMQABJLS .icon-shape .label{text-align:center;}#mermaid-svg-iWIa5hOtfMQABJLS .node.clickable{cursor:pointer;}#mermaid-svg-iWIa5hOtfMQABJLS .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-iWIa5hOtfMQABJLS .arrowheadPath{fill:#333333;}#mermaid-svg-iWIa5hOtfMQABJLS .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-iWIa5hOtfMQABJLS .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-iWIa5hOtfMQABJLS .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-iWIa5hOtfMQABJLS .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-iWIa5hOtfMQABJLS .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-iWIa5hOtfMQABJLS .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-iWIa5hOtfMQABJLS .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-iWIa5hOtfMQABJLS .cluster text{fill:#333;}#mermaid-svg-iWIa5hOtfMQABJLS .cluster span{color:#333;}#mermaid-svg-iWIa5hOtfMQABJLS div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-iWIa5hOtfMQABJLS .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-iWIa5hOtfMQABJLS rect.text{fill:none;stroke-width:0;}#mermaid-svg-iWIa5hOtfMQABJLS .icon-shape,#mermaid-svg-iWIa5hOtfMQABJLS .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-iWIa5hOtfMQABJLS .icon-shape p,#mermaid-svg-iWIa5hOtfMQABJLS .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-iWIa5hOtfMQABJLS .icon-shape .label rect,#mermaid-svg-iWIa5hOtfMQABJLS .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-iWIa5hOtfMQABJLS .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-iWIa5hOtfMQABJLS .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-iWIa5hOtfMQABJLS :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 主机端
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.c 的 CUSTOM_HID_ReportDesc_FS 数组里):
c
__ALIGN_BEGIN static uint8_t CUSTOM_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 是全文最关键的一处。0x06 是 USAGE_PAGE 标签,后面两个字节 0x00 0xFF 按小端 拼成 0xFF00,即 Vendor Defined(厂商自定义)页。选择这个页,等于向主机声明"我不是键盘也不是鼠标,数据含义由你自定义",主机就不会把字节翻译成按键或位移。
几个容易搞错的点,我逐条说明:
0x26 0xFF 0x00为什么不是0x25?0x25是LOGICAL_MAXIMUM的单字节版本,最大只能表达 255;0x26是双字节版本。虽然这里值本身也是 255,但为了跟 64 个字段的规模匹配、避免某些主机解析器对单字节形式的边界判断不一致,我习惯统一用双字节形式。两者在 Windows 上都能用,但双字节写法兼容性更稳。0x95 0x40里的0x40是 64 :REPORT_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 配置
- 新建工程,芯片选
STM32F103C8Tx。 System Core → RCC:HSE 选Crystal/Ceramic Resonator。Connectivity → USB:勾选Device (FS)。Middleware → USB_DEVICE:Class 选Custom Human Interface Device Class (HID)。- 时钟树: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。
USB_DEVICE参数页,改这三个值:CUSTOM_HID_FS_BINTERVAL:0x01(1ms 轮询,最快)USBD_CUSTOM_HID_REPORT_DESC_SIZE:33(上面描述符字节数)USBD_CUSTOMHID_OUTREPORT_BUF_SIZE:64(OUT 缓冲,最大包长)
- 生成代码。
相关阅读:《STM32 自定义 HID USB 设备的实现》 --- 用旧标准外设库手写描述符的经典版,理解底层更有帮助。
核心代码:收发回调
生成代码后,真正要改的就一个文件 usbd_custom_hid_if.c。发送由库函数完成,接收则要关注库里的 USBD_CUSTOM_HID_DataOut 触发点。
发送数据(设备 → 主机)
USBD_CUSTOM_HID_SendReport 内部会走中断 IN 端点,把缓冲区的数据按报告描述符的长度打包发给主机。封装一个对外函数:
c
// usbd_custom_hid_if.c
uint8_t usb_hid_send(uint8_t *buf, uint16_t len)
{
// 参数校验:HID 全速单包最大 64 字节
if (len > USBD_CUSTOMHID_OUTREPORT_BUF_SIZE) {
return 1;
}
// 拷贝到发送缓冲,避免上层缓冲区在中断发送期间被改写
memcpy(usb_tx_buf, buf, len);
return USBD_CUSTOM_HID_SendReport(&hUsbDeviceFS, usb_tx_buf, len);
}
这里有个顺序陷阱 :USBD_CUSTOM_HID_SendReport 是异步的,它只是把数据写入端点的 FIFO 并启动发送,函数返回时数据可能还没真正发出去。如果调用方立刻改写传入的缓冲区,就可能发出半新半旧的数据。所以上面先 memcpy 到一块专用发送缓冲 usb_tx_buf,从根上避免数据竞争。
接收数据(主机 → 设备)
接收是异步回调模式。库在收到 OUT 数据后会调用 CUSTOM_HID_OutEvent_FS,我们在这里把数据读出来:
c
// usbd_custom_hid_if.c
static int8_t CUSTOM_HID_OutEvent_FS(uint8_t event_idx, uint8_t state)
{
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;
}
return USBD_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 驱动):
python
import hid
import time
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 ID
for _ in range(10):
data = dev.read(REPORT_LEN, timeout_ms=1000)
if data:
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(设备→主机) | 64B | 64KB/s | 61.3KB/s | 0% | 1.1ms |
| OUT(主机→设备) | 64B | 64KB/s | 58.7KB/s | 0% | 1.4ms |
| 双向同时收发 | 64B×2 | 128KB/s | 105.2KB/s | 0% | 1.6ms |
理论 vs 实测对照
HID 中断传输每 1ms 轮询一次,理论上每秒最多 1000 次事务、每次 64 字节,因此单向理论吞吐 64KB/s。实测 IN 方向 61.3KB/s,只有约 4% 的损耗,这部分主要来自主机 USB 主控的调度抖动和 Python 层 read 的开销;OUT 方向更低(58.7KB/s),因为 dev.write 是阻塞式提交,Python 解释器切换带来的固定开销更明显。
| 方向 | 理论值 | 实测值 | 偏差 | 原因 |
|---|---|---|---|---|
| IN | 64.0KB/s | 61.3KB/s | -4.2% | 主控调度抖动 + 应用层读取开销 |
| OUT | 64.0KB/s | 58.7KB/s | -8.3% | write 阻塞提交,解释器开销大 |
| 双向 | 128.0KB/s | 105.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=64与USBD_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的细节有补充。
总结
回头看这个项目,核心收获有三条:
- 报告描述符是 HID 的"灵魂" :设备描述符决定"你是谁",报告描述符决定"你的数据长什么样"。选对
USAGE_PAGE (0xFF00)就拿到了"自定义设备"的钥匙,INPUT标志位写错则满盘皆输。 - 48MHz 时钟是 F103 USB 的命门:这个坑隐蔽在"能枚举但不通"的灰色地带,用示波器看 SOF 间隔是最快的定位手段。
- 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 库,报告描述符逻辑不变但回调与句柄结构不同