车载Android USB Host开发实战:USB串口、USB-CAN与HID外设接入全解析
2026/9/12 12:16:56 网站建设 项目流程

搞过车载 Android 开发的人应该都有同感:车机这个场景里,USB 几乎是所有外设的汇聚点。不管是你想接一个 USB-CAN 诊断工具去读整车报文,还是拿 USB 转串口线连 MCU 看日志,或者让方向盘物理按键通过 HID 协议跟中控交互,背后都得先跟 Android 的 USB Host 体系打交道。这篇文章就是我针对车载项目中常用的 USB Host、USB 串口、USB-CAN、HID 以及相关系统 API 做的开发笔记,既聊原理,也放能直接用的代码和踩坑记录,适合做车机系统开发、车载应用层开发以及想从手机 App 转车载方向的工程师参考。

先交代一下我遇到的实际场景。我们那台 Android 车机主板上有 4 路 USB,两路固定给内置 4G 模组和行车记录仪,剩下两路从中央扶手箱引出给用户。而项目里要支持的设备包括:一个 USB 转串口调试线、一个诊断用的 USB-CAN 盒子、一个外接方向盘按键控制器(本质是 HID 键盘设备),再加上 U 盘批量导出日志。这几样东西本质上都走 USB,但协议栈完全不一样,处理方式也完全不同。

1. 项目背景与整体技术选型思路

1.1 为什么车载场景绕不开 USB Host

先明确一点:车机上的 Android 并不是像手机那样默认作为 USB Device 存在的。手机插上电脑是 MTP 模式,但在车里,车机才是那个主动枚举外部设备的主机,U 盘、USB-CAN 盒子、串口线、按键控制器,全部挂在车机 USB Host 控制器下面。这也是为什么做车载开发时,android.hardware.usb.host这个 feature 几乎都会出现在车机固件里。

USB Host 模式的意义在于,它让车机有能力同时管理多个不同功能的 USB 外设。但这也带来一个麻烦:每种外设背后是不同的协议和访问策略。比如 U 盘走的是 Mass Storage Class,系统拿到后自动挂载到某一路径;USB 串口走 CDC ACM 或者厂商私有命令;USB-CAN 盒子通常就是一个自定义的 bulk 传输设备;HID 键鼠则由内核 input 子系统接管。如果这些设备同时插在同一个 Hub 上,怎么让它们互不干扰,这就是我们需要在项目里重点解决的问题。

1.2 “Android 充当 USB 主机”到底意味着什么

从 Android 架构看,USB Host 支持分为两层。上面一层是 Java API,也就是UsbManagerUsbDeviceUsbDeviceConnection这一套,普通 App 通过它可以枚举设备、申请权限、做 bulk 读写和 control 传输。下面一层是 Linux 内核的 usbfs,也就是/dev/bus/usb/xxx/yyy节点,系统进程或者 root 应用可以直接对这个节点做ioctl,绕开 Java 层更灵活,但也更危险。

这里需要先建立一个认知:Android 的 USB Host API 只是一个“管道”,它本身不解析设备和数据。真正要跟 USB 串口、USB-CAN、HID 这种具体设备通信,还是得自己按照对应协议组织数据。拿 USB 串口举例,你知道要在哪个 endpoint 上写数据没有用,还得先通过 control transfer 把波特率、数据位这些参数配置对,对方才认你的帧。所以下文所有的实操都是这个思路:先用 USB Host API 打开设备,再按具体协议做配置和数据交换。

1.3 整体技术栈选型:串口、CAN、HID 的取舍

项目初始我做了个简单对比,把几种常见外设的接入方式、难易程度和典型场景列出来,这样后面开发方向就不会跑偏。

通信方式典型设备Android 接入方式车载典型场景
USB 串口PL2303、FT232、CH340、CP2102UsbBulk 读写 + Line Coding 配置MCU 日志、诊断命令、外设控制
USB-CANCANable、各厂商 USB-CAN 盒串口协议或厂商私有 bulk 协议整车报文解析、UDS 诊断、节点仿真
HID方向盘按键盒、旋钮、手柄内核 input 子系统 / Raw HID 读写物理按键、音量旋钮、快捷操作
Mass StorageU 盘、移动硬盘StorageManager / MediaStore日志导出、固件升级包

