
简介面向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从思路到代码再到避坑经验完整过一遍。所有内容都基于我实际调试过的场景代码逻辑可以用Pythonpyusb/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 技术方案的取舍我当时在三种方案里做选择这里也列出来供你参考方案优点缺点适用场景串口APIReadFile/WriteFile最简单系统自带驱动代码量小只适用CDC类USB转串口设备无法直接访问自定义类设备设备本身就是USB转串口模块WinUSB SetupAPI原生Windows方案免第三方库可访问自定义设备需要写INF或使用WinUSB驱动绑定跨平台麻烦Windows下正式产品libusbpyusb跨平台Windows/Linux/macOS生态成熟支持各类USB设备Windows下需要安装/替换驱动如WinUSB/libusbK对新手有一定门槛跨平台工具、快速验证demo在这个项目里我选的是libusb/pyusb原因是它拿到了USB设备描述符和端点信息之后能直接做批量传输和中断传输不需要依赖任何系统协议驱动。而且Python写demo速度极快方便验证通信链路通了之后再移植到C工程里。如果你的项目限定Windows自有驱动那直接用SetupAPIWinUSB也是合理的选择但配套的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 设备匹配的三个策略代码里要做设备筛选最核心的是三个匹配参数VIDVendor ID厂商IDUSB-IF分配给厂商的唯一编号比如0x1234。PIDProduct ID产品ID厂商给自己的不同产品分配的编号。序列号iSerialNumber同一厂商同一型号如果每个设备烧录了不同序列号你可以通过序列号精确区分“这一台”还是“那一台”。我实测下来的匹配优先级是这样VIDPID序列号最精确适合产品现场多台设备并存比如一台工控机同时接两只同型号扫码枪就必须靠序列号区分。VIDPID适用单台设备或者多台设备不需要区分具体哪台。VIDPID端口路径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_allTrue) 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匹配设备: VID0x{vid:04X}, PID0x{pid:04X}, fSerial{serial}, Manufacturer{manufacturer}, Product{product}) # 如果你要的设备序列号是DEMO-2024-001 if serial DEMO-2024-001: target dev break这里有个细节在find_allTrue遍历时设备尚未真正打开很多字符串描述符的读取依赖系统驱动如果设备被内核驱动占用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(idVendorTARGET_VID, idProductTARGET_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, timeout1000)如果外设要求控制传输则# 控制传输写适合小数据量控制指令 request_type usb.util.build_request_type( usb.util.CTRL_OUT, usb.util.CTRL_TYPE_VENDOR, usb.util.CTRL_RECIPIENT_DEVICE ) dev.ctrl_transfer(bmRequestTyperequest_type, bRequest0x01, wValue0x1234, wIndex0, data_or_wLengthframe, timeout1000)这里timeout我强烈建议显式设置默认值有时候会无限等待现场一旦设备无响应程序就卡死了。所有USB读操作都设一个合理的超时时间比如500ms~2s配合应用层的“失败重试3次”策略稳定性会好很多。4.4 状态读取与应用层映射控制外设不能只发指令还得能读状态。我在demo里专门写了一个读命令轮询的函数每200ms读一次外设状态随后把状态映射成业务逻辑import time def read_device_status(ep_in, timeout1000): # 发送读状态命令 frame build_frame(0x04, b) ep_out.write(frame, timeouttimeout) # 接收响应注意USB端点包长度可能不足帧长度需要多次读 resp b while True: chunk ep_in.read(ep_in.wMaxPacketSize, timeouttimeout) 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_1234PID_5678\6123456703的信息最后的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上位机、加入自动重连机制设备拔出后重新枚举、增加多设备并发管理、把协议层封装成动态库供上层软件调用。每往前走一步你踩过的那些坑都会变成产品稳定性的一部分。本文还有配套的精品资源点击获取