☰
HIDAPI源码解析:跨平台USB HID通信与设备枚举实战
2026/10/11 12:54:24 网站建设 项目流程

简介:这份代码包提供开源 hidapi 库的完整源代码,面向需要在 Windows、Linux、macOS、Android 上与 USB 键鼠、游戏手柄、HID 自定义设备免驱动通信的 VC++ 或 QT 开发者。库通过统一跨平台 API,省去逐设备编写驱动的负担,可完成设备枚举、打开、报告读取与写入等操作,适合从应用集成到协议底层研究的不同层级开发者。压缩包共 152 个文件、约 43.15MB,核心源码包括 c/cpp/h 源文件、configure.ac 与 Makefile.am 构建脚本;同时附带 Visual Studio 工程文件、编译生成的 DLL/lib 库、pdb 调试信息以及 tlog 日志文件,便于在 VS2017 环境快速产出 DLL 并在 QT 项目中调用。包内还包含 mingw、Linux、macOS 适配目录、automake 规则和示例配置,能帮助开发者解析 hidapi 的跨平台设计思路,对照 libusb 理解易用性差异。目前已有 642 人学习,这份源码尤其适合阅读 hid.c 与公共头文件来掌握设备枚举、报告收发和异步 I/O 实现,并根据自身硬件对驱动层逻辑进行定制或移植。

1. HIDAPI 源代码到底解决什么问题:先看懂它再决定要不要读

做自定义 USB HID 设备的上位机、写量产测试工具,或者想搞清楚为什么同一套 HIDAPI 调用在 Windows 和 Linux 上表现完全不一样,HIDAPI 这套跨平台库的源代码,值得你花一个晚上完整读一遍。反直觉的地方在于:这套源码真正花心思处理的不是“发送/接收”本身,而是不同操作系统在设备枚举、句柄管理、读写超时上的差异。很多人把 HIDAPI 当黑匣子用,结果一换系统就被各种玄学问题卡住。这篇文章会沿着源码的骨架、通信流程、工程整合和踩坑记录四个方向展开,适合嵌入式软件、上位机开发和工具链维护者阅读。

2. HIDAPI 源码的骨架:平台抽象层与不同后端怎么协同

2.1 从 hidapi.h 的结构体看 API 设计意图

HIDAPI 对外暴露的头文件只有一个,就是 hidapi.h。所有平台相关的实现细节都被藏在了它背后。打开这个头文件,你会发现它刻意把“设备信息”和“设备句柄”分成了两种类型:struct hid_device_info是完整可见的结构体,而struct hid_device只是一个前置声明,真正的定义在每个平台后端各自的 .c 文件里。

struct hid_device_info { char *path; unsigned short vendor_id; unsigned short product_id; int interface_number; int usage_page; int usage; int bus_type; wchar_t *serial_number; wchar_t *manufacturer_string; wchar_t *product_string; struct hid_device_info *next; };

这段声明就是读懂整套源码的钥匙。path是设备路径,在 Windows 下是 UTF-8 编码的字符串,在 Linux 下就是/dev/hidraw0这样的节点路径。vendor_id和product_id用于匹配设备,interface_number对应 USB 接口号,usage_page和usage来自 HID 报告描述符里的 Usage 字段,bus_type标记设备走的是 USB 还是蓝牙,宏定义里分得清清楚楚。

特别注意serial_number、manufacturer_string、product_string这三个字段是wchar_t *。原因很直接:Windows 底层设备信息接口给出来的就是宽字符,HIDAPI 在 Windows 后端里直接用宽字符存储,省去了一次编码转换。在 Linux 后端里,这些字符串来自内核的 sysfs 文件,读出来后还得转成宽字符,这就是两个平台在字符串处理上天然不对称的根源。

hid_device这个不透明结构体的设计决定了整个库的扩展方式:每个平台后端可以自由定义自己的内部结构,里面放文件描述符、异步 IO 上下文、缓冲区分片等平台私有数据。调用者手里的hid_device *其实只是一个指向平台私有结构的指针,所有操作都通过hid_read、hid_write这些统一入口再分发到后端函数。

