Android串口通信开发实战:JNI+NDK从权限到收发全链路解析

发布时间:2026/11/5 10:29:38
Android串口通信开发实战:JNI+NDK从权限到收发全链路解析

简介:面向Android开发者与嵌入式联调人员的串口通信工具源码,聚焦在移动端完成串口设备连接、参数配置与数据收发这一常见需求,适合具备一定Android基础、希望打通硬件交互链路的初中级开发者参考。压缩包共43个文件、约469KB,以7个java源文件为核心,辅以6个so动态库、5个xml布局与配置、3个jar依赖及jni目录下的c、h、mk等原生构建文件,另有少量图片与工程配置,覆盖从Java层调用到本地库编译的完整结构。已有1794人学习下载,具备一定参考基础。源码围绕权限声明、波特率与数据位等串口参数配置、后台线程读写、异常捕获、设备枚举与连接状态反馈等环节展开,并包含界面输入与接收展示的示例实现,读者可据此理解串口通信的分层组织方式,对照自身项目排查权限、线程阻塞与设备兼容等问题,快速搭建可运行的调试工具雏形。

1. 一块开发板插上手机,串口调试为什么总在第一步卡住

做 Android 串口通信的开发者大概都遇到过这个场景:板子已经焊好,USB 转 TTL 线也接上了,手机插上去却什么反应都没有,日志里连个设备节点都看不到。问题往往不在协议层,而在最底层——权限、设备枚举、波特率配置,任何一环没对上,后面全是空谈。这份 Android 串口链接工具源码,解决的就是这个「第一步」的问题:它把打开串口、配置参数、收发数据这条链路完整封装出来,适合做工业手持机、智能硬件调试、嵌入式设备配套 App 的开发者直接拿来改。你拿到手不用从零啃驱动层,先把链路跑通,再谈业务逻辑。

2. 项目定位与技术选型:为什么是 JNI 加 NDK 这条路

2.1 这个工具到底在做什么

从标题就能看出来,这是一个「串口链接工具」,核心职责是把 Android 设备和串口设备之间的数据通道打通。它不是一个完整的业务 App,而是一个可复用的通信底座:上层拿到的是打开、关闭、读、写这几个动作,底层替你处理 Linux 设备节点、termios 参数、文件描述符这些脏活。

Android 本身没有开放标准串口 API,系统层面对串口的支持是缺失的。所以这类项目的通用做法是走 JNI,把 C 层的串口操作编译成 so 库,Java 层通过 native 方法调用。这份源码遵循的正是这条主流路线,常见做法是用 CMake 组织 native 代码,用open()打开/dev/tty*节点,用tcsetattr配置波特率、数据位、停止位、校验位,再用read/write做数据搬运。

适合谁?一类是做智能硬件配套 App 的,设备通过串口吐数据,App 负责展示和控制;另一类是做工业手持终端、POS 机、门禁设备的,串口是标配外设通道;还有一类是调试工具开发者,需要一个稳定的串口收发面板。如果你只是想在 App 里读个传感器,蓝牙或 USB HID 可能更省事,但只要设备端只给串口,这套代码就是绕不开的基础设施。

2.2 技术栈拆解:Java 层、JNI 层、Native 层三段式

整个项目在结构上是清晰的三段式,这也是 Android 串口项目的标准分层:

层级语言职责关键文件类型
应用层Java/Kotlin对外 API、回调分发、线程管理.java/.kt
桥接层JNIJava 与 C 的类型转换、方法注册.cpp/.c
原生层C/C++设备打开、termios 配置、读写.c/.cpp

应用层负责把 native 的阻塞读写包装成回调或队列,避免主线程被卡住;桥接层负责jstring和char*的互转、异常抛出;原生层才是真正跟内核打交道的地方。这种分层的价值在于:换设备、换波特率只动配置,换业务只动上层,互不干扰。

构建侧用的是 NDK + CMake,build.gradle里配置externalNativeBuild,把CMakeLists.txt挂进去,编译产物是各 ABI 的libserial_port.so。这里有个血泪经验:ABI 一定要配全,armeabi-v7a、arm64-v8a、x86_64至少覆盖主流设备,否则在某些机型上会直接UnsatisfiedLinkError,日志里还看不出所以然。

