☰
Android串口链接工具源码解析:从tty设备到JNI通信
2026/10/14 18:20:39 网站建设 项目流程

简介:这是一套基于安卓平台的串口通信工具源码,适合需要开发串口调试、硬件数据交互的开发者,可快速实现与GPS等串口设备的连接与通信。源码包共43个文件,压缩后大小约469KB,内容包含Java源码、so动态库、xml界面配置、jar依赖库、C层串口封装以及Android.mk构建脚本等,从应用层到底层驱动均有覆盖,目录结构清晰,便于按模块查阅。目前已有1790人学习下载。源码在权限管理、运行时权限适配、串口参数配置(波特率、数据位、停止位、校验位)、数据读写监听、后台线程处理、异常捕获以及设备枚举与连接状态管理等方面均有完整体现,并自带SerialPort.c与gen_SerialPort_h.sh脚本,方便理解JNI调用与串口驱动的实现原理,同时也提供so库和jar包用于快速集成,可直接导入工程进行二次开发,也可作为安卓串口通信学习参考。无论是想掌握底层串口通信机制,还是需要快速构建一个调试工具,这份源码都能提供很大帮助。

1. Android 串口链接工具源码到底解决什么问题,值得你折腾吗

在调试某块嵌入式主板时,我遇到过一件很没面子的事:模块明明通电、指示灯正常,手机 App 却怎么都收不到数据。折腾一小时后才发现,不是协议写错了,而是串口设备被系统权限卡住了,整个链路还没真正打通。这就是为什么你需要认真看一份 Android 串口链接工具源码——它不是一个“点一下就能用”的库,而是帮你掌握串口设备的打开、配置、读写和释放全流程,真正建立起手机与单片机、工业屏、车机设备之间的通信链路。

这篇内容适合两类人:一是从纯 Android 应用开发转向智能硬件联调的工程师,手里拿着 USB 转串口模块或焊接好的 TTL 板子,需要封装一个串口连接能力;二是维护老项目的人,项目里已经引用了某份串口 JNI 源码,但遇到权限、乱码、读阻塞这些坑时,连源码在哪一层出问题都分不清。我会从 tty 设备模型讲起,再把源码的 C 层、JNI 层、Java 层拆开,最后落到一组真机用得到的排查手法。看完整理后,你至少能自己定位是协议问题、参数问题,还是权限问题,而不是把锅全甩给“串口链接工具不好用”。

2. 串口源码的核心原理:tty 设备、termios 与 JNI 的选择

2.1 串口数据不神秘:Android 里的串口就是一个可读写的 tty 文件

串口通信在 Linux 内核中被抽象为终端设备(tty),Android 底层沿用这套机制。设备节点通常出现在/dev/ttyS0、/dev/ttyMTKx、/dev/ttyUSB0、/dev/ttyACM0这几类路径下。把串口当成文件读写是很自然的事,但这里有一个关键区别:普通文件只需要 open、read、write、close,而串口在 open 之后还必须通过 ioctl 和 termios 结构体完成参数配置,否则数据线的电平即使连接正常,读写的结果也完全不可控。

用一句口语概括:串口链接工具源码的核心工作,就是包装好这个“open 后立刻配置参数、配置后读写、读写在独立线程里做”的过程。你拿到的源码如果只做了 open 和 read/write 而没有正确处理tcgetattr、cfsetispeed、tcsetattr,那它八成会在真机上翻车。因为串口设备不同于 socket 或管道,它默认行为里可能开了行缓冲、回了显,还可能因为规范模式把读到的一批字节拆分得乱七八糟。

另外,Android 对 tty 设备的访问受到权限管控。系统应用和有 root 权限的调试进程可以访问大多数/dev/ttyS*;普通第三方 App 即使知道路径,也打不开。大多数串口工具源码会绕一步:在 open 之前先执行chmod或su命令调整设备权限。这个做法不优雅,但在开发调试阶段是最高效的,也是大量源码包里默认携带的逻辑。