2.2 不同后端的枚举与读写模型:从调用链看平台差异

HIDAPI 在主流平台上分别有对应的后端实现,Windows 走 hid.dll 动态库,Linux 分 hidraw 和 uhid 两条路线,macOS 走 IOKit,另有一个 libusb 后端作为补充。各后端的差异在枚举阶段就体现出来了。Windows 后端大概是先调HidD_GetHidGuid拿到设备接口 GUID,再用 SetupAPI 的SetupDiGetClassDevs枚举设备接口集合,对每个接口CreateFile打开后调HidD_GetAttributes取 VID/PID。Linux hidraw 后端则扫描/sys/class/hidraw下的设备目录,读device/uevent里内核上报的总线类型和 VID/PID,再通过 ioctl 拿厂商字符串。macOS 后端用IOHIDManagerCreate创建 Manager 后注册匹配字典。

这些差异可以整理成一张对照表,方便快速定位问题区间:

后端平台设备枚举方式读写机制典型场景
Windows 后端WindowsSetupAPI + hid.dll 动态加载CreateFile + HidD_GetInputReport / WriteFile与真实 USB HID 设备通信
hidraw 后端Linux扫描 /dev/hidraw* 与 sysfsopen + read/write + ioctl与真实 USB/蓝牙 HID 设备通信
uhid 后端Linux通过 /dev/uhid 创建虚拟设备UHID_* ioctl 模拟收发软件模拟 HID 键盘鼠标、驱动测试
IOKit 后端macOSIOHIDManager 匹配字典IOHIDDeviceRegisterInputReport免驱 HID 设备通信
libusb 后端多平台libusb 枚举libusb_interrupt_transfer在非主流平台上兜底

Windows 后端有个值得注意的细节:它不是直接链接系统库里的符号,而是通过LoadLibrary动态加载 HID.DLL,再用GetProcAddress拿到函数指针。这么做的好处是,即使在老旧的 Windows 系统上只要存在 hid.dll 就能运行,也方便程序在启动时给用户一个“缺少驱动”的明确提示而不是直接崩溃。

hidraw 与 uhid 的分工也容易让初学者困惑。hidraw 面向的是“内核已经识别出来的真实设备”,应用层open("/dev/hidraw0")后直接用read/write收发,简单直接。uhid 后端的角色正好相反:它从用户态创建一个内核可见的虚拟 HID 设备,让内核认为有一个真实设备插上了,适合模拟鼠标键盘或做驱动自动化测试。选 hidraw 还是 uhid,本质是在问“我要连真实的硬件,还是要假装我是一个设备”。

2.3 hid_init / hid_exit 在底层做了什么

很多从示例代码抄起就跑的人都会忽略hid_init和hid_exit,反正不调也能跑通大部分场景。但如果你想让程序在长时间运行、反复插拔设备的场景里稳定工作,这两个函数的语义必须搞清楚。在 Linux hidraw 后端下,hid_init看起来像一个空操作,因为打开设备就是简单的open系统调用,没有全局状态要准备。但 uhid 后端编译进同一个库时,hid_init就要负责打开/dev/uhid设备节点并初始化全局链表,用来管理由用户态创建的虚拟设备。

Windows 后端的情况更明显:设备枚举过程依赖 SetupAPI,这套接口在多线程并发场景下有内部状态竞争,所以源码里会用一个全局临界区把枚举和打开操作串行化。hid_init负责初始化这个临界区,hid_exit负责销毁它。如果一个人在自己的代码里忘了调用hid_exit,在进程退出时系统回收资源,一般不致命;但如果他把hid_init放在一个会被反复调用的循环里,临界区就可能被重复初始化,随之而来的就是各种摸不着头脑的挂死。

