做车载 USB 开发这行,很多人一开始都以为难点在 Android 本身,结果真机一插,授权框不弹、设备枚举不到、数据读出来是乱码,各种问题能把人逼疯。我陆陆续续在几个车机项目里折腾过 USB Host、USB 串口、USB-CAN 和 HID 设备,踩的坑比写的代码都多。这篇笔记就是把我在 Android 车载场景下用 USB 的完整思路、踩坑记录和能直接抄的代码方案整理出来,给同样在做车机、工控或者 OTG 外设接入的朋友做个参考。标题里这几个东西看着多,其实核心就一条线:搞清楚 USB Host 模式下,Android 怎么枚举设备、怎么拿权限、怎么收发数据,剩下的串口、CAN、HID 都是这条线上的具体应用而已。
1. 写在前面:为什么车载场景绕不开 USB
1.1 车载 USB 开发到底在解决什么问题
车机和手机有个本质区别:手机上的 USB 口主要拿来充电和传文件,但车机上的 USB 口是要接一堆外部设备的。调试口要接电脑、诊断仪要接 OBD 转接器、中控屏可能要接 USB 摄像头、方向盘按键模拟器、甚至你要在车机上跑自动化测试,用 USB HID 设备去模拟触摸和按键。这些需求全部依赖 Android 的 USB Host 能力。
USB Host 是什么?简单说,Android 设备当“主设备”,外接的 U 盘、键盘、串口线、CAN 盒都是“从设备”,主设备负责供电、枚举设备、发起通信。车机只要硬件支持 OTG 或者本身就是 Host 模式,系统里就能拿到 UsbManager 这个服务,然后通过它去操作所有外设。
我从实际项目里感受到,车载 USB 开发和手机 USB 开发最大的区别在于:车机的外设需求更“硬核”,不是传个文件那种量级的,而是要实时读车辆总线数据、控制外部执行器、长时间稳定通信。这意味着你不能只调通一个 demo,而是要处理掉线重连、数据粘包、权限持久化、时序抖动这些工程问题。
1.2 这篇笔记能帮你解决什么问题
这份笔记不光是代码堆砌,我把每个环节的“为什么”也说明白。比如为什么有些设备枚举不到、为什么授权框有时不弹、为什么串口读出来的数据断断续续。这些问题的答案大部分不在 Android 上层代码里,而在 USB 协议、内核驱动和硬件时序里。
适合看这篇笔记的人有三类:一是刚接手车机项目、对 USB Host 还比较陌生的 Android 开发;二是做工业或车载外设接入,需要快速把 USB 串口或 CAN 调通的嵌入式工程师;三是想了解 Android 系统 USB 框架,准备深入 framework 层的同学。如果你只是想在手机上插个 U 盘读文件,这篇笔记可能偏重了,但如果你是做设备接入的,那这篇文章里的内容应该能帮你省掉至少两周的摸索时间。
2. USB Host:Android USB 能力的基座
2.1 USB Host 和 OTG 的关系,以及车机的特殊性
很多人分不清 USB Host 和 USB OTG。一句话说明:OTG 是硬件接口规范,它允许一个设备既当 Host 又当 Device;而 USB Host 是系统能力,指的是 Android 系统能识别并管理外部 USB 设备。手机上的 OTG 口插 U 盘,用的就是 Host 模式。车机上基本也是这个思路,只是车机的主控方案五花八门,有的用高通、有的用瑞萨、有的用全志或 RK,这些 SoC 的 USB 控制器实现和内核驱动差异很大。
我遇到过一台车机,硬件上明明有 USB 口,但系统设置里没有“USB 连接”之类的选项,插上设备也没任何反应。查了半天,问题出在 ROM 裁剪上:厂商把android.hardware.usb.host这个 feature 声明删了,或者内核里根本没编 USB Host 相关驱动。所以做车载 USB 开发的第一步,不是写代码,而是先确认两个前提:第一,设备支持 USB Host,可以通过PackageManager.hasSystemFeature(PackageManager.FEATURE_USB_HOST)检测;第二,内核里编入了对应外设的驱动,比如 usb-storage、cdc_acm、ftdi_sio 等。
2.2 声明权限和特征过滤器:让系统把外设“交给你”
应用要使用 USB Host,必须在 AndroidManifest.xml 里声明两样东西:USB Host 权限和特征过滤器。
<uses-feature android:name="android.hardware.usb.host" android:required="true" /> <uses-permission android:name="android.permission.USB_PERMISSION" />USB_PERMISSION这个权限是系统内部权限,但你在 manifest 里声明了之后,当应用要访问 USB 设备时,系统会弹授权框让用户确认。如果没声明,授权流程会不正常。特征过滤器的作用更关键:当我们插上设备时,Android 会根据device_filter.xml中声明的 vendorId 和 productId 来判断是否弹出“是否打开这个应用?”的提示。这个文件放在res/xml/device_filter.xml下面,格式是:
<?xml version="1.0" encoding="utf-8"?> <resources> <usb-device vendor-id="1027" product-id="24577" /> </resources>vendor-id 和 product-id 是 16 进制转 10 进制的数。比如 CH340 的 vendorId 是 0x1A86,十进制就是 6790;productId 是 0x7523,十进制是 29987。你可以在设备插上后用adb shell lsusb查,或者直接在代码里打印device.getVendorId()和device.getProductId()。
2.3 运行时获取 UsbManager 和设备列表
应用拿到 USB 设备有两种方式:一种是主动扫描,一种是监听插拔广播。扫描用这段代码:
UsbManager usbManager = (UsbManager) getSystemService(Context.USB_SERVICE); HashMap<String, UsbDevice> deviceList = usbManager.getDeviceList(); for (UsbDevice device : deviceList.values()) { Log.d(TAG, "Device: " + device.getDeviceName() + ", VID: " + device.getVendorId() + ", PID: " + device.getProductId() + ", Class: " + device.getDeviceClass()); }监听插拔则是注册ACTION_USB_DEVICE_ATTACHED和ACTION_USB_DEVICE_DETACHED两个系统广播。这里有个车载场景很常见的需求:车机启动后自动连接上次使用的设备。如果你的应用是被授权框“被动唤起”的,那么在onCreate里直接调用usbManager.getDeviceList()就能拿到设备;如果应用是开机自启的,就要先等设备插上,再通过广播去获取设备对象。
拿到设备对象后,真正的数据收发要打开设备连接。UsbDeviceConnection 就是应用和内核 USB 驱动之间的桥梁,后面所有串口、CAN、HID 的操作都建立在这个连接之上。所以我说 USB Host 是基座,没有这个基座,后面全是空中楼阁。
3. USB 串口:把 Android 变成超级终端
3.1 常见的 USB 转串口芯片和驱动识别
USB 转串口大概是车载项目里用得最多的外设了。调试车载娱乐主机、读 GPS 模块、接 arduino 或 STM32 的开发板、连 OBD 诊断线,全是 USB 转串口。市面上常见的转接芯片就这么几个:CH340/CH341(国产,便宜量大)、CP2102/CP210x(Silicon Labs)、FT232(FTDI,贵但稳定)、PL2303(Prolific,老古董,兼容芯片很多)。
这些芯片在 Linux 内核里分别对应不同的驱动:CH340 对应 ch341 驱动,CP210x 对应 cp210x,FT232 对应 ftdi_sio,PL2303 对应 pl2303。内核有没有编入对应驱动,直接决定设备能不能枚举成功。经常有人问我:为什么我插上 CH340 的设备,在 Android 里getDeviceList()扫不到?大概率就是车机内核没编 ch341 驱动,这种问题在应用层无解,只能找厂商改内核或者换一个内核支持的芯片。
下面这个表格是车载项目里最常用芯片的参数和注意事项:
| 芯片型号 | 常见 VID:PID | 内核驱动 | 常见问题 |
|---|---|---|---|
| CH340 | 1A86:7523 | ch341 | 内核未编译驱动时识别不到 |
| CP2102 | 10C4:EA60 | cp210x | 驱动一般内置,好用 |
| FT232 | 0403:6001 | ftdi_sio | 稳定,但价格高 |
| PL2303 | 067B:2303 | pl2303 | 兼容芯片容易被驱动拒收 |
3.2 用 UsbSerial 库封装底层细节
Android 官方并没有提供现成的 USB 串口 API,所以社区里很流行用开源的 usb-serial-for-android 库(mik3y 的那个)。这个库的原理并不神秘:它把 UsbDeviceConnection 的 bulkTransfer 封装成了 SerialPort 接口,同时把不同芯片的协议差异封装在各个驱动类里。用起来很简单:
implementation 'com.github.mik3y:usb-serial-for-android:3.4.6'核心代码就这几步。先找到设备实例,通过 UsbSerialProber 获取串口驱动:
UsbSerialProber prober = UsbSerialProber.getDefaultProber(); UsbSerialPort port = prober.findDevice(device).getPorts().get(0); if (!usbManager.hasPermission(device)) { usbManager.requestPermission(device, pendingIntent); } UsbDeviceConnection connection = usbManager.openDevice(device); port.open(connection); port.setParameters(115200, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE);之前看到热词里有 PL2303 的驱动问题(usb\vid_067b&pid_2303),这个 VID 就是 Prolific 的,老批次的 PL2303 芯片在内核 3.8 之后的 Linux 里会被标记为“不支持的芯片”,原因跟芯片厂家的认证机制有关。遇到这种情况,如果系统里跑的是新版内核且驱动较新,你可能会发现设备枚举很费劲,或者打开串口后报错。最好的办法就是换芯片,不要死磕。
3.3 串口读写的关键代码和稳定性处理
串口通信看起来就是读写两个操作,但真正做车载数据采集时,问题都出在细节上。写入用port.write(data, timeout),这个简单,注意要处理好流控和时序。读取复杂一些,因为串口本身是流式协议,没有帧边界,车载设备的数据往往是自定义协议的,比如一帧固定 16 字节、以帧头 0xAA 0x55 开头、以校验和结尾。这时候需要自己处理粘包和半包。
我习惯用一个独立的读线程 + 阻塞队列。读线程不断读数据写入一个ByteArrayOutputStream,解析线程按协议切帧。这样即使某一次读到的数据跨越了两帧,也不会造成错位。下面是我常用的读线程模式:
new Thread(() -> { byte[] buffer = new byte[4096]; while (!Thread.currentThread().isInterrupted()) { int len = port.read(buffer, 1000); if (len > 0) { onDataReceived(buffer, len); } } }).start();这里的onDataReceived回调里要把字节流拷贝一份,因为 buffer 会被复用。我见过很多新手在这里栽跟头,数据读出来一截是新的、一截是旧的,排查半天才发现是 buffer 复用惹的祸。
稳定性方面,车载环境有个很典型的问题:车辆启动时电压波动大,USB 口可能会瞬间断电再上电,导致连接断开。应用要想办法监听ACTION_USB_DEVICE_DETACHED,在设备拔掉时清理资源,在重新插上时自动重连。另外,串口参数不匹配也是最常见的乱码原因,尤其是波特率,你设备端设 9600,应用端设 115200,读出来的当然全是乱码。项目里可以把波特率做成可配置项,方便现场调试。
4. USB-CAN:直接读取车辆总线数据的正确姿势
4.1 USB-CAN 适配器的选型和硬件连接
如果说串口是嵌入式开发的基本功,那 USB-CAN 就是车载开发的看家本领。车辆上几乎所有 ECU(发动机、变速箱、ABS、车身控制模块)都挂在 CAN 总线上,想实时了解车辆状态,就得从 CAN 总线上抓数据。USB-CAN 适配器的作用就是把车上的 CAN 总线信号转成 USB,让 Android 车机能直接读取。
市面上常见的 USB-CAN 适配器有几种:周立功 USBCAN-I/II、CANable(开源方案,固件可以自己烧)、PCAN 系列的(如 PCAN-USB),还有淘宝上大量基于 STM32 + MCP2515/SJA1000 方案的自制盒子。选型时第一看协议,第二看驱动。有些适配器内置了 USB 转串口(比如 slcan 协议),这种在 Android 里完全可以当串口用,只是传输的是 CAN 帧的文本或二进制封装;有些适配器需要厂商专有驱动,这种要确认车机内核是否支持。
CAN 总线物理上是差分信号,接的时候要注意 CAN_H 和 CAN_L 不要接反,OBD 接口的 6 脚是 CAN_H,14 脚是 CAN_L。我第一次调试的时候,贪图方便用杜邦线直接怼 OBD 座,结果线序搞反,一个下午都在查为什么收不到数据,最后换了线序秒通。千万记得:总线两端要接 120Ω 终端电阻,尤其当你的适配器只是临时接入而不是挂在总线的两端时,很多适配器内部已经带了终端电阻选项,需要确认一下。
4.2 CAN 帧结构解析和 Android 端的收发样例
CAN 2.0 协议的帧格式有几个关键字段:仲裁 ID(标准帧 11 位、扩展帧 29 位)、DLC(数据长度,0-8 字节)、数据场、CRC 校验。如果是 CAN FD,数据长度可以到 64 字节。拿到一帧数据后,最重要的是区分标准帧和扩展帧、数据帧和远程帧,这决定了你怎么解析 ID 和数据。
以 CANable 这种开源适配器为例,烧录 candleLight 固件后,它通过 USB CDC 枚举成一个串口设备,应用层可以用 slcan 协议和它通信。发指令很简单,比如要打开 CAN 通道就发S0\r,要发送一帧标准数据帧就发t123<8 bytes hex>\r,收到的每一帧文本解析一下就能用。但是 slcan 协议是文本协议,解析效率一般,带宽高的时候容易丢帧。所以在车载数据量大的场景下,我更推荐用带批量传输协议的适配器(像 PCAN 的驱动就提供更高效的数据通道),但这又要确认内核支持。
如果适配器是 USB HID 类的或者自定义 bulk 传输类,就得回到 UsbDeviceConnection 上用 bulkTransfer 收发。以常见的自定义协议为例,CAN 帧在 USB 包里通常有一个帧头、通道号、帧信息(包含 ID 类型、帧类型、DLC),然后是 4 字节 ID 和 8 字节数据。收到 USB 包后按这个结构解析即可:
private CanFrame parseCanFrame(byte[] data) { CanFrame frame = new CanFrame(); frame.channel = data[1] & 0xFF; frame.isExtended = (data[2] & 0x80) != 0; frame.isRemote = (data[2] & 0x40) != 0; frame.dlc = data[2] & 0x0F; if (frame.isExtended) { frame.id = ((data[3] & 0xFF) << 24) | ((data[4] & 0xFF) << 16) | ((data[5] & 0xFF) << 8) | (data[6] & 0xFF); } else { frame.id = ((data[3] & 0xFF) << 8) | (data[4] & 0xFF); } System.arraycopy(data, 7, frame.data, 0, frame.dlc); return frame; }4.3 高负载下的丢帧问题和整车联调的注意事项
整车 CAN 总线负载率正常在 30% 以下,但一些新车型的网关会有多路 CAN 总线,数据量加起来并不小。500kbps 的 CAN 总线,满负载每秒能传大约 4000 帧标准帧。Android 的串口库读数据默认用的是 Java 线程 + 内核缓冲区,如果处理不及时,用户态和内核态之间的缓冲区一旦满了,新来的帧就会丢。
想减少丢帧,有两个方向可以走:一个是关键路径用 native 层处理,比如通过 JNI 直接操作 UsbDeviceConnection 的 native 句柄,在一个紧密循环里做 bulkTransfer,把数据塞进环形缓冲区,解析也在 native 做;另一个方向是适当调大 read 缓冲区和减少每次读之间的延迟。实测下来,纯 Java 方案在 1000 帧/秒以内是稳定的,超过这个量级最好上 native。
整车联调的时候还有几个坑。首先是 CAN 总线要接终端电阻,前面说了。其次是有些车的 CAN 总线是休眠的,需要先给总线一点活动(比如通过诊断仪发个唤醒帧),否则你插上适配器收不到任何数据。第三是注意共地问题,适配器的地和车身的 GND 必须连接好,否则数据容易受干扰,甚至损坏设备。
5. HID 设备:模拟键盘按键和音量控制的实战
5.1 为什么车机上需要 HID,Host 和 Device 两种模式
HID(Human Interface Device)协议你可能听得最多的就是键盘和鼠标,但在车载场景里,HID 的身影无处不在。最常见的需求就是用 USB 键盘或者自定义 HID 设备去控制车机:比如方向盘上接一个自定义按键盒,通过 HID 发送按键指令,让车机切换歌曲、调节音量、接打电话。这里有个关键点要搞清楚:车机是 USB Host,外接的按键盒是 USB Device,数据流从 Device 流向 Host。反过来,还有一种场景是 Android 设备模拟成 HID 键盘,插到电脑或者其他主机上,模拟按键,这就是 Android 作为 USB Device 的情况。
在车机项目里,两种模式都会遇到。前者主要用于车机读取外部输入,后者则常见于自动化测试场景,比如用 Android 板子模拟键盘去控制被测设备。这篇文章我重点讲作为 Host 读取 HID 设备的场景,因为这是车机接入外部按键、触摸屏、扫码枪等设备的基础。
5.2 用 UsbDeviceConnection 收发 HID 报告的完整流程
HID 设备的通信单元是“报告”(Report),有输入报告(设备发给主机)、输出报告(主机发给设备)和特性报告(双向,用于配置)。在 Android 上,操作 HID 设备不需要额外的库,直接用UsbDeviceConnection.controlTransfer就能完成大部分工作。HID class 的请求定义在 USB HID 规范里,常见的请求有 GET_REPORT(0x01)、SET_REPORT(0x09)、GET_IDLE(0x02)、SET_IDLE(0x0A)等。
读输入报告最简单的方式是 interrupt IN 端点,也就是开一个线程在bulkTransfer或UsbRequest上阻塞读取。下面是一段通过 interrupt IN 端点读取 HID 报告的代码框架:
UsbInterface hidInterface = findHidInterface(device); UsbEndpoint inEndpoint = findEndpoint(hidInterface, UsbConstants.USB_DIR_IN); UsbDeviceConnection connection = usbManager.openDevice(device); connection.claimInterface(hidInterface, true); new Thread(() -> { byte[] buffer = new byte[inEndpoint.getMaxPacketSize()]; while (running) { int len = connection.bulkTransfer(inEndpoint, buffer, buffer.length, 1000); if (len > 0) { parseHidReport(buffer, len); } } }).start();如果是输出报告,比如你想给 HID 设备发送 LED 状态信息,一般用 interrupt OUT 端点或者控制传输。很多自定义 HID 设备把配置写在特性报告里,这时就要用 controlTransfer 发送 SET_REPORT 请求。协议细节我在后面的 HID Report Descriptor 部分展开。
5.3 HID 键盘模拟:音量修改和普通按键怎么发
热词里有“hid 键盘发送音量修改和普通按键”,这个需求其实很典型:用 USB HID 键盘设备给车机发指令,实现音量加减和普通按键。
HID 协议里有两种报告数据格式。普通按键走的是“键盘报告”,固定 8 字节:第 0 字节是修饰键(Ctrl、Shift、Alt、GUI),第 1 字节保留,第 2-7 字节是普通按键的键码。键码是 HID Usage ID,比如键盘上的 A 键是 0x04,B 键是 0x05,数字 1 是 0x1E,回车是 0x28。要模拟按一次 A 键,就发送两帧报告:先发[0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00]表示按下,再发全零帧表示释放。
音量键是 Consumer Page 的用法,这类需求要用 Consumer Control 报告(Usage Page 0x0C)。音量增的 Usage ID 是 0xE9,音量减是 0xEA,静音是 0xE2。这个报告不在键盘的 8 字节报告里,一般走单独的 HID Report ID。实际模拟的时候,用控制传输或者 interrupt OUT 发送:
// 0x02 是 Report ID,0xE9 是 Volume Increment byte[] volumeUp = new byte[] {0x02, (byte)0xE9, 0x00}; sendHidReport(connection, outEndpoint, volumeUp); // 释放 byte[] volumeUpRelease = new byte[] {0x02, 0x00, 0x00}; sendHidReport(connection, outEndpoint, volumeUpRelease);上面的sendHidReport可以用 interrupt OUT 端点发送,也可以用 controlTransfer 带USB_DIR_OUT | USB_TYPE_CLASS | USB_RECIP_INTERFACE的请求类型去发。很多自制的 USB 按键盒本质上就是一个带 HID 功能的 MCU,Android 端只需要按照它定义好的报告格式发送命令就行。
5.4 HID Report Descriptor:读懂外设能力的“说明书”
HID 设备的能力全部描述在 HID Report Descriptor 里,这是一段二进制描述数据,定义了设备有几个报告、每个报告的字节含义、每个字段的用途。你在 Android 里可以用controlTransfer发送 GET_DESCRIPTOR 请求来读取它,但更快的办法是在 Linux 下用adb shell cat /sys/kernel/debug/usb/devices或者用 Wireshark 抓枚举过程。
读一段典型的键盘 Report Descriptor,开头会是 0x05 0x01(Usage Page = Generic Desktop),然后是 0x09 0x06(Usage = Keyboard),接着 0xA1 0x01(Collection = Application)。对于 Consumer 按键,Descriptor 里会出现 0x05 0x0C(Usage Page = Consumer),0x09 0xE9(Usage = Volume Increment)之类的定义。
理解 Report Descriptor 的意义在于:当你接上一个不认识的 HID 设备时,先读它的 Descriptor,才能知道报告里的每一位代表什么。不然你发了半天数据,设备没反应,结果发现设备根本没定义这个按钮,或者按钮对应的 Usage ID 和你以为的不一样。车载项目里外设五花八门,这种“先读描述符再干活”的习惯能帮你少走很多弯路。
6. 系统 API:UsbManager 之外的那些细节
6.1 USB 权限的两种获取方式和 Android 版本差异
USB 权限是 Android 设备访问外设的第一道门槛,也是最容易出问题的地方。系统提供两种授权方式:一种是动态请求,通过usbManager.requestPermission(device, pendingIntent)弹窗让用户确认;另一种是应用声明了对应设备的过滤规则,设备插入时系统自动把设备“交给”应用,此时不一定弹窗,但应用要处理Intent.ACTION_USB_DEVICE_ATTACHED带过来的设备对象。
动态请求的代码模式很固定:
PendingIntent permissionIntent = PendingIntent.getBroadcast( this, 0, new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE); usbManager.requestPermission(device, permissionIntent);然后在广播接收器里处理授权结果。这里有个要注意的坑:PendingIntent.FLAG_IMMUTABLE是 Android 12(API 31)之后强制要求的,如果 targetSdk 升到 31 或更高,不写这个 flag 会直接崩溃。另一个坑是:授权本身不持久化,车机重启后,之前授权过的设备还会重新弹窗。如果你想做到开机自启后无感连接,需要自己在应用里保存已授权设备的标识(VID/PID/序列号),重启后直接调openDevice,但要注意这并不代表系统一定允许你访问——在没有弹窗授权的情况下,应用只能打开自己曾经申请过授权的设备,这是系统权限模型决定的。
6.2 USB 插拔监听和设备变化处理
车载场景中,设备热插拔是家常便饭。Android 提供了两个相关的系统广播:ACTION_USB_DEVICE_ATTACHED(设备插入)和ACTION_USB_DEVICE_DETACHED(设备拔出)。这两个广播既可以静态注册在 manifest 里,也可以动态注册。静态注册的好处是应用被杀死后,设备插入时还能唤醒应用;坏处是静态注册时必须把你要处理的设备写进 device_filter.xml,否则不会收到广播。
设备拔掉之后有个非常典型的问题:连接的句柄会变得无效,如果你没及时清理,再调bulkTransfer可能返回负值或者抛异常。所以ACTION_USB_DEVICE_DETACHED里一定要做完整的资源释放:关闭UsbDeviceConnection、释放UsbInterface的 claim、关掉读线程。不然接下来再插入同一个设备,有可能出现“设备在但打不开”的诡异状态,甚至要重启车机才能恢复。
6.3 系统层 API 背后是怎么工作的
很多人用 UsbManager 用得挺溜,但不知道它底层是怎么转的。Android 的 USB Host 栈大致分三层:Java 层是UsbManager、UsbDevice、UsbDeviceConnection这些 API;JNI 层通过libusbhost和 Linux 内核的 usbfs 打交道;内核层则是各 USB 设备驱动在跑。具体点说,UsbDeviceConnection.open()的核心是打开/dev/bus/usb/xxx/yyy这个设备节点,后续的controlTransfer和bulkTransfer本质上是 ioctl 调用USBDEVFS_CONTROL和USBDEVFS_BULK。
这个底层模型解释了前面遇到的很多问题。比如设备的设备节点权限不对,应用就没有权限打开它;再比如内核没有编入对应驱动,getDeviceList就根本看不到设备。做车载 USB 开发,遇到问题不能只盯着上层代码,要能顺着这个链路往下排查:设备插上后,adb shell lsusb能不能看到?设备节点存不存在?设备节点属主和权限对不对?搞懂这层,排查问题的思路就清晰多了。
7. 实测中的常见问题与排查实录
7.1 授权框不弹,设备也扫不到
这个问题十有八九出在内核驱动或设备权限上。先别急着改代码,按下面的顺序排查:第一步,用adb shell lsusb看设备是否被内核识别;第二步,看adb shell getprop sys.usb.config之类属性有没有异常;第三步,检查/dev/bus/usb/下有没有对应设备节点,以及节点权限有没有被限制。如果lsusb都看不到设备,那就是驱动或硬件问题,应用层怎么改都没用。
如果lsusb能看到设备但应用扫不到,多半是应用没有声明FEATURE_USB_HOST,或者device_filter.xml的 VID/PID 不对。注意usbManager.getDeviceList()返回的是全部 USB 设备,跟 filter 无关;但要把设备“自动交给”应用处理,filter 就必须匹配。所以我建议调试阶段先用getDeviceList()打日志,确认设备在不在列表里,再去处理 filter 的问题。
7.2 串口数据乱码和黏包
乱码先查波特率。我之前做过一个项目,设备端默认的波特率被之前的调试人员改成了 38400,但代码里写死 115200,读出来的数据毫无规律。后来把波特率做成可配置项,才彻底解决了这种因为设备参数变动导致的问题。另外检查数据位、停止位和校验位:标准配置是 8 数据位、1 停止位、无校验(8N1),但有些设备可能用 7E1、8O1 这种冷门配置,务必看设备手册确认。
黏包问题是流式协议的经典难题,常见于连续采集场景。我的经验是,设备端尽量固定帧长,并且带帧头和校验;应用端在解析时不要频繁做数组拷贝,用一个“累积缓冲 + 帧解析”的状态机。同时还有一个容易忽略的点:一次read可能只读到一帧的一半,也可能一次读到三帧,解析代码一定要能正确处理“半包”、“整包”和“多包”的情况。
7.3 USB-CAN 收不到数据
CAN 收不到数据,先不要怀疑代码。第一步查物理层:CAN_H 和 CAN_L 是否接反、终端电阻是否接上、总线是否被休眠。第二步查适配器模式:有些适配器默认是静默模式(只听不发送 ACK),或者通道配置错误。第三步查协议:如果用的是 slcan 协议,确认波特率设置指令有没有发对。我之前碰到过一次诡异的问题,明明波特率设置成了 500kbps,但就是收不到数据,后来发现适配器出厂默认波特率是 250kbps,需要先发S8\r切到 500k 才能通。
最后查应用层:确认打开的是不是正确的读端点,读缓冲区是否够大。CAN 总线的数据速率相对较高,如果应用层处理不过来,丢帧是必然的。这里给个判断依据:如果lsusb能看到设备、Adaptor 指示灯正常闪烁、但应用收不到数据,八成是协议或配置问题;如果连指示灯都不闪,那就是物理层问题。
7.4 设备拔插后应用“失联”
这个问题在车载场景非常常见。原因通常是应用在ACTION_USB_DEVICE_DETACHED时没有释放资源,或者系统没有及时回调广播。系统回调DETACHED广播有延迟,设备重新插入时,应用可能还在用旧的连接,导致 open 新连接失败。
我的处理方式是在应用里维护一个全局的“设备连接代理”,屏蔽掉底层的插拔细节。插入时自动打开连接,拔出时自动断开,并在重连成功前阻塞所有上层调用。代理内部监听ATTACHED/DETACHED广播,同时做去重和防抖。这样做的好处是上层业务代码完全不用关心设备什么时候被拔掉了,反正连接代理会尽力维持一个“看起来一直在线”的连接状态。
7.5 权限持久化问题
系统授权不持久化,这是 Android 的限制,但有变通办法。如果你的车机有 root 权限,可以用pm grant把 USB 权限授予给应用,或者在系统设置里把应用加入“设备控制”白名单(不同厂商差异比较大)。如果没有 root,只能接受弹窗。不过有一种场景可以规避:如果你的应用声明了ACTION_USB_DEVICE_ATTACHED过滤器,并且系统已经把设备分配给了你的应用,那么只要应用进程活着,就可以直接访问设备,不需要再次弹窗。所以“开机自启 + 过滤器声明 + 保持后台进程存活”是车载项目里最常见的组合拳。
8. 给新人的建议和一些工程经验
8.1 从最小 Demo 开始,把链路拆碎验证
新手做 USB 开发最容易上来就写一大堆代码,结果问题一片,不知道从哪里查。我的建议是,每一个环节都先用最笨的办法验证。枚举不到设备就先写个最简 Activity,只打印getDeviceList();串口乱码就先在电脑上用串口助手确认设备端上报的数据格式;CAN 收不到数据就先拿一个 CAN 分析仪确认总线上到底有没有数据。链路要从前到后一层层打通,而不是在最后一层瞎猜。
先看一个最简单的 USB 读设备 Demo 需要哪几步:获取 UsbManager,获取设备列表,请求权限,openDevice,claimInterface,找端点,bulkTransfer/controlTransfer,关闭连接。把这 8 步拆开,每步打日志,日志打清楚了,问题基本也定位了。
8.2 把调试的主动权握在自己手里
工具方面,adb shell lsusb、adb shell dmesg | grep usb和adb shell cat /sys/kernel/debug/usb/devices这三个命令,每一个做车载 USB 开发的都该刻在脑子里。dmesg能看到内核收到 USB 事件之后的应答,sysfs里能看到设备描述符的完整解析结果。信息量比任何 Android 上层日志都大。再配合一个 USB 协议分析仪(或者临时用 Wireshark 抓 USB 包),任何协议问题都能在十分钟内定位。
另外,现场调试时串口工具一定要备好。我常用的是一个临时 Android 应用,把 USB 串口收到的原始字节流直接打印成 hex 输出,再配合时间戳。很多“莫名其妙”的问题,在原始数据面前都会现出原形,尤其是协议字段对不上的时候。
8.3 车载环境特殊,做好异常降级
车机和手机最大的不同是,它没有一个“用户”在旁边随时插拔重试。车机启动之后可能几个月不关机,外设也可能随时因为震动松脱、因为电压波动重启。所以代码里一定要做自动重连、做异常降级、做数据缓存。比如 GPS 模块暂时掉线,应用要能继续用惯性数据;CAN 总线暂时断开,要能缓存最近一段时间的车辆状态,等总线恢复后补发或补算。这些工程化的东西,比单纯把 USB 通信调通更重要。
最后再说一个小技巧:车载 USB 开发一定要把日志维度做细。USB 层的日志、协议层的日志、业务层的日志分开,定义好 tag。经常现场调了一天,最后发现是应用一开始就把日志级别打错了,关键信息全被过滤掉。日志是车载现场唯一的“眼睛”,不要省。
做这行久了,最大的体会就是:Android 车载 USB 开发的知识点并不是难在某个协议上,而是难在它横跨了 Android 系统框架、Linux 内核驱动、USB 协议和嵌入式硬件多个层面。任何一个环节的短板都会变成实际项目的坑。我写这份笔记,其实就是想把自己踩过这些坑的位置标出来,能让你少走一段弯路。