// 打开串口设备的常见写法 int fd = open(device_path, O_RDWR | O_NOCTTY | O_NONBLOCK); if (fd < 0) { // 这里大概率可以看到“Permission denied” // 或者“No such device or address” LOGE("open %s failed: %s", device_path, strerror(errno)); return -1; }

上面这段代码里有三个标志值得注意:O_RDWR表示读写都要;O_NOCTTY防止串口成为控制终端,避免进程收到来自串口的 Ctrl-C 等信号;O_NONBLOCK影响后续 read 的阻塞行为,建议保留,因为可以在用户态用 poll 来等待数据,而不是让线程死等。

2.2 为什么绕不开 JNI:纯 Java 无法完成的 termios 配置

Java 标准 API 里没有直接暴露 termios 的能力。Android SDK 有FileDescriptor、FileInputStream和FileOutputStream,它们可以用来读写 tty 文件,却无法设置波特率、数据位、停止位、校验位和流控。如果只做一款固定波特率 9600、8 数据位、无校验、1 停止位的 USB 串口设备,也许可以靠默认配置碰运气,但绝大多数设备都在 115200 或更高波特率下工作,还必须和硬件手册里的帧格式完全一致。

因此,串口链接工具源码里最核心的部分通常是一个用 NDK/JNI 写的 C 代码文件。它在 native 层完成以下步骤:打开设备节点、获取当前 termios、修改 c_cflag、设置输入输出波特率、关闭规范模式和回显、最后把fd传回 Java 层。只靠 Java 反射拿FileDescriptor再传给 native 层的玩法也可以,但命令过程更绕,还不如让 native 方法直接返回 int 型 fd 简单。

// 串口参数与 termios 常量的关系 // 波特率 115200 在 Linux 下不是直接写整数 115200 // 它对应宏 B115200,而 B115200 实际是一个整型常量 // 所以源码中通常会出现一张映射表

这里就是串口源码最容易被忽略的玄学点:cfsetispeed(fd, 115200)在部分交叉编译环境下不会报错,但实际不生效。你需要把整数波特率换算成B9600、B19200、B38400、B57600、B115200、B230400这类 termios 宏。后面的源码拆解里,我会直接把映射表写出来。

2.3 三条技术路线的取舍:自己写 JNI、USB 串口库、网络透传

当你在搜索“Android 串口链接工具源码”时,实际上会看到三个方向。第一种是不依赖外部设备的板载串口,代表路径是/dev/ttyS0、/dev/ttyMTK2,这类必须自己封装 JNI,因为硬件层面是 CPU 内部 UART 或外扩 16550 这类控制器。第二种是 USB 转串口,如 CH340、CP2102、FT232、CH32 芯片,Android 通过 USB Host 接口访问,既可以用 UsbManager + UsbSerialDevice 这类 Java 库,也可以让 JNI 直接操作底层设备。第三种是串口服务器网络透传,比如把串口数据转换成 TCP/UDP 报文,那就不需要本地串口源码了。

从实际投入产出比看,我的建议是:如果你的目标是学习串口原理和源码实现,直接啃 JNI 方案,因为源码结构最小,只包含一个 C 文件、一个 Java 类和一个线程管理类,适合完整读通。如果业务上只要连接某个固定 USB 串口模块,那优先考虑成熟的 USB 串口库,少写代码,稳定性也够。只有当你需要同时兼容板载串口和 USB 串口、还要自定义超时和流控策略时,才值得维护一套自己的源码。

下面这个表可以帮你快速做决策:

实现方式适用设备是否需 root维护成本稳定性
自带 JNI 链接工具板载 ttyS、ttyMTK、ttyUSB板载串口通常需要高,方便定制中
USB 串口 Java 库外置 USB 转串口芯片否,但需 USB 授权低高
TCP/UDP 串口服务器远程设备否低取决于网络

需要注意的是,标题里强调的是“源码”,而串口链接工具源码最有学习价值的场景是板载串口。因此本文后续的代码示例都围绕 JNI 实现,你可以顺手把 USB 转串口的驱动层理解成“一开始还多一步 USB 权限授权,之后读写的套路几乎一样”。

