简介:这是一份面向Windows平台USB编程学习者的资源包,围绕LibUSB-Win32开源库,展示如何用Visual C++、C#和VB三种语言直接控制USB设备。LibUSB-Win32属于用户态驱动库,开发者无需编写复杂的内核驱动程序即可与设备通信,因而特别适合嵌入式上位机开发、设备驱动调试及硬件爱好者快速上手。压缩包共91个文件,其中36个C语言源文件与9个头文件构成主要代码框架,8个批处理脚本用于驱动安装与卸载,另有lib导入库、def导出定义、exe测试工具、rc资源脚本、iss安装脚本以及txt说明文档,整体仅442KB,目录划分清晰,便于按模块查阅。目前已有417人学习下载。资源内包含设备枚举、配置读取、端点收发数据的完整示例,并给出C#与VB通过P/Invoke调用原生DLL的写法,测试工具和安装脚本可直接用于验证环境。对于想理解USB描述符结构或快速搭建Windows下USB通信程序的开发者,这是一份具备直接参考价值的实战代码集合。
1. LibUSB-Win32 到底是什么:为什么在 Visual C++ 里读写 USB 总绕不开它
在 Visual C++ 里做 USB 编程,很多人第一步就是去找一个叫 LibUSB-Win32.rar 的压缩包。这个包里装的是 Windows 平台上一套老牌的用户态 USB 访问库:它把设备从系统驱动手里“借”出来,让你用一组usb_xxx函数完成枚举、打开、控制传输和批量读写,不用碰 DDK,也不用写内核驱动。适合给 USB 加密狗、指纹仪、串口转 USB、自定义 HID 设备写上位机的人。反直觉的是,代码本身不难,真正让人翻车的往往是环境配置、驱动安装和端点选错。这篇文章按实际调试顺序讲,把配置、枚举、读写和排错一次说透,照着做能省下大半天试错时间。
2. 用 Visual C++ 把 LibUSB-Win32 的库和头文件配进工程
2.1 解压后的目录结构:头文件、lib、DLL 各放在哪
LibUSB-Win32 的压缩包解出来后,目录结构各家打包略不同,但核心文件就四类:include/libusb.h是唯一的头文件,所有 API 声明和结构体定义都在里面;lib目录下是导入库libusb.lib,注意里面通常会按编译器分成VC6、VC2005等子目录,因为不同版本编译器生成的库二进制格式有差异;bin目录里是libusb0.dll,注意 DLL 名字带个0,这是配套libusb0.sys驱动的,和后来 libusb-1.0 的libusb-1.0.dll完全是两套东西;另外还有一个inf-wizard.exe,它是用来给指定 USB 设备生成驱动安装包的向导工具。
拿到包先别急着往工程里拖。我一般会在项目根目录下建一个third_party/libusb-win32文件夹,把include、lib、bin整个拷进去,保持相对路径干净。这样代码在别的机器上 checkout 下来也能编译,不用每个人再去配一遍环境变量。如果你把libusb.h放得到处都是,后面维护时找头文件都费劲。
2.2 Visual C++ 6.0 / Visual Studio:include、lib 和运行库路径一次配好
Visual C++ 6.0 的老玩家习惯走 Tools > Options > Directories 去配。在Include files里加third_party/libusb-win32/include,在Library files里加third_party/libusb-win32/lib/VC6,注意一定选 VC6 子目录而不是 VC2005。用 Visual Studio 2005 及以上版本的话,在项目属性 > VC++ 目录里配置同样两行,lib 路径选lib/VC2005。选错库目录的典型报错是 LNK1112——“module machine type 'x86' conflicts with target machine type 'x64'”,或者干脆 LNK2001 找不到_usb_init符号,因为导入库里的函数符号表对不上你的编译器。
链接方式我推荐在代码里直接写#pragma comment(lib, "libusb.lib"),这样只要 include 路径指对了,链接库就自动带上,不用在项目设置里反复确认。运行时要把libusb0.dll放到 exe 同级目录,或者加到系统 PATH 里。这里有个血泪教训:不要把老版本的 dll 直接扔进C:\Windows\System32,机器上有多个程序用到版本的libusb0.dll时,会被后来者覆盖,老程序莫名打不开设备,排查半天才发现是 dll 版本被顶掉了。放在自己 exe 目录是最省心的做法。
2.3 跑通最小初始化:为什么 usb_init() 之后要先枚举一次
配好环境后,先写一个最小程序验证库能不能用。别急着写业务逻辑,先确认libusb.h被正确包含、链接没问题、dll 能加载。下面这段代码就是我在新机器上的第一步:
#include <stdio.h> #include "libusb.h" int main(void) { int r; /* 1. 初始化 libusb-win32 内部状态,成功后返回 0 */ r = usb_init(); if (r < 0) { printf("usb_init failed: %d\n", r); return -1; } /* 2. 扫描系统里的 USB 总线,返回找到的总线数量 */ r = usb_find_busses(); printf("find_busses ret = %d\n", r); /* 3. 扫描每条总线上的设备,返回新增设备数 */ r = usb_find_devices(); printf("find_devices ret = %d\n", r); return 0; }这段代码的逻辑是典型的“初始化—扫描总线—扫描设备”三步。usb_init()内部会加载驱动接口并准备好链表,所以必须最先调用;usb_find_busses()和usb_find_devices()是每次枚举都要重新调用的,别只调一次就以为设备列表永远不变。返回值小于 0 表示调用失败,大于等于 0 表示找到的数量,实际开发里判断负值就够,数量本身一般不拿来用。
提示:如果你链接的是 libusb-1.0 的库却引用了 libusb-win32 的
usb_init(),会出现一个典型的编译错误——error C2198: 'usb_init' : too few arguments for call。libusb-1.0 的初始化函数是libusb_init(&ctx),带一个上下文指针参数。两套库的函数名相近但签名不同,混用头文件和 lib 是第一大坑。
程序编译通过后,放到有libusb0.dll的目录运行。如果只输出find_busses ret = 0而没有任何报错,说明库本身已经能工作,接下来才能谈枚举设备。Windows 下 USB 设备的热插拔不像 Linux 那样能靠 udev 事件通知,libusb-win32 也没有事件回调机制,所以每次都要重新usb_find_busses()再usb_find_devices()来刷新设备列表,这个习惯要从一开始养成。
3. 枚举设备与描述符解析:在 VC++ 里把 USB 设备“认”出来
3.1 遍历 usb_bus 链表:usb_get_busses 拿链表头
设备枚举完,真正的数据结构是一个struct usb_bus的双向链表,通过usb_get_busses()拿到链表头。每个usb_bus里有dirname(总线名,类似/dev/bus/usb/001或一个数字)、next指针,还有一个devices指针指向该总线上的struct usb_device链表。每个usb_device里又有filename(设备文件名)、descriptor(设备描述符)、config(配置描述符数组)和next指针。这个“总线套设备”的嵌套结构就是整个枚举的核心。
实际遍历时,两层 for 循环就能把系统里所有 USB 设备过一遍。注意bus->devices和dev->next都可能为空,判断条件要写在循环条件里,不要在循环体里解引用空指针。VC6 的编译器对空指针警告不敏感,但运行时崩溃一样跑不掉。下面这段代码我在多个项目里都是直接照搬的:
struct usb_bus *bus; struct usb_device *dev; /* 每次枚举前都要重新 find 一遍,拿到的是最新链表 */ usb_find_busses(); usb_find_devices(); for (bus = usb_get_busses(); bus != NULL; bus = bus->next) { for (dev = bus->devices; dev != NULL; dev = dev->next) { printf("bus=%-8s dev=%-16s VID=0x%04X PID=0x%04X\n", bus->dirname, dev->filename, dev->descriptor.idVendor, dev->descriptor.idProduct); } }这个循环的用途是摸底:先看看系统里到底有哪些设备被识别到了,VID、PID 打印出来对不对。实际项目里第一次跑枚举,最常见的现象是设备管理器里能看到设备,但这里打不出来,原因基本是驱动没装好,或者usb_find_devices()返回负值。当打印出的 VID/PID 全是 0 或者缺行,优先怀疑枚举前没有重新调用 find 系列函数。
3.2 读设备描述符:VID/PID、序号和配置描述符从哪里取
dev->descriptor是一个struct usb_device_descriptor,字段名和 USB 规范一一对应:bcdUSB、bDeviceClass、idVendor、idProduct、bcdDevice、iManufacturer、iProduct、iSerialNumber、bNumConfigurations。这里注意,iManufacturer这些字段是字符串描述符的索引值,不是字符串本身,要拿到厂商名、产品名这类文本,得调用usb_get_string_simple()。
字符串描述符读取要打开设备之后才能做。常见做法是先按 VID/PID 找到目标设备,然后:
usb_dev_handle *hdev = usb_open(dev); if (hdev == NULL) { printf("open device failed\n"); return; } char buf[256]; /* iProduct 是描述符里的索引字段,读回来的是 UTF-8 字符串 */ if (usb_get_string_simple(hdev, dev->descriptor.iProduct, buf, sizeof(buf)) > 0) { printf("product name: %s\n", buf); } usb_close(hdev);usb_get_string_simple()内部帮你处理了字符串描述符的标准请求流程,返回读到的字节数,失败返回负值。这个函数有个限制:字符串长度超过传入缓冲区会被截断,所以缓冲区给 256 字节一般够用。很多老设备不实现字符串描述符,调用返回负值是正常的,不是程序 bug。
3.3 按 VID/PID 过滤目标设备:打开前先确认端点存在
枚举打印没问题后,就可以写过滤逻辑了。一个系统里往往挂着摄像头、U盘、鼠标键盘,你的程序只关心那个特定 VID/PID 的设备。过滤代码本身很简单,但我要强调的是过滤之前先检查config数组里的端点信息,否则后面打开设备、claim interface 时才发现端点地址不对,又要回来改。
/* 找到目标设备后,检查它的端点配置 */ struct usb_config_descriptor *cfg = dev->config; if (cfg == NULL) { printf("no config descriptor\n"); return; } /* 遍历接口和备用设置,打印端点地址 */ for (int i = 0; i < cfg->bNumInterfaces; i++) { struct usb_interface *iface = &cfg->interface[i]; for (int j = 0; j < iface->num_altsetting; j++) { struct usb_interface_descriptor *alt = &iface->altsetting[j]; printf("interface %d altsetting %d class 0x%02X\n", i, alt->bInterfaceNumber, alt->bInterfaceClass); for (int k = 0; k < alt->bNumEndpoints; k++) { printf(" ep 0x%02X maxpacket %d\n", alt->endpoint[k].bEndpointAddress, alt->endpoint[k].wMaxPacketSize); } } }bEndpointAddress的低 4 位是端点号,最高位是方向位:0 表示 OUT(主机到设备),1 表示 IN(设备到主机)。比如0x81就是端点 1 IN,0x01是端点 1 OUT。wMaxPacketSize决定了一次批量传输的最大包长,常见的是 64 字节,后面做读写缓冲区设计时要参照这个值。很多初次做 USB 编程的人上来就猜端点,猜错了usb_bulk_read返回-1,其实查一下端点描述符就能确定。
4. 控制传输与批量读写:写一个能实际收发数据的 VC++ 程序
4.1 usb_control_transfer 的六个参数:一次控制传输的完整拆解
枚举和打开只是前戏,真正干活是收发数据。USB 世界里最基础的数据通道是控制传输,所有设备都支持,端点 0 就是干这个的。libusb-win32 提供了usb_control_transfer(),签名如下:
int usb_control_transfer(usb_dev_handle *dev, int requesttype, int request, int value, int index, char *bytes, int size, int timeout);requesttype是最容易填错的参数。一个字节拆成三段看:bit7 是方向,0 表示 OUT,1 表示 IN,标准宏USB_ENDPOINT_IN(0x80)和USB_ENDPOINT_OUT(0x00)直接可用;bit6-5 是请求类型,USB_TYPE_STANDARD(0x00)、USB_TYPE_CLASS(0x20)、USB_TYPE_VENDOR(0x40)三选一,厂商自定义命令一般用 VENDOR;bit4-0 是接收者,USB_RECIP_DEVICE(0x00)、USB_RECIP_INTERFACE(0x01)、USB_RECIP_ENDPOINT(0x02)。把这三位或在一起就是完整的requesttype。
request是请求号,标准请求里有USB_REQ_GET_DESCRIPTOR(0x06)、USB_REQ_SET_CONFIGURATION(0x09)等;厂商自定义设备里这个值由协议决定。value和index都是 16 位参数,标准请求里各有含义,厂商请求通常填 0,但如果协议里定义了用途就按协议来。bytes是数据缓冲区指针,size是期望传输的字节数,timeout是超时毫秒数。返回值是实际传输的字节数,负值表示失败。
4.2 用控制传输读版本号:一个典型的 0x40 类请求
写一个完整的控制读例子。假设设备的厂商协议是:向端点 0 发请求 0x01,requesttype为USB_ENDPOINT_IN | USB_TYPE_VENDOR | USB_RECIP_DEVICE,即可读回 16 字节的设备版本信息。代码里必须先把设备打开、设置配置、声明接口,才能发起控制传输:
usb_dev_handle *hdev = usb_open(dev); if (hdev == NULL) { printf("open failed\n"); return; } /* 设备有多套配置时先选配置,一般的设备配置 1 就够 */ usb_set_configuration(hdev, 1); /* 声明接口 0,同一时刻只有一个程序能声明同一个接口 */ if (usb_claim_interface(hdev, 0) < 0) { printf("claim interface failed\n"); usb_close(hdev); return; } unsigned char buf[16] = {0}; int len = usb_control_transfer(hdev, USB_ENDPOINT_IN | USB_TYPE_VENDOR | USB_RECIP_DEVICE, 0x01, /* bRequest: 厂商定义读版本 */ 0x0000, /* wValue: 协议里通常为 0 */ 0x0000, /* wIndex: 协议里通常为 0 */ (char *)buf, sizeof(buf), 1000); /* 超时 1 秒 */ if (len >= 0) { printf("control read %d bytes, first bytes: %02X %02X\n", len, buf[0], buf[1]); } else { printf("control transfer failed: %d\n", len); } usb_release_interface(hdev, 0); usb_close(hdev);usb_set_configuration()是容易出幺蛾子的一步。有些设备对SetConfiguration请求比较敏感,调用后设备会重新枚举,导致原来的usb_dev_handle失效。我遇到过一个设备,第一次usb_set_configuration(hdev, 1)后紧接着usb_claim_interface就失败,后来改成先 claim 再 set,或者干脆跳过 set,只靠默认配置,问题就消失了。做法是:先不调usb_set_configuration,直接usb_claim_interface,如果返回 0 就继续;如果失败,再补 set configuration 后重新打开设备。这是比较稳的流程。
4.3 批量读写的端点选择:ep 地址里的 IN/OUT 位
控制传输带宽小、延迟高,大量数据传输要用批量传输。libusb-win32 的批量接口只有两个函数:usb_bulk_write(dev, ep, bytes, size, timeout)和usb_bulk_read(dev, ep, bytes, size, timeout)。端点号直接用上一步枚举时打印出来的bEndpointAddress,OUT 端点写、IN 端点读,方向搞反就是-1加LIBUSB_ERROR_IO。
unsigned char outbuf[64] = {0}; unsigned char inbuf[64] = {0}; int wlen, rlen; /* 构造一条发给设备的数据帧,0xAA 是协议里的命令头 */ outbuf[0] = 0xAA; outbuf[1] = 0x00; /* 向端点 0x01 OUT 写一帧,超时 1 秒 */ wlen = usb_bulk_write(hdev, 0x01, (char *)outbuf, sizeof(outbuf), 1000); if (wlen != sizeof(outbuf)) { printf("bulk write fail: %d\n", wlen); } /* 从端点 0x81 IN 读响应,超时 2 秒 */ rlen = usb_bulk_read(hdev, 0x81, (char *)inbuf, sizeof(inbuf), 2000); if (rlen >= 0) { printf("bulk read %d bytes\n", rlen); } else { printf("bulk read failed: %d\n", rlen); }size参数有个关键细节:批量传输是按端点最大包长分片的。如果写 64 字节而端点最大包长是 64,正好一个包。如果写 100 字节,底层会拆成 64 + 36 两个包发出去,设备端如果按固定帧长解析就会出错。读的时候同理,请求 64 字节,设备只回了 32 字节,usb_bulk_read返回 32,剩余数据不会再补给你。所以读数据要么明确知道设备会回多长,要么循环调用usb_bulk_read直到收满你要的长度,或者收到短包判定传输结束。短包是 USB 批量传输的结束标志,请求 64 返回 32,说明设备主动结束了这次传输。
timeout也不建议随手填 0。timeout为 0 在某些旧版本 libusb-win32 里表示无限等待,如果设备不响应,你的界面就卡死了。我给读写都设 1~2 秒超时,失败后走重试逻辑,而不是无脑等。重试要记得先检查hdev是否还有效,设备拔掉后句柄其实已经失效,直接重试会反复返回失败。
4.4 同步模式下先把数据流跑通,异步任务后面再说
libusb-win32 本身是同步接口,usb_bulk_write和usb_bulk_read都会阻塞到超时或完成。对一个简单的上位机,单线程同步完全够用。真正要并发的场景,常见做法是开一个工作线程专门做读写,主线程只管界面,线程间用消息或标志位同步。别试图在 VC6 里用BeginThread去并发调用同一个hdev的读写,usb_claim_interface的互斥只保证接口级别的占用,不保证你说的两个线程之间数据不错乱。
提示:如果你想做的事超过了一个上位机的基本读写,比如要同时盯好几个设备、要热插拔事件通知,那应该考虑 libusb-1.0 而不是 libusb-win32。libusb-1.0 的事件循环、异步 IO 和热插拔回调都比这套老接口强得多。libusb-win32 的价值在于简单直接,尤其适合 Visual C++ 6.0 老工程快速接一个 USB 设备,不需要引入新依赖。
5. LibUSB-Win32 的常见坑与排查:驱动、权限、VC 运行时一个都跑不掉
5.1 设备管理器里认不到设备:inf-wizard 生成的 inf 只认 VID/PID
现象:插上 USB 设备,系统提示“安装设备驱动程序”的弹窗一直转圈,设备管理器里出现一个带黄色感叹号的“未知设备”,程序枚举列表里也没有它。原因:libusb-win32 需要设备有对应的驱动,驱动安装包要用inf-wizard.exe针对你的设备现场生成,这个 inf 文件里写死了 VID 和 PID。解决:打开 inf-wizard.exe,选中你的设备,确认界面里显示的 VID、PID 和设备管理器里的一致,生成 inf 后右键选择“安装”。安装完设备管理器里会变成一个叫 “libusb-win32 devices” 的分类。注意换一个 USB 口如果设备重新枚举,可能又要装一次驱动,这是正常的,系统把它当新设备了。
5.2 打开设备成功但 claim interface 失败:设备被别的驱动占着
现象:usb_open()返回了句柄,接着usb_claim_interface()返回-1。原因:设备已被系统驱动接管。典型的是 HID 设备被hidusb.sys占用,或者你的程序上一个实例没释放接口就退出了,驱动还认为接口被占用。解决:先在程序退出前usb_release_interface()再usb_close(),养成习惯;排查是否开了两个调试实例,任务管理器里把旧的结束掉;对 HID 设备直接走 Windows 的 HID API 更省事,别用 libusb-win32 硬刚。
5.3 VC++ 编译报错:cl.exe failed 和缺少 Redistributable
现象:编译时报error: command '...cl.exe' failed with exit status 2,或者程序拷到别的机器上运行,弹窗提示缺少MSVCP100.dll/MSVCR100.dll。前者是 include 路径或编译环境没配对,编译器根本找不到头文件;后者是目标机器没装对应的 Visual C++ Redistributable,VC2010 对应 SP1 版本,VC2015 以上对应 2015-2022 系列。解决:回到 2.2 的配置步骤检查 include 和 lib 路径;发布时把对应年份的 Visual C++ Redistributable 安装包一起带上,或者用/MT静态链接运行库,让 exe 不依赖系统里的 vc runtime dll。Visual C++ 6.0 的程序在新系统上还额外容易缺mfc42.dll,老项目建议直接把 MFC 静态链接进 exe。
5.4 新系统上驱动签名翻车:装了驱动但设备起不来
现象:Windows 10/11 64 位系统上安装 libusb-win32 驱动时提示数字签名问题,设备管理器里设备图标带感叹号,代码枚举能偶尔看到设备,但打开就超时。原因:旧版 libusb-win32 的驱动文件没有通过新系统的签名校验。解决:最省事的是在“高级启动”里选择“禁用驱动程序强制签名”后重装驱动,但这只能撑到下次重启,调试可以,交付不行。新项目建议直接改用 libusb-1.0 + Zadig 安装 WinUSB 驱动,这是 Windows 官方推荐路径,签名问题少得多。老设备实在离不开 libusb-win32,就找找新签名的 fork 版本,别在官方老包上死磕。
5.5 读回来的数据错位或丢包:包长和短包处理不当
现象:设备明明回了 96 字节,usb_bulk_read只返回 64,再调用一次又返回 32,但两次数据算一帧时总不对。原因:批量传输按端点最大包长分包,64 是常见值,96 字节是 64 + 32 两个包。解决:按帧长循环读,直到累计长度达到预期值或收到短包;wMaxPacketSize查端点描述符获取,不要猜。还有一类玄学丢包是 USB 供电不稳,设备突然掉线,插主板后置 USB 口而不是机箱前面板,能少很多莫名其妙的错误。
6. 收尾:用 Bus Hound 对比抓包验证读写,这是最后的后悔药
程序写完了,怎么确认你发的指令真的到了设备、回的每一字节都是对的?光看usb_bulk_read返回值不够,设备回什么你都信,那是拿黑匣子当白盒子用。我的验证习惯是开一个 Bus Hound,把目标设备的抓包过滤打开,跑一遍你的程序,然后把 Bus Hound 捕获到的 URB 里的数据和你程序里打印的数据逐字节比对。控制传输分别看 SETUP 阶段的bmRequestType、bRequest、wValue、wIndex,再看 DATA 阶段内容;批量传输直接看 DATA 阶段的字节流。绝大多数读写“不对”的 bug,在这里一眼就能定位到是参数填错还是设备本来就没回对数据。
端点选型也可以验证。在设备管理器里右键设备,选“查看设备实例路径”,再用 usbview 类工具看设备当前的配置号和接口号,确认你 claim 的 interface 编号和设备描述符里的bInterfaceNumber一致。这些工具都能在系统里免费找到,不用额外装环境。等程序稳定了,再把 Bus Hound 关掉,因为它会拖慢总线速度,影响批量传输时序。
最后说个习惯:我一般在工程里把枚举、打开、控制读写、批量读写分别封装成usb_dev.c、usb_io.c两个模块,接口层不直接抛 libusb 的数据结构,而是转换成自己的设备对象。这样将来从 libusb-win32 换到 libusb-1.0,只改底层两个文件,上层协议代码一行不用动。老平台老设备,libusb-win32 仍然够用;新项目则直接上 libusb-1.0 少踩签名坑。按这套流程走下来,USB 上位机的开发周期基本能控制在两三天内,希望帮到你。
本文还有配套的精品资源,点击获取