ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Android USB HID通信Demo:USB Host模式数据收发

Android USB HID通信Demo:USB Host模式数据收发 简介一套基于安卓USB Host与OTG模式的HID通信Demo面向需要实现手机与单片机双向数据交互的嵌入式及安卓开发人员。资源内含完整Android工程覆盖UsbManager设备枚举、权限申请、UsbDeviceConnection建立连接、输入输出端点读写及HID报告解析等关键环节Ground_Station示例展示了将安卓设备作为地面站与单片机通信的落地方式可迁移至飞控、遥测等场景。压缩包共1055个文件以XML布局配置、Java源码、JSON数据、PNG图片和协议原始文件为主包含Gradle构建脚本及运行库整体仅17.44MB结构清晰便于按功能模块查阅。目前已有3752人学习下载适合有一定安卓基础、希望快速上手USB HID通信的开发者作为参考模板。1. 项目概述与整体思路拆解做安卓 USB 外设开发最绕不开的就是跟 HID 设备打交道。我这次要分享的“安卓 usb hid通信demo”一句话说清楚就是让 Android 设备通过 USB Host 模式直接和 HID 协议的设备进行数据收发不依赖任何第三方库纯 Android SDK 原生 API 实现Demo 代码可以直接拷到自己的项目里改改就用。这个需求在真实场景里出现频率很高。比如你要做一个工业数据采集的安卓 App设备端是一个 USB 键盘式的扫码枪或者你手上有一块自定义 HID 协议的 USB 传感器板子想用手机读取数据再或者你想做一个安卓端的上位机控制一个 USB HID 继电器模块。这些场景背后都是同一件事Android 作为 USB Host和 HID 外设建立通信通道按 HID Report 格式收发数据。在动手之前先讲清楚几个选型层面的决策这是很多人一上来就踩坑的地方。第一个决策为什么选 HID 而不是串口安卓端做 USB 通信最常见的有三条路USB 转串口CDC ACM、USB HID、USB 自定义 Vendor 类。串口方案在工业场景很成熟但有个硬伤——需要外设侧预先烧录 CDC 固件而且很多 USB 转串口芯片比如常见的 FT232R、CH340在安卓上需要额外处理系统权限。HID 协议则不同它是操作系统级别“免驱”的安卓原生支持 HID Host插上就能枚举不需要 root不用装驱动这是它最大的优势。第二个决策通信模式选中断传输Interrupt还是控制传输Control这取决于设备的实际配置。绝大多数 HID 键盘、扫码枪、自定义 HID 设备数据通道都是中断传输端点Interrupt OUT/IN因为 HID 协议设计之初就是为低延迟、小数据量交互准备的。控制传输用于读取 HID 描述符和发送 Set_Report/Get_Report 请求更多是初始化阶段用。我在 Demo 里两种都实现了但主力走中断传输。第三个决策用官方 UsbManager 还是封装库网上有人推 usb-serial-for-android 这类库但那是针对 CDC 串口设备的。做 HID 通信直接用系统 API 就够了封装反而碍事。官方 API 的核心就四个对象UsbManager、UsbDevice、UsbInterface、UsbEndpoint理解这四个对象整个通信链路就打通了。这个 Demo 适合谁看已经会安卓基础开发、想进入 USB 外设领域的人以及做嵌入式、手头有 HID 设备需要快速出一版安卓调试工具的工程师。对于完全没碰过安卓的小白文中涉及 Activity 生命周期和异步线程的部分建议先补一下基础。2. 开发环境准备与基础设施搭建2.1 硬件选型与设备准备我这次实测用的设备组合是手机红米 12C安卓 11 系统Type-C 口HID 设备一个自制的 STM32 自定义 HID 设备Report 长度 64 字节为什么强调 64 字节因为USB HID 协议规定中断传输的最大数据包长度不能超过 64 字节全速设备。如果你的设备一次要传超过 64 字节的数据必须在驱动层做分包组包这是 HID 通信一个绕不开的限制。实测下来低速设备最大 8 字节全速设备最大 64 字节高速设备理论上可以更大但绝大多数嵌入式 HID 设备都是全速模式所以按 64 字节来设计数据帧是最稳妥的。如果你手头没有 HID 设备有两个替代方案可以先用起来一个是 USB 键鼠因为它们是标准 HID 设备另一个是用 USB 转串口模块刷一个 HID 固件注意这个只用于学习调试不算完整方案。代码逻辑是一样的只要厂商 IDVendor ID和产品 IDProduct ID不冲突系统就能识别。2.2 动态权限申请与设备过滤声明做 USB Host 通信第一步是让系统知道“我的 App 要在特定设备插入时被拉起”。这个在AndroidManifest.xml里声明一个 intent-filter 就行。activity android:name.MainActivity android:launchModesingleTop intent-filter action android:nameandroid.hardware.usb.action.USB_DEVICE_ATTACHED / /intent-filter meta-data android:nameandroid.hardware.usb.action.USB_DEVICE_ATTACHED android:resourcexml/device_filter / /activity同时需要在res/xml/device_filter.xml里配置设备过滤规则?xml version1.0 encodingutf-8? resources usb-device vendor-id1155 product-id22352 / !-- 这里是示例的 VID/PID你要是用标准 HID 键盘一般不需要在这里声明 -- /resources这里的 vendor-id 和 product-id 是十进制数用lsusb命令或 Windows 设备管理器查到的十六进制值先转成十进制再填别直接填十六进制不然会踩一个很隐蔽的坑明明设备插上却过滤不到系统完全没有反应。注意过滤声明写好后如果设备没出现在列表里优先检查 VID/PID 是否转换成了十进制这是我在实际调试中遇到频率最高的问题之一。权限申请这一步要区分两种情况如果你的 App 是在设备“已插入”状态下通过 intent-filter 被拉起的系统会自动带上权限可以直接获取但如果 App 是手动启动、设备已经在系统里就必须用requestPermission()动态申请。这个动态申请在真机上表现很直接会弹一个系统对话框问用户“是否允许此应用访问该 USB 设备”用户点了“确定”回调才会给权限。权限回调的结果是异步的用广播接收器BroadcastReceiver监听ACTION_USB_PERMISSION。我最初写这个 Demo 时犯过一个错误就是没注册这个广播就急着去 open 设备结果拿到 null 设备时一脸懵。3. 核心代码实现从设备识别到数据收发3.1 枚举设备与权限申请完整代码核心逻辑放在MainActivity里第一步是获取 UsbManager 并枚举设备。看到这里你会发现安卓的 USB Host 架构其实就是“管理器”模式所有对 USB 的操作都要过 UsbManager 这个口子。public class MainActivity extends AppCompatActivity { private static final String TAG UsbHidDemo; private static final String ACTION_USB_PERMISSION com.example.usbhid.USB_PERMISSION; private UsbManager usbManager; private PendingIntent permissionIntent; private UsbDevice targetDevice; private UsbDeviceConnection usbConnection; private UsbInterface usbInterface; private UsbEndpoint outEndpoint; // 中断输出端点 private UsbEndpoint inEndpoint; // 中断输入端点 private final BroadcastReceiver usbReceiver new BroadcastReceiver() { Override public void onReceive(Context context, Intent intent) { String action intent.getAction(); if (ACTION_USB_PERMISSION.equals(action)) { synchronized (this) { UsbDevice device intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) { if (device ! null) { // 拿到权限开始打开设备 openUsbDevice(device); } } else { Log.e(TAG, 用户拒绝了 USB 权限); Toast.makeText(MainActivity.this, USB权限被拒绝, Toast.LENGTH_SHORT).show(); } } } else if (UsbManager.ACTION_USB_DEVICE_DETACHED.equals(action)) { // 设备拔出清理连接 closeUsbDevice(); } } }; }这里的重点在于广播接收器的两个 action 缺一不可一个是权限回调一个是设备拔出通知。拔出的监听特别重要因为不少 HID 设备存在热插拔场景如果不做处理App 很容易在设备拔出后崩溃。然后是枚举和申请权限的代码private void findAndRequestDevice() { HashMapString, UsbDevice deviceList usbManager.getDeviceList(); if (deviceList.isEmpty()) { Log.e(TAG, 没有检测到 USB 设备); return; } for (UsbDevice device : deviceList.values()) { Log.d(TAG, 发现设备: VID device.getVendorId() , PID device.getProductId() , DeviceClass device.getDeviceClass()); // 判断是否是 HID 设备 if (device.getDeviceClass() UsbConstants.USB_CLASS_HID) { targetDevice device; break; } } if (targetDevice ! null) { // 第一次连接需要动态申请权限 permissionIntent PendingIntent.getBroadcast(this, 0, new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE); usbManager.requestPermission(targetDevice, permissionIntent); } else { // 这里很容易踩坑很多设备 deviceClass 不是 HID但 interfaceClass 才是 Log.w(TAG, 没有找到 HID 设备尝试按 interface 匹配); findHidDeviceByInterface(deviceList); } }日志里那句提示是我后来加上的因为很多自制的 HID 设备比如 STM32 用 CubeMX 生成的例程deviceClass 写的是0x00但实际上它的接口Interface Class是 HID。写代码时不能只看设备类还要往下看一层接口类所以配套一个按 interface 匹配的函数是必须的。private void findHidDeviceByInterface(HashMapString, UsbDevice deviceList) { for (UsbDevice device : deviceList.values()) { for (int i 0; i device.getInterfaceCount(); i) { UsbInterface usbIf device.getInterface(i); if (usbIf.getInterfaceClass() UsbConstants.USB_CLASS_HID) { targetDevice device; usbInterface usbIf; return; } } } }3.2 打开设备、选择端点和数据收发拿到权限后就可以打开设备了。这一步最核心的是选对端点和传输方向。一个 HID 接口通常有多个端点中断输入端点设备发给主机、中断输出端点主机发给设备、控制端点 0。如果选错端点读写都会失败而且报错信息不一定直观可能只是超时。private void openUsbDevice(UsbDevice device) { UsbInterface targetInterface null; for (int i 0; i device.getInterfaceCount(); i) { UsbInterface usbIf device.getInterface(i); if (usbIf.getInterfaceClass() UsbConstants.USB_CLASS_HID) { targetInterface usbIf; break; } } if (targetInterface null) { Log.e(TAG, 未找到 HID 接口); return; } usbConnection usbManager.openDevice(device); if (usbConnection null) { Log.e(TAG, 打开设备失败请检查权限或设备是否被占用); return; } // 声明独占接口 usbConnection.claimInterface(targetInterface, true); // 遍历端点找到输入和输出端点 for (int i 0; i targetInterface.getEndpointCount(); i) { UsbEndpoint ep targetInterface.getEndpoint(i); if (ep.getType() UsbConstants.USB_ENDPOINT_XFER_INT) { if (ep.getDirection() UsbConstants.USB_DIR_IN) { inEndpoint ep; Log.d(TAG, 找到中断输入端点地址: ep.getAddress() , 包大小: ep.getMaxPacketSize()); } else if (ep.getDirection() UsbConstants.USB_DIR_OUT) { outEndpoint ep; Log.d(TAG, 找到中断输出端点地址: ep.getAddress() , 包大小: ep.getMaxPacketSize()); } } } }数据发送和接收就非常简洁了// 发送数据注意 input 必须是 64 字节以内的 byte 数组 public boolean sendData(byte[] data) { if (outEndpoint null || usbConnection null) { Log.e(TAG, 输出端点或连接未就绪); return false; } if (data.length outEndpoint.getMaxPacketSize()) { Log.e(TAG, 数据长度超过端点最大包长); return false; } int transferred usbConnection.bulkTransfer(outEndpoint, data, data.length, 1000); return transferred 0; } // 接收数据阻塞式读取建议放在子线程 public byte[] receiveData() { if (inEndpoint null || usbConnection null) { return null; } byte[] buffer new byte[inEndpoint.getMaxPacketSize()]; int received usbConnection.bulkTransfer(inEndpoint, buffer, buffer.length, 1000); if (received 0) { byte[] result new byte[received]; System.arraycopy(buffer, 0, result, 0, received); return result; } return null; }关于bulkTransfer这个方法多说一句它虽然名字带 bulk但对中断端点同样适用。本质就是“同步阻塞式端点传输”可以简单理解成“把数据丢进一个管道数据到了就返回不到就阻塞到超时时间为止”。它唯一的缺点是不能设置多个超时周期不像 AOA 方案里可以通过别的方式做异步但在 Demo 阶段完全够用。提示bulkTransfer最后一个参数是超时时间单位毫秒。如果你设 0表示无限等待在 UI 线程调用必卡死所以接收数据一定要放到子线程。我习惯用 1000ms 超时既能保证及时性又不会让线程长期挂起。3.3 数据读取线程与 UI 更新接收数据是阻塞的不能放在主线程。我用一个HandlerThread循环读数据读到就通过 Handler 回调到主线程更新 UIprivate HandlerThread readThread; private Handler readHandler; private Handler mainHandler new Handler(Looper.getMainLooper()); private void startReading() { readThread new HandlerThread(UsbReadThread); readThread.start(); readHandler new Handler(readThread.getLooper()); readHandler.post(new Runnable() { Override public void run() { while (isReading) { byte[] data receiveData(); if (data ! null) { // 通过主线程 Handler 更新 UI mainHandler.post(() - { String hexStr bytesToHex(data); tvReceive.setText(hexStr); }); } else { // 超时或读取失败这里不要 break否则线程就死了 Log.d(TAG, 读取超时继续等待); } } } }); }注意那个else分支读取超时是正常的不应该让它退出循环。很多新手在这里写成if (data ! null) break;导致只读了一次后面再也收不到数据排查半天还以为是设备问题。发送数据我测试的时候是用了一个简单的“定长帧”协议比如前两位是命令字后 62 位是数据因为 HID 包固定 64 字节发多长其实就是填满 64 字节设备侧解析也方便// 封包命令 0x01 代表查询状态 byte[] packet new byte[64]; packet[0] 0x01; packet[1] 0x00; sendData(packet);4. 实测过程与典型问题排查4.1 一次完整的实测记录我把这个 Demo 部署到红米 12C 上做了完整测试。设备是一个 STM32 模拟 HID 键盘的设备插上电之后系统立刻弹了授权框点允许后进入主界面日志打印出找到的端点和包大小。实际读到了设备端返回的 64 字节数据包内容为设备状态结构体我按字节解析后显示在界面上通信链路完全打通。有一个细节值得注意HID 设备插入时系统不一定立刻弹出授权框如果 App 是后台进程授权框可能被系统窗口遮挡。所以我的 Demo 的设计是App 启动时直接调用findAndRequestDevice()主动要权限这样用户一打开 App 就能看到授权弹窗而不是傻等插入事件。4.2 遇到的典型问题与解决思路我把调试过程中遇到的高频问题整理成了表格方便大家对照排查问题现象可能原因解决办法设备插上后 App 没反应VID/PID 过滤没生效或没填对检查 device_filter.xml确认 VID/PID 是否为十进制设备能枚举但openDevice返回 null设备已被其他应用占用或没有申请权限确认权限回调成功后再 open检查是否有其他 App 持有设备打开设备后读写超时端点选择错误或claimInterface未执行成功打印所有端点信息确认选的是中断输入/输出端点claimInterface第二个参数传 true读到的数据全是0x00读取缓冲区和设备发送的数据长度不匹配或读取周期太快将缓冲区设为getMaxPacketSize()检查解析逻辑设备拔出后 App 闪退没有注册ACTION_USB_DEVICE_DETACHED监听注册广播拔出时调用closeUsbDevice()释放连接系统提示“该设备找不到足够资源可以使用。(代码 12)”设备的端点资源分配冲突或 USB Host 控制器资源不足尝试重新插拔、重启手机、关掉占用 USB 的其他应用如果设备是高速/超速设备考虑换一个 USB HUB 供电“代码 12”这个问题在 Windows 上最常见在安卓上比较少见但一旦出现大概率是 USB Host 控制器的资源被耗尽。多数情况下重启设备或者换一个 USB 口就能解决不用太焦虑。4.3 抓包验证USB 通信看不到数据时的终极大法如果代码看起来没问题但就是读不到数据我强烈建议用Wireshark 抓一次 USB 通信前提是你在 Linux 环境下运行抓包或者使用安装了 USB 抓包插件的 Windows 系统。在 Linux 下抓 USB 包需要先加载 usbmon 模块sudo modprobe usbmon然后用 Wireshark 选择 usbmon 网卡比如 usbmon1、usbmon2对应不同的 USB 总线就能看到设备枚举、控制传输、中断传输的完整报文。这是排查 HID 设备通信问题最有效的手段没有之一。它能让你一眼看出来是设备根本没发数据还是主机发了但设备没应答还是数据被解析错了。抓包步骤虽然稍微复杂一点但在“看不到数据”这种疑难杂症面前比反复改代码猜问题高效太多了。5. 实际调试中的坑点回顾与排查技巧5.1 最容易忽略的细节这些坑是在项目中真正踩过的大多数时候不会直接报错而是表现为“诡异行为”我把它们单独列出来说。第一设备拔出后的清理顺序。很多人的closeUsbDevice()只做了 close 连接忘了releaseInterface。如果你开发的 App 是常驻后台的比如做数据采集的那么这个释放步骤会直接影响下一次插拔的体验。正确顺序是private void closeUsbDevice() { isReading false; if (usbConnection ! null) { if (usbInterface ! null) { usbConnection.releaseInterface(usbInterface); } usbConnection.close(); } usbConnection null; usbInterface null; inEndpoint null; outEndpoint null; }第二PendingIntent.FLAG_IMMUTABLE在安卓 12 及更高版本是强制要求。如果你 targetSdk 切到 31 以上不加这个 flag 会直接崩。这个问题看起来小但真的影响 App 启动。第三不要在 USB 权限回调里做耗时操作比如打开数据库、初始化网络连接等。权限回调是跑在 Binder 线程上的不是主线程也不是专门的回调线程。你在里面做耗时操作虽然短期内看不出问题但一旦设备拔插频繁就可能出现连不上或数据丢失。第四个坑比较隐蔽bulkTransfer的返回值不一定等于你传入的 data.length。它返回的是实际传输的字节数可能小于请求长度。如果你的协议是定长帧解析数据时一定要以received为准不要用buffer.length或者固定的 64否则会读到上次残留的旧数据。我在写 receiveData 时专门做了System.arraycopy裁剪就是为了避免这个问题。5.2 安卓 11 及以上版本的适配问题安卓 11 开始USB 权限相关的行为有一些微妙变化。我在红米 12C安卓 11上测试时发现几个问题系统对“后台使用 USB 设备”的限制更严了。如果你的 App 退到后台即使之前已经拿到权限也可能收不到设备的数据。这是系统休眠策略导致的不是你的代码问题。解决方案是让你的 App 在前台运行或用前台服务保持进程活跃。requestPermission返回的弹窗如果被用户取消不会再弹出第二次。所以最好在 UI 上给用户一个“重新申请权限”的按钮而不是只能重启 App。某些定制 ROM尤其国产 ROM对 USB 授权的弹窗显示策略不同在个别设备上弹窗可能不出现。遇到这种情况优先检查自己 App 的通知权限因为系统把 USB 授权弹窗通过通知栏渠道展示通知被关的话弹窗就会被吞掉。5.3 进阶HID Report Descriptor 与自定义协议解析如果你的 HID 设备是自定义设备比如自己用 STM32 实现的 HID 传感器你还需要了解Report Descriptor这个概念。它描述了设备的报告格式但其实在安卓侧我们可以不解析它直接按固定长度收发原始数据即可。真正需要关心里面内容的时候是你要逆向一个不熟悉的 HID 设备时或者要实现 HID 键盘/鼠标模拟功能时。Report Descriptor 的样子以最简单的 64 字节自定义 HID 设备为例核心部分大概长这样0x06, 0x00, 0xFF, // Usage Page (Vendor Defined 0xFF00) 0x09, 0x01, // Usage (0x01) 0xA1, 0x01, // Collection (Application) 0x09, 0x01, // Usage (0x01) 0x15, 0x00, // Logical Minimum (0) 0x26, 0xFF, 0x00, // Logical Maximum (255) 0x75, 0x08, // Report Size (8) 0x95, 0x40, // Report Count (64) 0x81, 0x02, // Input (Data, Var, Abs) 0xC0 // End Collection这段描述符定义了 64 字节的输入报告。它在安卓侧的对应物就是UsbEndpoint的getMaxPacketSize()返回 64。如果你要在电脑上好好调这类设备建议配合 HID Descriptor Tool、USBlyzer 这类工具去看得更清楚。不过这些细节在安卓开发侧不是必需的——你只要遵守 64 字节以内的数据包长度通信就能通。6. 从 Demo 到产品级应用的几点建议很多人做完 Demo 就完了但真要放到产品里这几个地方一定要提前规划。通信协议设计。Demo 里简单发送定长 64 字节没问题但产品化时我建议设计自己的帧格式比如帧头2 字节 长度1 字节 命令字1 字节 数据区 校验CRC16 或累加和。这能大大减少排障难度。实测过很多工业 HID 设备它们内部都是这种结构。如果只是裸发数据一旦出现错位基本没法定位。多设备支持。如果你的 App 需要同时连接多个 HID 设备要注意安卓系统对 USB Host 同一时刻只允许一个 App 独占权限多个设备能接入但每个设备都要单独申请权限。代码层面要把deviceList的遍历、权限申请、连接管理做成按设备维度的“会话”不能像 Demo 里那样只存一个targetDevice。线程模型优化。bulkTransfer的阻塞模式虽然在 Demo 里够用但产品里推荐用UsbRequest做异步或者干脆用BlockingQueue加多个工作线程来解耦读写。特别是在高频率收数据比如 1kHz 上报率的传感器时阻塞模式很容易丢包。功耗管理。USB Host 模式下手机可以作为外设的供电方如果你连接的是无外部供电的 HID 设备要考虑手机电量消耗。实测下来长时间 USB Host 通信对手机电量消耗在每小时 8%~12% 左右这是一块不小的开销。产品里建议增加供电提示和低电量告警必要时引导用户使用外部供电的 USB Hub。这个细节很多人忽略但真的做了产品化你会发现它很重要。7. 个人实操经验分享最后分享一点我自己的体会。这个 HID 通信 Demo 看起来简单但从“能通”到“稳定通”中间隔着一堆细节。我最初做的时候光在端点选择上就卡了两天因为设备端和安卓端的理解不一致设备认为自己是输出端点安卓却以主机的视角去看这个端点。想明白“端点方向是相对于主机来说的”这件事之后一切豁然开朗。另外如果你打算长期做 USB 相关开发强烈建议在工位上常备一根 USB 转 TTL 的调试线再备一个Wireshark 抓包环境。很多 USB 问题在代码层面根本看不出来但是抓包数据一到手设备波形、字节流全摆在那里问题就清晰了。这比看日志、printf 高效太多了。把这个 Demo 跑通之后你在安卓端做 USB 键盘监听、HID 扫码枪接入、自定义 HID 设备通信就都有了地基。下一步可以考虑往 AOAAndroid Open Accessory协议或者 USB 音频方向扩展但那是另一个话题了。先把 64 字节以内的中断传输玩明白后面遇到再复杂的外设思路都是相同的。本文还有配套的精品资源点击获取
返回列表