3. 源码结构拆解:C 层 open/close/read/write 与波特率映射表

3.1 一份能跑的 Native 串口源码长什么样

一份典型的串口链接工具源码,从工程结构上看并不复杂。最精简的版本只需四个文件:一个 C 源文件、一个 C 头文件、一个 Java 类和一个负责读线程的管理类。C 文件里通常有一个全局static int fd = -1,因为大多数 JNI 串口工具在同一进程中只会打开一个串口设备;如果你的源码支持多路串口同时打开,就不会用全局变量,而会把 fd 放到一个句柄结构体里返回给 Java。

app/src/main/ ├── java/com/example/serial/ │ └── SerialPort.java ├── cpp/ │ ├── CMakeLists.txt │ ├── serial_port.c │ └── serial_port.h

下面是一个头文件的最小声明,它把 native 方法暴露给 Java,同时保留 C 侧内部使用的函数。

// serial_port.h #ifndef SERIAL_PORT_H #define SERIAL_PORT_H #include <jni.h> #include <termios.h> // Java 层调用入口 JNIEXPORT jint JNICALL Java_com_example_serial_SerialPort_open(JNIEnv *env, jobject thiz, jstring path, jint baudrate); JNIEXPORT jint JNICALL Java_com_example_serial_SerialPort_close(JNIEnv *env, jobject thiz); JNIEXPORT jbyteArray JNICALL Java_com_example_serial_SerialPort_read(JNIEnv *env, jobject thiz, jint maxLen); JNIEXPORT jint JNICALL Java_com_example_serial_SerialPort_write(JNIEnv *env, jobject thiz, jbyteArray data, jint length); #endif

这里我用jint来传递文件描述符。你可能会看到网上有些源码把 fd 封装成FileDescriptor后转给 Java 的FileInputStream,那种写法在某些场景下更“正规”,但异常处理分散到两个层,反而让链接工具源码的职责变得模糊。我更推荐让 native 层直接持有 fd,open/read/write/close 全走 JNI,Java 层只拿到 int 类型句柄,排查问题时更容易追踪。

3.2 打开并配置串口:打开 ioctl 之外的细节

接下来是源码里最核心的 open 函数。只做 open 是没用的,必须把 termios 配置一次性打通。下面这段 C 代码可以当成模板:

// serial_port.c 部分片段 #include <fcntl.h> #include <unistd.h> #include <sys/ioctl.h> #include <errno.h> #include <string.h> #include <android/log.h> #define TAG "SerialPort" #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) static int serial_fd = -1; // Java_com_example_serial_SerialPort_open JNIEXPORT jint JNICALL Java_com_example_serial_SerialPort_open(JNIEnv *env, jobject thiz, jstring path, jint baudrate) { const char *path_utf = (*env)->GetStringUTFChars(env, path, 0); if (!path_utf) return -1; serial_fd = open(path_utf, O_RDWR | O_NOCTTY | O_NONBLOCK); (*env)->ReleaseStringUTFChars(env, path, path_utf); if (serial_fd < 0) { LOGE("open failed, errno=%d (%s)", errno, strerror(errno)); return -1; } struct termios options; memset(&options, 0, sizeof(options)); if (tcgetattr(serial_fd, &options) < 0) { close(serial_fd); serial_fd = -1; return -1; } // 波特率换算:baudrate 是 int,比如 115200 speed_t speed = get_termios_speed(baudrate); if (speed == 0) { close(serial_fd); serial_fd = -1; return -1; } cfsetispeed(&options, speed); cfsetospeed(&options, speed); // 8N1 默认帧格式 options.c_cflag &= ~PARENB; // 无校验 options.c_cflag &= ~CSTOPB; // 1 位停止位 options.c_cflag &= ~CSIZE; // 清除数据位掩码 options.c_cflag |= CS8; // 8 位数据 options.c_cflag |= CLOCAL | CREAD; // 忽略调制解调器控制,启用接收 // 关闭规范模式、回显和信号处理 options.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG); // 关闭软件流控 options.c_iflag &= ~(IXON | IXOFF | IXANY); // 关闭输出后处理,避免 \n 被转换成 \r\n options.c_oflag &= ~OPOST; // 最后一步:应用配置 if (tcsetattr(serial_fd, TCSANOW, &options) < 0) { close(serial_fd); serial_fd = -1; return -1; } return serial_fd; }