选型上有一个原则我一直在用:能走标准协议就不走私有协议。比如 HID,如果按键盒能模拟成标准键盘,Android 系统直接就把它当输入设备了,上层用KeyEvent监听就行;如果非要走自定义 HID Report,那就得自己解析 Report Descriptor,开发量和维护成本都会明显上去。USB 串口同理,优先选支持 CDC ACM 标准的芯片,因为 Android 系统层面对标准类有基础支持,而不是所有厂商私有方案都能直接跑通。

2. USB Host 模式下的设备发现与权限管理

2.1 从 UsbManager 拿到设备列表

USB Host 开发的第一步就是枚举设备。Android 里UsbManager.getDeviceList()返回一个HashMap<String, UsbDevice>,key 是设备节点路径,value 就是设备对象。拿到UsbDevice之后,上面有 VID、PID、设备类、接口列表这些关键信息。

下面是我在项目里常用的枚举代码,测试车机 USB 插了什么设备非常方便:

UsbManager usbManager = (UsbManager) getSystemService(Context.USB_SERVICE); HashMap<String, UsbDevice> deviceList = usbManager.getDeviceList(); if (deviceList.isEmpty()) { Log.d("USB", "no device"); return; } for (UsbDevice device : deviceList.values()) { Log.d("USB", String.format("device=%s vid=%04x pid=%04x", device.getDeviceName(), device.getVendorId(), device.getProductId())); for (int i = 0; i < device.getInterfaceCount(); i++) { UsbInterface intf = device.getInterface(i); Log.d("USB", " interface #" + i + " class=" + intf.getInterfaceClass() + " subclass=" + intf.getInterfaceSubclass() + " protocol=" + intf.getInterfaceProtocol()); for (int e = 0; e < intf.getEndpointCount(); e++) { UsbEndpoint ep = intf.getEndpoint(e); Log.d("USB", " endpoint dir=" + ep.getDirection() + " type=" + ep.getType() + " address=0x" + Integer.toHexString(ep.getAddress())); } } }

这里比较关键的是看interface class。比如串口芯片的 CDC ACM 接口,class 一般是 2(UsbConstants.USB_CLASS_COMM),HID 键盘的接口 class 是 3(USB_CLASS_HID),U 盘的 Mass Storage 接口 class 是 8。如果看到某个设备的接口 class 数值很奇怪,大概率是厂商私有协议,这种就要按厂商文档去解析了。

很多刚接触 USB 开发的同学会忽略一个细节:同一个物理设备可能挂多个接口,比如一个带耳机孔的 USB-CAN 盒子,它可能同时枚举出 HID 接口和厂商私有接口,访问不同接口要用不同的 endpoint。别只盯第一个接口,一定要循环遍历。

2.2 动态权限申请与 USB 设备拔插监听

跟蓝牙权限类似,Android 上访问 USB 设备需要动态申请。流程是构造一个PendingIntent,通过usbManager.requestPermission(device, pendingIntent)弹窗,用户在系统弹窗里允许之后,应用才会拿到设备的访问权。

实际项目中,我习惯于把权限申请和拔插监听放到一起处理。我看过不少人的代码只申请权限,不注册拔插广播,结果设备一断开就崩溃或者连接假死。正确的姿势是注册两个广播:ACTION_USB_DEVICE_ATTACHEDACTION_USB_DEVICE_DETACHED

private static final String ACTION_USB_PERMISSION = "com.example.carusb.action.USB_PERMISSION"; 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)) { openDevice(device); } else { Log.e("USB", "permission denied for " + device.getDeviceName()); } } } else if (UsbManager.ACTION_USB_DEVICE_ATTACHED.equals(action)) { UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); Log.d("USB", "attached " + device.getDeviceName()); } else if (UsbManager.ACTION_USB_DEVICE_DETACHED.equals(action)) { UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); closeDevice(device); Log.d("USB", "detached " + device.getDeviceName()); } } };

广播注册别忘了在onResume里注册、onPause里注销。另外注意,从 Android 13(targetSdk 33)开始,PendingIntent必须显式指定 mutable 或 immutable,我一般用PendingIntent.FLAG_MUTABLE,避免低版本机型上因为 flag 不对导致权限回调不触发。

2.3 权限持久化与系统 API 的边界