所以我的习惯是:hid_init在main函数最开始调用一次,hid_exit在程序退出路径上统一调用一次,所有枚举和读写操作都夹在这两者之间。这样既符合源码层面的设计预期,也避免在业务代码里分散地管理库的全局状态。

3. 从源码到一次完整通信:枚举、打开、读写报告的四个关键步骤

3.1 用 hid_enumerate 拿到设备清单:链表节点与释放逻辑

设备枚举是整套 HIDAPI 通信流程的起点。hid_enumerate接受 VID 和 PID 两个参数,都传 0 表示“列出所有设备”。返回值是一个单链表,每个节点对应一台设备。源码里每个后端的枚举逻辑不一样,但最终都会填充同一个hid_device_info结构体并把节点串成链表。

#include <stdio.h> #include "hidapi.h" int main(void) { if (hid_init() < 0) { fprintf(stderr, "hid_init failed\n"); return 1; } struct hid_device_info *devs = hid_enumerate(0x0, 0x0); struct hid_device_info *cur = devs; while (cur) { printf("vid=0x%04x pid=0x%04x bus=%d usage_page=%d path=%s\n", cur->vendor_id, cur->product_id, cur->bus_type, cur->usage_page, cur->path ? cur->path : "(null)"); cur = cur->next; } hid_free_enumeration(devs); hid_exit(); return 0; }

这段代码里hid_enumerate(0x0, 0x0)表示不过滤任何厂商和产品,遍历系统里所有可见的 HID 设备。path字段是最有区分度的信息,Windows 下它是一长串包含 VID、PID 和序列号信息的字符串,Linux 下就是/dev/hidrawN。bus_type会告诉你这台设备是走 USB 总线还是蓝牙,这在排查设备不识别问题时很有用。

最后一步hid_free_enumeration(devs)千万别省。枚举返回的链表是源码里通过malloc逐节点创建的,其中厂商字符串、产品字符串等宽字符字段也是单独分配的。不释放的话,每枚举一次就泄漏一批节点,在长时间运行的工具程序里这是不可接受的。

3.2 hid_open 的路径与句柄:从 VID/PID 到可读写通道

拿到设备信息后,下一件事是打开设备获得句柄。hid_open(vid, pid, NULL)的做法是在枚举链表里找 VID/PID 匹配的节点,找到后调用hid_open_path(dev->path)。hid_open_path才是真正打开设备底层通道的函数,它接收的是设备路径字符串而不是 VID/PID。

/* 打开指定 VID/PID 的设备 */ hid_device *handle = hid_open(0x1234, 0x0001, NULL); if (!handle) { fprintf(stderr, "open failed\n"); return 1; }

hid_open的第三个参数是序列号,传 NULL 表示“匹配第一个 VID/PID 相同的设备”。如果你的电脑上同时插了两台同型号设备,就必须用序列号区分,否则永远打开的只是枚举顺序排第一的那台。在源码层面,hid_open内部会自动调用一次枚举、逐节点比对、匹配成功后再调hid_open_path,最后释放枚举链表。所以它的开销比直接调hid_open_path大,但胜在方便。生产环境里如果已经通过枚举拿到了 path,直接调hid_open_path效率更高、也更可控。

3.3 hid_read / hid_write 的字节约定:报告 ID、长度与超时语义

HIDAPI 的读写接口看起来简单,实际使用时字节约定才是最容易出错的地方。hid_write的缓冲区第一个字节代表报告 ID:如果设备没有使用报告 ID,这个字节写 0;如果设备用了报告 ID 1、2 等等,就填对应的 ID 值。设备固件那边收到的第一个字节也是这个 ID,然后才是真正的有效数据。很多工程师刚开始调协议时,写出去一个数组{0x01, 0xA1, 0x00},结果设备收到的数据里第一个字节变成0xA1,就是因为设备的报告描述符里默认报告 ID 是 1,而应用层又额外多写了一个0x01。