代码里最关键的是get_termios_speed函数,因为 C 标准库并不能保证cfsetispeed(&options, 115200)一定生效。不同 Android 平台的 B 常量值不同,甚至某些定制内核会把 115200 识别为未知速率。我会在 3.4 节专门展开这张映射表。

TCSANOW表示立刻生效,而不是等数据传输完。打开设备时建议使用O_NOCTTY | O_NONBLOCK,因为有些脚本或调试工具会从终端读取控制信号,加了O_NOCTTY可以避免串口意外响应 Ctrl-C。至于OPOST,它控制输出转换,若不做改写,Linux 默认可能把 LF 转成 CRLF,导致你发 AT 指令对方收到的是“AT\r\n”里混了多余字符。

3.3 读写与线程模型:别在 UI 线程里直接 read

串口 read 是一个容易出现“玄学”的地方。如果 open 时没有设置O_NONBLOCK,那么当没有数据时read()会一直阻塞,这意味着如果开发者在主线程调用 read,Android 会弹 ANR。就算设置成非阻塞,直接写一个while(1) read()循环也会把 CPU 吃满。所以源码通常会有两种设计模式:要么在 native 层用 poll 加超时;要么在 Java 层开一个线程循环 read,然后通过回调派发到业务层。

下面是最常见的 Java 读线程模型:

// SerialReadThread.java 示意 public class SerialReadThread extends Thread { private final SerialPort serialPort; private volatile boolean running = true; private final OnDataReceivedListener listener; @Override public void run() { while (running) { byte[] data = serialPort.read(256); if (data != null && data.length > 0) { listener.onDataReceived(data); } // 每次空读后让出 CPU,避免忙轮询 try { Thread.sleep(2); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } } public void close() { running = false; // 在 native 层 close(fd) 之后,阻塞的 read 会返回 -1 serialPort.close(); } }

如果你不想在 Java 层读一次、sleep 一次,可以把 poll 写在 native 层。poll 的优点是可以设置明确的超时,比如 20ms 内没有数据就返回 0,让线程进入下一次循环;同时它还支持监听异常事件。把 poll 放到 native 层有一个额外好处:Java 层拿到的永远是一段连续的数据块,不会出现 read 只返回半个字节这种让人头大的情况。

// C 层带超时的 read 示例 #include <poll.h> JNIEXPORT jbyteArray JNICALL Java_com_example_serial_SerialPort_read(JNIEnv *env, jobject thiz, jint maxLen) { struct pollfd fds[1]; fds[0].fd = serial_fd; fds[0].events = POLLIN; int pollRet = poll(fds, 1, 20); // 20ms 超时 if (pollRet <= 0) return NULL; char buf[256]; int n = read(serial_fd, buf, sizeof(buf)); if (n <= 0) return NULL; jbyteArray result = (*env)->NewByteArray(env, n); // 注意:必须使用 GetByteArrayElements 或直接 SetByteArrayRegion (*env)->SetByteArrayRegion(env, result, 0, n, (jbyte *)buf); return result; }

这个版本的缺点是每次调用都要在 JNI 层做一次poll+read,多线程连续读还行,但要保证 Java 层读线程只有一个,否则两个线程同时 poll 同一个 fd 会互相竞争。源码迭代到这一步时,我习惯把poll的超时做成可配置项,因为有些低速设备 20ms 内根本来不了一帧完整数据,会导致业务层频繁收到残缺帧。

3.4 支持非标准波特率的进阶写法

串口源码最容易被新手忽略的是波特率映射表。Linux 的Bxxx常量并不是直接等于数字。比如在大部分 ARM 平台上B115200的值,和你在 x86 桌面上编出来的值并不一样。更棘手的是,很多工业设备使用 4800、14400、57600 这些还容易处理,但如果碰到 921600 或 250000、500000,你根本找不到对应的B宏。

