简介:面向Android开发者的一份USB外设通信示例工程,主要解决多USB设备同时接入时,如何筛选指定设备并完成稳定读写的实际问题。项目源自公司量产组件,经过真实业务测试,性能稳定,核心逻辑涵盖USB设备枚举、权限申请、指定型号过滤以及批量数据收发;过滤规则集中在一处,拿到后替换为自己的USB名称与厂商标识即可快速集成。压缩包为RAR格式,解压后共518个文件,大小约12.92MB,整体是一个完整的Android Studio工程,包含gradle构建脚本、gradlew包装器、iml模块配置、lock依赖锁定文件、flat编译中间资源、jar依赖、java源码、xml布局、json配置、properties资源索引与txt说明;同时也提供app-debug.apk和resources-debug.ap_,可先安装到真机体验设备选择与通信效果,再对照源码理解Android USB Host的工作流程。已有912人学习/下载,适合需要实现USB外设控制、工业数据采集或嵌入式调试的Android开发者参考。
1. 项目概述与需求拆解
这个项目标题看起来很简单——“源一个USB读写demo,从多个USB设备中选择一个实现外设控制的通信”,但做起来之后你会发现,真正麻烦的不是USB读写本身,而是“从多个USB设备中选择一个”这半句话。
为什么这么说?因为在Windows或Linux主机上挂一堆USB设备是常态:指纹仪、扫码枪、USB转串口模块、USB转CAN卡、加密狗、HID键盘……它们在系统里混在一起,如果不做设备筛选,程序打开的不是你想控制的那个外设,那后续一切通信和控制都无从谈起。我一开始接这个需求时,对方只说了句“写个demo,能从USB口控制外设就行”,结果光是“选对设备”这一步就花了一半的调试时间。
这个项目本质上是解决这么一件事:在主机端(PC/嵌入式上位机),从已枚举到的多个USB设备中,按规则筛选出目标设备,然后通过USB协议栈完成数据收发,从而对外设(传感器、执行器、下载器、透传模块等)进行控制。它适合的人也很明确:正在做上位机+USB外设联调的嵌入式工程师、刚接触libusb/WinUSB的PC端开发者、以及需要在产品里实现“一机多USB设备”管理的同学。
下文我会按“需求分析 → 设备枚举与选择 → 通信实现 → 问题排查”的顺序,把整个demo从思路到代码再到避坑经验完整过一遍。所有内容都基于我实际调试过的场景,代码逻辑可以用Python(pyusb/libusb)或C++复现,协议部分与具体USB芯片无关,通用性很强。
2. 整体设计思路与方案选型
2.1 为什么“选设备”是首要矛盾
你可能会想:USB设备插上电脑,给它分配一个设备节点(比如COM5),直接打开不就行了?问题就出在“多个设备”上。设备节点的分配并不稳定,比如USB转串口模块,Windows按“枚举顺序+VID/PID+端口位置”分配COM号,同一台机器今天可能分配COM6,明天插拔顺序一变就变COM9。如果你的程序写死了COM口,客户现场就会频繁出现“找不到设备”“打开失败”。
更麻烦的是,很多USB外设不是标准串口类设备,而是自定义VID/PID的HID或供应商自定义类设备。这种设备在系统里根本不会生成COM口,也不会出现在“设备管理器-端口”列表里,只能用USB底层API(如libusb、WinUSB、SetupAPI)直接访问。
所以整套方案的第一步,不是“怎么读写”,而是把“识别和筛选”做成一个独立的模块,让程序能在所有USB设备里精确锁定目标。我在demo里给这个模块起名叫“设备发现层(Device Discover Layer)”,它的任务只有两件事:枚举 + 匹配。
2.2 技术方案的取舍
我当时在三种方案里做选择,这里也列出来供你参考:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 串口API(ReadFile/WriteFile) | 最简单,系统自带驱动,代码量小 | 只适用CDC类(USB转串口)设备;无法直接访问自定义类设备 | 设备本身就是USB转串口模块 |
| WinUSB + SetupAPI | 原生Windows方案,免第三方库,可访问自定义设备 | 需要写INF或使用WinUSB驱动绑定;跨平台麻烦 | Windows下正式产品 |
| libusb(pyusb) | 跨平台(Windows/Linux/macOS),生态成熟,支持各类USB设备 | Windows下需要安装/替换驱动(如WinUSB/libusbK);对新手有一定门槛 | 跨平台工具、快速验证demo |
在这个项目里我选的是libusb/pyusb,原因是它拿到了USB设备描述符和端点信息之后,能直接做批量传输和中断传输,不需要依赖任何系统协议驱动。而且Python写demo速度极快,方便验证通信链路通了之后再移植到C++工程里。如果你的项目限定Windows+自有驱动,那直接用SetupAPI+WinUSB也是合理的选择,但配套的INF签名工作会多出很多。
2.3 模块划分
整个demo的代码结构可以分成四层:
- 设备发现层:枚举所有USB设备,读取VID/PID、序列号、接口描述符,匹配目标设备。
- 通道建立层:打开设备,判断内核驱动是否已占用,若被占用则进行驱动分离或绑定,找到通信端点,建立读写通道。
- 协议通信层:按自定义帧格式封装指令,发送控制命令,接收响应,处理校验和超时。
- 业务控制层:把通信层的数据映射成外设控制行为,比如开灯、关灯、读取温度、设置电机转速。
这样分层之后,“换外设”和“换上位机语言”都只改某一层,其他部分的代码不用动。
3. 核心细节解析与实操要点
3.1 USB协议里你必须懂的三个概念
做这个demo之前,建议先搞清楚USB协议里的三个核心概念,否则你面对枚举出来的那一堆端点描述符会一头雾水:
控制传输(Control Transfer):所有USB设备默认必须具备的传输类型。你读设备描述符、配置描述符、设置配置,走的就是控制传输。它适合小数据量、低频率的配置类操作。
批量传输(Bulk Transfer):适合大批量数据、实时性要求不高的传输,能充分利用带宽,失败可以重试。很多自定义USB外设(比如采集卡、下载器)的数据通道都走批量端点。
中断传输(Interrupt Transfer):虽然有“中断”两个字,但它本质是主机主动轮询端点,适合小数据量、周期性读取,比如鼠标、键盘、游戏手柄。外设要周期上报状态(比如温度传感器每秒上报一次),用中断端点最合适。
在这个demo里,我控制的外设是一个自定义的USB开发板,它对外暴露了两个批量端点(端点1输出、端点1输入),外加一个控制端点0。控制命令用控制传输去发,数据流用批量传输去收,这是大多数自定义USB外设常见的端点布局。
3.2 设备匹配的三个策略
代码里要做设备筛选,最核心的是三个匹配参数:
- VID(Vendor ID,厂商ID):USB-IF分配给厂商的唯一编号,比如0x1234。
- PID(Product ID,产品ID):厂商给自己的不同产品分配的编号。
- 序列号(iSerialNumber):同一厂商同一型号,如果每个设备烧录了不同序列号,你可以通过序列号精确区分“这一台”还是“那一台”。
我实测下来的匹配优先级是这样:
- VID+PID+序列号:最精确,适合产品现场多台设备并存,比如一台工控机同时接两只同型号扫码枪,就必须靠序列号区分。
- VID+PID:适用单台设备,或者多台设备不需要区分具体哪台。
- VID+PID+端口路径(USB Port Path):在Windows下可通过SetupAPI拿到设备所在的总线端口路径,比如“1-1.2.3”,它比序列号稳定,因为很多廉价USB设备不实现序列号。
如果你遇到“所有设备都返回空序列号”,千万别只靠VID/PID判断,否则插两台同型号设备程序只会打开第一台。此时第三个策略(端口路径或接口描述符)能救命。
3.3 枚举设备时的关键代码
我用Python的pyusb库做个示例,这段代码能在所有USB设备里按VID/PID过滤并打印出序列号:
import usb.core import usb.util TARGET_VID = 0x1234 TARGET_PID = 0x5678 # 枚举所有USB设备 devices = usb.core.find(find_all=True) for dev in devices: # 读取设备描述符(注意:此时还没打开设备) vid = dev.idVendor pid = dev.idProduct if (vid, pid) != (TARGET_VID, TARGET_PID): continue # 读取序列号字符串描述符 try: serial = dev.serial_number except Exception as e: serial = None # 读取厂商和产品字符串描述符 try: manufacturer = dev.manufacturer product = dev.product except Exception as e: manufacturer = None product = None print(f"匹配设备: VID=0x{vid:04X}, PID=0x{pid:04X}, " f"Serial={serial}, Manufacturer={manufacturer}, Product={product}") # 如果你要的设备序列号是"DEMO-2024-001" if serial == "DEMO-2024-001": target = dev break这里有个细节:在find_all=True遍历时,设备尚未真正打开,很多字符串描述符的读取依赖系统驱动,如果设备被内核驱动占用,dev.serial_number可能抛异常。我在实际代码里都套了try/except,避免一个设备读取失败导致整个枚举中断。
如果你是C++工程,同样用libusb的libusb_get_device_list做枚举,然后libusb_get_device_descriptor取VID/PID,再libusb_get_string_descriptor_ascii取序列号,逻辑一模一样。
3.4 打开设备与配置端点
找到设备后,接下来的流程是:打开设备 → 检测内核驱动是否占用(Windows下常见)→ 设置配置 → 找到通信端点 → 开始读写。
# 打开设备 dev = usb.core.find(idVendor=TARGET_VID, idProduct=TARGET_PID) if dev is None: raise ValueError("设备未找到,请检查USB连接") # 在Windows上,设备可能被系统驱动(如hidusb、usbccgp)占用, # 需要尝试分离内核驱动。 if dev.is_kernel_driver_active(0): try: dev.detach_kernel_driver(0) except usb.core.USBError as e: print(f"分离内核驱动失败: {e}") # 设置配置 try: dev.set_configuration() except usb.core.USBError as e: print(f"设置配置失败: {e}") # 获取配置描述符,找到端点 cfg = dev.get_active_configuration() intf = cfg[(0, 0)] # 接口0,备用设置0 ep_out = None ep_in = None for ep in intf: if usb.util.endpoint_direction(ep.bEndpointAddress) == usb.util.ENDPOINT_OUT: ep_out = ep elif usb.util.endpoint_direction(ep.bEndpointAddress) == usb.util.ENDPOINT_IN: ep_in = ep if ep_out is None or ep_in is None: raise ValueError("找不到输入/输出端点")这里最容易踩的坑是内核驱动占用。Windows下很多设备默认被系统驱动(比如HID类设备被hidusb.sys占用),你直接set_configuration会报“Resource Busy”。解决办法就是先detach_kernel_driver再操作。Linux下也可能需要root权限才能分离驱动,所以调试时建议直接用管理员权限或sudo运行。
另外一个坑是set_configuration的时机。有些设备在刚插上时已经由系统配置好了,你再设一遍可能出错。稳妥做法是先尝试获取当前配置,非必要不重设。
4. 通信协议设计与外设控制实现
4.1 自定义帧格式
USB底层传输只是管道,真正要控制外设,上位机和下位机必须约定一套数据帧格式。我在这个demo里用了一套极简但可靠的帧协议,发送和接收都遵循:
| 帧头(0xAA 0x55) | 命令字(1B) | 数据长度(2B) | 数据区(NB) | CRC16校验(2B) |帧头用0xAA55,是为了让下位机在字节流里能快速对齐。命令字定义控制类型,比如:
| 命令字 | 含义 | 数据区内容 |
|---|---|---|
| 0x01 | 设置外设GPIO输出 | 1字节,0/1对应高/低电平 |
| 0x02 | 读取外设ADC值 | 无,响应返回2字节ADC结果 |
| 0x03 | 设置PWM占空比 | 2字节,0~10000对应0.00%~100.00% |
| 0x04 | 读取外设状态 | 无,响应返回8字节状态结构体 |
这里有个经验:数据长度一定要带。虽然USB批量传输的最大包长度固定(通常是512字节或1024字节),但你不能保证下位机一次读完,带长度字段方便下位机粘包处理。CRC16校验必须带。USB物理层虽然有CRC保护,但应用层加一道校验能防止总线上意外错误和固件状态机错乱导致的数据污染。实际产品里,我见过不带校验的demo在实验室跑了一天都没问题,到了现场电缆一长就偶发收到错误指令,排查到崩溃。
4.2 下位机侧的通信并行处理
这里要结合热搜词里提到的一个问题——“C++两个线程分别读写一个大数组”。在USB外设控制场景下,下位机(比如STM32)经常要同时处理上位机下发的控制帧和主动上报的数据帧,如果读写逻辑共用同一个缓冲区,就要考虑互斥和同步。
我在demo中的下位机逻辑是这样设计的:
- 接收线程(或中断):从USB端点把数据搬进环形缓冲区,解析帧头、长度、CRC,完整帧送入“命令队列”。
- 业务循环:从命令队列取出命令,执行GPIO/PWM/ADC等硬件操作。
- 发送线程:把要上报的数据包排队,写入USB发送端点。
这样做的好处是,接收、处理、发送三个环节不互相阻塞,只要缓冲区开得够大,上位机连发100帧也不会丢。若你非要“两个线程分别读写同一个大数组”,那你至少要做到:读线程加锁访问,写线程加锁写入;或使用双缓冲(一个线程写BUF-A,另一个线程读BUF-B,写完交换);或采用无锁环形缓冲区(单生产者/单消费者)。具体选哪种,取决于数据帧的最大长度和实时性要求。
4.3 上位机发送控制指令
回到上位机,发送一帧控制命令的代码类似于:
def build_frame(cmd, data: bytes) -> bytes: length = len(data) payload = bytes([cmd]) + length.to_bytes(2, 'little') + data crc = compute_crc16(payload) return b'\xAA\x55' + payload + crc.to_bytes(2, 'little') # 示例:控制外设GPIO输出高电平 frame = build_frame(0x01, bytes([0x01])) ep_out.write(frame, timeout=1000)如果外设要求控制传输,则:
# 控制传输写(适合小数据量控制指令) request_type = usb.util.build_request_type( usb.util.CTRL_OUT, usb.util.CTRL_TYPE_VENDOR, usb.util.CTRL_RECIPIENT_DEVICE ) dev.ctrl_transfer(bmRequestType=request_type, bRequest=0x01, wValue=0x1234, wIndex=0, data_or_wLength=frame, timeout=1000)这里timeout我强烈建议显式设置,默认值有时候会无限等待,现场一旦设备无响应,程序就卡死了。所有USB读操作都设一个合理的超时时间(比如500ms~2s),配合应用层的“失败重试3次”策略,稳定性会好很多。
4.4 状态读取与应用层映射
控制外设不能只发指令,还得能读状态。我在demo里专门写了一个读命令轮询的函数,每200ms读一次外设状态,随后把状态映射成业务逻辑:
import time def read_device_status(ep_in, timeout=1000): # 发送读状态命令 frame = build_frame(0x04, b'') ep_out.write(frame, timeout=timeout) # 接收响应,注意USB端点包长度可能不足帧长度,需要多次读 resp = b'' while True: chunk = ep_in.read(ep_in.wMaxPacketSize, timeout=timeout) resp += bytes(chunk) if len(resp) >= 12: # 帧头4 + 命令1 + 长度2 + 数据8 + CRC2,举例 break return parse_frame(resp) while True: status = read_device_status(ep_in) print("外设状态:", status) time.sleep(0.2)“一次读到的数据长度不一定是完整一帧”,这个点非常容易被新手忽略。USB批量传输是按包走的,一包可能是64/512字节,你的完整协议帧可能分散在两包或多包里,也可能一包里包含了两帧。所以读数据时务必循环读、按帧解析、把剩余字节缓存起来,这就是经典的“粘包/拆包”处理。pyusb的read方法会按端点最大包大小返回,你自己拼包就行。
5. 实测过程中的常见问题与排查技巧
5.1 找不到设备或枚举为空
这个现象十有八九是以下原因之一:
- 设备没被系统识别:先打开设备管理器,确认有没有未知设备或黄色感叹号,如果设备枚举出来都是问号,先解决驱动问题。
- 权限不足:Linux下访问USB设备需要root权限或配置udev规则;Windows下某些操作需要管理员权限。可以在命令行测试一下是否用管理员身份运行后枚举就正常了。
- 驱动绑定冲突:在Windows下,设备被系统驱动(比如hidusb)占用,
find_all是在底层枚举层面,通常还能看到设备,但打开会失败。这时你需要用Zadig工具把设备驱动切换为WinUSB或libusbK,注意,这会替换系统对该设备的驱动,建议测试完在设备管理器里还原。
5.2 “Resource Busy”或“Access Denied”
这个错误在Windows上尤其常见。处理思路是:
- 判断设备是否被内核驱动占用:
dev.is_kernel_driver_active(0) - 如果占用,且你的代码有权限,先执行分离驱动。
- 如果分离驱动失败,检查是否有其他进程(比如官方工具、其他上位机程序)已经打开了这个设备。USB设备同一时刻只允许一个进程独占访问。
我在调试时就碰到过一次,前面用设备厂商的demo工具点了几下没关,后台进程还占着设备,我的Python程序怎么打开都报错。排查了半天,最后打开任务管理器把那几个残留进程清了才解决。所以遇到打开失败,先确认没有别的程序占着设备。
5.3 读取到空数据或超时
如果发送了读命令但一直收不到数据,按这个顺序排查:
- 端点方向是否选对:很多新手把
ENDPOINT_OUT和ENDPOINT_IN搞反了,读数据用了OUT端点,必然超时。 - 设备是否真的发数据:用USB抓包工具(比如Windows版Wireshark配USBPcap,或Linux的usbmon)抓一下总线上的数据流,立刻能看出主机发了什么、设备回了没有、回了什么内容。抓包是USB调试里最直观的保命技能,强烈建议学会。
- 设备固件是否有回应逻辑:有些外设不是收到命令就回,而是满足一定条件才回。这时候先读一下设备文档,确认握手时序。
5.4 多个同型号设备如何精确锁定
这是这个项目标题里的重点。如果两台设备同VID/PID,又没有序列号,我的实测经验是:
- 按端口路径匹配:在Windows下,用SetupAPI能拿到“设备实例路径”(Device Instance Path),里面有类似
USB\VID_1234&PID_5678\6&1234567&0&3的信息,最后的&3往往代表插入的端口顺序。你可以把这个路径作为匹配依据。 - 按物理连接顺序:如果产品现场USB口是固定拓扑(比如上位机背后有固定的1/2/3口),你可以直接用端口号来区分。但需要注意,USB Hub的接入会改变端口路径,现场布线尽量让设备直接插主机USB口,别串Hub。
- 按设备描述符里的特殊字符串:有些厂商把自定义信息放在iManufacturer或iProduct里,可以用来辅助匹配。
5.5 通讯偶尔出错、数据校验不过
这种情况大多不是USB物理层问题,而是应用层协议设计不严谨。我踩过的一个具体坑是:上位机发指令太快,下位机还没处理完,下一条就来了,导致下位机包头错位。解决办法有三:
- 上位机发完一条后等待ACK,再发下一条。
- 下位机用DMA+环形缓冲区接收,保证任意时刻都能完整接收帧。
- 增加“0xAA 0x55”双字节帧头,并在下位机状态机里做好“找帧头-读长度-收数据-校验”的状态转换。
我在demo里把下位机的接收状态机写成了四个状态:WAIT_HEAD1、WAIT_HEAD2、WAIT_LEN、WAIT_DATA,极大增强了抗干扰能力,即使总线上出现乱码也能快速恢复同步。
6. 写在最后的实操心得
这个项目做完之后,我最大的体会是:USB通信demo的“技术含量”不在USB这两字,而在“多设备选择、协议设计、异常处理”这三件不那么显眼的事上。
如果你只是想把单个USB设备的数据读出来,用现成的串口助手或官方demo就够了,不需要自己写。但一旦涉及多设备并存、选特定外设、稳定控制,就必须把“设备发现-匹配-打开-通信-协议”这条链路全部打通。这部分能力是通用技术,学会了之后换任何USB外设都能快速适配。
最后再分享两个小技巧:
- 调USB通信时不要一上来就写业务代码。先用抓包工具确认主机与设备能通,再用最小demo收发一帧数据,最后才写控制逻辑,这样能把问题边界划清楚。
- 给设备驱动打补丁前先备份。在Windows上做驱动替换(比如用Zadig换WinUSB驱动)后,设备可能不再被其他系统应用识别。调完一定要记得还原驱动,不然产品交付时会被现场运维骂。
这个demo做好之后,你可以往很多方向扩展:把Python原型换成C++上位机、加入自动重连机制(设备拔出后重新枚举)、增加多设备并发管理、把协议层封装成动态库供上层软件调用。每往前走一步,你踩过的那些坑都会变成产品稳定性的一部分。
本文还有配套的精品资源,点击获取