用HID协议造一个“即插即用”的USB小工具:从零实现自定义控制器
你有没有遇到过这样的场景?
在工厂产线调试设备时,想快速触发某个动作,却要先装驱动、配串口;
或者开发一款自动化测试夹具,结果客户电脑因安全策略禁用了未知USB设备;
甚至只是想做个宏键盘来简化日常操作,却被一堆注册表和权限拦住。
这些问题背后的核心矛盾是:我们想要的是“拔插即用”,现实却是“驱动地狱”。
而解决之道,其实早已藏在你的键盘和鼠标里——那就是HID(Human Interface Device)协议。
今天我们就来干一件“反向利用标准”的事:不写驱动、不依赖管理员权限,用最普通的MCU做出一个真正跨平台、免驱识别的自定义USB小工具。它能上报传感器数据、响应主机指令、控制LED灯,还能在Windows、Linux、macOS甚至iPad上无缝工作。
准备好了吗?让我们从一颗STM32芯片开始,把它变成一台被操作系统“信任”的智能外设。
为什么选HID?因为它天生就被允许通行
USB有几十种类别(Class),比如大名鼎鼎的CDC(虚拟串口)、MSC(U盘)、Audio(声卡)。但如果你要做一个非标设备,比如工业按钮面板或环境监测仪,这些类要么带宽过剩,要么需要驱动支持。
而 HID 不一样。
操作系统对 HID 设备有一种天然的信任感——毕竟谁会怀疑一个“键盘”呢?这种信任带来了四个关键优势:
- ✅无需安装驱动:三大主流系统都内置了HID驱动
- ✅用户态访问:普通程序就能读写,不用管理员权限
- ✅穿透企业防火墙:很多IT策略放行HID但封锁自定义类
- ✅真正的即插即用:插入后几秒内即可通信
更妙的是,HID 协议虽然最初为输入设备设计,但它允许最大64KB的报告长度,并支持三种数据流:
-Input Report:设备 → 主机(如按键状态)
-Output Report:主机 → 设备(如点亮LED)
-Feature Report:双向配置交互(如设置采样频率)
这意味着我们可以把任何传感器、开关、执行器封装成“看起来像游戏手柄”的东西,然后畅通无阻地接入任何主机。
核心秘密:读懂并编写报告描述符
如果说 USB 是公路,那报告描述符(Report Descriptor)就是这辆车的说明书——它告诉主机:“我有多少个按钮?”、“X轴代表什么?”、“怎么解析我的数据包?”
这个描述符使用一种紧凑的二进制语言(HID Usage Language),初看像天书,实则逻辑清晰。我们来看一个实用案例:做一个带4个按钮 + 双轴摇杆 + LED控制的小控制器。
__ALIGN_BEGIN static uint8_t My_HID_ReportDesc[HID_REPORT_DESC_SIZE] __ALIGN_END = { 0x05, 0x01, // USAGE_PAGE (Generic Desktop) 0x09, 0x05, // USAGE (Game Pad) 0xA1, 0x01, // COLLECTION (Application) // 按钮(4个) 0x05, 0x09, // USAGE_PAGE (Button) 0x19, 0x01, // USAGE_MINIMUM (Button 1) 0x29, 0x04, // USAGE_MAXIMUM (Button 4) 0x15, 0x00, // LOGICAL_MINIMUM (0) 0x25, 0x01, // LOGICAL_MAXIMUM (1) 0x75, 0x01, // REPORT_SIZE (1 bit) 0x95, 0x04, // REPORT_COUNT (4 bits) 0x81, 0x02, // INPUT (Data,Var,Abs) // 填充剩余4位(字节对齐) 0x75, 0x01, 0x95, 0x04, 0x81, 0x03, // INPUT (Constant,Var,Abs) // X/Y轴模拟输入(0-255) 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 bits) 0x95, 0x02, // REPORT_COUNT (2 bytes) 0x81, 0x02, // INPUT (Data,Var,Abs) // 输出报告:LED控制(1字节) 0x75, 0x08, 0x95, 0x01, 0x09, 0x32, // USAGE (LED Bank) 0x91, 0x02, // OUTPUT (Data,Var,Abs) // 特征报告:配置参数(1字节) 0x75, 0x08, 0x95, 0x01, 0x09, 0x33, // USAGE (Configuration Command) 0xB1, 0x02, // FEATURE (Data,Var,Abs) 0xC0 // END_COLLECTION };别被这一堆十六进制吓到。我们拆解一下它的结构:
| 字段 | 含义 |
|---|---|
USAGE_PAGE | 功能类别,这里选通用桌面(0x01) |
USAGE | 具体用途,如 Button、X轴、LED 等 |
LOGICAL_MIN/MAX | 数据逻辑范围,例如按钮是0~1,模拟量是0~255 |
REPORT_SIZE/COUNT | 每个字段多少位、有几个 |
INPUT/OUTPUT/FEATURE | 数据方向 |
重点说明几个坑点:
- 字节对齐很重要:前4个按钮只占4bit,必须补满8bit才能进入下一个字节,否则主机解析错位。
- Collection嵌套不宜过深:尽量保持一层Application级Collecton即可。
- Usage建议标准化:用
Generic Desktop里的标准定义(如X=0x30),避免自创码值导致兼容问题。
当你把这段描述符烧录进MCU,主机枚举时就会看到:“哦,这是个有两个摇杆和四个按钮的游戏手柄。” 接下来的一切通信都将基于这份“伪装身份”进行。
固件实战:让STM32发出第一份Input Report
本例采用STM32F407VG+ STM32CubeIDE 开发环境,启用USB OTG FS作为设备端。
初始化与架构
整个系统采用事件驱动模式,主要包括:
- USB HID接口初始化(由CubeMX自动生成)
- 外设采集模块(ADC读摇杆、GPIO读按键)
- 报告打包与发送
- Output/Feature回调处理
CubeMX会自动生成USBD_HID_SendReport()函数,我们的任务就是合理调用它。
发送Input Report:把物理状态传给主机
void Send_HID_Report(uint8_t button_state, uint8_t x_axis, uint8_t y_axis) { uint8_t report[3]; report[0] = button_state & 0x0F; // 按钮状态(低4位) report[1] = x_axis; // X轴值 report[2] = y_axis; // Y轴值 if (hUsbDeviceFS.dev_state == USBD_STATE_CONFIGURED) { USBD_HID_SendReport(&hUsbDeviceFS, report, sizeof(report)); } }注意判断dev_state是否为USBD_STATE_CONFIGURED,否则未完成枚举就发数据会导致异常。
你可以每10ms轮询一次ADC和按键,在主循环中调用此函数:
while (1) { buttons = Read_Buttons(); // 获取GPIO状态 x_val = ADC_GetValue(ADC_CHANNEL_X); y_val = ADC_GetValue(ADC_CHANNEL_Y); Send_HID_Report(buttons, x_val, y_val); HAL_Delay(10); // 控制频率,避免总线拥堵 }这样,主机就能持续收到设备的状态更新了。
接收Output Report:让主机控制你的硬件
比如你想通过PC软件远程点亮板载LED,就需要处理Output Report。
HAL库提供了回调函数接口:
extern USBD_HandleTypeDef hUsbDeviceFS; static int8_t OutEventCallback_FS(uint8_t* event, uint16_t length) { if (length > 0 && event[0] == 1) { // 假设第一个字节是命令ID HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, (event[1] & 0x01) ? GPIO_PIN_SET : GPIO_PIN_RESET); } return 0; } // 在main.c中注册该回调(通常在MX_USB_DEVICE_Init之后) ((HID_HandleTypeDef*)hUsbDeviceFS.pClassData)->OutEvent = OutEventCallback_FS;一旦主机发送一个Output Report(例如[0x01, 0x01]),LED就会亮起。反过来也可以实现蜂鸣器提醒、继电器开关等远程控制功能。
上位机对接:用Python三分钟写出控制台
现在设备已经“说话”了,接下来我们要做的,是让它被人听懂。
Python方案:hidapi一统江湖
推荐使用hidapi库,它是跨平台的C封装,支持Win/macOS/Linux,安装简单:
pip install hidapi以下是完整的通信脚本:
import hid import time def find_and_communicate(): # VID 和 PID 需与固件一致 device = hid.Device(vendor_id=0x0483, product_id=0x5710) print(f"Connected to: {device.product_string}") try: while True: # 读取Input Report(最多64字节) data = device.read(64, timeout=1000) if data: print("Received:", list(data)) # 显示原始字节 # 解析数据 buttons = data[0] & 0x0F x_axis = data[1] y_axis = data[2] print(f"Buttons: {bin(buttons)}, X={x_axis}, Y={y_axis}") # 控制LED(发送Output Report) device.write([0x01, 0x01]) # 第一字节为Report ID(可选),第二字节为数据 time.sleep(0.5) device.write([0x01, 0x00]) except KeyboardInterrupt: print("\nExiting...") finally: device.close() if __name__ == "__main__": find_and_communicate()运行后你会看到类似输出:
Connected to: STM32 Custom HID Controller Received: [1, 128, 64] Buttons: 0b1, X=128, Y=64短短十几行代码,你就拥有了一个实时监控+反向控制的能力。后续可以扩展为图形界面、快捷键映射、自动化测试流程等高级应用。
Windows原生API:更精细的控制选择
如果你追求极致性能或不想引入第三方库,可以直接调用Windows的HidD.dll。
下面是一个精简版的设备查找函数:
HANDLE OpenHIDDevice(USHORT vendor_id, USHORT product_id) { GUID guid; HDEVINFO dev_info; SP_DEVICE_INTERFACE_DATA interface_data; PSP_INTERFACE_DEVICE_DETAIL_DATA detail_data; HANDLE device_handle = INVALID_HANDLE_VALUE; HidD_GetHidGuid(&guid); dev_info = SetupDiGetClassDevs(&guid, NULL, NULL, DIGCF_PRESENT | DIGCF_INTERFACEDEVICE); interface_data.cbSize = sizeof(SP_DEVICE_INTERFACE_DATA); for (DWORD i = 0; SetupDiEnumDeviceInterfaces(dev_info, NULL, &guid, i, &interface_data); i++) { ULONG required_size; SetupDiGetDeviceInterfaceDetail(dev_info, &interface_data, NULL, 0, &required_size, NULL); detail_data = (PSP_INTERFACE_DEVICE_DETAIL_DATA)malloc(required_size); detail_data->cbSize = sizeof(SP_INTERFACE_DEVICE_DETAIL_DATA); if (SetupDiGetDeviceInterfaceDetail(dev_info, &interface_data, detail_data, required_size, NULL, NULL)) { device_handle = CreateFile(detail_data->DevicePath, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL); HIDD_ATTRIBUTES attrib; attrib.Size = sizeof(attrib); if (HidD_GetAttributes(device_handle, &attrib)) { if (attrib.VendorID == vendor_id && attrib.ProductID == product_id) { break; } } CloseHandle(device_handle); } free(detail_data); } SetupDiDestroyDeviceInfoList(dev_info); return device_handle; }拿到句柄后,就可以用ReadFile和WriteFile进行高效通信。适合集成到C++桌面工具或工业软件中。
实际应用场景:不只是玩具
别以为这只是做个“伪键盘”玩玩。事实上,这种模式已在多个领域落地:
🧪 自动化测试夹具
- 工厂老化测试台上,每个工位插一个HID小工具,自动上报产品状态
- 无需安装驱动,更换电脑也不影响产线运行
🔧 工业控制面板
- 用几个按钮+旋钮组合,控制PLC启停、模式切换
- 所有操作记录通过Input Report上传至MES系统
⌨️ 定制化快捷输入设备
- 视频剪辑师用专用按钮一键执行“导出+上传”
- 直播主播用踏板触发场景切换,解放双手
🧑🔬 科研数据采集前端
- 连接温度、压力传感器,周期性上报数值
- 在实验室公共电脑上即插即用,无需IT审批
它们的共同特点是:功能简单、可靠性要求高、部署环境受限。而这正是 HID 方案的最强战场。
踩过的坑与最佳实践
1. 报告描述符一定要对齐字节
前面提到的“补位”不是可选项。如果按钮只用了4bit却不填充,第二个字节的X轴会被误认为属于第一个字段。
2. 不要频繁发送报告
中断传输虽快,但USB总线资源有限。建议最小间隔设为1ms以上,动态变化时再主动上报,静止时不刷屏。
3. VID/PID怎么选?
- 学习阶段可用开源保留ID:
0x1209 / 0x4E56(Open Source Hardware标识) - 量产务必申请合法VID或购买授权PID,防止冲突
4. 错误处理不能少
添加连接状态检测:
if (hUsbDeviceFS.dev_state != USBD_STATE_CONFIGURED) { // 可尝试重新初始化或延后发送 }同时监听Suspend/Resume事件,做好低功耗管理。
5. 别忘了字符串描述符
给设备起个好名字,方便识别:
__ALIGN_BEGIN static uint8_t USBD_StringSerial[USB_SRLN_STRING_SIZE] __ALIGN_END = "1234567890AB";这样在设备管理器里就不会显示“Unknown HID Device”。
结语:把复杂留给自己,把简单带给用户
当我们谈论“用户体验”时,往往聚焦于UI动效或交互流畅度。但在嵌入式世界,最大的体验提升可能来自这样一个瞬间:用户把设备插上电脑,还没来得及问“要不要装驱动”,程序已经正常运行了。
这正是 HID 协议的魅力所在——它不是一个炫技的技术,而是一种工程智慧:借助系统的信任机制,绕开繁琐的权限障碍,让设备真正成为“即插即用”的存在。
下次当你面对一个需要“简单可靠通信”的项目时,不妨停下来想想:
能不能把它做成一个“看起来人畜无害”的HID设备?
也许答案就是:让它假装是个键盘,干的却是大事。
如果你在实现过程中遇到了其他挑战,欢迎在评论区分享讨论。