处理方式有两种。一种是按整数波特率 switch 到对应宏:

static speed_t get_termios_speed(int baudrate) { switch (baudrate) { case 300: return B300; case 1200: return B1200; case 2400: return B2400; case 4800: return B4800; 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; default: return 0; } }

这段代码的问题是对非标波特率无能为力。第二次踩这个坑后,我开始用termios2结构体。Linux 内核支持BOTHER作为扩展波特率标志,配合TCGETS2/TCSETS2直接传入整数波特率。Android 的系统头文件里不一定到处公开这个结构,需要你在源码里自己定义:

// 自定义 termios2,用于非标波特率 struct termios2 { tcflag_t c_iflag; tcflag_t c_oflag; tcflag_t c_cflag; tcflag_t c_lflag; cc_t c_line; cc_t c_cc[19]; speed_t c_ispeed; speed_t c_ospeed; }; int set_baudrate_with_terms2(int fd, int baudrate) { struct termios2 t2; if (ioctl(fd, TCGETS2, &t2) < 0) { return -1; } t2.c_cflag &= ~CBAUD; t2.c_cflag |= BOTHER; t2.c_ispeed = baudrate; t2.c_ospeed = baudrate; if (ioctl(fd, TCSETS2, &t2) < 0) { return -1; } return 0; }

使用termios2+BOTHER后,波特率就可以是任意整数,只要对方设备支持。不过需要注意,这种方式对系统 Linux 内核版本有依赖,并非所有定制 Android 系统都带 TCSETS2 ioctl。在量产项目里,我在拿到一台陌生设备后,会先用stty -F /dev/ttyUSB0 115200在 adb shell 里验证内核是否支持,再决定源码里要不要提供这条路。

4. 把源码落地成 App:Android Studio + CMake 从 0 到 1

4.1 CMakeLists.txt 与 build.gradle 的最小配置

现在的 Android Studio 中,NDK 的默认构建系统是 CMake,老式的 Android.mk 已经逐渐被替代。串口链接工具源码并不需要复杂的构建配置,把 C 文件编译成一个共享库就行。下面这份 CMakeLists 是比较精简的版本:

# CMakeLists.txt cmake_minimum_required(VERSION 3.22.1) project(serial_native) add_library(serial_native SHARED serial_port.c serial_port.h) # Android 日志库,用于在 C 层输出 error find_library(log-lib log) target_link_libraries(serial_native ${log-lib})

工程里只有一个 C 源文件和一个头文件,不需要额外链接第三方库。如果你的源码还需要使用android/log.h,那务必要把log-lib链接进来,否则 C 层的__android_log_print调用在编译时会报 undefined reference。

build.gradle的配置也不难:

android { compileSdk 34 defaultConfig { minSdk 23 targetSdk 34 ndk { abiFilters "armeabi-v7a", "arm64-v8a" } externalNativeBuild { cmake { cppFlags "" arguments "-DANDROID_STL=c++_static" } } } externalNativeBuild { cmake { path "src/main/cpp/CMakeLists.txt" version "3.22.1" } } }

abiFilters 我一般只留两个主流架构:armeabi-v7a和arm64-v8a。如果你只做新的 ARM64 设备,甚至只保留arm64-v8a。加 x86_64 主要是为了在模拟器上调试,但模拟器通常没有真实串口设备,所以意义不大。需要注意的是,不同 NDK 版本对 CMake 最低版本的要求不同,如果 CMake 版本太老,serial_port.c里的termios2结构体定义可能和系统头文件冲突;遇到这种情况,优先升级 NDK 到较新版本,而非死守旧工程配置。

4.2 Java 侧封装:SerialPort 类与串口管理器

Java 层是整个链接工具源码的门面。一个设计合理的SerialPort类应当只负责 native 方法绑定和基础读写,真正的缓存、线程和事件分发交给上层管理器。下面是最基础的原生方法声明:

// SerialPort.java public class SerialPort { static { System.loadLibrary("serial_native"); } public native int open(String devicePath, int baudrate); public native void close(); public native int write(byte[] data, int length); public native byte[] read(int maxLen); }