hid_read的返回值语义也值得细说:返回 -1 表示读取出错,返回 0 表示在非阻塞模式下当前没有数据,返回正数表示实际读取到的字节数。数据缓冲区里的第一个字节同样是报告 ID。阻塞模式下hid_read会一直等到有数据或出错才返回,这在 UI 线程里会卡死界面,所以工具类程序一般用hid_read_timeout带超时参数更稳妥。

3.4 一个能编译的跨平台 Demo:找设备、写命令、读应答

把前面几个步骤串起来,就是一个完整的跨平台通信 Demo。下面这个程序实现的功能是:按 VID/PID 查找设备、打开句柄、发送一条三字节命令、等待 2 秒内读回应答并打印。

#include <stdio.h> #include "hidapi.h" #define TARGET_VID 0x1234 /* 替换成你的设备实际 VID */ #define TARGET_PID 0x0001 /* 替换成你的设备实际 PID */ int main(void) { int ret = 0; if (hid_init() < 0) { fprintf(stderr, "hid_init failed\n"); return 1; } hid_device *handle = hid_open(TARGET_VID, TARGET_PID, NULL); if (!handle) { fprintf(stderr, "open failed: %ls\n", hid_error(NULL)); hid_exit(); return 1; } /* 第一个字节 0x00 表示设备的输出报告不带报告 ID */ unsigned char out[4] = {0x00, 0xA1, 0x00, 0x00}; int w = hid_write(handle, out, sizeof(out)); if (w < 0) { fprintf(stderr, "write failed: %ls\n", hid_error(handle)); ret = 1; goto done; } unsigned char in[64] = {0}; int r = hid_read_timeout(handle, in, sizeof(in), 2000); if (r > 0) { for (int i = 0; i < r; i++) printf("%02x ", in[i]); printf("\n"); } else if (r == 0) { printf("read timeout, no data\n"); } else { printf("read error: %ls\n", hid_error(handle)); ret = 1; } done: hid_close(handle); hid_exit(); return ret; }

代码里hid_error(NULL)取的是最后一次全局错误的描述,hid_error(handle)取的是指定设备句柄的错误描述,二者返回值都是宽字符串,打印时用%ls。hid_read_timeout的最后一个参数单位是毫秒,2000 表示最多等 2 秒。写命令时数组第一字节填0x00是因为这个 Demo 假设设备输出报告没有使用报告 ID,如果你的设备固件定义里带了报告 ID,这里要改成对应的 ID 值。

4. 把 HIDAPI 源码编进自己的工程:最小文件清单与编译选项

4.1 源码目录里哪些文件必须进工程、哪些可以裁掉

HIDAPI 源码的组织形式决定了它很适合直接以源码形式放进第三方库目录。核心部分只有两个层级:公开头文件hidapi.h加上每个平台各自的一个实现文件。拿到源码包后按平台裁剪,最终进工程的代码非常少。

以 Linux 平台为例,最小文件清单就是hidapi/hidapi.h和linux/hid.c两个文件。Windows 平台对应windowing 目录里的 hid.c。macOS 平台对应mac/hid.c。libusb 后端对应libusb/hid.c,但那个通常只在嵌入式交叉编译或者非主流平台上才需要。裁剪原则很简单:目标平台上用不到的后端文件不参与编译,这样既少了编译时间,也避免 uhid 后端在权限不足的机器上引发启动告警。

平台必选文件可裁剪文件额外依赖
Linux (hidraw)hidapi.h + linux/hid.clinux/hid.c 中的 uhid 分支无运行时依赖
Linux (uhid)hidapi.h + linux/hid.c不使用 hidraw 时不必关心需要内核头文件 uhid.h
Windowshidapi.h + windows/hid.clinux/mac 目录全部hid.dll、setupapi
macOShidapi.h + mac/hid.c其他平台目录IOKit、CoreFoundation 框架

4.2 用 CMake 集成的关键配置项

如果工程本身用 CMake 组织,常见的做法是把 hidapi 源码目录放进third_party/hidapi,然后通过add_subdirectory引入,再用选项开关选择后端。CMake 构建脚本里的核心配置项一般长这样:

# 假设 hidapi 源码放在 third_party/hidapi set(HIDAPI_WITH_HIDRAW ON CACHE BOOL "enable linux hidraw backend") set(HIDAPI_WITH_UHID OFF CACHE BOOL "disable linux uhid backend") add_subdirectory(third_party/hidapi) target_link_libraries(app PRIVATE hidapi_hidraw)

这里的HIDAPI_WITH_HIDRAW和HIDAPI_WITH_UHID是 hidapi 构建系统里的常见开关,打开哪个就编译哪个后端。生成的 target 名字通常是hidapi_hidraw或hidapi_uhid这类,具体以你拿到的版本里add_library的命名为准。把HIDAPI_WITH_UHID设为 OFF 可以避免在普通用户权限下运行时去尝试打开/dev/uhid,减少不必要的权限报错。

4.3 不用 CMake 时的直接编译命令

很多内部工具工程不引入 CMake,直接用 gcc 一条命令编译。这时源码方式的优势就体现出来了,因为 hidapi 的源码不依赖额外的库,Linux 下直接编译即可。

# Linux + hidraw 后端,源码编译 gcc -c linux/hid.c -I./hidapi gcc -c hid_demo.c -I./hidapi gcc hid.o hid_demo.o -o hid_demo

Windows 下用 MinGW 编译时,需要额外链接系统库,因为 Windows 后端依赖 hid.dll 和 setupapi 这两个系统组件。Linux 下则完全不需要链接额外的库,hidraw 后端只依赖内核提供的系统调用。

# Windows + MinGW 编译 gcc -c windows/hid.c -I./hidapi gcc -c hid_demo.c -I./hidapi gcc hid.o hid_demo.o -o hid_demo.exe -lhid -lsetupapi

macOS 下编译链接后端文件后,要链接 IOKit 和 CoreFoundation 框架。这几个平台编译要额外带什么参数,本质上是看后端源码里用了哪些系统 API:Windows 后端调用 SetupAPI 全家桶,macOS 后端调用 IOKit,Linux 后端活在纯系统调用世界里。

4.4 移植取舍:什么时候考虑 libusb 后端

libusb 后端存在的意义在于覆盖 Linux hidraw 和 Windows/macOS 原生后端覆盖不到的场景。比如在某些嵌入式 Linux 环境里内核没开启 hidraw 支持,或者需要跨 FreeBSD 这类非主流系统时,libusb 后端就是一个通用兜底。代价是引入了 libusb 这个外部依赖,运行时需要系统里有 libusb 动态库。取舍逻辑很简单:默认真实设备用平台原生后端,只有原生后端不可用时才切 libusb。

5. HIDAPI 源码阅读与使用中最多人翻车的 5 个问题与排查方法

5.1 枚举返回空:权限、udev 与报告描述符

现象:程序正常运行,但hid_enumerate返回的链表为空,或者枚举到了设备但hid_open失败。

原因:Linux 下最常见的是权限问题。普通用户默认没有/dev/hidraw*节点的读写权限,而 hidapi 的 open 操作会直接因为这个权限被拒绝。Windows 下则较少出现这种问题。

解决:写一条 udev 规则,把目标设备的权限放开。规则文件放在/etc/udev/rules.d/下,内容按设备实际 VID 替换:

KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="1234", MODE="0666"

保存后执行sudo udevadm control --reload-rules并重新插拔设备。注意 ATTRS 里的 VID 是设备的实际 VID,别把 0x 前缀后面的零填错。

5.2 hid_read 返回 0 但是设备明明有数据

现象:设备端确认已经发出了输入报告,但应用层hid_read返回 0,看起来像数据丢了。

原因:返回值 0 在 HIDAPI 里不代表出错,它只表示“当前没有数据”。在非阻塞模式下,设备数据还没到就返回 0 是预期行为。很多人把这个 0 当成失败,于是直接放弃读取,自然拿不到数据。