用户点一次“允许”之后,这个授权是临时的,设备拔掉再插上、或者应用重启,大概率会再次弹窗。如果车机上不想每次插拔外设都弹窗,有两个方向。普通应用层面,只能让用户勾选“默认使用这个 USB 设备”或者捕获取向下一次授权;如果是系统集成,可以改UsbUserSettingsManager相关的持久化逻辑,或者用系统签名应用直接调grantDevicePermission这类隐藏 API。

还有一个容易被坑的地方:UsbManager虽然能拿到设备,但拿到的UsbDeviceConnection是独占的,同一时间只能有一个连接持有。多个应用同时抢同一个 USB 串口设备,后面打开的一方会失败或者读不到数据。车载项目里经常有中控 App 和诊断 App 同时想访问同一个 USB-CAN 盒子,这种冲突必须靠上层互斥或系统服务去仲裁,而不是两个应用各干各的。

系统 API 的边界也得心里有数。Java 层能做的事情到bulkTransfercontrolTransferclaimInterface基本就到底了。遇到 USB 的异步中断、批量大数据吞吐、轮询高实时性的场景,Java API 的性能并不理想,需要走 JNI 直接操作 usbfs,用USBDEVFS_SUBMITURB做异步传输。我在后面 CAN 高频收发时实测过,Java 层bulkTransfer在 500kbps CAN 总线上连续收报文,偶尔会出现帧间隙抖动,想要稳定抓包还是得下沉到 native 层。

3. USB 串口通信实战:从驱动到数据帧

3.1 USB 转串口芯片与驱动原理

车载调试中最常见的 USB 转串口芯片就那么几颗:FTDI FT232、Prolific PL2303、沁恒 CH340、Silicon Labs CP210x。它们把 USB 包转成 UART 信号,本质上是做协议转换。芯片型号不同,means 不同,但在 Android 上不用太关注“驱动”这个词,因为绝大多数情况用户态是不需要装驱动的,关键是识别它枚举成什么接口。

CH340 和 PL2303 很多实例会枚举成厂商私有接口,FT232 和 CP210x 则更接近标准 CDC ACM 虚拟串口。这意味着你在 Android 上用同一套 UsbDeviceConnection 不一定都能直接跑通。碰到私有接口的设备,要么按厂商手册手动配置 baud rate,要么参考开源库里对特定 VID/PID 做的适配。

这里放一个常见芯片的 VID/PID 对照表,开发时可以直接对照日志来判断设备类型:

芯片VIDPID接口类型
FT232R/FT230X0x04030x6001自定义串口类
PL23030x067B0x2303厂商私有
CH3400x1A860x7523厂商私有
CP2102/CP210x0x10C40xEA60CDC ACM 类

3.2 Android 上实现串口读写

在 Linux 桌面系统上,插上一个 USB 转串口,内核会生成/dev/ttyUSB0,应用直接 open 这个节点就行。但 Android 默认情况下,普通应用拿不到这个 tty 节点权限,而且很多车机固件的内核默认没有把 USB serial 驱动编译进去。所以 Android 上更通用的做法是:用 UsbDeviceConnection 直接跟 USB bulk 端点通信,绕开内核 tty 驱动。

具体来说,打开设备之后要遍历接口,找到那个接口的子类为 2(CDC ACM)或者厂商自定义数据的接口,拿到 bulk IN 和 bulk OUT endpoint,然后做配置。

以 FT232 为例,初始化流程大概是:

UsbDeviceConnection connection = usbManager.openDevice(device); // 选接口,这里假设是第 0 个 UsbInterface intf = device.getInterface(0); connection.claimInterface(intf, true); int baudRate = 115200; // FT232 的设置波特率命令是通过 vendor 请求 0x03 实现的,不同芯片不同 int requestType = 0x40; // host-to-device, vendor, device int request = 0x03; // FTDI 的 SET_BAUDRATE int value = 115200; // 有的芯片直接传波特率,有的是分频系数 int index = 0; connection.controlTransfer(requestType, request, value, index, null, 0, 5000);

这里我不打算把每家芯片的初始化命令全抄一遍,因为不同芯片差异很大,建议直接参考开源库felHR85/UsbSerialksksue/UsbSerial。我最早是自己对照芯片手册手撸的,后来发现开源库已经把 VID/PID 适配、波特率计算、流控这些都处理好了,车机上直接引库能省不少时间。

3.3 波特率、流控与数据粘包处理