注意open返回的是 int 型句柄,不是FileDescriptor。这样做的好处是 native 层能用独立的serial_fd保存连接状态,Java 层不需要持有文件描述符对象,也就少了一个被 GC 回收导致连接被释放的隐患。但代价是,如果 native 层忘记 close,进程不退出,这个 fd 就一直被占着。所以一定要在应用退出或设备断开时显式调用 close。

串口管理器负责保证同一时间只有一个读线程在运行。如果你的 App 界面里同时有多个功能模块都在调read(),就会触发多个线程去竞争同一个 fd。比如协议解析线程和 UI 刷新线程同时读串口,数据会被莫名其妙地“瓜分”,而且你很难复现。因此管理器里最好提供一个注册监听器的机制:

// SerialManager.java 片段 public class SerialManager { private SerialPort serialPort; private SerialReadThread readThread; public void open(String path, int baudrate) { serialPort = new SerialPort(); int fd = serialPort.open(path, baudrate); if (fd < 0) { throw new RuntimeException("open serial failed"); } readThread = new SerialReadThread(serialPort); readThread.setOnDataReceivedListener(data -> { // 投递到业务层,例如 EventBus 或 Handler }); readThread.start(); } public void release() { if (readThread != null) { readThread.close(); readThread = null; } if (serialPort != null) { serialPort.close(); serialPort = null; } } }

这里有一个血泪教训:SerialReadThread.close()里别急着先调用serialPort.close(),应当先把running=false,然后等一行循环结束,或者直接让close()方法先设置running=false,再在流的尾部调用serialPort.close()。因为如果 read 阻塞在 native 层,一旦先 close 了 fd,阻塞的 read 可能返回 0 或直接抛异常,你的线程就会异常退出,还没把缓冲里的数据消费完。正确的退出方式是先置标志位,再 close fd,让阻塞的读立刻返回 -1,线程自然结束。

4.3 最简单的一条 Demo:打开 /dev/ttyUSB0,发 AT,读回显

落到真机时,你需要先确认设备路径。插入一个典型 CP2102 USB 转串口后,设备节点通常是/dev/ttyUSB0;树莓派和某些开发板则使用/dev/ttyS0或/dev/ttyAMA0。在 adb shell 里执行ls -l /dev/ttyUSB* /dev/ttyS* /dev/ttyACM*就能看到所有可能的路径。

Demo 代码可以长这样:

// DemoActivity 中仅演示最小链路 SerialManager manager = new SerialManager(); try { manager.open("/dev/ttyUSB0", 115200); manager.send("AT\r\n".getBytes("UTF-8")); // 停顿一小段时间等待设备响应,真正工程中应使用回调 Thread.sleep(100); } catch (Exception e) { Log.e("SerialDemo", "连接失败", e); }

send 方法内部调用 native write。写入之后并不能立刻读,因为串口是异步传输,设备端处理 AT 指令也需要时间。如果你执行完 write 马上 read,大概率返回 null。这也是为什么源码管理器需要把读线程常驻,而不是写一次读一次。真机验证时,最好把设备端的 TX、RX 短接,先做回环测试,排除外部接线干扰,后面第 6 章会展开讲这个技巧。

当这段 Demo 跑通后,你就完成了从源码到 APK 的全部闭环。可实际开发中永远没有这么顺利,最常见的几个坑都集中在权限、波特率和线程阻塞上,

5. 串口源码的常见坑与排查手册(避坑)

5.1 打不开设备:报错 “Permission denied”

现象:调用open()返回 -1,Logcat 里看到open failed, errno=13 (Permission denied)。

原因:普通 App 没有访问/dev/ttyS*、/dev/ttyMTK*这类节点的权限。系统只允许 root、system 用户或拥有inet/uart相关组权限的进程访问。链接工具源码即使正确调用 open,内核依然会拒绝。

