☰
Android 串口通讯:权限、termios、JNI 与 USB 转串口避坑
2026/10/26 3:32:28 网站建设 项目流程

简介:面向需要在Android设备上连接硬件、实现设备间数据交互的开发者,这份Android Studio串口通讯项目提供了一套可参考的完整工程。内容围绕SerialPort库的集成与使用展开,涉及串口打开、波特率与数据位等参数配置、数据读写、异常处理及资源关闭,并包含可直接安装测试的apk,便于验证与硬件通信效果。压缩包共851个文件,约19.59MB,以291个xml、264个png、131个json等资源与配置为主,辅以26个java、50个class、15个jar和9个so等代码与依赖文件,结构覆盖应用工程、构建产物及串口底层实现。已有3452人学习,适合物联网、自动化控制、嵌入式方向的中级开发者对照项目理解串口通信流程、排查配置问题并复用核心代码。

1. 串口通讯在 Android 上到底难在哪:三条路线的选型判断

在 Android Studio 里新建个工程,加几行代码就能读串口——这个念头让不少人第一天就撞了墙。Android 串口通讯真正的难点从来不在代码量,而在权限模型和硬件形态:设备节点默认归 root 或 system 所有,普通应用连 open 都过不去;市面上的板子有的把串口引到 /dev/ttyS3,有的只留一个 USB 口要外挂转换芯片。所以动手前第一个决定不是写代码,是选路线:内置串口靠系统权限或厂商配合,USB Host 转串口免 root 但通用性最好,厂商封装的 AIDL 服务最省心却受制于人。这篇把我拆过的工程链路、termios 参数和五类翻车现场摊开讲,面向要接扫码枪、电子秤、工控板、小票打印机的 Android 开发者。

2. 设备节点与 termios:串口能不能打开,参数说了算

Android 的串口开发,第一层分水岭就是「能不能打开」。很多人把异常日志往上一翻,看到open failed: EACCES (Permission denied)或者干脆返回 -1,就以为是代码写错了,其实代码一行没错,是内核那层根本没给你这个应用的访问资格。这一章先把设备节点认清楚,再把 termios 的参数一个个对到位,最后才落到 JNI 层的实际写法。顺序不能反,反过来就是盲改参数。

2.1 先看清 /dev 下面是什么:ttyS、ttyUSB、ttyACM 的区别

用adb shell进去,ls -l /dev/tty*会列出一堆东西,名字不同,含义完全不同:

  • /dev/ttyS0、/dev/ttyS3:SoC 原生串口,通常是主板引出的 UART,工控板、POS 机、自助终端最常见。
  • /dev/ttyUSB0:外挂 USB 转串口芯片(CH340、CP210x、FT232 这一类)在 Linux 下由 usbserial 驱动生成的节点。
  • /dev/ttyACM0:USB CDC-ACM 类设备,Arduino、部分 4G 模块、STM32 虚拟串口走这个。

权限列长这样:

adb shell ls -l /dev/ttyS3 # crw-rw---- 1 root system 4, 67 2024-01-01 00:00 /dev/ttyS3

crw-rw----加root system,意思是只有 root 和 system 组能读写。普通应用跑在自己的沙箱 UID 下,既不是 root 也不在 system 组,open 必然被拒。这就是为什么「代码没问题、就是打不开」。

确认设备到底在哪个节点上,别猜,用驱动表反查:

adb shell cat /proc/tty/drivers # serial /dev/ttyS /dev/ttyS 4 64-111 serial # usbserial /dev/ttyUSB /dev/ttyUSB 188 0-255 serial

输出里4 64-111表示主设备号 4、次设备号范围 64~111,对应 ttyS0~ttyS47。再配合adb shell dmesg | grep tty,插拔设备时看内核有没有新节点冒出来,一眼就能判断节点号。我一般会让硬件同事在板子上丝印标好对应的 ttyS 编号,省得每次都要试。

拿到节点之后,权限怎么解决?三条路,优先级从高到低:

方式做法适用场景代价
系统签名 + 共享 UID应用与 system_server 同 UID,或申请 system 权限整机厂自有应用需要平台签名
ueventd 规则改权限在ueventd.rc里加chmod 0666 /dev/ttyS3定制 ROM、板卡厂商需要改系统镜像
服务端打开 fd 再下发system_server 或厂商服务 open 后把 fd 传给应用应用与系统服务分离需要 AIDL 通道配合