解决:区分 0 和 -1 的语义。0 是超时或暂时无数据,-1 才是真正的错误。调试阶段建议先用hid_read_timeout带 2000ms 超时来验证通路,确认设备确实没数据再考虑协议层面的问题。

5.3 hid_write 第一个字节:报告 ID 才是关键

现象:hid_write返回写入成功,但设备收到的数据整体错位,第一位总是莫名丢失或者变成了别的值。

原因:HID 协议规定输出报告的第一个字节是报告 ID。如果设备描述符里定义了报告 ID,这里就必须填对应的 ID;如果没定义,填 0。很多初学者拿着缓冲区直接填业务数据,导致第一个业务字节被设备当成报告 ID 吃掉。

解决:先读设备报告描述符,确认是否有报告 ID,再决定缓冲区第一个字节填什么。这一点在自研固件和设备对接时尤其容易踩,因为两边往往是不同的人写的。

5.4 不释放枚举链表且 init/exit 不成对:长时间运行后泄漏与退出挂死

现象:工具程序跑一天后内存持续增长,或者退出时进程卡在某个诡异的系统调用上。

原因:hid_enumerate返回的链表节点是源码里逐节点 malloc 出来的,厂商字符串等宽字符字段也是独立分配的,不调hid_free_enumeration就会泄漏。退出挂死的场景则多是线程正在hid_read阻塞时,主线程直接调了hid_exit。

解决:枚举用完立即释放;hid_init/hid_exit成对出现且在程序整体生命周期里只调一次;需要停止读取时先让阻塞读退出,再调hid_close和hid_exit,不要硬杀。

5.5 Windows 下编译链接失败:库名和宽字符乱码

现象:同样的源码在 Linux 编译通过,到 Windows 上 MinGW 编译报未定义的HidD_*符号,或者运行时错误信息打印出来是乱码。

原因:Windows 后端依赖 hid 和 setupapi 系统库,编译命令里没加-lhid -lsetupapi就会链接失败。乱码则是%ls打印宽字符串时当前控制台代码页不匹配导致的。

解决:编译时补上系统库;打印宽字符错误信息前先setlocale(LC_ALL, "")或改用专门转换函数转成 UTF-8 后再打印。这个问题不算 hidapi 的缺陷,而是 Windows 控制台对宽字符输出的固有坑。

6. 用一份可复现的连通性验证方法,把 HIDAPI 调试链路变成自己的

读 HIDAPI 源码最大的收获不是记住 API 原型,而是建立一条从应用层到系统调用的排查链路。我现在拿到一个陌生设备时的流程是固定的:先跑枚举程序确认设备有没有被系统识别,再跑一次只读不写的超时读确认设备是否主动上报数据,最后才进入真正的协议联调。这三步做完,80% 的问题都被隔离到了“设备没枚举出来”或“设备根本没发数据”这两个环节里。

一个实用的技巧是在源码的 hid_read 和 hid_write 入口处临时加日志宏,打印出调用参数和返回值,验证完再撤掉。比如在 linux/hid.c 里找到write系统调用返回的地方,插一行fprintf(stderr, "raw write ret=%d\n", res);,就能立刻区分“应用层发错了”和“内核层没发出去”这两种情况。用宏包装的关键在于日志里要带上__func__,否则面对一堆打印根本分不清是哪一层打出来的。

#include <stdio.h> #define HID_DEBUG(fmt, ...) \ fprintf(stderr, "[hid][%s] " fmt "\n", __func__, ##__VA_ARGS__) /* 在临时排查时使用,例如 */ HID_DEBUG("write ret=%d errno=%d", res, errno);

我吃过最没必要的亏,是设备没插稳就去查了半天报告 ID 的逻辑。从那以后,任何关于报告的疑问都先回到枚举链路确认设备真的在线,再谈协议。这套从源码入口开始的小习惯帮我在三个不同平台上都能快速定位问题,换成新环境也一样有效。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询