解决办法有三个层级。开发调试阶段,可以直接在代码里执行Runtime.getRuntime().exec("su -c chmod 666 " + devicePath);如果你的设备已经 root,会弹授权窗;没有 root 但设备是工程机,也可以用 adb 从电脑端下发命令。第二级是修改 udev 规则或 Android 的 ueventd.rc,让特定设备节点自动赋予应用可读写权限,这属于系统定制,不适合普通 App。第三级是改用 USB Host 方案,避免直接碰/dev/ttyUSB0,走 UsbManager 授权。

我强烈建议不要在正式 App 里用su命令。某开发者把 su 逻辑写在循环 retry 里,结果线上设备没 root 就疯狂执行,引发卡顿和 OOM。权限问题应该在安装或首次连接时做一次检测,失败就提示用户授权,不在 open 里反复尝试。

5.2 乱码:波特率、帧格式、硬件流控三个参数排查

现象:设备能打开,收发正常,但收到的字节不规律,有时能看出一两个字符,大部分是乱码。

原因:波特率不匹配是首位。发送方 9600,接收方设置 115200,数据必然乱。第二个原因是数据位、校验位、停止位不对,常见的是对方设备是 7E1 或 8N2,源码却写死 8N1。第三个原因是硬件流控,如果对方启用了 RTS/CTS,而源码没有打开CRTSCTS,对方发送到一半会暂停,接收端则在无数据时收到一堆 0xFF。

排查时不要靠猜。先用串口调试助手或tty工具确认对方设备的实际参数。如果手里有示波器,可以直接看 TX 引脚波形计波特率,这是最硬核的验证方式。当波特率确定后,再去调源码里的cfsetispeed。最后检查硬件流控连线,如果没有接 CTS/RTS 线,就一定要在 termios 里关闭CRTSCTS。

// 关闭硬件流控,避免未接 CTS/RTS 时卡住 options.c_cflag &= ~CRTSCTS;

注意这行代码:很多源码模板里默认不带它。如果你的串口线只接了 TX、RX、GND,开流控就是给自己挖坑。

5.3 读数据卡死:ANR 与线程退出

现象:App 启动后连接串口,界面没反应,几秒后弹 ANR;或整个线程无声无息地消失,之后无论怎么 write 都没响应。

原因:第一种情况是 read 在 UI 线程执行且 fd 没有设置非阻塞,数据迟迟不来,UI 线程被卡死。第二种情况是读线程在 Native 层 read 返回时报错,Java 层又没有 catch,导致线程中断。

解决:所有读取必须放进独立线程;native 层 open 时加O_NONBLOCK;read 外层包 poll 并设置超时;Java 线程循环使用volatile boolean running控制退出。如果你用的是阻塞 read,那 close fd 是让阻塞 read 返回的可靠手段,必须保证 close 一定会被调用到,否则线程永远挂在那里。

现象 → 读线程没有任何 log,write 也不报错 原因 → read 死在 native 层的阻塞等待中 解决 → 线程退出前调用 serialPort.close(),使 fd 失效

5.4 数据总丢最后一个字节 / 粘包现象

现象:调用read(1024),理论上一次能读一大包,但拿到的数据要么少一个字节,要么把上一帧的尾巴和下一帧的头合在一起。

原因:丢最后一个字节通常和硬件寄存器或 FIFO 配置有关。部分 USB 转串口芯片默认 FIFO 阈值是 14 字节,如果数据没达到阈值且后续没有数据触发,最后一个字节会滞留在芯片 FIFO 里。粘包则是串口本身没有消息边界,你一次性读到的是内核缓冲区的所有累积数据,如果发送方分了两次写但时间间隔极短,接收方 read 一次就全部读走了。

解决丢最后一个字节,可以在 write 后fsync或等待tcdrain(fd),把内核缓冲区里的数据全部排空。粘包问题应该在协议层解决,给每帧加头、长度、校验、尾,不要在 read 函数里较劲。链接工具源码只要保证“读到什么就原样返回什么”,哪怕一次 read 返回了 200 字节,也说明它忠实稳定。

// 等待数据全部从缓冲区输出后再关闭或切换模式 tcdrain(serial_fd);