2.3 目录结构:先认清每个文件夹的角色

拿到源码先别急着点运行,把目录捋一遍能省很多时间。典型结构如下:

serial-port-demo/ ├── app/ │ ├── src/main/java/com/example/serial/ │ │ ├── SerialPort.java # Java 层入口,native 方法声明 │ │ ├── SerialPortFinder.java # 扫描 /dev 下的串口节点 │ │ └── SerialPortUtil.java # 收发封装、回调 │ ├── src/main/cpp/ │ │ ├── SerialPort.c # 核心 native 实现 │ │ └── CMakeLists.txt # native 构建脚本 │ └── build.gradle # NDK、ABI、CMake 配置 └── settings.gradle

SerialPort.java是门面,所有 native 方法在这里声明;SerialPortFinder.java负责枚举设备节点,因为不同板子的串口名可能是ttyS0、ttyS1、ttyUSB0,不能写死;SerialPort.c是真正干活的地方。把这三者的关系理清,后面读代码就不会迷路。

3. 核心模块拆解:从打开设备到收发数据的完整链路

3.1 Java 层入口:native 方法怎么声明和加载

先看 Java 层的门面类。它的职责很单纯:声明 native 方法、加载 so 库、持有文件描述符。代码大致长这样:

public class SerialPort { private FileDescriptor mFd; private FileInputStream mFileInputStream; private FileOutputStream mFileOutputStream; // 加载 native 库,库名要和 CMakeLists 里的 target 一致 static { System.loadLibrary("serial_port"); } // 打开串口:路径、波特率、标志位 public native static FileDescriptor open(String path, int baudrate, int flags); // 关闭串口,释放 fd public native void close(); public SerialPort(File device, int baudrate, int flags) throws SecurityException, IOException { // 先做权限检查,避免 open 返回 -1 却不知道原因 if (!device.canRead() || !device.canWrite()) { try { Process su = Runtime.getRuntime().exec("/system/bin/su"); // 通过 su 改权限,仅 root 设备可用 String cmd = "chmod 666 " + device.getAbsolutePath() + "\n" + "exit\n"; su.getOutputStream().write(cmd.getBytes()); if (su.waitFor() != 0 || !device.canRead() || !device.canWrite()) { throw new SecurityException("串口设备无读写权限"); } } catch (Exception e) { throw new SecurityException("串口设备无读写权限", e); } } mFd = open(device.getAbsolutePath(), baudrate, flags); if (mFd == null) { throw new IOException("native open 返回空"); } mFileInputStream = new FileInputStream(mFd); mFileOutputStream = new FileOutputStream(mFd); } }

这段代码有几个关键点值得说。System.loadLibrary("serial_port")里的名字必须和CMakeLists.txt中add_library(serial_port ...)的 target 名完全一致,差一个字母就是UnsatisfiedLinkError。open返回的是FileDescriptor,拿到之后包成FileInputStream/FileOutputStream,这样上层就能用标准的流式 API 读写,不用自己管缓冲区。

权限那段是很多新手翻车的地方。普通 App 没有权限直接打开/dev/ttyS*,canRead()/canWrite()返回 false 时,代码尝试用su提权。注意这只是兜底,量产设备一般会通过ueventd.rc或 SELinux 策略给 App 授权,而不是依赖 root。如果你的设备没 root,这段会抛SecurityException,属于预期行为,别以为是 bug。

3.2 JNI 桥接:方法注册与类型转换

JNI 层要做两件事:把 Java 的open映射到 C 函数,把 C 的fd包装成 Java 的FileDescriptor。方法注册有两种方式,静态注册靠命名约定,动态注册靠JNINativeMethod数组。这份源码常见做法是静态注册,函数名形如Java_com_example_serial_SerialPort_open。

#include <jni.h> #include <fcntl.h> #include <termios.h> #include <unistd.h> // 对应 Java 层 SerialPort.open JNIEXPORT jobject JNICALL Java_com_example_serial_SerialPort_open(JNIEnv *env, jclass clazz, jstring path, jint baudrate, jint flags) { const char *path_utf = (*env)->GetStringUTFChars(env, path, NULL); int fd = open(path_utf, O_RDWR | O_NOCTTY | flags); (*env)->ReleaseStringUTFChars(env, path, path_utf); if (fd == -1) { return NULL; // Java 层据此抛 IOException } // 配置 termios 参数 struct termios cfg; if (tcgetattr(fd, &cfg) != 0) { close(fd); return NULL; } cfmakeraw(&cfg); // 原始模式,不做行缓冲 cfsetispeed(&cfg, baudrate); // 输入波特率 cfsetospeed(&cfg, baudrate); // 输出波特率 cfg.c_cflag |= (CLOCAL | CREAD); // 忽略调制解调器控制,启用接收 cfg.c_cflag &= ~CSIZE; cfg.c_cflag |= CS8; // 8 数据位 cfg.c_cflag &= ~PARENB; // 无校验 cfg.c_cflag &= ~CSTOPB; // 1 停止位 tcsetattr(fd, TCSANOW, &cfg); // 立即生效 // 把 fd 包装成 Java 的 FileDescriptor jclass fdClass = (*env)->FindClass(env, "java/io/FileDescriptor"); jfieldID fdField = (*env)->GetFieldID(env, fdClass, "descriptor", "I"); jobject fdObj = (*env)->NewObject(env, fdClass, (*env)->GetMethodID(env, fdClass, "<init>", "()V")); (*env)->SetIntField(env, fdObj, fdField, fd); return fdObj; }

逐段解释。open的标志位里O_NOCTTY很关键,它告诉内核这个终端不要成为进程的控制终端,否则某些信号会干扰通信。cfmakeraw把终端设成原始模式,关掉回显、行缓冲、特殊字符处理,串口通信必须这样,不然你发出去的数据会被终端层篡改。

波特率这里有个坑:cfsetispeed接受的是B115200这类常量,不是数字 115200。如果 Java 层直接传 115200,需要在 C 层做一次映射,常见做法是写个switch把整数转成Bxxx常量。源码里如果没做这层转换,你传 115200 会得到乱码或配置失败,这是排查时第一个要看的地方。

最后包装FileDescriptor用的是反射式字段访问,descriptor字段是 int 类型,存的就是 fd 值。这样 Java 层拿到的FileDescriptor是「真」的,FileInputStream才能正常工作。

3.3 原生读写:阻塞模型与线程配合

打开之后就是读写。native 层的read/write是阻塞的,这意味着不能在主线程调用,否则 UI 直接卡死。源码的常见处理是把读操作放到独立线程里循环:

// 读线程:持续从串口取数据,通过回调抛给业务层 private class ReadThread extends Thread { @Override public void run() { byte[] buffer = new byte[1024]; while (!isInterrupted()) { try { int size = mFileInputStream.read(buffer); if (size > 0 && mListener != null) { byte[] data = new byte[size]; System.arraycopy(buffer, 0, data, 0, size); mListener.onDataReceived(data); // 回调到业务层 } } catch (IOException e) { break; // 串口被关闭或异常,退出循环 } } } }

mFileInputStream.read(buffer)会一直阻塞到有数据或出错,所以放在while里是合理的。缓冲区大小 1024 是经验值,工业设备单帧一般不超过这个量级,如果你的协议帧更大,调到 4096 也行。回调里把数据 copy 出来再抛,避免业务层持有复用缓冲区导致数据错乱,这个细节很多示例代码会忽略。

写操作相对简单,mFileOutputStream.write(data)即可,但要注意flush(),否则数据可能停在缓冲区没发出去。另外写操作也要放到子线程,尤其是数据量大或设备响应慢的时候。

3.4 设备节点扫描:为什么不能写死 ttyS0

不同硬件的串口节点名不一样,写死ttyS0在 A 板子上能跑,换 B 板子就找不到设备。所以源码里通常有个SerialPortFinder,遍历/dev目录匹配ttyS、ttyUSB、ttyACM等前缀:

public Vector<String> getAllDevices() { Vector<String> devices = new Vector<>(); File dev = new File("/dev"); File[] files = dev.listFiles(); if (files == null) return devices; for (File f : files) { String name = f.getName(); // 匹配常见串口节点命名 if (name.startsWith("ttyS") || name.startsWith("ttyUSB") || name.startsWith("ttyACM")) { devices.add(f.getAbsolutePath()); } } return devices; }

这个扫描逻辑简单但够用。实际项目里我一般会再加一层过滤:读/sys/class/tty/*/device/driver判断驱动类型,或者用UsbManager拿到 USB 转串口的 VID/PID 做精确匹配。但作为工具源码,前缀匹配已经能覆盖大部分场景,先跑通再优化。

4. 跑起来:编译、部署与第一次收发验证

4.1 环境准备与 Gradle 配置

跑这个项目需要 Android Studio、NDK、CMake 三件套。NDK 版本建议用 r21 以上,太老的版本对 CMake 支持不好。build.gradle里关键配置如下:

android { compileSdk 33 defaultConfig { applicationId "com.example.serial" minSdk 21 targetSdk 33 ndk { // 按需裁剪 ABI,减小包体 abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64' } externalNativeBuild { cmake { cppFlags "-std=c++11" } } } externalNativeBuild { cmake { path "src/main/cpp/CMakeLists.txt" version "3.18.1" } } }

minSdk 21是串口项目的常见下限,再低的话部分 NDK API 不可用。abiFilters里如果只留arm64-v8a,包体会小很多,但老设备装不上,调试阶段建议全留,发布前再裁。CMakeLists.txt里要确保add_library的 target 名和System.loadLibrary一致,target_link_libraries里链上log库方便打日志。

4.2 部署到设备与权限处理

编译出 APK 后装到设备上,第一次打开大概率会失败,因为权限没给。分两种情况:如果是 root 设备,代码里的su兜底能生效;如果是非 root 设备,需要系统层配合。调试阶段我一般用adb shell手动改权限验证链路:

# 查看设备节点 adb shell ls -l /dev/ttyS* # 临时放开权限,仅调试用,重启失效 adb shell su -c "chmod 666 /dev/ttyS1"

改完权限再打开 App,如果open成功,日志里能看到 fd 值。量产方案则是改ueventd.rc加chmod 0666 /dev/ttyS1,或者配 SELinux 的file_contexts,让 App 的 domain 有权限访问该节点。这一步是串口项目落地的分水岭,很多团队卡在这里以为代码有问题,其实是系统权限没配。

4.3 用回环测试验证收发链路

链路通没通,最直接的验证是回环:把串口的 TX 和 RX 短接,发什么收什么。写个简单的测试按钮,发一串字节,看回调里能不能原样收到:

// 发送测试数据 byte[] test = "HELLO".getBytes(); serialPort.getOutputStream().write(test); serialPort.getOutputStream().flush(); // 回调里打印收到的内容 mListener = data -> Log.d("SerialTest", "收到: " + new String(data));

如果发出去收不到,按这个顺序排查:先确认 TX/RX 是否真的短接,再看波特率是否一致,然后看cfmakeraw是否生效,最后看读线程是否启动。回环能通,说明从 Java 到 native 到硬件这条链路没问题,剩下的就是对接真实设备调协议。

5. 避坑与排查:串口开发最常见的五个坑

5.1 现象:open 返回 -1,日志无任何提示

原因通常是权限不足或节点路径错误。open失败时 errno 会给出具体原因,但很多示例代码直接返回 NULL 不打印 errno,导致黑匣子。解决方式是在 C 层加LOGE("open failed: %s", strerror(errno)),把 errno 打出来。如果是EACCES就是权限问题,ENOENT就是路径不存在,EBUSY说明设备被别的进程占用了。

5.2 现象:能打开但收到的全是乱码

九成是波特率不匹配。检查 Java 层传的整数有没有在 C 层正确映射成Bxxx常量,cfsetispeed和cfsetospeed是否都设了。还有一种可能是数据位/校验位/停止位和对方设备不一致,比如对方是 7 位数据位带偶校验,你按 8N1 配,收到的就是乱码。用示波器或逻辑分析仪量一下波形最直接,没有仪器就逐个参数试。

5.3 现象:读线程收不到数据,但设备确实在发

先确认读线程真的启动了,很多代码在open成功后忘了start()。其次看read的返回值,如果一直是 -1,看 errno 是不是EINTR(被信号打断),这种情况重试即可。还有一种隐蔽情况:O_NONBLOCK标志被误加,导致read立即返回 0,看起来像没数据。检查open的 flags 参数,阻塞模式不要带O_NONBLOCK。

5.4 现象:应用切后台后串口断连

Android 在内存紧张时会回收后台进程,fd 随之关闭。如果业务要求后台持续通信,需要前台服务加通知,把进程优先级提上去。另外部分定制系统有省电策略,会主动杀后台,这种情况要在系统白名单里加应用。代码层面可以在onDestroy里主动close(),避免 fd 泄漏,下次打开前先检查旧 fd 是否已释放。

5.5 现象:某些机型上 UnsatisfiedLinkError

ABI 没覆盖全,或者 so 库没打进 APK。用unzip -l app.apk | grep lib看lib/目录下有没有对应 ABI 的 so。如果只有armeabi-v7a而设备是arm64-v8a,在 64 位系统上可能加载失败。解决是把abiFilters配全,或者用abiSplit分包。还有一种情况是System.loadLibrary的名字和 so 文件名不一致,so 叫libserial_port.so,加载时写serial_port,不带lib前缀和后缀。

6. 二次开发:把工具源码改造成业务通信层

拿到这份源码,多数人不是直接用它,而是把它嵌进自己的项目。改造的核心思路是「隔离变化」:串口参数、协议解析、线程模型这三块最容易变,要把它们抽出来。

第一步,把SerialPort的构造参数收拢成一个配置对象。波特率、数据位、校验位、停止位、节点路径这些,用SerialConfig承载,避免构造函数参数膨胀。第二步,把读线程的回调改成带缓冲的帧解析器。串口数据是流式的,业务层需要的是完整帧,所以在回调里加一个ByteBuffer累积,按协议头尾或长度字段切帧,切出完整帧再抛给业务。第三步,写操作加发送队列,避免多线程同时写导致数据交错。

// 帧解析器骨架:按固定头尾切帧 public class FrameParser { private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); private static final byte HEAD = 0xAA; private static final byte TAIL = 0x55; public void feed(byte[] data) { buffer.write(data, 0, data.length); byte[] all = buffer.toByteArray(); int start = -1; for (int i = 0; i < all.length; i++) { if (all[i] == HEAD) { start = i; break; } } if (start < 0) { buffer.reset(); return; } for (int i = start + 1; i < all.length; i++) { if (all[i] == TAIL) { byte[] frame = Arrays.copyOfRange(all, start, i + 1); onFrame(frame); // 完整帧交给业务 buffer.reset(); buffer.write(all, i + 1, all.length - i - 1); // 保留残余 return; } } } protected void onFrame(byte[] frame) { /* 业务实现 */ } }

这个解析器只是骨架,真实协议可能是长度字段加 CRC,把TAIL判断换成长度读取加校验即可。关键是「残余数据要保留」,否则跨包帧会丢。验证方法很简单:构造一个分两次发送的帧,第一次发一半,第二次发另一半,看解析器能不能拼出完整帧。这个测试我每次改解析逻辑都会跑一遍,因为跨包丢帧是最难复现的 bug。

还有一个改造点是错误重连。串口线被拔、设备重启都会导致 fd 失效,业务层需要感知并重开。做法是在读线程捕获IOException后,置一个标志位,由上层定时器尝试重新open,重开成功再恢复读线程。重连要有退避,别一秒重试十次把 CPU 打满。

最后说个习惯。串口项目最怕「在我这能跑」,因为硬件差异太大。我现在的做法是每次改完 native 层,先在至少两种 ABI 的设备上各跑一遍回环测试,再对接真实设备。有一次偷懒只测了 arm64,结果客户的老设备是 32 位,so 加载失败,现场排查了半天。从那以后我每次发版前都强制走一遍多 ABI 回环,宁可多花十分钟,也不想到现场救火。希望帮到你。

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