串口通信第一步是配置波特率和帧格式,两边参数必须一致,否则收到的全是乱码。常用参数是波特率 115200、数据位 8、停止位 1、无校验,也就是我们在代码里说的 8N1。有些老设备还在用 9600 波特率,车机上对接 MCU 时一定要跟硬件同事确认,猜是猜不出来的。

再就是流控。RS232 串口上有 RTS/CTS 硬件流控,USB 虚拟串口也有对应的控制请求。大部分车载调试场景不需要开流控,用默认 None 就好。但如果串口线很长或者对端设备比较老,可以考虑在SET_LINE_CODING请求里把 flow control 位设置上。

数据粘包这个是串口开发的老问题。USB 传输层是按包发的,应用层读到的是连续字节流,协议上要自己定帧边界。我常用的做法是:帧头(比如 0xAA 0x55)加长度字节加数据加校验,接收端用状态机或者环形缓冲区来切帧。千万不要直接bulkTransfer一次就读一个“完整包”,因为对方可能把一帧数据拆成两个 USB 包发出来,也可能把两帧数据拼在一个 USB 包里。

4. USB-CAN 转换器的接入与 CAN 帧收发

4.1 CAN 总线基础与车载 CAN 报文格式

说 USB-CAN 之前得先简单补一下 CAN 总线的底子。CAN 是车载上最常用的现场总线,现在还有 CAN FD 在普及,但最基础的还是 CAN 2.0A 和 CAN 2.0B,分别对应 11 位标准帧 ID 和 29 位扩展帧 ID。一个标准 CAN 数据帧包含这么几个部分:帧起始、仲裁段(ID 加 RTR)、控制段(IDE 加 DLC)、数据段(最多 8 字节)、CRC、ACK、EOF。

从上层的角度看,我们最关心的就三样:ID、DLC、数据。比如动力 CAN 上常见的转速报文,ID 是 0x0C0,8 个数据字节里前两个字节拼出一个转速值。具体的位定义每个车型都不一样,需要拿到 OEM 的 DBC 文件或者通过逆向去标定。

车载上不同总线通常波特率不同,我见过比较多的是动力 CAN 500kbps、车身 CAN 125kbps。如果波特率配错,CAN 控制器会一直报总线错误,抓回来的报文也全是垃圾。这个在 USB-CAN 调试时是最容易踩的坑。

4.2 USB-CAN 适配器在 Android 上的工作方式

USB-CAN 适配器本质上是一个 USB 设备,它把 USB 包转成 CAN 帧。市面上产品分两类。一类是 CANable、CANtact 这种开源方案,它们支持 slcan 协议,简单说就是把 CAN 帧编码成 ASCII 字符串,通过串口发送,所以 Android 这边可以把它当作 USB 串口来访问。另一类是各厂商的私有协议 USB-CAN 盒,比如创芯、周立功的,有专门的上位机 SDK,在 Android 上走的是私有 bulk 传输,需要对照厂商协议文档来自行封装。

我项目里优先用了 CANable 这类支持 slcan 的盒子,因为它协议公开、解析简单,调试周期最短。slcan 协议的常用命令也很直白:

  • S+ 波特率字符:设置 CAN 波特率
  • O:打开 CAN 接口
  • C:关闭 CAN 接口
  • t:发送标准帧
  • T:发送扩展帧

波特率字符和实际速率的对应关系通常是:0=10k,1=20k,2=50k,3=100k,4=125k,5=250k,6=500k,7=800k,8=1M。不同固件可能略有差异,以固件源码里的码表为准。

4.3 实操:从打开设备到收发 CAN 帧

我用 CANable 举例,完整流程分三步:先当作 USB 串口打开,再发 slcan 命令配置波特率并把 CAN 接口打开,最后按帧格式收发。

发送标准帧时,协议格式是t<id><len><data>,id 固定 3 个 HEX 字符,len 是 1 个 HEX 字符,data 是 2*len 个 HEX 字符。比如发送 ID 为 0x123、8 字节数据11 22 33 44 55 66 77 88的标准帧,对应的 ASCII 行就是:

t12381122334455667788\r

这里的t表示标准数据帧,123是 ID,8是 DLC,后面 16 个字符是数据。注意 ID 必须补齐三位,比如 0x7E0 就写7E0,不能写成7E0以外的格式,否则固件解析不了。

接收端从串口读取 ASCII 行,以\r为行结束符。下面是简化版的 CAN 帧解析代码:

private void processCanLine(String line) { if (line == null || line.length() < 4) return; char type = line.charAt(0); if (type == 't' || type == 'T') { int idx = 1; int idLen = (type == 't') ? 3 : 8; long id = Long.parseLong(line.substring(idx, idx + idLen), 16); idx += idLen; int dlc = Integer.parseInt(String.valueOf(line.charAt(idx)), 16); idx++; if (line.length() >= idx + dlc * 2) { byte[] data = new byte[dlc]; for (int i = 0; i < dlc; i++) { data[i] = (byte) Integer.parseInt( line.substring(idx + i * 2, idx + i * 2 + 2), 16); } long timestamp = System.nanoTime(); onCanFrameReceived(id, data, timestamp); } } }

实测下来,slcan 方案在 500kbps 总线上跑普通数据采集是没问题的,但到了需要大量连续发送或者需要精确时间戳的场景,slcan 的文本解析会成为瓶颈。这时候就得改走厂商私有二进制协议,用更紧凑的帧格式。另外,CAN 总线两端需要接 120 欧姆终端电阻(一般 CAN 盒上会带跳线或者内置开关),否则会因为信号反射导致持续报错,这是一个硬件层面的坑,软件再调也没用。

5. HID 设备交互:方向盘按键、音量旋钮与专用 HID 设备

5.1 HID 协议:报告描述符与发送/接收路径

HID 设备在 USB 里属于人机交互类,接口 class 是 3。Android 系统对接标准 HID 键盘鼠标时,内核 input 子系统会把 HID report 翻译成 Linux input event,再往上层走 InputManagerService,最终变成应用层能收到的KeyEventMotionEvent。这也是为什么你把一个 USB 键盘插到车机上,不写一行代码就能打字。

但“能打字”和“能拿到你想要的按键”是两回事。很多非标 HID 设备,比如自定义按键盒、旋钮,它不一定按标准键盘 report 发送。这种设备就需要应用自己去拿 HID Report Descriptor,按照 Usage Page 和 Usage ID 去理解数据含义。

第一步是用 control transfer 读 Report Descriptor:

// GET_DESCRIPTOR request,wValue 高字节 0x22 表示 HID Report Descriptor byte[] desc = new byte[512]; int len = connection.controlTransfer( 0x81, // device-to-host, standard, interface 0x06, // GET_DESCRIPTOR 0x2200, // HID Report Descriptor intf.getInterfaceNumber(), desc, desc.length, 1000);

拿到 desc 之后可以丢给hidrd-convert这类工具去看,也可以写个简单的解析器。实际开发中很多时候不需要完整解析,只要确认设备上报的数据结构,然后用 bulk interrupt IN 端点拿数据、把 report 里的 Usage ID 和实际物理按键对应起来就行。

5.2 模拟 HID 键盘发送音量修改和普通按键

再看如何发送。车载项目里除了接收 HID 按键,还有一个常见需求是在测试或者自动化场景下模拟一个 HID 设备,让车机以为用户按了物理按键。比如我们要模拟“音量加”键,这个其实是 Consumer Page(Usage Page 0x0C)下的 Usage,不是标准键盘页面的按键。

USB HID 键盘有专门的标准报告格式,通常是 8 字节:第一个字节是修饰键,第二个字节保留,后面最多 6 个普通按键。普通按键的 Usage ID 从 0x04 开始,0x04 是字母 a,0x28 是 Enter。要发送普通按键,需要同时发送“按下”和“松开”两个报告。比如模拟按字母 a:

byte[] press = new byte[] { 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00 }; byte[] release = new byte[] { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; // 发送到 interrupt OUT endpoint connection.bulkTransfer(epOut, press, press.length, 2000); Thread.sleep(20); connection.bulkTransfer(epOut, release, release.length, 2000);

音量加减这类多媒体键不同,它走的是 Consumer Page,很多键盘设备会有第二个 HID 接口专门发这个。常见 Usage ID:0xE9 是 Volume Increment,0xEA 是 Volume Decrement,0xCD 是 Play/Pause。发送一个音量加,报告内容一般是两三个字节,比如:

byte[] volUp = new byte[] { 0x00, (byte) 0xE9 }; connection.bulkTransfer(epOut, volUp, volUp.length, 2000);

不过要注意,这里我举的是“设备发送 HID report”的例子。正常情况下我们不会在 Android 应用里模拟 HID 上报,更常见的做法是用adb shell input keyevent KEYCODE_VOLUME_UP或者Instrumentation.sendKeyDownUpSync向系统注入按键事件,那是走 InputManager 的注入通道。但如果你的外设本身就是一个带 MCU 的自制按键盒,那通过 HID 固件模拟键盘设备就是完全合理的路子。

5.3 与 Android 事件分发机制的衔接

不管 HID 键鼠事件来自哪个设备,进入 Android 后都会经过 InputManagerService 和 InputDispatcher 做分发。应用层关心的是两个方法:dispatchKeyEvent负责按键事件,dispatchGenericMotionEvent负责旋钮这类相对位移事件。

实际项目里,我们那套方向盘按键盒上报的是标准 HID 键盘报告,但因为车载场景需要把这些按键映射成车机业务,比如左边按键是“上一曲”,右边是“音量加”,我们做了一个动态按键映射层。做法是:在 framework 层把 HID usage 映射成自定义KeyEventkey code,应用层根据 key code 执行对应操作。这块如果只做应用,不打算改 framework,也能通过OnKeyListener监听 KEYCODE_MEDIA_NEXT、KEYCODE_VOLUME_UP 这些标准 key code,但灵活性就差一些。

另外,如果按键设备上报的是模拟鼠标或者旋钮滚轮,那事件类型会变成ACTION_SCROLL或者ACTION_MOVE,并且带有MotionEvent.AXIS_VSCROLL这类的轴值。判断之前建议先打日志看 event 的 source 类型,InputDevice.SOURCE_KEYBOARDSOURCE_MOUSESOURCE_ROTARY_ENCODER分别对应不同的处理路径。

6. 车载环境下的系统 API 细节与权限踩坑

6.1 Android 版本差异:分区存储、FileProvider 与共享路径

车载项目里经常要跟文件路径打交道,尤其是 U 盘日志导出、升级包拷贝。Android 11 之后分区存储收紧,很多以前随手访问的路径现在都过不去了。最常见的现象是:U 盘挂载后,应用想直接读/storage/XXXX-XXXX/下的文件,发现没有权限;或者接收方打开content://路径时提示文件不存在。

这里有个细节要特别注意:content://com.tencent.wework.fileprovider/external_path/android/data/com.xxx这种 Uri 是某个应用把自己的 FileProvider 暴露出来的,但它指向的物理路径通常在/storage/emulated/0/Android/data/com.xxx/下面。Android 11 以后,这个目录对别的应用就不可见了,所以别人拿到 Uri,即使权限没断,也可能因为路径被限制而打不开。

车载上我们常用一个折中方案:U 盘日志先通过MediaStore或者StorageManager写入公共目录,再通过 FileProvider 分享,而不是直接分享应用私有目录下的文件。另外,如果在调试阶段需要快速执行脚本,比如adb shell sh /storage/emulated/0/android/data/com.omarea.vtools/up.sh这种场景,需要注意 Android 对/storage/emulated/0/Android/data的访问限制同样存在,USB 调试模式下 adb shell 权限高能读,但应用进程并不能随意访问别人目录下的文件。

6.2 SELinux、USB 设备节点权限与直接访问节点

如果是做系统集成,权限问题突出表现在 SELinux 上。默认enforcing模式下,普通应用通过UsbManager拿到的 fd 是 UsbService 代开的,审批流程没问题。但如果你想在 native 层直接打开/dev/bus/usb/xxx/yyy,就涉及 SELinux domain 的usb_device节点访问权限。车机固件如果不加上对应 policy,系统应用 open 也会得到 Permission denied。

调试阶段可以用setenforce 0临时关掉 SELinux 验证功能,但正式版本一定要写好 policy。另外,就算关掉 SELinux,普通目录的权限也不够,/dev/bus/usb下面的节点默认只有 USB 服务进程能访问。车机上如果有很多功能要直接访问 USB 节点,我见过不少方案是做一个系统级 USB 服务,统一收口设备访问,而不是让每个应用各自去开节点。

6.3 多 USB 设备并发、供电与热拔插稳定性

最后必须说供电和并发。车机 USB Host 口供电能力有限,同时插 USB-CAN 盒、4G 模块、U 盘,经常会出现电压跌落导致某个设备掉线。我踩过最深的坑是:USB-CAN 盒子在整车电源波动时反复掉线重连,重连后固件没有恢复到打开状态,上位机一直收不到帧。

解决思路分几层:第一,硬件上选择带外部供电的 USB Hub;第二,软件上监听ACTION_USB_DEVICE_DETACHED,在重连后自动重配置设备,比如重新设置波特率、重新打开 CAN 通道;第三,如果一台车要同时接多个 USB 设备,尽量让应用按设备节点路径而不是 VID/PID 去区分,因为同型号两个设备插上后,节点号可能会变。之前我们车机上插了两块同型号 USB-CAN,应用一开始只按 PID 匹配,结果一块拔掉再插上之后,节点路径变了,应用就把两块设备的访问对象搞混了,抓回来的报文全都乱了。

7. 常见问题与排查技巧实录

7.1 设备枚举不到,getDeviceList一直为空

最基础的排查点是硬件。先确认车机 USB 口本身是 Host 模式,有些车机或者板卡会把 USB 口配成 Device 模式,那UsbManager什么也拿不到。再看系统里有没有声明 USB Host feature,manifest 里漏了<uses-feature android:name="android.hardware.usb.host" />虽然不一定导致设备列表为空,但会导致在某些市场或者系统服务里被阉割掉。

还有一种情况是 Hub 供电不足导致设备枚举失败。规格书上写着 5V 1A,实际满载瞬间电压降到 4.5V 以下,设备就起不来。这种问题在日志里往往能看到内核打印device descriptor read/64, error -71。可以考虑换带供电的 Hub,或者给外设单独供一路电。

7.2 USB 转串口驱动识别失败:VID_067B 与 PID_2303

很多做串口调试的同行应该都遇到过USB\VID_067B&PID_2303\5&2a81f847&0&3这种设备实例路径,这就是一颗 PL2303。Windows 上这个芯片的问题是经典中的经典:新版 3.x 驱动会检测到老款 HXA 芯片或者兼容芯片,然后直接驱动加载失败,设备管理器里黄色感叹号,错误码 10。

解决办法不是一味更新驱动,而是找到对应的旧版 2.x 驱动来安装,或者换一颗官方新版芯片。在车机 Android 端,PL2303 通常会被内核的pl2303驱动识别,如果内核没编这个驱动,就把它当普通 USB 设备用UsbManager打开。这一点跟 Windows 完全是两套体系,别把 Windows 的驱动思维带过来。

7.3 数据乱码、CAN 帧丢失怎么排查

乱码优先怀疑波特率不匹配和电平不匹配。电平方面,TTL 电平的串口不能直接接 RS232 设备,必须加 MAX232 这类电平转换芯片,否则数据大概率是乱码或者根本不通。CAN 帧丢失也不一定是代码问题,先确认终端电阻、总线波特率、总线负载率,再看是不是上层解析丢包。

如果确认硬件没问题,再看 USB 传输层。Java 层bulkTransfer是同步阻塞的,接收速率跟不上总线速率时,缓冲区溢出丢帧很常见。我的经验是开一个专门的读线程,并且把读缓冲设成设备最大包长的整数倍,比如 64 字节端点就开 4096 的缓冲,减少bulkTransfer次数。

7.4 常见问题速查表

现象可能原因排查建议
getDeviceList为空USB 口非 Host / Hub 供电不足换口、换供电 Hub、查内核日志
授权弹窗不出现PendingIntent flag 错误 / intent-filter 缺失检查FLAG_MUTABLE和设备过滤配置
串口打开失败接口被占用 / 权限未授予关掉其他 App、检查claimInterface返回
串口数据乱码波特率不匹配 / 电平不匹配确认参数 8N1、加电平转换
CAN 一直报总线错误波特率配错 / 缺终端电阻两端加 120Ω、确认总线速率
HID 按下无事件设备不是标准键盘 / Report Descriptor 解析错读 Descriptor,确认 Usage Page
App 退后台后 USB 失效广播未处理 / 连接未持有前台服务持有连接并注册监听

最后再分享一个我觉得很有用的经验:车载 USB 开发一定要在台架上把“插拔、断电重启、多个外设同时接入”这三种场景反复测,因为车机上的 USB 环境比开发板恶劣得多。很多问题不是代码写错,而是时序和资源竞争。调试时多用adb shell dmesg看内核日志,多用adb shell cat /sys/kernel/debug/usb/devices看设备树,这些信息往往比应用层日志可靠得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询