6. USB HID设备开发实战:从协议到代码
大家好,欢迎来到第六章。这一章我们聊点实在的——HID设备。说实话,HID(Human Interface Device)是USB世界里最接地气的协议之一。鼠标、键盘、游戏手柄、甚至刷卡器,都属于HID设备。我最早接触HID是在做平板电脑外设适配的时候,当时被报告描述符折腾得够呛。嗯,今天我们就把它彻底讲透。
6.1 HID协议概述:报告描述符
HID协议的核心,说白了就是一套“设备告诉主机我怎么用”的语言。设备通过报告描述符(Report Descriptor)来描述自己有几个按键、几个轴、数据怎么组织。主机解析这个描述符后,就知道怎么读写数据了。
核心概念:报告描述符不是二进制固件,而是一串字节码,由HID协议定义的“Item”组成。每个Item告诉主机:我有一个叫“X”的字段,它占n位,取值范围是a到b。
举个例子,一个简单的鼠标报告描述符大概长这样:
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x02, // Usage (Mouse)
0xA1, 0x01, // Collection (Application)
0x09, 0x01, // Usage (Pointer)
0xA1, 0x00, // Collection (Physical)
0x05, 0x09, // Usage Page (Button)
0x19, 0x01, // Usage Minimum (1)
0x29, 0x03, // Usage Maximum (3)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x95, 0x03, // Report Count (3)
0x75, 0x01, // Report Size (1)
0x81, 0x02, // Input (Data,Var,Abs)
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x30, // Usage (X)
0x09, 0x31, // Usage (Y)
0x15, 0x81, // Logical Minimum (-127)
0x25, 0x7F, // Logical Maximum (127)
0x75, 0x08, // Report Size (8)
0x95, 0x02, // Report Count (2)
0x81, 0x06, // Input (Data,Var,Rel)
0xC0, // End Collection
0xC0 // End Collection
这段描述符定义了:3个按键(每个1位,表示按下/松开),以及X/Y轴移动数据(每个8位,有符号相对值)。我在项目中遇到过好几次描述符写错导致设备不被识别的情况,最常见的是Logical Minimum和Logical Maximum写反了,或者Report Size和Report Count算错了总位数。
我的调试技巧:用USBlyzer或Wireshark抓包,直接看主机请求的描述符原始字节。然后对照HID Descriptor Tool(HIDDT)生成的描述符,逐字节比对。这招帮我解决过至少5个疑难杂症。
6.2 Android端HID Host实现:读取鼠标/键盘数据
Android从3.1(API 12)开始支持USB Host模式。要读取HID设备数据,我们不需要自己解析报告描述符——Android的InputDevice框架已经帮你做了。但如果你想在应用层直接获取原始HID报告,那就得用UsbDeviceConnection配合UsbRequest。
我个人习惯的做法是:先判断设备是不是HID类,然后通过中断端点读取报告。下面是一个读取鼠标移动数据的核心片段:
// 获取USB设备的中断端点
UsbEndpoint interruptEndpoint = null;
for (int i = 0; i < usbInterface.getEndpointCount(); i++) {
UsbEndpoint ep = usbInterface.getEndpoint(i);
if (ep.getType() == UsbConstants.USB_ENDPOINT_XFER_INT) {
interruptEndpoint = ep;
break;
}
}
// 建立连接并声明独占
UsbDeviceConnection connection = usbManager.openDevice(device);
connection.claimInterface(usbInterface, true);
// 异步读取报告
byte[] buffer = new byte[8]; // 鼠标报告通常8字节
UsbRequest request = new UsbRequest();
request.initialize(connection, interruptEndpoint);
request.queue(ByteBuffer.wrap(buffer), 8);
// 在独立线程中等待完成
while (isRunning) {
UsbRequest wait = connection.requestWait();
if (wait == request) {
// buffer中就是HID报告数据
int x = buffer[1]; // 相对位移X
int y = buffer[2]; // 相对位移Y
int buttons = buffer[0] & 0x07; // 按键状态
// 处理数据...
}
}
这里有个坑:USB中断传输的间隔由设备的bInterval描述符决定,鼠标通常是8ms(125Hz),键盘可能是10ms(100Hz)。如果你发现数据读得不够快,先检查这个值,而不是怀疑代码有问题。
曾经踩过的坑:有一次我读键盘数据,发现某些按键没反应。折腾了半天,原来是键盘的HID报告里用了“数组”类型(Array),而不是“位图”类型(Bitmap)。数组类型需要遍历所有按键值,不能直接按位判断。嗯,从那以后我每次都会先看报告描述符的Input类型。
6.3 HID设备端实现:模拟游戏手柄
现在换个角度——让Android设备扮演HID设备。这通常需要硬件支持USB Gadget模式,比如树莓派或某些开发板。我们通过配置Gadget驱动,让设备枚举成一个游戏手柄。
游戏手柄的报告描述符比鼠标复杂一些,因为要包含方向键、摇杆、多个按钮。下面是一个简化的手柄描述符(2个摇杆 + 8个按钮):
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x05, // Usage (Game Pad)
0xA1, 0x01, // Collection (Application)
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x30, // Usage (X)
0x09, 0x31, // Usage (Y)
0x15, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x00, // Logical Maximum (255)
0x75, 0x08, // Report Size (8)
0x95, 0x02, // Report Count (2)
0x81, 0x02, // Input (Data,Var,Abs)
0x05, 0x09, // Usage Page (Button)
0x19, 0x01, // Usage Minimum (1)
0x29, 0x08, // Usage Maximum (8)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x08, // Report Count (8)
0x81, 0x02, // Input (Data,Var,Abs)
0xC0 // End Collection
在Linux内核中,通过ConfigFS配置Gadget的步骤大致是:
- 创建g1目录,设置UDC(USB Device Controller)
- 创建hid.usb0函数,写入报告描述符
- 创建配置,关联函数
- 使能Gadget
然后通过/dev/hidg0设备节点写入报告数据:
// 模拟摇杆位置和按键
unsigned char report[4];
report[0] = 128; // X轴居中
report[1] = 128; // Y轴居中
report[2] = 0x01; // 按下A键(bit0)
write(fd, report, sizeof(report));
经验之谈:模拟手柄时,摇杆的Logical Minimum/Maximum最好和真实手柄一致(通常是0-255或-127-127)。有些游戏只认特定范围,写错了摇杆动不了。我遇到过一款赛车游戏,摇杆范围必须是-32768到32767,否则方向盘只能转一半。
6.4 常见问题与避坑指南
做HID开发,说白了就是和描述符、端点、报告格式打交道。下面是我总结的几个高频问题:
| 问题 | 现象 | 解决方案 |
|---|---|---|
| 报告描述符错误 | 设备枚举成功,但主机不识别 | 用HIDDT验证描述符,检查Usage Page和Report Size |
| 断连处理 | USB线松动或设备休眠后断开 | 注册BroadcastReceiver监听ACTION_USB_DEVICE_DETACHED |
| 报告长度不匹配 | 读取数据时出现BufferUnderflow | 检查Report Count × Report Size是否等于实际报告长度 |
| 中断传输超时 | requestWait()长时间不返回 | 设置超时时间,或重新claimInterface |
关于断连处理,我曾经在量产阶段吃过亏。设备在振动环境下偶尔断开,但应用没有及时释放USB连接,导致重新插上后无法再次claimInterface。正确的做法是:
// 在BroadcastReceiver中处理断开
if (UsbManager.ACTION_USB_DEVICE_DETACHED.equals(action)) {
UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
if (device != null && device.equals(currentDevice)) {
connection.releaseInterface(usbInterface, true);
connection.close();
currentDevice = null;
// 清理资源,准备重新连接
}
}
特别提醒:不要在主线程中做USB读写操作。Android的USB API是同步的,requestWait()会阻塞线程。我曾经见过同事直接在UI线程里读USB,结果ANR弹个不停。嗯,用HandlerThread或者协程吧。
知识体系总览
下面这张图概括了本章的核心逻辑:从HID协议描述符,到Android Host端读取,再到设备端模拟,最后是常见问题的排查路径。
好了,这一章的内容就到这里。HID开发其实不难,关键是理解报告描述符的语义,以及端点传输的机制。下次你遇到USB鼠标不工作、手柄没反应,不妨先从描述符入手查起——八成问题都出在那里。