第三条路最干净:应用不碰设备节点,只接收一个已经打开的ParcelFileDescriptor,然后基于这个 fd 做读写。SELinux 域的问题也绕过去了,因为 open 发生在有权限的进程里。

提示:不要用Runtime.getRuntime().exec("su")去 chmod 设备节点。这条路径在多数量产固件上走不通,而且会让你的应用在安全扫描里直接挂掉。

2.2 termios 参数逐项对:波特率、数据位、校验位和 VMIN/VTIME

fd 到手之后,紧接着就是配置。串口是「两边参数必须完全一致」的典型场景,差一位就是满屏乱码或者一字节都收不到。Linux 下这套参数放在struct termios里,关键在于c_cflag、c_iflag、c_lflag和c_cc四个成员。

先看一张对照表,把每个参数的作用和设错的表现列清楚:

参数取值含义设错的现象
波特率B9600 / B115200 / B921600位速率乱码、固定错字节
数据位CS7 / CS8单帧有效位数高位置乱或整帧偏移
校验位PARENB + PARODD奇偶校验偶发校验错、帧被丢
停止位CSTOPB1 位或 2 位连续多帧错位
流控CRTSCTS硬件流控大数据量时卡死或丢包
VMIN0 或 1最少可读字节数读线程空转或永久阻塞
VTIME0~255读超时(单位 100ms)超时粒度不对

VMIN和VTIME的组合是新手最容易栽的地方,四种组合语义完全不同:

  • VMIN=1, VTIME=0:一直阻塞到至少 1 字节到达。适合纯阻塞读线程,但退出时会被卡住。
  • VMIN=0, VTIME=5:最多等 500ms,超时返回 0。适合需要周期性检查退出标志的线程。
  • VMIN=0, VTIME=0:立即返回,配合O_NONBLOCK用,CPU 会飙高。
  • VMIN=N, VTIME=T:凑够 N 字节或超时 T 才返回。

我的习惯是VMIN=0, VTIME=5,让read()每 500ms 返回一次,读线程里就有机会检查volatile boolean running。这比「close fd 强行打断阻塞读」稳得多,后者在某些内核上和read()有竞争,close 之后 fd 号被复用,会出现读到别人数据的玄学问题。

cfmakeraw()会把大部分标志位清成原始模式,省掉逐位关回显、关行缓冲的麻烦,但它同时会把CSIZE设成CS8、VMIN设成 1。所以顺序是:先cfmakeraw,再改数据位、校验位、停止位,最后改VMIN/VTIME。这个顺序错了,你的改动会被cfmakeraw覆盖掉,表现为「代码里明明写了 CS7,就是不起作用」。

2.3 JNI 层落地:CMake 配置与 fd 回传 Java 的写法

配置逻辑放在 C 层,Java 层只负责调用和生命周期管理。工程里建好cpp目录,CMakeLists.txt至少要这么写:

cmake_minimum_required(VERSION 3.18) project(serialport LANGUAGES C) add_library(serialport SHARED serial_port.c serial_port_jni.c) find_library(log-lib log) target_link_libraries(serialport ${log-lib})

build.gradle里要显式声明 ABI,别指望默认值:

android { defaultConfig { ndk { // 32 位和 64 位都要打,否则 64 位机型会 UnsatisfiedLinkError abiFilters 'armeabi-v7a', 'arm64-v8a' } externalNativeBuild { cmake { cppFlags "" } } } externalNativeBuild { cmake { path file('src/main/cpp/CMakeLists.txt') version '3.18.0' } } }

核心的打开与配置代码:

// serial_port.c #include <fcntl.h> #include <termios.h> #include <unistd.h> #include <errno.h> static speed_t to_speed(int baud) { switch (baud) { case 9600: return B9600; case 19200: return B19200; case 38400: return B38400; case 57600: return B57600; case 115200: return B115200; case 230400: return B230400; case 460800: return B460800; case 921600: return B921600; default: return 0; // 0 表示不支持,调用方要判错 } } int serial_open(const char *path, int baud, int data_bits, char parity, int stop_bits) { int fd = open(path, O_RDWR | O_NOCTTY); // 不加 O_NONBLOCK,靠 VTIME 控制超时 if (fd < 0) return -errno; // 负的 errno,方便 Java 层区分原因 struct termios opt; if (tcgetattr(fd, &opt) != 0) { int e = errno; close(fd); return -e; } cfmakeraw(&opt); // 原始模式:关回显、关行缓冲、关信号转换 speed_t sp = to_speed(baud); if (sp == 0) { close(fd); return -EINVAL; } cfsetispeed(&opt, sp); cfsetospeed(&opt, sp); // 数据位:cfmakeraw 已设成 CS8,这里要按需覆盖 opt.c_cflag &= ~CSIZE; opt.c_cflag |= (data_bits == 7 ? CS7 : CS8); // 校验位:'N' 无校验,'O' 奇校验,'E' 偶校验 if (parity == 'N') { opt.c_cflag &= ~PARENB; } else { opt.c_cflag |= PARENB; if (parity == 'O') opt.c_cflag |= PARODD; else opt.c_cflag &= ~PARODD; } // 停止位:2 位才有 CSTOPB 标志 if (stop_bits == 2) opt.c_cflag |= CSTOPB; else opt.c_cflag &= ~CSTOPB; opt.c_cflag |= (CLOCAL | CREAD); // 忽略调制解调器状态,启用接收 opt.c_cflag &= ~CRTSCTS; // 没接流控线就必须关掉,否则会卡死 opt.c_cc[VMIN] = 0; opt.c_cc[VTIME] = 5; // 500ms 超时返回 tcflush(fd, TCIOFLUSH); // 清掉配置前的脏数据 if (tcsetattr(fd, TCSANOW, &opt) != 0) { int e = errno; close(fd); return -e; } return fd; }

几个参数说明一下。返回值设计成「负数 = errno」,比返回 -1 强,Java 层能区分EACCES(13)、ENOENT(2)、EBUSY(16),排查时不用再靠猜。O_NOCTTY必须加,否则串口会变成进程的控制终端,收到 Ctrl+C 之类的字节时程序会被信号打断。tcflush(fd, TCIOFLUSH)放在配置之后、返回之前,清的是上电瞬间积累的脏数据,不加的话第一次读经常读到一堆 0x00。

close单独包一层,别在 Java 层直接调 fd:

void serial_close(int fd) { if (fd >= 0) { tcflush(fd, TCIOFLUSH); close(fd); } }

JNI 这层的函数名要对上包名,假设 Java 侧类是com.example.serial.SerialPort:

#include <jni.h> JNIEXPORT jint JNICALL Java_com_example_serial_SerialPort_nativeOpen(JNIEnv *env, jclass clazz, jstring path, jint baud, jint dataBits, jchar parity, jint stopBits) { const char *p = (*env)->GetStringUTFChars(env, path, NULL); int fd = serial_open(p, baud, dataBits, (char) parity, stopBits); (*env)->ReleaseStringUTFChars(env, path, p); return fd; } JNIEXPORT void JNICALL Java_com_example_serial_SerialPort_nativeClose(JNIEnv *env, jclass clazz, jint fd) { serial_close(fd); }

拿到 fd 之后,Java 侧要把它变成FileDescriptor才能接上 InputStream/OutputStream,或者自己写 native 读写。两种做法我都用过,反射更省事:

private static FileDescriptor toFileDescriptor(int fd) throws Exception { FileDescriptor descriptor = new FileDescriptor(); try { // Android 新版本提供了 setInt$,优先用它 Method setInt = FileDescriptor.class.getDeclaredMethod("setInt$", int.class); setInt.setAccessible(true); setInt.invoke(descriptor, fd); } catch (NoSuchMethodException e) { // 老版本退回反射私有字段 Field field = FileDescriptor.class.getDeclaredField("descriptor"); field.setAccessible(true); field.setInt(descriptor, fd); } return descriptor; }

这里有个血泪经验:setInt$这个带美元符号的方法在部分定制 ROM 上被裁掉了,只保留字段反射那一套。所以两级降级都要写,别只写一种,否则在某些机型上直接抛NoSuchMethodException,日志里看起来像是串口驱动坏了,其实是反射方式没兜住。另外FileDescriptor一旦被 GC 回收会触发 finalize 去close这个 fd,所以要保证它被长期持有,不能写完就丢。

3. Java 层的封装:读写线程、缓冲与帧解析的三段式

JNI 只解决「怎么打开、怎么读一次」,真正的工程问题在 Java 层:谁来持有 fd、读线程怎么起停、半包怎么拼、写操作怎么避免多线程踩同一个 fd。这一层写歪了,前面参数配得再准也白搭。我的做法是拆成三段——持有资源的SerialPort、只负责搬运的读线程、只负责拼帧的解析器,三段之间用阻塞队列串起来,谁都不越界。

3.1 SerialPort 对象与 FileDescriptor 的配对方式

SerialPort只做三件事:打开、持有、关闭。打开时同步调用 native,失败立刻把 errno 转成可读异常抛出去;持有阶段暴露InputStream/OutputStream给上层;关闭时保证顺序正确——先停线程,再关流,最后 close fd。

public final class SerialPort implements Closeable { private final int fd; private final FileDescriptor descriptor; private final FileInputStream inputStream; private final FileOutputStream outputStream; private volatile boolean opened = true; private SerialPort(int fd, FileDescriptor descriptor) { this.fd = fd; this.descriptor = descriptor; this.inputStream = new FileInputStream(descriptor); this.outputStream = new FileOutputStream(descriptor); } public static SerialPort open(String path, int baud, int dataBits, char parity, int stopBits) throws IOException { int ret = nativeOpen(path, baud, dataBits, parity, stopBits); if (ret < 0) { throw new IOException("open " + path + " failed, errno=" + (-ret)); } try { return new SerialPort(ret, toFileDescriptor(ret)); } catch (Exception e) { nativeClose(ret); // 构造失败必须回滚,否则 fd 泄漏 throw new IOException("wrap fd failed", e); } } public InputStream getInputStream() { return inputStream; } public OutputStream getOutputStream() { return outputStream; } @Override public synchronized void close() { if (!opened) return; opened = false; try { inputStream.close(); } catch (IOException ignored) {} try { outputStream.close(); } catch (IOException ignored) {} nativeClose(fd); } private static native int nativeOpen(String path, int baud, int dataBits, char parity, int stopBits); private static native void nativeClose(int fd); }

close()加synchronized和幂等判断不是为了美观。串口页面通常有多个出口:返回键、Activity 销毁、断线重连、业务主动断开,任何一个没判重都会导致同一个 fd 被 close 两次。第二次 close 时 fd 号可能已经被系统分配给别的文件,结果就是你关掉了别人的句柄,报错信息还会指向完全无关的地方,这种问题查起来极其费劲。

构造失败时的回滚也要写。toFileDescriptor抛异常的情况真实存在,不 close 掉就漏一个 fd。一个应用反复进出串口页面几十次,泄漏的 fd 就能把进程的句柄表撑爆,表现为「用一会儿就打不开任何文件」。

3.2 读线程为什么要阻塞,又怎么让它干净退出

读线程只有两种写法:阻塞读和轮询。轮询的 CPU 占用在低端板子上很难看,所以主流还是阻塞读;真正的难点是「怎么让一个阻塞中的线程停下来」。

前面配的VMIN=0, VTIME=5已经给了解法:read()最多 500ms 就会带着 0 或若干字节返回,线程里每轮检查一次退出标志即可。这比在另一个线程里直接close(fd)温和得多,不会有「close 与 read 竞争」的问题。

public class ReadThread extends Thread { private final InputStream in; private final FrameParser parser; private volatile boolean running = true; public ReadThread(InputStream in, FrameParser parser) { this.in = in; this.parser = parser; setName("serial-read"); } @Override public void run() { byte[] buf = new byte[256]; while (running) { try { int n = in.read(buf); // VTIME=5,最长 500ms 返回一次 if (n > 0) { parser.feed(buf, 0, n); // 交给解析器拼帧,不在读线程里做业务 } } catch (IOException e) { if (running) { Log.w("SerialLink", "read error, will retry", e); sleepQuietly(200); // 避免异常时空转打满 CPU } } } Log.i("SerialLink", "read thread exit"); } public void shutdown() { running = false; interrupt(); // 打断 sleep,让线程尽快走完循环 } private static void sleepQuietly(long ms) { try { Thread.sleep(ms); } catch (InterruptedException ignored) {} } }

这段代码里有三个点值得多说。

第一,in.read(buf)的返回值为 0 是正常现象,代表超时,不是 EOF,不要因为 0 就退出循环。真正的 EOF 是 -1,但串口场景基本不会返回 -1。

第二,异常分支里的 200ms 休眠是必须的。设备被拔掉之后read会持续抛异常,没有休眠就是死循环刷日志,Logcat 一秒钟几千行,把有用的信息全冲掉了。

第三,解析逻辑不能塞进读线程。串口的读取节奏由硬件决定,业务处理可能有耗时操作(数据库、网络、UI 刷新),一旦在读线程里做,缓冲区就会堆起来,表现为「数据延迟越来越大最后丢包」。正确做法是feed只做拷贝和拼帧,拼出完整帧后丢到BlockingQueue,由业务线程消费。

读线程的退出顺序也要固定:先shutdown()让循环结束,再join(500)等一下,最后close()释放 fd。反过来调,读线程会读到一个已经关闭的流,抛一堆EBADF,日志里全是噪音。

3.3 写队列与粘包处理:一帧数据到齐了再交给业务

串口是字节流,没有消息边界。你发出去的 10 个字节,对方可能一次收到 6 个、下一次收到 4 个;对方发来的数据也会被内核缓冲区切片。所以业务层绝对不能拿「一次 read 的结果」当一帧,必须自己定帧协议。常见三种:

方式结构优点缺点
固定长度每帧 N 字节解析最简单长度变化就不适用
帧头 + 长度 + 载荷 + 校验AA 55 LEN ... CRC通用、好扩展要处理半包和错位
分隔符以\r\n结尾文本协议友好载荷里出现分隔符要转义

工控设备多用第二种。解析器做成一个带状态的缓冲区,每次feed往里塞,能拼出完整帧就回调:

public class FrameParser { private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(512); private final BlockingQueue<byte[]> frames = new LinkedBlockingQueue<>(64); private static final int MAX_FRAME = 256; public void feed(byte[] data, int offset, int len) { buffer.write(data, offset, len); byte[] all = buffer.toByteArray(); int pos = 0; while (all.length - pos >= 5) { // 最短帧:头 2 + 长度 1 + 载荷 0 + 校验 2 if ((all[pos] & 0xFF) != 0xAA || (all[pos + 1] & 0xFF) != 0x55) { pos++; // 没对齐就滑动一位重新找帧头 continue; } int payloadLen = all[pos + 2] & 0xFF; int frameLen = 2 + 1 + payloadLen + 2; if (frameLen > MAX_FRAME) { pos += 2; continue; } if (all.length - pos < frameLen) break; // 半包,等下一次 feed byte[] frame = Arrays.copyOfRange(all, pos, pos + frameLen); if (crc16(frame, 3, payloadLen) == readCrc(frame, pos + frameLen - 2)) { frames.offer(frame); } pos += frameLen; } // 把没用掉的尾巴留在缓冲区,避免重复解析 buffer.reset(); if (pos < all.length) buffer.write(all, pos, all.length - pos); } public byte[] poll(long timeoutMs) throws InterruptedException { return frames.poll(timeoutMs, TimeUnit.MILLISECONDS); } }

参数说明:帧头两个字节用于对齐,长度字段只算载荷不算头尾,crc16覆盖「长度 + 载荷」,校验不通过就整帧丢弃而不是重同步——重同步更容易在噪声大的线路上陷入死循环。MAX_FRAME是防御性上限,收到的长度字节如果被噪声打成 0xFF,没有这个上限会一直等 255 字节,看起来就像设备「死了」。

写侧也要串行化。多个业务线程同时往同一个OutputStream写,帧会互相穿插,对方收到的是拼接后的乱码。用一个单线程ExecutorService或者加锁的写队列:

private final Object writeLock = new Object(); public void send(byte[] frame) throws IOException { synchronized (writeLock) { outputStream.write(frame); outputStream.flush(); // 必须 flush,FileOutputStream 有缓冲 } }

flush()这行被漏掉的情况我见过不止一次,现象是「单条命令发得出去,连续发就丢一半」,因为数据还在 Java 的缓冲里没落到内核。

4. USB 转串口路线:免 root 方案的三块拼图

不是每个项目都能拿到系统签名,普通手机、消费级平板接外设就只能走 USB Host。这条路不用碰 /dev 节点,全靠UsbManager加手动实现芯片协议,好处是免 root、装机即用,坏处是所有事情都得自己干:枚举设备、申请权限、找端点、设线速、处理拔插。三块拼图缺一块都跑不起来。

4.1 UsbManager 枚举设备与权限广播的回调顺序

权限声明先在AndroidManifest.xml里落地:

<uses-feature android:name="android.hardware.usb.host" android:required="true" /> <uses-permission android:name="android.permission.USB_PERMISSION" />

然后在res/xml/device_filter.xml里声明你关心的设备,用厂商 ID 和产品 ID 过滤:

<resources> <!-- vendor-id / product-id 是十进制,可以从 UsbDevice 里打印出来再填 --> <usb-device vendor-id="6790" product-id="29987" /> </resources>

对应的 Activity 里注册 intent-filter,插入设备时系统会拉起你的应用:

<intent-filter> <action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" /> </intent-filter> <meta-data android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" android:resource="@xml/device_filter" />

拿到设备之后必须申请权限,这一步是异步的,很多人在这里翻车:

UsbManager manager = (UsbManager) getSystemService(Context.USB_SERVICE); UsbDevice device = findTargetDevice(manager); // 遍历 manager.getDeviceList().values() if (device != null && !manager.hasPermission(device)) { Intent intent = new Intent(ACTION_USB_PERMISSION); intent.setPackage(getPackageName()); // Android 14 起要求显式包名 PendingIntent pi = PendingIntent.getBroadcast( this, 0, intent, PendingIntent.FLAG_MUTABLE); // 12 起必须显式声明可变性 manager.requestPermission(device, pi); }

三个版本相关的坑集中在这里。Android 12 开始PendingIntent的 flags 必须写FLAG_MUTABLE或FLAG_IMMUTABLE,不写直接崩;USB 权限这个场景必须用 MUTABLE,因为系统要往 Intent 里塞EXTRA_DEVICE和EXTRA_PERMISSION_GRANTED。Android 14 又要求隐式 Intent 指定包名,所以setPackage也得加。这两行如果不写,现象是「广播永远收不到」,日志里干干净净,纯靠经验排查。

广播接收器里做判断,别拿到广播就直接 open:

private final BroadcastReceiver usbReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); if (ACTION_USB_PERMISSION.equals(action)) { boolean granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false); UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); if (granted && device != null) { openDevice(device); // 这里再 open,权限才算真正生效 } } else if (UsbManager.ACTION_USB_DEVICE_DETACHED.equals(action)) { handleDetach(); } } };

hasPermission只在本次授权内有效,应用重启或者设备重插之后要重新申请。这一点和/dev节点的权限模型完全不同,别把两套逻辑混着写。

4.2 bulk transfer 收发与 CDC 线速设置

UsbDevice只是描述,真正读写要找UsbInterface和UsbEndpoint。一个典型的 USB 转串口芯片会有两个接口:一个是通信控制接口(CDC Control),一个是数据接口(CDC Data),数据接口里有 IN 和 OUT 两个 bulk 端点。

UsbInterface dataInterface = null; for (int i = 0; i < device.getInterfaceCount(); i++) { UsbInterface itf = device.getInterface(i); // 0x0A 是 CDC Data 类,厂商自定义芯片则看 bulk 端点是否存在 if (itf.getInterfaceClass() == 0x0A) { dataInterface = itf; break; } } UsbDeviceConnection connection = manager.openDevice(device); connection.claimInterface(dataInterface, true); UsbEndpoint epIn = null, epOut = null; for (int i = 0; i < dataInterface.getEndpointCount(); i++) { UsbEndpoint ep = dataInterface.getEndpoint(i); if (ep.getType() != UsbConstants.USB_ENDPOINT_XFER_BULK) continue; if (ep.getDirection() == UsbConstants.USB_DIR_IN) epIn = ep; else epOut = ep; }

波特率在 USB 转串口这边不是配置内核,而是给芯片发控制命令。CDC 类用SET_LINE_CODING,载荷是 7 字节结构:

private boolean setLineCoding(UsbDeviceConnection conn, int baud) { byte[] cfg = new byte[7]; cfg[0] = (byte) (baud & 0xFF); // dwDTERate,小端 4 字节 cfg[1] = (byte) ((baud >> 8) & 0xFF); cfg[2] = (byte) ((baud >> 16) & 0xFF); cfg[3] = (byte) ((baud >> 24) & 0xFF); cfg[4] = 0; // bCharFormat: 0=1 停止位 cfg[5] = 0; // bParityType: 0=无校验 cfg[6] = 8; // bDataBits // 0x21 = Host-to-Device, Class 接口; 0x20 = SET_LINE_CODING int r = conn.controlTransfer(0x21, 0x20, 0, 0, cfg, cfg.length, 500); if (r != cfg.length) return false; // 0x22 = SET_CONTROL_LINE_STATE,bit0=DTR, bit1=RTS,很多芯片不拉高不吐数据 int s = conn.controlTransfer(0x21, 0x22, 0x03, 0, null, 0, 500); return s >= 0; }

SET_CONTROL_LINE_STATE这一步经常被忽略,现象是「命令发得出去,一个字节都收不到」。原因是一些转换芯片在 DTR/RTS 没拉高时会认为自己未被主机启用,直接把接收通道关掉。示波器上看 TX 有波形、RX 一条直线,大概率就是这个。

收发就是 bulk 调用,读写都要放在独立线程里:

// 写 int sent = connection.bulkTransfer(epOut, frame, frame.length, 1000); if (sent != frame.length) { /* 超时或部分发送,需要重发策略 */ } // 读 byte[] buf = new byte[256]; int n = connection.bulkTransfer(epIn, buf, buf.length, 200); // 200ms 超时,便于退出 if (n > 0) parser.feed(buf, 0, n);

超时参数是这里的关键。写超时给 1000ms,读超时给 200ms,两个值不能都设成 0,0 表示无限等待,读线程就退不出来了。部分发送(sent < frame.length)要处理,不能假设一次 bulk 就发完,分包大小受端点getMaxPacketSize()限制,超过要自己切。

4.3 拔插、重连与失败退避

USB 最大的不确定因素就是物理连接。线松了、供电不足、HUB 带不动,都会导致设备在运行中消失。这一块没有「一次写对」的方案,只能靠状态机加退避。

状态机就三个状态:DISCONNECTED、OPENING、CONNECTED。ACTION_USB_DEVICE_DETACHED广播里把状态打回DISCONNECTED,同时停掉读线程、释放claimInterface、关 connection。重连不要用固定间隔死循环,我一般用指数退避:200ms、400ms、800ms、1600ms,上限 5 秒,连上就重置。

private long nextDelay = 200; private void scheduleReconnect() { if (state == State.CONNECTED) return; handler.postDelayed(() -> { if (tryOpen()) { nextDelay = 200; } else { nextDelay = Math.min(nextDelay * 2, 5000); scheduleReconnect(); } }, nextDelay); }

还有一个容易被忽视的点:UsbDeviceConnection和UsbInterface在 detach 之后不会自动清理,必须手动releaseInterface和close。不清理的话,同一个设备重新插上时openDevice会返回 null,现象是「第一次插能用,拔了再插就没反应,非得重启应用」。这个坑我在两个项目里各踩过一次,后来固定成「detach 广播里做完整清理」的规矩。

5. 串口调试避坑:五类翻车现场和排查顺序

串口的问题有个特点:报错信息极少,很多故障在日志里只留一行或者干脆什么都不留。所以排查顺序比排查技巧更重要——从权限往下查,从物理层往上查,别一上来就怀疑代码。下面五条是我这些年真金白银换来的。

5.1 打开阶段:EACCES、-1 和无设备节点

现象一:open failed: EACCES (Permission denied),或者 native 返回 -13。

原因基本只有一个:应用没有该设备节点的读写权限,或者被 SELinux 拦了。文件权限和 SELinux 是两道独立的门,过了第一道不代表过第二道。

解决顺序:先adb shell ls -l /dev/ttyS3看权限位,确认是不是crw-rw---- root system;再adb shell getenforce看是不是 Enforcing;最后adb shell dmesg | grep avc看有没有avc: denied记录。如果有 avc 记录,说明是 SELinux 域的问题,需要平台侧加策略或者在 ueventd 里把节点权限放开。临时验证可以用adb shell su -c "chmod 666 /dev/ttyS3",但记住这只是验证手段,正式方案必须是改系统镜像或者走 fd 下发。

现象二:native open 返回 -1,日志里什么都没有。

原因通常是路径写错了。板子上的串口不一定是ttyS0,可能是ttyS1、ttyS3,甚至是ttyHSL0这种厂商自定义命名。

解决:adb shell ls /dev/tty*全列一遍,配合cat /proc/tty/drivers和dmesg | grep tty交叉确认。另外注意大小写,ttyS和ttys是两个不同的东西,写错了系统不会报「文件不存在」之外的任何信息。

现象三:返回 -16(EBUSY)。

原因是这个串口已经被别的进程占用了,可能是厂商的后台服务,也可能是上一次没关干净的自己。

解决:adb shell lsof | grep ttyS3或者fuser /dev/ttyS3查占用者。如果是自己的进程泄漏,回到 3.1 检查 close 的幂等和异常回滚。

5.2 收发阶段:乱码、退不出的线程和二次打开失败

现象四:能打开,但收到的全是乱码或者固定错位的字节。

原因排序:波特率不一致(占七成)、数据位/校验位不一致、VMIN/VTIME配错导致读到半帧。

解决:先拿一个串口助手在 PC 侧对打,确认设备本身的输出是正常的;再逐项核对参数表,特别注意对方是不是 7 位数据位加偶校验这种老配置。乱码如果呈现「每隔几个字节错一次」的规律,多半是校验位或停止位的问题;如果是整体随机,基本就是波特率。还有一种隐蔽情况:cfmakeraw之后你改了CSIZE但忘了清位,opt.c_cflag &= ~CSIZE这行漏了,设置不生效。

现象五:Activity 退出后读线程还在跑,或者第二次进页面打不开串口。

原因是读线程阻塞在read上没退出,fd 也没关,第二次 open 拿到 EBUSY。更糟的情况是线程持有 Activity 引用,直接内存泄漏。

解决:固定成「先running=false+interrupt(),再join(500),最后close()」的顺序,把这段写进onDestroy或者 ViewModel 的onCleared。同时在SerialPort.close()里加synchronized和opened标志,保证幂等。我现在的习惯是给每个串口连接配一个AtomicBoolean记录状态,打开、关闭、重连都先 CAS 一次,多线程下不会出现「两个地方同时 close」。

注意:不要指望Thread.stop()解决问题,它在现代 JVM 上已经被弃用,而且会让 fd 处于不确定状态,下次打开必挂。

6. 上线前把串口链路压一遍:三个观测点与一份脚本

功能跑通和能上量产是两件事。串口这种「平时没事、关键时刻掉链子」的链路,不压一遍根本不知道边界在哪。我通常在上线前做三件事:连续收发压测、拔插恢复测试、长时间挂机。

压测不用写复杂的测试框架,一段 adb 脚本加日志统计就够:

#!/bin/bash # 连续触发发送指令,统计收发数量,用于评估丢包率 PKG=com.example.serial adb logcat -c adb logcat -s SerialLink:V > /tmp/serial_stress.log & LOGPID=$! for i in $(seq 1 1000); do # 用广播触发应用内的一次发送,避免每次都要点界面 adb shell am broadcast -a ${PKG}.ACTION_SEND \ --es hex "AA5501000001020304" > /dev/null sleep 0.1 done sleep 5 kill $LOGPID # 统计发送与接收计数 echo "TX: $(grep -c 'TX seq' /tmp/serial_stress.log)" echo "RX: $(grep -c 'RX seq' /tmp/serial_stress.log)" echo "CRC 错误: $(grep -c 'crc mismatch' /tmp/serial_stress.log)"

脚本里--es hex传的是十六进制字符串,应用侧解析成字节数组再发;sleep 0.1控制发送节奏,1000 次大概 100 秒,够覆盖大部分时序问题。日志里每条发送和接收都打上自增序号,脚本靠grep -c统计,序号还能用来定位具体丢哪一帧。

三个观测点我用一张表盯:

观测点怎么测合格线不达标先查什么
丢包率压测 1000 帧,比对 TX/RX 序号< 0.1%写侧是否 flush、读侧缓冲区是否够大
首字节时延发送时刻到收到回包第一字节< 100ms读线程 VTIME 是否设得过大
断线恢复时间拔线 3 秒再插回,到恢复收发< 5sdetach 清理是否完整、退避上限是否过长

丢包率这一项,如果发现丢的都是「连续发送时的第 N 帧之后」,八成是写侧没 flush 或者对端缓冲区溢出,加流控或者降低发送频率就能缓解。首字节时延偏高,先看VTIME,设成 10(1 秒)的话最坏情况要等满 1 秒才返回,业务体感就是「点了没反应」。

长时间挂机我一般跑 8 小时,中间不做任何操作,第二天看三个数字:内存曲线是否平稳、fd 数量是否增长、日志里有没有反复出现的异常。fd 增长是最常见的隐性泄漏,adb shell ls /proc/<pid>/fd | wc -l隔一小时采一次,曲线往上走就说明某条路径没 close。

从那以后我每次接新板子,都会先把「权限→参数→线程退出→拔插恢复」这四步在样机上强制走一遍,一个都不跳。这四步里任何一步含糊,后面量产阶段就会以最难受的方式补回来。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询