这段调用放在自动化测试脚本里很有用,能避免数据还在飞线上的时候就关串口。

5.5 App 升级或 build-tools 变化后 JNI 崩溃

现象:项目代码没动,只是从旧 NDK 升级到新 NDK,或 Android Studio 自动更新 build-tools 后,运行到 JNI 调用时SIGSEGV崩溃。

原因:常见原因是 JNI 方法签名不一致。如果 native 函数声明和 Java 方法签名匹配不上,JVM 在调用时会找不到入口或误入错误函数,严重时直接崩溃。旧版 NDK 对某些未定义的termios结构体处理得更宽松,升级后编译器优化路径改变,暴露了源码里的未定义行为。

解决:推荐使用静态注册并保持方法名与 Java 包名路径完全一致。如果使用动态注册RegisterNatives,一定要反复核对签名表里的(Ljava/lang/String;I)I之类描述符。另外,C 代码中读jbyteArray时,必须用GetByteArrayElements拿到底层指针,用完立刻ReleaseByteArrayElements,不要直接对jbyteArray指针做强制转换。

// 错误的做法:直接把 jbyteArray 当 char* 用 char *data = (char *)jdata; // 这是错的,jdata 不是可直接访问的内存 // 正确的做法 jboolean isCopy; jbyte *byteArray = (*env)->GetByteArrayElements(env, jdata, &isCopy); if (byteArray == NULL) return -1; memcpy(buf, byteArray, len); (*env)->ReleaseByteArrayElements(env, jdata, byteArray, JNI_ABORT);

升级 NDK 后如果还能保持稳定,源码才值得信任。我的习惯是在每次升级依赖后,跑一遍回环测试,把收发数据对比打印出来,崩溃和乱码问题会很快暴露。

6. 进阶:用回环测试验证源码,然后把串口通信变成协议层问题

要把一份串口链接工具源码真正吃透,第一步是回环测试。准备一根杜邦线或直接短接 USB 转串口模块的 TX 和 RX 引脚,然后用你的 App 循环发送一组递增字节,再把读到的数据和发送数据做逐字节对比。如果字符完全一致,说明 open、termios、JNI 读写链路都没问题;如果不一致,优先检查波特率和帧格式,再查源码里有没有多余的数据转换。

下面是一段极简的回环测试伪代码,可以放在 JUnit 或普通线程里执行:

byte[] sent = new byte[64]; byte[] recv = new byte[64]; for (int i = 0; i < sent.length; i++) { sent[i] = (byte) (0x55 + i); } serialPort.write(sent, sent.length); Thread.sleep(50); serialPort.read(recv, recv.length); boolean ok = Arrays.equals(sent, recv);

这里会踩到一个细节:由于内核缓冲区和 USB 控制器调度,发送之后不能立刻读,需要给设备一点时间完成回环。我在第一次跑这类测试时,用了 0ms 延迟,结果几十个字节里总有四五个对不上,后来把 sleep 加到 50ms,测试就稳定通过了。

通过回环测试后,你要开始有意地制造异常场景:降低波特率、改变数据位、拔插串口线,观察源码是否能优雅报错而不是 crash。等源码的容错能力过关,后续工作就进入协议层。串口没有天然消息边界,所以连接工具源码只负责“可靠无损伤地搬运字节”,真正稳定业务逻辑需要自己定义帧头、帧长、CRC 校验和超时重发机制。这也是为什么“链接工具”和“通信协议”永远是两件事——前者保证字节不丢,后者保证数据有意义。

我现在遇到串口相关的业务需求,不会再急着写 write/read,而是先在硬件上做一次回环测试把基线打稳,然后用自定义帧协议把所有可能出现的半包、粘包问题统一吸纳。回环测试通过,再调协议;协议通过,再考虑界面和状态机。这是一套能少走很多弯路的习惯。串口开发没有黑匣子,所有问题最后都能定位到权限、参数、线程或协议这四类原因之一。希望这篇拆解能帮你把串口链接工具源码用得更明白,也让下一次硬件联调不再靠玄学。

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

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

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

立即咨询