资讯动态

Android 串口通讯:权限、termios、JNI 与 USB 转串口避坑

发布时间:2026/10/26 3:32:33 来源:尧图企业网站定制
简介面向需要在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/ttyS3SoC 原生串口通常是主板引出的 UART工控板、POS 机、自助终端最常见。/dev/ttyUSB0外挂 USB 转串口芯片CH340、CP210x、FT232 这一类在 Linux 下由 usbserial 驱动生成的节点。/dev/ttyACM0USB CDC-ACM 类设备Arduino、部分 4G 模块、STM32 虚拟串口走这个。权限列长这样adb shell ls -l /dev/ttyS3 # crw-rw---- 1 root system 4, 67 2024-01-01 00:00 /dev/ttyS3crw-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/VTIMEfd 到手之后紧接着就是配置。串口是「两边参数必须完全一致」的典型场景差一位就是满屏乱码或者一字节都收不到。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的组合是新手最容易栽的地方四种组合语义完全不同VMIN1, VTIME0一直阻塞到至少 1 字节到达。适合纯阻塞读线程但退出时会被卡住。VMIN0, VTIME5最多等 500ms超时返回 0。适合需要周期性检查退出标志的线程。VMIN0, VTIME0立即返回配合O_NONBLOCK用CPU 会飙高。VMINN, VTIMET凑够 N 字节或超时 T 才返回。我的习惯是VMIN0, VTIME5让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必须加否则串口会变成进程的控制终端收到 CtrlC 之类的字节时程序会被信号打断。tcflush(fd, TCIOFLUSH)放在配置之后、返回之前清的是上电瞬间积累的脏数据不加的话第一次读经常读到一堆 0x00。close单独包一层别在 Java 层直接调 fdvoid 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 占用在低端板子上很难看所以主流还是阻塞读真正的难点是「怎么让一个阻塞中的线程停下来」。前面配的VMIN0, VTIME5已经给了解法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); // VTIME5最长 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 BlockingQueuebyte[] 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(); // 必须 flushFileOutputStream 有缓冲 } }flush()这行被漏掉的情况我见过不止一次现象是「单条命令发得出去连续发就丢一半」因为数据还在 Java 的缓冲里没落到内核。4. USB 转串口路线免 root 方案的三块拼图不是每个项目都能拿到系统签名普通手机、消费级平板接外设就只能走 USB Host。这条路不用碰 /dev 节点全靠UsbManager加手动实现芯片协议好处是免 root、装机即用坏处是所有事情都得自己干枚举设备、申请权限、找端点、设线速、处理拔插。三块拼图缺一块都跑不起来。4.1 UsbManager 枚举设备与权限广播的回调顺序权限声明先在AndroidManifest.xml里落地uses-feature android:nameandroid.hardware.usb.host android:requiredtrue / uses-permission android:nameandroid.permission.USB_PERMISSION /然后在res/xml/device_filter.xml里声明你关心的设备用厂商 ID 和产品 ID 过滤resources !-- vendor-id / product-id 是十进制可以从 UsbDevice 里打印出来再填 -- usb-device vendor-id6790 product-id29987 / /resources对应的 Activity 里注册 intent-filter插入设备时系统会拉起你的应用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 /拿到设备之后必须申请权限这一步是异步的很多人在这里翻车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也得加。这两行如果不写现象是「广播永远收不到」日志里干干净净纯靠经验排查。广播接收器里做判断别拿到广播就直接 openprivate 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: 01 停止位 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_STATEbit0DTR, bit1RTS很多芯片不拉高不吐数据 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两个值不能都设成 00 表示无限等待读线程就退不出来了。部分发送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是两个不同的东西写错了系统不会报「文件不存在」之外的任何信息。现象三返回 -16EBUSY。原因是这个串口已经被别的进程占用了可能是厂商的后台服务也可能是上一次没关干净的自己。解决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 引用直接内存泄漏。解决固定成「先runningfalseinterrupt()再join(500)最后close()」的顺序把这段写进onDestroy或者 ViewModel 的onCleared。同时在SerialPort.close()里加synchronized和opened标志保证幂等。我现在的习惯是给每个串口连接配一个AtomicBoolean记录状态打开、关闭、重连都先 CAS 一次多线程下不会出现「两个地方同时 close」。注意不要指望Thread.stop()解决问题它在现代 JVM 上已经被弃用而且会让 fd 处于不确定状态下次打开必挂。6. 上线前把串口链路压一遍三个观测点与一份脚本功能跑通和能上量产是两件事。串口这种「平时没事、关键时刻掉链子」的链路不压一遍根本不知道边界在哪。我通常在上线前做三件事连续收发压测、拔插恢复测试、长时间挂机。压测不用写复杂的测试框架一段 adb 脚本加日志统计就够#!/bin/bash # 连续触发发送指令统计收发数量用于评估丢包率 PKGcom.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设成 101 秒的话最坏情况要等满 1 秒才返回业务体感就是「点了没反应」。长时间挂机我一般跑 8 小时中间不做任何操作第二天看三个数字内存曲线是否平稳、fd 数量是否增长、日志里有没有反复出现的异常。fd 增长是最常见的隐性泄漏adb shell ls /proc/pid/fd | wc -l隔一小时采一次曲线往上走就说明某条路径没 close。从那以后我每次接新板子都会先把「权限→参数→线程退出→拔插恢复」这四步在样机上强制走一遍一个都不跳。这四步里任何一步含糊后面量产阶段就会以最难受的方式补回来。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