直接开工。Android车载USB开发这块,网上资料要么是Android手机USB调试的零散帖子,要么是纯Linux端的USB协议分析,真正把“车机”这个特殊场景和USB Host外设串起来的完整笔记,少之又少。我这两年正好在车载项目中把USB串口、USB-CAN、HID设备挨个踩了一遍,这里把能直接用的经验整理出来,包含系统API的坑、权限管理的细节、以及几个自己写的工具类,给后面接手类似项目的兄弟省点时间。
1. 项目整体思路与车载USB开发环境筹备
1.1 车载场景为什么绕不开USB外设
车机的USB口和手机上那个只用来充电或者连电脑的USB口,本质上是两码事。手机上的USB绝大多数时候工作在Device模式,也就是作为从设备被电脑识别;而车机上需要一个USB Host口,目的是去主动连接外部设备。
车机上常见的USB外设无外乎这四类:
- USB串口设备(CDC ACM):接调试板、OBD盒子、外挂传感器模块,最常见的调试和数据采集通道。
- USB-CAN适配器:接入整车CAN总线网络,读取车速、转速、档位、电池状态等车身信号,这块在商用车、新能源车诊断和车联网终端上尤其多。
- HID设备:USB键盘、刷卡器、自定义按键面板、指纹模块,这类设备不需要厂商专门写驱动,系统自带HID驱动就能识别。
- USB存储设备:U盘、移动硬盘,用于本地媒体播放、数据导出、系统升级包拷贝,虽然原理简单,但媒体扫描和权限隔离的坑不少。
我做这个项目时的核心诉求,是让车机上运行的一款诊断类App能够同时管理上述多种USB外设,并且在设备热插拔时做到自动识别、自动重连、多路并发不互相干扰。这个需求看起来不复杂,但真正落地的过程中涉及到的内容远比“调一个API”多得多,所以这篇文章不打算只贴代码,而是把从环境搭建、协议选择、权限处理到稳定性排查的完整链路写清楚。
1.2 开发环境与工具链准备
先把开发环境说清楚,避免大家卡在第一步。我这次用的是标准Android Studio开发环境,系统是Ubuntu 20.04,目标车机平台是某款高通平台的车载中控,系统版本是Android 11(API级别30),带系统签名权限,所以应用的签名方式和市面上普通的Android应用不一样——走的是平台签名,这直接影响后续能否访问一些系统级API。
如果你是在普通Android手机上做初步验证,注意一个前提:手机必须支持USB Host模式,也就是硬件上具备OTG能力。绝大多数手机都支持,但个别早期平板或定制设备会阉割掉Host功能,检测方法很简单:
UsbManager usbManager = (UsbManager) getSystemService(Context.USB_SERVICE); if (usbManager == null) { // 不支持USB Host }工具方面,除了Android Studio本身,我强烈推荐准备一个USB协议分析工具。软件层面推荐Wireshark + usbmon(Linux主机)或者USBPcap(Windows主机),硬件层面如果条件允许,搞一个Beagle USB 480协议分析仪,排查设备枚举失败、描述符解析错误时这是救命工具。我用Wireshark抓包解决过一个HID设备上报事件丢失的问题,后面会细说。
模拟设备方面,我准备了三类硬件用于测试:CH340串口模块(最常用的USB转串口芯片)、某国产USB-CAN适配器(使用兼容SLCAN协议的固件)、以及一个自定义的HID按键面板。这些硬件单买都很便宜,强烈建议开发者在做软件架构设计之前先把手头的目标外设硬件定下来,否则后面写的代码全是空中楼阁。
2. Android USB Host架构与设备枚举的核心原理
2.1 USB Host整体架构:从内核到应用的四层链路
Android的USB Host架构,从最底层往上,大致分四层:
- 内核层(Kernel Space):Linux内核里的USB Core + Host Controller驱动(EHCI/XHCI等)+ 各类设备驱动(usb-storage、cdc_acm、hid等)。
- HAL层:Android的USB HAL(
android.hardware.usb)负责最底层控制,不过车机上通常用AOSP默认实现。 - 框架层(Framework):
UsbManager、UsbDevice、UsbInterface、UsbEndpoint、UsbDeviceConnection这些类构成了开发者接触到的API面。 - 应用层(App):也就是你写的App,通过Binder调用框架层的UsbService。
对应用开发者来说,内核和HAL层基本不需要碰,但你必须理解一件事:设备驱动在Linux内核里是不是已经存在。这决定了你的设备是“即插即用”还是“需要自己用UsbDeviceConnection做Bulk传输”。
USB转串口芯片(CH340、CP2102、FT232)这类设备,内核里通常有对应的CDC ACM驱动,所以驱动层的工作Linux内核已经做完了,Android框架层以UsbDevice的形态暴露给应用。而有些非标设备(比如部分CAN盒、工业级的数据采集卡)在内核里没有现成驱动,Android会把整个设备暴露给你的应用,你需要用AOA或者USB Host API做用户态驱动开发——也就是自己通过控制传输和批量传输实现协议。
区分这两种情况,最简单的切入点就是看设备的UsbDevice信息。在代码里,你可以用下面这段代码做一个简单的信息打印:
UsbManager usbManager = (UsbManager) getSystemService(Context.USB_SERVICE); HashMap<String, UsbDevice> deviceList = usbManager.getDeviceList(); for (UsbDevice device : deviceList.values()) { Log.d("UsbInfo", "Device: " + device.getDeviceName()); Log.d("UsbInfo", "VID=" + device.getVendorId() + " PID=" + device.getProductId()); Log.d("UsbInfo", "Class=" + device.getDeviceClass() + " SubClass=" + device.getDeviceSubclass()); for (int i = 0; i < device.getInterfaceCount(); i++) { UsbInterface usbInterface = device.getInterface(i); Log.d("UsbInfo", "Interface " + i + " class=" + usbInterface.getInterfaceClass() + " subclass=" + usbInterface.getInterfaceSubclass() + " endpoints=" + usbInterface.getEndpointCount()); } }从打印结果里可以非常直观地判断设备类型:CDC ACM串口设备的interfaceClass通常是UsbConstants.USB_CLASS_CDC(0x02)且interfaceSubclass是0x02;存储设备是USB_CLASS_MASS_STORAGE(0x08);HID设备是USB_CLASS_HID(0x03)。我建议在真正写业务代码之前,先在设备上打印一遍这些信息,这会让你对你自己要处理的硬件有个直觉。
2.2 USB设备权限获取:动态权限请求与静态授权方案的取舍
USB设备权限是这个开发里第一个“劝退点”,也是不同厂商封装差异最大的地方。Android框架要求:应用在使用USB设备前,需要获得用户授权。授权有两种方式:
第一种:动态授权系统会弹出一个对话框询问用户“是否允许该应用访问此USB设备”,用户点击确认后,系统为这个应用和这个设备建立绑定关系。
final String ACTION_USB_PERMISSION = "com.example.USB_PERMISSION"; PendingIntent permissionIntent = PendingIntent.getBroadcast(this, 0, new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE); usbManager.requestPermission(device, permissionIntent);第二种:静态授权在AndroidManifest.xml中声明<uses-feature android:name="android.hardware.usb.host" />,配合<intent-filter>来让系统在设备插入时自动把设备转交给你,并在某些系统版本上可以直接免弹窗访问(尤其在系统应用/平台签名的前提下)。
车载场景的特殊性就在这里:车机在中控台上,不可能让司机每次都伸手去点“允许访问该USB设备”的弹窗,所以车载应用必须走系统级集成路线。我们实际开发时的做法是:用平台签名(Platform Key),然后在应用里向UsbManager申请权限时,系统因为应用是系统特权应用,会直接授权,不弹窗。真机验证时,需要在privapp-permissions.xml配置清单中放开USB相关权限:
<privapp-permissions> <permission name="android.permission.MANAGE_USB"/> </privapp-permissions>如果你不是做系统集成的,这部分只需要提前知道“车载场景需要避免弹窗,要通过系统签名或白名单机制处理”,免得后面集成到车厂系统时再来返工。
2.3 设备热插拔监听:广播注册与生命周期
车载上的USB口必须支持热插拔,就是设备随时插、随时拔、随时再插,程序不能崩,也不能丢失状态。这部分的核心是注册一个BroadcastReceiver监听两个Action:UsbManager.ACTION_USB_DEVICE_ATTACHED(设备插入)和UsbManager.ACTION_USB_DEVICE_DETACHED(设备拔出)。
建议把Receiver动态注册在主线程,同时用IntentFilter加上两个Action:
private final BroadcastReceiver usbReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); if (UsbManager.ACTION_USB_DEVICE_ATTACHED.equals(action)) { UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); handleDeviceAttached(device); } else if (UsbManager.ACTION_USB_DEVICE_DETACHED.equals(action)) { UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); handleDeviceDetached(device); } } }; @Override protected void onResume() { super.onResume(); IntentFilter filter = new IntentFilter(); filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED); filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED); registerReceiver(usbReceiver, filter); } @Override protected void onPause() { super.onPause(); unregisterReceiver(usbReceiver); }这里有一个必须注意的坑:如果你在Manifest里静态声明了USB_DEVICE_ATTACHED的intent-filter,那么设备插入时系统会弹窗让用户选择一个目标App(除非你已经获取了默认设置),这在车机上极度不合适。建议在车载项目中一律不静态注册,全部动态注册,同时把它放在常驻的前台Service或者Activity启动之前,确保应用启动后能第一时间拿到设备事件。
3. USB串口(CDC ACM)通信实战:参数配置、数据收发与稳定性
3.1 识别串口设备并建立连接
USB串口设备在Android眼里就是一个复合接口设备。接线通过CH340或CP2102时,UsbDevice的接口通常包含两个Bulk端点:一个输入(设备到主机,即数据读取通道),一个输出(主机到设备,即数据写入通道)。如果你用的是带硬件流控的串口,还会有个中断端点,但大多数场景用不到。
建立连接的核心代码如下:
UsbDeviceConnection connection = usbManager.openDevice(device); UsbInterface usbInterface = device.getInterface(0); // 大多数串口设备接口0就是数据接口 connection.claimInterface(usbInterface, true); // true表示独占接口 UsbEndpoint endpointIn = null; UsbEndpoint endpointOut = null; for (int i = 0; i < usbInterface.getEndpointCount(); i++) { UsbEndpoint ep = usbInterface.getEndpoint(i); if (ep.getType() == UsbConstants.USB_ENDPOINT_XFER_BULK) { if (ep.getDirection() == UsbConstants.USB_DIR_IN) { endpointIn = ep; } else if (ep.getDirection() == UsbConstants.USB_DIR_OUT) { endpointOut = ep; } } }这段代码里有一个容易被忽视的问题:接口的claimInterface有第二个参数force。如果传true,会强行抢占接口;传false,若接口被其他应用占用则claim失败。在车机上,因为可能有多个App同时在抢同一个USB外设(比如系统设置里的USB检测、厂商诊断App、你的业务App),我建议传true,并且在业务层做设备独占标志,保证时刻只有一个App持有连接。
还有一个细节是openDevice返回的UsbDeviceConnection是null的情况。概率不高,但在部分车机上出现过,原因是权限还没授权或者设备已被系统占用。务必要判空并做重试机制,不要直接调用方法导致空指针崩溃。
3.2 串口参数配置:波特率、数据位、停止位、校验位
串口通信首先要配置波特率、数据位、停止位和校验位。Android官方USB Host API里面没有直接的“setBaudRate”方法,你是通过UsbDeviceConnection.controlTransfer来发送SET_LINE_CODING(0x20)请求给串口芯片的CDC ACM接口。
SET_LINE_CODING对应的请求结构是:bmRequestType = 0x21(Host to Device,Class,Interface),bRequest = 0x20,wValue = 0,wIndex = interfaceIndex,data是一个7字节的结构体。这个结构体的定义是:
- byte 0-3:波特率(小端序,4字节)
- byte 4:停止位(0=1位,1=1.5位,2=2位)
- byte 5:校验位(0=无,1=奇校验,2=偶校验,3=mark,4=space)
- byte 6:数据位(8、7、6、5)
我用一个工具方法封装了这个配置:
public static int setSerialParams(UsbDeviceConnection connection, UsbInterface usbInterface, int baudRate, int dataBits, int stopBits, int parity) { byte[] bytes = new byte[7]; bytes[0] = (byte) (baudRate & 0xFF); bytes[1] = (byte) ((baudRate >> 8) & 0xFF); bytes[2] = (byte) ((baudRate >> 16) & 0xFF); bytes[3] = (byte) ((baudRate >> 24) & 0xFF); bytes[4] = (byte) stopBits; bytes[5] = (byte) parity; bytes[6] = (byte) dataBits; return connection.controlTransfer(0x21, 0x20, 0, usbInterface.getId(), bytes, bytes.length, 5000); }这里有几个实际经验:
- 波特率不是随意定的,需要和外设约定好。我遇到的多数诊断设备是9600或115200,少数CAN盒用500000。如果波特率配错了,收到的数据是乱码或者完全没数据。
- dataBits在Linux/Android的CDC ACM约定里,一般需要用“5、6、7、8”这种真实位数,但也有厂家对dataBits的编码不一致,如果发现通信失败,先尝试调换dataBits参数值(比如填8还是填0x08)。
- 控制传输的timeout参数不要设计太短。CH340这类芯片响应很快,但如果经过USB Hub中转,时间会长一些,我的经验是给5000ms比较稳妥。
3.3 串口数据的读取与写入:阻塞与非阻塞的取舍
USB串口的读取,Android提供的是UsbDeviceConnection.bulkTransfer接口,这是一个同步阻塞调用。你传入一个byte数组缓冲区,它会阻塞到读取到指定长度的数据,或者到达超时时间后返回。在车机场景中,这种做法会导致两个问题:
- 如果设备长时间不上报数据,你的读取线程会一直阻塞着,浪费线程资源。
- 如果有突发大数据量,一个缓冲区不够用,你必须在循环里反复读取。
我的做法是开启一个专门的读线程,循环读取,并把读到的数据交给一个环形缓冲区(RingBuffer)或者直接回调给业务层。核心代码:
private void startReadLoop() { readThread = new Thread(() -> { byte[] buffer = new byte[4096]; while (!Thread.currentThread().isInterrupted()) { int bytesRead = connection.bulkTransfer(endpointIn, buffer, buffer.length, 200); if (bytesRead > 0) { byte[] received = Arrays.copyOf(buffer, bytesRead); onDataReceived(received); } } }); readThread.start(); }这里几个关键细节:
endpointIn的maxPacketSize通常是512字节(USB 2.0高速模式),但实际读取到的数据长度是不定的。所以读取时缓冲区给2048或者4096都没问题,但逻辑上要按实际返回长度截取。- bulkTransfer的timeout参数200ms是我试出来的平衡值。太短(比如50ms)会导致线程空转严重消耗CPU,太长(比如1000ms)会让拔插和断开检测变慢。200ms在车机平台上实测CPU占用可以控制在很低水平。
- 永远不要在读取循环里做耗时处理,比如直接写数据库、做JSON解析。正确做法是回调到业务线程,或者至少丢到一个独立的消息队列里异步处理,否则高速串口设备容易把读线程拖垮。
写入端就简单多了,直接bulkTransfer(endpointOut, data, data.length, timeout),但要注意一次写入的数据长度不要超过endpointOut.getMaxPacketSize()和驱动缓存的限制。大包数据发送前最好自己拆包,串口芯片的缓存通常只有几百个字节,一次发2048字节可能会导致数据被截断或丢失。
3.4 串口稳定性优化:粘包拆包、断线重连、流控
串口最令人头疼的问题就是粘包和断包。由于串口是流式协议,没有帧边界(除非你在应用层自定义了帧格式),所以读取线程拿到的数据常常是半包或者多包粘连。传统的解决办法是在应用层定义帧头帧尾和长度字段。
举个例子,我定义的数据帧格式为:
| 帧头 | 长度 | 数据区 | 校验 |
|---|---|---|---|
| 0xAA 0x55 | 2字节长度 | N字节 | CRC16 |
接收端维护一个累积缓冲,只有识别到完整帧才交付业务层。这个过程看起来简单,但代码里面的边界判断很容易写错,推荐直接用ByteArrayOutputStream配合状态机做拆包逻辑。我这边直接贴出一个我封装好的拆包工具:
public class PacketDecoder { private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); private static final byte[] HEADER = new byte[]{(byte) 0xAA, (byte) 0x55}; public List<byte[]> feed(byte[] data) { buffer.write(data, 0, data.length); List<byte[]> packets = new ArrayList<>(); byte[] all = buffer.toByteArray(); int offset = 0; while (all.length - offset >= 4) { if (all[offset] != HEADER[0] || all[offset + 1] != HEADER[1]) { offset++; continue; } int len = ((all[offset + 2] & 0xFF) | ((all[offset + 3] & 0xFF) << 8)); if (all.length - offset - 4 >= len) { byte[] packet = Arrays.copyOfRange(all, offset, offset + 4 + len); packets.add(packet); offset += 4 + len; // CRC校验等逻辑 } else { break; } } buffer.reset(); buffer.write(all, offset, all.length - offset); return packets; } }这段代码看起来简单,但真正调试时要特别注意:当数据不完整时不要丢弃,要保留剩余字节继续等待。我之前在写第一版时犯过一个错,对不完整包直接清空缓冲,导致大量数据永久丢失。
断线重连也是车机上必须考虑的。由于USB口物理连接松动或线材老化,设备偶尔会断开。我的策略是:检测到ACTION_USB_DEVICE_DETACHED后,不立即停止服务,而是延迟2秒后尝试重新枚举设备(因为有些设备只是瞬时掉线又重新枚举成功),如果重连失败再进入等待插拔状态。这里有个经验:不要对同一设备做无限重连,建议重试3次后停止,等下一次ATTACHED广播再连,否则车机上容易出现资源泄漏。
4. USB-CAN适配器接入车载CAN网络:数据帧解析与收发策略
4.1 CAN总线基础与USB-CAN硬件选型
CAN(Controller Area Network)总线是汽车电子中最常用的现场总线,用于连接ECU、传感器、执行器等节点。对于车机来说,如果你想读取车辆的转速、车速、里程、电池SOC等信息,或者向某个ECU发送诊断请求(比如UDS诊断的读取DID数据),一条连接到OBD-II接口的CAN通道是绕不开的物理链路。
市面上常见的USB-CAN适配器主要分两类:
- SLCAN(Serial Line CAN)协议类:这类适配器内部有一个MCU,把CAN帧转换成串口透传数据,通过USB虚拟串口上报给主机。典型如周立功的USBCAN系列、CANable等。它们的优势是兼容性好,内核可以直接识别为串口设备,开发简单;劣势是波特率受限,而且总有协议转换的开销。
- 原生USB-CAN类:适配器在USB协议层直接暴露CAN接口,需要自己写驱动或用厂商SDK。这类适配器延迟低,吞吐能力强,但开发成本高。
车载项目中我强烈推荐SLCAN协议类的USB-CAN适配器,原因很简单:它对于Android来说就是一个普通的USB串口设备,你完全可以复用前面讲的串口通信框架,只是在数据负载层解析CAN帧。而且SLCAN协议是开源的,再冷门的芯片也能找到参考源码。
硬件选型上需要注意:买CAN适配器时一定要确认支持CAN 2.0A(11位标准帧)和CAN 2.0B(29位扩展帧),并且最好支持SLCAN_CFG命令来动态切换波特率。分辨率方面,绝大多数适配器支持125kbps、250kbps、500kbps、1Mbps几种,这也是汽车上最常见的几个CAN波特率。
4.2 CAN帧的SLCAN协议解析
SLCAN协议的命令格式不复杂。比如要设置CAN波特率为500kbps,发送的命令是:
C\r // 打开CAN通道 S8\r // 设置波特率:S0=10k,S1=20k,S2=50k,S3=100k,S4=125k,S5=250k,S6=500k,S7=800k,S8=1M O\r // 打开通道接收到的CAN帧格式通常是这样的:
t11 22 33 44 55 66 77 88\r其中t表示标准数据帧,11是11位ID的十六进制(3字节),后面是1到8字节的负载数据。扩展帧用T开头,ID为8位十六进制。远程帧用r或R开头。
我封装了一个CAN帧解析函数,核心代码:
public class CanFrame { public boolean isExtended; public boolean isRemote; public int id; public byte[] data; public int dlc; } public static CanFrame parseSLCANFrame(String line) { if (line == null || line.length() < 4) return null; CanFrame frame = new CanFrame(); char type = line.charAt(0); frame.isExtended = (type == 'T' || type == 'R'); frame.isRemote = (type == 'r' || type == 'R'); int idLen = frame.isExtended ? 8 : 3; try { frame.id = Integer.parseInt(line.substring(1, 1 + idLen), 16); frame.dlc = frame.isRemote ? 0 : (line.length() - 1 - idLen) / 2; frame.data = new byte[frame.dlc]; for (int i = 0; i < frame.dlc; i++) { frame.data[i] = (byte) Integer.parseInt(line.substring(1 + idLen + i * 2, 3 + idLen + i * 2), 16); } } catch (NumberFormatException e) { return null; } return frame; }这里有个细节,SLCAN协议的CAN ID在标准帧里是3位十六进制(最大0x7FF),对应11位ID;扩展帧是8位十六进制(最大0x1FFFFFFF),对应29位ID。解析时千万注意位数判错,否则ID会解析错位。
DLC(Data Length Code)在SLCAN文本协议里可以通过字符串长度推断,但有的固件会固定输出8字节(不足部分填0x00),有的则输出实际长度。最好根据实际硬件行为做适配,我在代码里做了两种兼容判断。
4.3 多帧收发与高负载场景下的缓存策略
车载CAN总线上数据量不大,但频率高。举个例子,发动机转速信号通常以100ms周期发送,车速信号50ms周期发送,而ABS或BMS的数据可能10ms就发一次。如果多个信号叠加到一个CAN通道上,总线负载可能达到30%以上,意味着你的USB-CAN适配器每秒要处理几百帧数据。
对Android应用来说,这个数据量完全扛得住,但是要注意读取线程和解析线程之间的缓冲设计。我推荐使用一个有界阻塞队列(ArrayBlockingQueue<byte[]>)来承接串口原始数据,解析线程从队列中拉取数据做帧解析,避免串口读取线程因为解析耗时而被阻塞住。
private final ArrayBlockingQueue<byte[]> rawDataQueue = new ArrayBlockingQueue<>(4096); // 串口读取线程 while (!isStopped) { int len = connection.bulkTransfer(endpointIn, buffer, buffer.length, 200); if (len > 0) { try { rawDataQueue.offer(Arrays.copyOf(buffer, len), 100, TimeUnit.MILLISECONDS); } catch (InterruptedException e) { break; } } } // 解析线程 while (!isStopped) { byte[] data = rawDataQueue.poll(200, TimeUnit.MILLISECONDS); if (data != null) { // 按行解析,因为SLCAN是文本协议 String text = new String(data, StandardCharsets.US_ASCII); handleCanText(text); } }队列大小的经验值是4096,因为车机系统内存有限,太大的缓冲容易导致OOM,太小的缓冲遇到突发数据容易丢弃。实测下来,即使车机CPU负载较高,4096的容量也能平滑处理1Mbps波特率下的CAN数据突峰。
向CAN总线发送数据就简单了,直接把SLCAN文本帧写入串口:
public void sendCanFrame(CanFrame frame) throws IOException { StringBuilder sb = new StringBuilder(); if (frame.isExtended) { sb.append(frame.isRemote ? 'R' : 'T'); } else { sb.append(frame.isRemote ? 'r' : 't'); } String idHex = String.format("%X", frame.id); if (frame.isExtended) { while (idHex.length() < 8) idHex = "0" + idHex; } else { while (idHex.length() < 3) idHex = "0" + idHex; } sb.append(idHex); if (!frame.isRemote && frame.data != null) { for (byte b : frame.data) { sb.append(String.format("%02X", b)); } } sb.append('\r'); byte[] out = sb.toString().getBytes(StandardCharsets.US_ASCII); bytesWritten = connection.bulkTransfer(endpointOut, out, out.length, 1000); }这里提醒一个来自实践的问题:CAN发送出去后,适配器可能会在串口回显发送的数据帧,导致你自己读到自己的发送帧,如果业务层不区分“收到”和“发出”,很容易造成逻辑混乱。可以在发送时打一个标记,接收解析时对匹配到的帧做过滤。我在代码里维护了一个最近发送帧的时间戳和ID集合,收到相同ID且时间差小于50ms的帧时直接忽略,实测误判率很低。
说到协议支持,市面上CANable和部分国产CAN盒支持SLCAN,但周立功的老款USBCAN使用的是自家的私有协议,需要专门处理。这提醒你,买硬件之前先确认它是否有SLCAN模式,或者是否提供Android SDK,这是决定你项目工作量的关键因素。
5. HID设备接入与自定义协议交互
5.1 HID协议基础:报告描述符与系统自带HID驱动
HID全称Human Interface Device(人机接口设备),键盘、鼠标、触摸屏都属于这个范畴。Android系统内核本身就带了HID驱动,所以对标准HID设备(尤其是键盘鼠标类)几乎零开发成本,插上就能用。
但车载场景里常见的HID设备往往不是键盘鼠标,而是自定义HID按键面板、刷卡器、指纹仪、自定义多媒体控制旋钮等。这些设备虽然也是HID,但它们的报告描述符(Report Descriptor)是厂商自定义的,上报的数据格式需要你自己解析。
要理解HID设备的数据流向,关键要抓三个点:
- HID报告(Report):HID设备和主机之间传输的最小数据单元。分为输入报告(设备到主机)、输出报告(主机到设备)和特性报告(双向)。
- 报告描述符(Report Descriptor):描述了报告的字节布局、每个字段的含义、数值范围、使用页(Usage Page)等信息。它本质上就是一张“数据字典”。
- HID Endpoint:HID设备通常是中断端点(Interrupt Endpoint)传输,读取方式与Bulk类似,但以报告为单位。
5.2 通过UsbDeviceConnection读写HID报告
当一个HID设备插入车机,你可以通过UsbDeviceConnection获取它的接口和端点,然后使用controlTransfer发送GET_REPORT/SET_REPORT请求,或者直接使用中断端点收发报告。
以读取自定义HID按键面板的输入报告为例,核心代码:
UsbInterface hidInterface = device.getInterface(0); UsbEndpoint interruptIn = null; for (int i = 0; i < hidInterface.getEndpointCount(); i++) { UsbEndpoint ep = hidInterface.getEndpoint(i); if (ep.getType() == UsbConstants.USB_ENDPOINT_XFER_INT && ep.getDirection() == UsbConstants.USB_DIR_IN) { interruptIn = ep; } } connection.claimInterface(hidInterface, true); byte[] report = new byte[interruptIn.getMaxPacketSize()]; int bytesRead = connection.bulkTransfer(interruptIn, report, report.length, 1000);注意这里有个坑:虽然HID的中断传输类型是Interrupt,但Android的bulkTransfer方法可以用于中断端点,因为在内核视角下,中断传输和批量传输在数据链路层的处理方式很接近。但别用controlTransfer去读中断端点,那会失败的。
读取到的report字节数组需要按你在报告描述符中定义的格式解析。假如你定义了一个10字节的输入报告,report[0]是按键状态位掩码,report[1]是旋钮增量值,那么业务层只需要做相应的位移和掩码操作:
int keyStatus = report[0] & 0xFF; int encoderDelta = (report[1] << 8 | report[2]) & 0xFFFF; if (encoderDelta > 0x7FFF) encoderDelta -= 0x10000; // 符号位处理很多自定义HID设备的报告描述符是固定的,拿不到文档时怎么办?我习惯用Wireshark去抓HID描述符。先用Linux的usbmon抓到设备的描述符原始字节,然后在Wireshark里可以直接解析出报告结构。这个方法曾帮我搞定过一个没有提供文档的指纹模块,非常管用。
5.3 HID Feature Report:用控制传输实现双向配置
HID设备除了通过中断端点上报数据,还常常需要主机下发配置。比如按键面板需要修改按键的背光颜色、刷卡器需要设置扫码模式参数,这些大多通过Feature Report实现。
发送Feature Report的控制传输请求格式:
int requestType = 0x21; // Host to Device, Class, Interface int request = 0x09; // SET_REPORT int value = 0x0300 | reportId; // 0x03表示Feature Report类型,reportId是你的报告ID int index = hidInterface.getId(); byte[] reportData = new byte[]{...}; // 你要下发的数据 int result = connection.controlTransfer(requestType, request, value, index, reportData, reportData.length, 1000);获取Feature Report:
int requestTypeGet = 0xA1; // Device to Host, Class, Interface int requestGet = 0x01; // GET_REPORT int valueGet = 0x0300 | reportId; byte[] buffer = new byte[64]; int result = connection.controlTransfer(requestTypeGet, requestGet, valueGet, index, buffer, buffer.length, 1000);这里的reportId如果设备没有用到(即报告描述符里没有Report ID字段),就是0。有的设备即使是Feature Report,也要在报告中带上字节长度和报告ID作为前缀,具体以设备说明书为准。
从实践来看,HID设备的Feature Report下发成功后,最好再读取一次回读确认(GET_REPORT),很多设备有内部错误状态,直接下发完不管可能你不知道它其实没进入配置模式。
6. 系统API集成与车机环境的避坑实战
6.1 UsbManager权限与系统应用的协同问题
车机上USB外设的访问权限,不只是应用层面的requestPermission弹窗问题,还涉及到系统服务层面的占用。举个例子,有些车机ROM内置了媒体扫描服务,只要你插入U盘,系统就会自动开始扫描媒体文件。如果你的App同时想独占访问这个U盘,就会和媒体扫描服务发生冲突,导致文件句柄被占用,读取失败。
解决思路有两个:
- 使用
UsbDeviceConnection直接读取文件系统(跳过Linux VFS层),这种方式需要你自己实现FAT32/exFAT文件系统解析(用libusb和libfuse),非常复杂,不推荐在车机上做。 - 在系统应用层禁用媒体扫描服务,或者在Manifest中声明
android:sharedUserId="android.uid.system",然后用系统API暂停媒体扫描。这种方式最简单直接,但需要车厂同意你的应用有系统级权限。
我在项目中是用了第二种方式,配合平台签名,同时把核心USB访问逻辑做成了一个常驻的USBService,独立于UI进程。好处是:即使前台的UI应用被系统回收,USBService还在后台运行,设备连接状态不会断。
实现System Service时,需要注意Binder调用的线程模型。因为多路USB设备的数据回调是工作线程触发的,向主进程/UI进程发送消息时不能直接持有Activity的Context,要改用事件总线的思路或者封装Listener回调。
6.2 AOA协议与车载Android的特殊适配
AOA(Android Open Accessory)协议这名字听起来古老,但在车载场景里其实特别常用。AOA是Android设备作为Device端向外部硬件(Arduino等)通信的协议,但很多时候车机系统本身支持AOA模式,让车机作为USB Device去连接外部主机(比如调试电脑或者测试台架)。
如果你在做车机的HAL层开发,那你需要关心AOA协议。不过对应用层开发者来说,更多场景是解决兼容性问题:某些车机系统出于安全考虑,禁止了USB Device模式,这时你的USB Host功能反而是正常的,因为两者的物理通道不冲突。
这个部分的建议是,在项目启动前先确认车机系统的USB模式开关。有些车机默认把USB口配置成Device模式(接电脑用),需要去开发者选项或者工厂模式菜单里切换成Host模式。这个问题曾经让我排查了整整两天:代码没任何问题,设备就是不枚举,最后发现是车机设置里USB模式默认在Device。千万别跳过这一步。
6.3 USB-Service的架构设计:多路设备并发管理
当车机上同时插着串口模块、CAN盒、HID面板三样设备时,如果每个外设各起一套线程,代码会越来越难维护。我当时设计了一个三层结构:
- 设备管理层:监听USB热插拔广播,维护当前设备列表(Map),每个设备对应一个状态机(枚举 -> 连接 -> 配置 -> 运行 -> 断开)。
- 协议解析层:处理每种设备的协议(SLCAN、HID、串口透传),向业务层提供统一封装的回调接口。
- 业务逻辑层:只关心从设备层拿到的结构化数据,比如CAN车速信号、串口GPS数据、HID按键值。
核心的设备管理接口大概长这样:
public interface IUsbDeviceHandler { void onAttached(UsbDevice device); void onDetached(UsbDevice device); void onDataReceived(byte[] data); void onError(int errorCode, String message); }每一种设备类型都实现自己的IUsbDeviceHandler,然后在USBService层维护一个Handler的注册表。当设备插入时根据VID/PID/InterfaceClass做匹配,分发到对应的Handler。这样做的好处是后续加入新的USB外设,只需要新增一个Handler实现类,不用改动核心逻辑。
6.4 车机系统API的权限声明示例
下面是Manifest中需要声明的内容,车机平台的特性都在这里面体现:
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.car.usb"> <uses-feature android:name="android.hardware.usb.host" android:required="true" /> <uses-permission android:name="android.permission.USB_PERMISSION" /> <uses-permission android:name="android.permission.MANAGE_USB" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <application android:persistent="true" android:sharedUserId="android.uid.system"> <service android:name=".service.USBService" android:enabled="true" android:exported="false" /> </application> </manifest>注意几个点:
android:persistent="true"让Service常驻,但只有系统应用才能使用这个属性,普通应用会被忽略。android:sharedUserId="android.uid.system"也是系统应用专属,非平台签名无法安装。MANAGE_USB权限在普通Android API里没有,需要系统编译环境或反射调用。
如果你不是做系统集成,这些系统级的配置就无法使用。这时候退而求其次,可以用前台Service + 动态权限请求的组合方案:监听USB设备插入后,在通知栏弹出常驻通知,同时后台保持Service存活。这个方案可以覆盖绝大多数量产车机方案。
7. 常见问题排查与项目总结
7.1 典型问题速查表
以下是项目过程中遇到的高频问题,做成了一个速查表格:
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 插上设备后App收不到广播 | 权限未授予、静态广播与动态广播冲突 | 检查logcat中的UsbDevice日志,确认权限;去掉Manifest静态注册 |
bulkTransfer返回-1 | 传输超时或端点选择错误 | 确认endpoint方向,检查timeout时间;用另一台设备交叉测试 |
| 串口数据乱码 | 波特率不一致或数据位配置错误 | 检查两边的波特率和数据位设置;示波器或逻辑分析仪测波形 |
| USB-CAN收不到数据 | SLCAN波特率与CAN总线不一致 | 发送S8\r设置500k,再发O\r打开通道;用PC端工具验证CAN收发 |
| HID设备能枚举但无数据 | 中断传输端点读取超时或设备未进入工作模式 | 用Wireshark抓HID报告;发SET_REPORT配置 |
| 车机重启后外设不能自动连接 | 没有处理开机自启动,或Service被系统回收 | 配置开机广播BOOT_COMPLETED;Service提升为前台服务 |
| 多个App抢占USB设备 | 接口被其他应用claim | 统一走同一个USBService;系统签名下独占访问 |
| U盘无法读取 | 媒体扫描服务占用或文件系统不支持 | 检查挂载点;确认FAT32/exFAT;设置系统权限 |
| 低频采样时读取线程空转 | timeout过短导致高频无效调用 | 适当延长bulkTransfer超时;引入事件等待机制 |
7.2 排查方法论:日志抓取与USB协议分析
排查USB问题,第一步永远不是看代码,而是先确认链路状态。Android系统日志中与USB相关的logcat标签有UsbDeviceManager、UsbHostManager、UsbAlsaManager等,插入设备后第一时间抓取这几个标签的日志:
adb logcat -s UsbHostManager UsbDeviceManager UsbAlsaManager从日志中可以看到设备是否被内核正确识别、接口是否被解析成功。如果UsbHostManager里出现了“Could not get device list”之类的错误,基本说明底层已经出问题了。
接下来是Wireshark抓包。将车机USB Host的控制权交给调试电脑(车机切到Device模式),或者用硬件分析仪接到USB线上,实时抓取USB总线上传输的包。这里获取到的信息非常直观:枚举阶段有没有失败、控制传输的返回值是什么、中断传输是否在正常轮询。
7.3 关于设备热插拔稳定性的一些额外心得
最后再分享几个从实际项目中总结的非代码层面的经验:
延迟枚举策略:部分USB设备在刚插入瞬间会有一个“上电-自检-就绪”的过程,如果在插入广播到达时立刻openDevice,经常失败。我做了延迟枚举:收到ATTACHED广播后sleep 200ms再开始连接。这个200ms对多数设备够用,如果你用的是功耗较高的工业USB设备,可以放宽到500ms。
线程命名与监控:在车机上调试多路USB设备时,所有线程要起有意义的名字,比如“can-reader-thread”、“serial-writer-thread”。不然后期排查CPU占用或者死锁时,看到的全是Thread-12、Thread-15这种,根本分不清谁是谁。
统一错误码与看门狗:为每个设备Handler定义一个错误上报接口,同时在USBService里维护一个看门狗任务,定期检查各个设备的接收心跳。如果某路设备超过一定时间没上报数据,自动重连或上报上层UI。这套机制在实车路测中帮我提前发现过线束松动的问题。
关注系统休眠省电策略:车机系统在熄火后往往会进入休眠或深度睡眠状态,USB Host接口这时候会断电,所有USB外设都会断开。重新上电后,系统会重新枚举所有设备,你的App代码必须保证在休眠唤醒后能够重新初始化连接状态机。这里最容易出的bug是:设备断开时没有释放UsbDeviceConnection,导致唤醒后重新枚举时claimInterface失败。所以拔插检测里的“释放资源”一定要做得彻底。
拿USB-CAN适配器举例,整个设备生命周期是这样的:ACTION_USB_DEVICE_ATTACHED广播到达 -> 延迟200ms -> openDevice + claimInterface -> 配置串口参数(波特率等) -> 打开CAN通道(发送C\r、S8\r、O\r) -> 启动读取线程和解析线程 -> 定期发送心跳检查 -> 收到DETACHED广播 -> 发送关闭命令(C\r) -> 释放连接、结束线程。每一步的日志都打出来,后期问题定位会轻松非常多。
这个项目做下来,最深的体会是:USB开发本身不复杂,复杂的是把各种边界情况处理干净——设备枚举失败、权限抢占、断电重连、数据丢包、系统休眠唤醒……每一件看起来都不难,叠在一起就成了稳定性的试金石。如果你正在做类似的车机USB项目,希望这篇笔记能帮你少踩几个坑。