简介:面向需要在安卓设备上连接硬件、完成串口收发的开发者,这份资料以安卓开发工程形式展示SerialPort库的集成与调用,适用于物联网终端、自动化控制、嵌入式调试和课堂实验等场景,对初中级开发者较友好,也可作为项目原型参考。压缩包共851个文件、约19.59MB,含Java源码、依赖库、so库和C原生实现,并配有xml布局、图片、json配置及可直接安装测试的apk,较完整地保留了一个可运行的串口通讯项目。已有3452人学习下载。读者可了解串口打开、波特率与数据位等参数配置、数据读写、接收监听、异常处理及资源释放等环节,还能观察原生层与上层的配合方式,并掌握工程组织结构与模块划分,借助现成工程对照调试,减少环境搭建、依赖配置和故障排查时间。
1. 工业手持机上的串口通讯:Android Studio 里这套工程能省掉哪些重复劳动
做 Android 串口通讯,第一次卡住的地方往往不是代码,而是设备上压根没有/dev/ttyS0这个节点。有人对着教程把权限代码抄了一遍又一遍,最后发现手上那台机器只有 USB 口,得走 USB Host 加转换芯片的路子。这份 Android Studio 工程把两条路线都放进去了:JNI 直接打开系统串口节点,以及基于 USB Host 的免 root 方案,配套读写线程、帧拼接和 CRC 校验示例。适合做工业手持机、自助终端、电子秤对接、门禁控制器的人,也适合刚开始接触 JNI 和 USB 权限模型、想找一个能跑通的骨架的人。下面按选型、参数、代码、避坑的顺序拆开讲。
2. 两条技术路线怎么选:/dev/ttyS* 节点与 USB Host 的边界
串口通讯在 Android 上不是一个 API 能通吃的事情。系统本身没有对外暴露open("/dev/ttyS0")的 Java 接口,只有两种可行路径:一种是自己写 JNI 去操作 Linux 设备节点,另一种是走 USB Host,让转换芯片在 Android 侧枚举成一个 USB 设备,再用用户态驱动跟它通信。这两条路的前提条件、适配成本、出错形态完全不同,选错了后面全是返工。
判断顺序我一般是这样的:先拿设备连电脑,跑adb shell ls -l /dev/tty*,如果有节点且当前应用有权限读写,优先原生节点;如果只有 USB 口,或者节点存在但权限拿不到,就走 USB Host。别反过来,先写 USB 方案再发现板子自带四路串口,那是白忙。
2.1 原生节点方案:设备上得先有 /dev/ttyS*
原生节点方案的本质是:内核里的串口驱动把 UART 控制器注册成 tty 设备,用户态通过open、read、write、ioctl操作它。Android 沿用 Linux 的这一套,只是 SELinux 和权限模型把口子收紧了。常见的节点命名有/dev/ttyS0~/dev/ttyS3(通用 8250 驱动)、/dev/ttyHSL0(部分高通平台)、/dev/ttyMSM0(老高通平台)、/dev/ttyUSB0(USB 转串口在 Linux 侧的命名,注意这个和 USB Host 方案不是一回事)。
先用几条命令把底摸清楚,不要凭猜测写节点路径:
# 列出所有串口节点及其权限位 adb shell ls -l /dev/ttyS* /dev/ttyHSL* /dev/ttyMSM* 2>/dev/null # 看内核启动日志里串口控制器的注册情况 adb shell dmesg | grep -iE "ttyS|ttyHSL|serial8250" # 确认当前 SELinux 状态,Enforcing 下多半还要处理策略 adb shell getenforce # 看有没有被 SELinux 拒掉的记录 adb shell dmesg | grep -i avc第一条命令输出里重点看两列:设备号(比如4, 64)和权限位(比如crw-rw----)。权限位如果不是crw-rw-rw-,普通应用进程就打不开,open会直接返回EACCES。第二、三条命令用来确认驱动确实加载了,有些定制板子串口控制器是编译进内核的,有些是模块,日志里能看到ttyS0 at MMIO这类信息。第四条命令是排查Permission denied时最有用的一条,avc: denied后面会写明哪个域访问哪个节点被拒,凭这一行就能判断是补 SELinux 策略还是改文件权限。
这里有个前提必须说清楚:原生节点方案在多数量产设备上需要 root,或者应用要被签成系统应用、放进/system/priv-app,再由厂商在init.rc或ueventd.rc里把节点权限改成0666。如果是自己做的定制主板,这一步在系统镜像阶段就该做掉,别等到应用层再想办法。我在项目里见过用Runtime.exec("su")去chmod 666的写法,能用,但依赖设备已 root,商用出货基本不成立,只能当调试手段。
| 节点名 | 典型平台 | 备注 |
|---|---|---|
/dev/ttyS0~/dev/ttyS3 | 通用 8250 驱动 | 最常见,数量取决于板子引出几路 |
/dev/ttyHSL0 | 部分高通平台 | 调试串口常占用这一路,慎用 |
/dev/ttyMSM0 | 较老的高通平台 | 新平台已少见 |
/dev/ttyUSB0 | USB 转串口在 Linux 侧 | 与 USB Host 方案要区分开 |
2.2 USB Host 转换芯片方案:免 root 的通用做法
USB Host 方案走的是另一条链路:Android 设备作为 USB 主机,给外挂的 USB 转 TTL 模块供电并枚举它,模块内部的芯片(比如 CH34x、CP21xx、PL2303、FTDI 这几类)被识别成一个 USB 设备,应用通过UsbManager拿到UsbDevice,再通过批量端点收发字节。整条链路上没有 tty 节点,也不需要 root,代价是要占用一个 USB 口,而且需要处理权限弹窗和热插拔。
Android 从 3.1 开始提供 USB Host API,核心类是UsbManager、UsbDevice、UsbDeviceConnection、UsbEndpoint。裸写这套 API 的话,每家芯片的初始化序列、波特率设置命令、流控寄存器都不一样,工作量很大。实际项目里一般用usb-serial-for-android这类开源库,它把常见芯片的驱动都实现了,对外暴露UsbSerialDriver和UsbSerialPort两个抽象,切芯片只要换驱动表,业务代码不用动。
它的工作方式是:UsbSerialProber遍历当前挂载的 USB 设备,按内置的 VID/PID 表匹配出对应的驱动类,实例化UsbSerialDriver;driver.getPorts()拿到端口列表(多数单路模块只有一个端口,多路模块如 FT4232 有四个);port.open(connection)打开后,port.setParameters(baudRate, dataBits, stopBits, parity)把帧格式写进芯片寄存器;数据读写通过port.read()/port.write(),库内部再转成 USB 批量传输。
需要提前确认两件事:设备内核是否编译了 USB Host 支持(android.hardware.usb.host特性),以及硬件上是否有 OTG 或标准 USB-A 口。前者在AndroidManifest.xml里声明<uses-feature>之后,安装到不支持 host 的设备上会被拒装;后者只能看板子规格书或者直接插上试。
| 方案 | 前提条件 | 适配成本 | 典型故障 |
|---|---|---|---|
| 原生 tty 节点 | 节点存在 + 权限可写(root 或系统签名) | 低,一次打通长期可用 | EACCES、SELinux 拒绝、节点号随固件变化 |
| USB Host | 硬件支持 host + 转换模块 | 中,要处理权限和热插拔 | 权限弹窗、芯片不识别、拔插后 fd 泄漏 |
2.3 工程结构与依赖配置
这份工程按两条路线拆成两个包,公共的分帧和校验放在protocol包里,互不干扰。目录结构大致如下:
app/src/main/ ├── java/com/example/serialdemo/ │ ├── nativeport/ │ │ ├── SerialPort.java # JNI 声明与流封装 │ │ └── SerialManager.java # 读线程、写方法、生命周期 │ ├── usbport/ │ │ └── UsbSerialManager.java # 权限、热插拔、芯片枚举 │ └── protocol/ │ ├── FrameDecoder.java # 粘包/半包处理 │ └── Crc16.java # Modbus CRC16 ├── cpp/ │ ├── CMakeLists.txt │ └── SerialPort.c └── AndroidManifest.xmlapp/build.gradle里跟串口直接相关的配置有三块,分别是 NDK 架构过滤、CMake 路径和 USB Host 声明:
android { compileSdk 34 defaultConfig { minSdk 21 targetSdk 34 ndk { // 只保留实际出货会遇到的架构,减少包体 abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64' } externalNativeBuild { cmake { // 不引 C++ 标准库,纯 C 编译,包更小 cppFlags '' } } } externalNativeBuild { cmake { path 'src/main/cpp/CMakeLists.txt' version '3.22.1' } } } dependencies { // 版本号按 JitPack 上最新 release 填,不要照抄旧版本 implementation 'com.github.mik3y:usb-serial-for-android:<latest>' }abiFilters这一项不要图省事全开。x86只在模拟器上有意义,真机几乎不会遇到,留着只是让 APK 变大;arm64-v8a现在是主力,armeabi-v7a留给老设备。如果只做自有设备,甚至可以只留一个架构。minSdk 21是 USB Host API 稳定可用的下限,再往下兼容成本陡增,没有必要。
CMake 配置保持最小:
cmake_minimum_required(VERSION 3.22.1) project(serialport C) add_library(serial_port SHARED SerialPort.c) find_library(log-lib log) target_link_libraries(serial_port ${log-lib})project(serialport C)里的C很关键,不加的话 CMake 默认按 C++ 处理,纯 C 源文件会报一堆链接错误。find_library(log-lib log)是为了在 native 层打日志,排查open失败时靠它输出errno,比在 Java 层瞎猜有效率得多。
AndroidManifest.xml里补上 USB Host 特性声明,不支持 host 的设备会被应用市场过滤掉,避免装上去才发现用不了:
<uses-feature android:name="android.hardware.usb.host" android:required="true" />如果同时要兼容没有 USB Host 的机型,把required改成false,在代码里用PackageManager.hasSystemFeature(PackageManager.FEATURE_USB_HOST)判断后再决定是否启用 USB 分支。
3. 打开串口之前:termios 参数、JNI 封装和读写线程怎么配合
串口通讯里最容易被忽略的是参数匹配。波特率、数据位、停止位、校验位这四项,只要有一项和对端不一致,收到的就是稳定但无意义的乱码,看起来像驱动坏了,实际是帧格式没对上。这一章把 native 层的 termios 配置、Java 层的封装、读写线程的配合一次讲透,代码可以直接拿去改包名用。
3.1 用 termios 把波特率和帧格式钉死
Linux 侧配置串口靠struct termios,几个关键字段:c_cflag管波特率、数据位、停止位、校验位和流控,c_cc[VMIN]和c_cc[VTIME]管读行为。cfmakeraw()会把终端切成原始模式,关掉回显、行缓冲、信号字符处理,串口通讯必须走这一步,否则收到的0x0D会被当成回车处理掉。
#include <jni.h> #include <termios.h> #include <unistd.h> #include <fcntl.h> #include <errno.h> #include <android/log.h> #define LOG_TAG "SerialPort" #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) static speed_t to_speed(int baud) { switch (baud) { 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; #ifdef B460800 case 460800: return B460800; #endif #ifdef B921600 case 921600: return B921600; #endif default: return B115200; } } JNIEXPORT jobject JNICALL Java_com_example_serialdemo_nativeport_SerialPort_open( JNIEnv *env, jclass clazz, jstring path, jint baudrate, jint dataBits, jint stopBits, jint parity) { const char *dev = (*env)->GetStringUTFChars(env, path, NULL); // O_NOCTTY:不让这个口成为进程的控制终端 int fd = open(dev, O_RDWR | O_NOCTTY); (*env)->ReleaseStringUTFChars(env, path, dev); if (fd < 0) { LOGE("open failed, errno=%d", errno); return NULL; } struct termios tio; if (tcgetattr(fd, &tio) != 0) { LOGE("tcgetattr failed, errno=%d", errno); close(fd); return NULL; } cfmakeraw(&tio); // 原始模式 cfsetispeed(&tio, to_speed(baudrate)); cfsetospeed(&tio, to_speed(baudrate)); tio.c_cflag |= (CLOCAL | CREAD); // 忽略调制解调器线,允许接收 tio.c_cflag &= ~CRTSCTS; // 关硬件流控 tio.c_cflag &= ~CSIZE; switch (dataBits) { case 5: tio.c_cflag |= CS5; break; case 6: tio.c_cflag |= CS6; break; case 7: tio.c_cflag |= CS7; break; default: tio.c_cflag |= CS8; break; } if (stopBits == 2) tio.c_cflag |= CSTOPB; else tio.c_cflag &= ~CSTOPB; tio.c_cflag &= ~(PARENB | PARODD); if (parity == 1) tio.c_cflag |= PARENB; // 偶校验 else if (parity == 2) tio.c_cflag |= (PARENB | PARODD); // 奇校验 tio.c_cc[VMIN] = 0; tio.c_cc[VTIME] = 10; // 读超时 1 秒,单位 100ms if (tcsetattr(fd, TCSANOW, &tio) != 0) { LOGE("tcsetattr failed, errno=%d", errno); close(fd); return NULL; } tcflush(fd, TCIOFLUSH); // 丢掉上电残留数据 // 把 fd 塞进 FileDescriptor,交给 Java 流复用 jclass cFd = (*env)->FindClass(env, "java/io/FileDescriptor"); jmethodID ctor = (*env)->GetMethodID(env, cFd, "<init>", "()V"); jfieldID fid = (*env)->GetFieldID(env, cFd, "descriptor", "I"); jobject fdObj = (*env)->NewObject(env, cFd, ctor); (*env)->SetIntField(env, fdObj, fid, fd); return fdObj; }几个参数的含义和取值规则:
| 参数 | 常用值 | 说明 |
|---|---|---|
baudrate | 9600 / 115200 | 必须和对端一致,PLC 多用 9600,模组多用 115200 |
dataBits | 8 | 8 位数据位是绝大多数协议的默认 |
stopBits | 1 | 1 位停止位,仪表偶有 2 位 |
parity | 0 | 0 无校验 / 1 偶校验 / 2 奇校验 |
VMIN | 0 | 读操作不强制等够字节数 |
VTIME | 10 | 最多等 1 秒,配合 VMIN=0 形成超时读 |
VMIN=0 + VTIME=10的组合是我一般会用的:读线程调用read()后最多阻塞 1 秒,有数据就返回,没数据返回 0,这样线程本身不会被永久卡死,stop()的时候也不用指望底层能立刻唤醒。如果对端数据来得很碎,把VTIME调小到 2~5,让读线程更频繁地回到 Java 层做缓冲。
to_speed()里#indef B460800这层判断不能省,不同内核版本暴露的波特率常量不完全一致,直接写case 460800在部分低版本 NDK 上编译不过。
3.2 Java 层的 SerialPort 封装与读写线程
Java 侧只做三件事:加载 so、持有一对输入输出流、把close做干净。构造时先把权限判掉,别让 native 层去撞errno,堆栈信息不直观。
package com.example.serialdemo.nativeport; import java.io.File; import java.io.FileDescriptor; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; public class SerialPort { static { System.loadLibrary("serial_port"); } private FileDescriptor fd; private FileInputStream input; private FileOutputStream output; public SerialPort(String path, int baudRate, int dataBits, int stopBits, int parity) throws IOException { File dev = new File(path); if (!dev.exists() || !dev.canRead() || !dev.canWrite()) { throw new IOException("节点不存在或当前进程没有读写权限: " + path); } fd = open(path, baudRate, dataBits, stopBits, parity); if (fd == null) { throw new IOException("native open 返回空,查 logcat 里的 errno"); } input = new FileInputStream(fd); output = new FileOutputStream(fd); } public FileInputStream getInput() { return input; } public FileOutputStream getOutput() { return output; } public void close() { // 先关流,底层 read 会抛 IOException 跳出阻塞 try { if (input != null) input.close(); } catch (IOException ignored) { } try { if (output != null) output.close(); } catch (IOException ignored) { } input = null; output = null; } private static native FileDescriptor open(String path, int baudRate, int dataBits, int stopBits, int parity); }读线程单独封装在SerialManager里,构造函数接收一个回调接口,业务层只关心「收到一段字节」和「出错了」两个事件:
package com.example.serialdemo.nativeport; import java.io.IOException; public class SerialManager { public interface SerialListener { void onData(byte[] chunk); void onError(IOException e); } private final SerialListener listener; private volatile boolean running; private Thread readThread; private SerialPort port; public SerialManager(SerialListener listener) { this.listener = listener; } public void start(SerialPort port) { this.port = port; this.running = true; this.readThread = new Thread(() -> { byte[] buf = new byte[512]; while (running) { try { int n = port.getInput().read(buf); if (n > 0) { byte[] data = new byte[n]; System.arraycopy(buf, 0, data, 0, n); listener.onData(data); // 回调仍在读线程,业务侧别做重活 } else if (n < 0) { break; // 流被关闭 } } catch (IOException e) { if (running) listener.onError(e); break; } } }, "serial-read"); readThread.start(); } public void send(byte[] frame) throws IOException { if (port == null) throw new IOException("串口未打开"); port.getOutput().write(frame); port.getOutput().flush(); } public void stop() { running = false; if (port != null) port.close(); if (readThread != null) readThread.interrupt(); port = null; } }buf大小 512 是经验值:太小会切成很多段,太大在一次read返回的数据里混进多帧的概率变高,但反正后面有分帧层兜底,影响不大。listener.onData()是在读线程上回调的,业务侧如果要做 Modbus 解析、更新 UI,必须自己切到工作线程或主线程,别在这个回调里直接改 View。
send()里flush()不能省,FileOutputStream包FileDescriptor时缓冲区行为不一定可靠,主动 flush 一次能避免「写了但对方没收到」的玄学问题。
3.3 关闭、超时与异常退出
close()的顺序是有讲究的:先关FileInputStream,让阻塞在read()上的线程抛IOException或者返回 -1 退出循环,再关FileOutputStream。如果反过来先关输出流,输入流上的read()可能还会阻塞一会儿,线程退不干净,下次打开同一个节点会出现数据错乱。
stop()里同时置了running=false和interrupt(),但interrupt()对阻塞在read()上的线程是无效的,真正让它退出的是port.close()。这一点在写代码时容易误解,以为interrupt()能救命,实际上 native 的read不响应 Java 中断。
还有一种情况要处理:read()返回 -1。原生流关闭时会返回 -1,如果上层没判断,System.arraycopy会因为n=-1抛IndexOutOfBoundsException,日志里看起来像是一个莫名其妙的数组越界,实际是关闭时序问题。上面的代码里else if (n < 0) break;就是专门拦这个的。
4. USB 转串口接入:权限、热插拔与芯片识别的实操路径
USB Host 方案的坑集中在三处:权限弹窗、热插拔、芯片识别。这三件事只要有一件没处理好,表现都是「代码看起来没问题,但就是收不到数据」。这一章按接入顺序拆开讲,代码基于usb-serial-for-android的抽象,裸写UsbDeviceConnection的话逻辑差不多,只是多了每家芯片的初始化命令。
4.1 权限申请:requestPermission 和那个必须可变的 PendingIntent
Android 对 USB 设备的访问是默认拒绝的,应用必须先拿到用户授权。流程是:UsbManager.requestPermission(device, pendingIntent)弹出系统对话框,用户点允许后,系统给这个 PendingIntent 对应的广播接收器发一条带EXTRA_PERMISSION_GRANTED的广播,应用收到后才能真正openDevice()。
public class UsbSerialManager { private static final String ACTION_USB_PERMISSION = "com.example.serialdemo.action.USB_PERMISSION"; private final Context ctx; private final UsbManager usbManager; private UsbSerialPort port; private SerialInputOutputManager ioManager; private final BroadcastReceiver receiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); if (ACTION_USB_PERMISSION.equals(action)) { UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); boolean granted = intent.getBooleanExtra( UsbManager.EXTRA_PERMISSION_GRANTED, false); if (granted && device != null) { openDevice(device); } else { Log.w("UsbSerial", "用户拒绝了 USB 权限"); } } else if (UsbManager.ACTION_USB_DEVICE_DETACHED.equals(action)) { close(); // 拔掉必须主动关,不然 fd 泄漏 } else if (UsbManager.ACTION_USB_DEVICE_ATTACHED.equals(action)) { requestPermission(); // 下次上电自动申请 } } }; public UsbSerialManager(Context ctx) { this.ctx = ctx.getApplicationContext(); this.usbManager = (UsbManager) ctx.getSystemService(Context.USB_SERVICE); } public void register() { IntentFilter filter = new IntentFilter(ACTION_USB_PERMISSION); filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED); filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // Android 13 起注册广播必须显式声明是否对外导出 ctx.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED <p> <a href="https://download.csdn.net/download/zw18390729321/10261359" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>