简介:面向Android开发者的完整串口通信示例工程,特别适合需要连接串口外设、USB转串口模块、蓝牙串口或工业控制设备的应用开发者。压缩包共2000个文件,约16.69MB,内容包含可直接阅读和编译的Java源代码、Gradle与XML工程配置、JSON资源文件,以及第三方JAR包、so底层动态库;同时附带编译生成的class、dex、APK等构建产物,其中class与dex为中间编译文件,APK为可直接安装的包,覆盖开发、编译、安装各阶段,便于对照学习。目前已有605人下载学习。
项目系统展现了Android串口通信的关键链路:从基础原理入手,演示如何借助Android-SerialPort-API扫描可用串口、配置波特率/数据位/停止位/校验位,并封装writeData与readData方法实现双向数据收发,同时包含打开串口、关闭端口、权限声明、异常处理等完整流程。示例还提供简单的输入输出界面和连接状态提示,降低上手门槛。对于需要对接定制硬件的项目,可直接参考其架构并扩展成业务工具,开发者也能借此掌握串口设备的兼容性和调试技巧。并给出了串口打开失败、设备未授权等异常处理方式,便于排查问题。 搞Android硬件开发的人,十有八九都躲不过串口通信这道坎。不管是接个扫码枪、控制个电机、读个传感器数据,还是跟嵌入式设备做联调,串口永远是最直接、最稳定的通信方式之一。而SerialPort_Android串口通信demo+源代码.zip这种命名形式的资源包,在各大开源社区和技术群里出现的频率相当高——说白了一句话:这就是一份可以直接拿来跑、照着改的Android串口通信样例工程。这篇内容我会从实际使用的角度出发,把这个demo该有的东西、底层调用的逻辑、以及与具体代码对应的知识点全部拆开讲讲,保证你拿到手之后不只是会开箱跑通,更能知道每一行关键代码到底在干什么,后续遇到问题该怎么查。
串口这玩意儿说难不难,但坑也不少。难的不是“打开串口、读写数据”这个流程本身,而是你对底层不透明、权限搞不定、波特率对不上、粘包断包处理不好这些细节没底。这篇博客我会围绕这个demo涉及的核心技术点做一次相对系统的梳理,覆盖串口基础概念、Android串口开发原理、SerialPort库的典型实现、上手实操流程,以及我在实际调试中踩过的一些坑。适合刚开始接触Android串口开发、或者拿到demo但不知道从哪下手的读者参考。
1. 整体设计与核心思路拆解
1.1 这个demo到底解决什么问题
Android系统本身对串口设备是没有原生API支持的。Google从没在Android SDK里提供过标准的串口读写接口,因为绝大部分手机用户根本用不到这功能。但在工业平板、物联网网关、医疗设备、收银机、智能硬件这些场景里,Android设备往往要直接跟各种外设打交道,而串口恰恰是这些设备最通用的接口。
这就产生了一个真空地带:应用层想访问串口,但系统层没有给路。于是社区里通常会采用两种方案绕过这个限制:一种是直接用Android的NDK(Native Development Kit)编写C/C++层代码,通过JNI(Java Native Interface)封装后在Java/Kotlin层调用;另一种是在应用里执行open()系统调用打开设备节点文件,然后进行read()/write()操作。这里的SerialPort demo基本走的就是这两条路线,核心目标就是把“打开串口、设参数、读写数据、关串口”这套流程封装成上层可以直接调用的稳定接口。
那些在标题里带“demo+源代码”字样的资源包,本质上是把完整的工程源码(含底层native代码)都打包了出来,这比只丢给你一个APK要有价值得多。因为串口开发几乎不可能不二次修改:串口路径要换、波特率要调、数据位校验位要改、收发模式要分帧处理,如果没源代码,那真就是两眼一抹黑。
1.2 技术选型:为什么普遍使用android-serialport-api
目前主流方案基本都源自Google官方的那套Demo代码android-serialport-api(一个GitHub上的示例工程)。虽然已经很多年没更新了,但底层的核心逻辑依然够用。这套库的核心结构其实非常简单:
SerialPort.java:Java层接口封装,负责加载so库、绑定native方法SerialPort.c/SerialPort.h:JNI层实现,负责调用Linux系统函数打开串口Application+SerialPortFinder.java:辅助类,用于遍历设备节点、做全局串口管理
我第一次看到这套代码的时候也愣了一下:就这么点东西?后来深入看了看才发现,它该做的事情全都做了。打开串口时通过open()函数设置波特率、数据位、停止位、校验位,并且使用了tcgetattr()/tcsetattr()这套POSIX终端控制接口来配置参数,读写的部分直接暴露了文件描述符给上层,简单粗暴但极其高效。
选这套方案而不是自己从零写JNI,原因也很实际:串口配置的参数组合非常多,波特率从300到921600不等,还有各种奇偶校验、数据位组合,自己从头调termios结构体非常容易出错。SerialPort库把这些参数定义成了常量并做了映射,上层传个整数进来就行,省掉大量调试时间。而且它是C写的,编译出来体积小,兼容性也稳定。
1.3 demo文件包里通常包含哪些内容
一般来说,一个完整的SerialPort_Android串口通信demo+源代码.zip会包含这样的结构布局:
app/src/main/java/:Java或Kotlin源码,主要是Activity、SerialPort封装、串口管理类app/src/main/cpp/(或jni/):C/C++源码,包含JNI实现,核心是串口打开和参数配置app/src/main/jniLibs/:预先编译好的so库文件(libserial_port.so),覆盖armeabi-v7a、arm64-v8a等不同架构app/src/main/assets/或res/:配置文件、布局文件README.md或说明文档:介绍使用方法和板卡适配情况
拿到源码之后,第一步不要急着改代码,先把目录结构看清楚。确认so库支持的CPU架构跟你的设备是否匹配,这个很关键。现在市面上的Android设备绝大多数是arm64-v8a,但一些老旧工业平板的系统还是32位的,这时候如果只有arm64-v8a下的so库,就会出现加载不到本地库、打开串口直接崩溃的问题。
2. 串口通信核心原理与关键实现
2.1 串口基础:UART、RS232、RS485与TTL电平
写Android串口代码之前,先把串口通信的基础概念捋清楚。串口通信本质是串行的,发送方把并行数据逐个bit地按固定时序发送,接收方按同样的时序恢复数据。Android设备里所说的“串口”,绝大多数指UART(Universal Asynchronous Receiver/Transmitter)控制器外设通过TTL电平引出的调试串口或外接串口。
有一个误区需要澄清:Android串口应用直接操作的是设备节点(如/dev/ttyS0、/dev/ttyMT0、/dev/ttyHSL0),这一层通常是TTL电平。而工程上常说的RS232、RS485,那是外部物理层转换芯片(如MAX232、SP3485)做的事情。TTL电平0~3.3V、RS232是±12V差分、RS485是差分双绞线,区别很大。但到了软件这一层,它们统统表现为一个字符设备节点,操作方式完全一样。所以你在网上买一个USB转串口模块(比如CH340、CP2102),插到开发板或者工控机上,出来的节点名可能是/dev/ttyUSB0,处理逻辑跟板载串口一样。
在demo代码里,你会看到SerialPort构造函数接收一个String path参数,这个path就是设备节点路径。不同的主板平台,串口节点命名规律完全不同。市面上常见的有:
- 高通平台:
/dev/ttyHSL0、/dev/ttyMSM0 - MTK联发科平台:
/dev/ttyMT0、/dev/ttyMT1 - 全志平台:
/dev/ttyS1、/dev/ttyS2 - 瑞芯微平台:
/dev/ttyS0、/dev/ttyS3,部分USB转串口是/dev/ttyUSB0 - 展锐平台:
/dev/ttyS0、/dev/ttyS1
这就牵扯出一个重要技巧:板子拿到手,先别急着写代码,用串口调试工具或者adb shell进到系统里,敲一下ls -l /dev/tty*,把节点路径和权限看清楚,再决定你的代码里该写哪个路径。我的习惯是直接用adb shell跑cat /proc/tty/drivers,能看到哪个驱动注册了哪些串口,对比手里的原理图,基本能定位到目标串口。
2.2 JNI与NDK在SerialPort库中的角色
很多只做过Java层开发的Android工程师,第一次看到SerialPort库的C代码会有点懵,但这里面的逻辑其实非常清晰。我们先看JNI层的核心方法声明:
JNIEXPORT jobject JNICALL Java_android_serialport_SerialPort_open (JNIEnv *env, jclass thiz, jstring path, jint baudrate, jint flags)这段声明对应Java层的private native FileDescriptor open(String path, int baudrate, int flags)。关键点在于,open()函数返回的是Java层的FileDescriptor对象,而不是一个int类型的fd。为什么这么做?因为Java层拿到FileDescriptor之后,可以直接用它构造FileInputStream和FileOutputStream,这样上层读写串口就变成了普通的文件流读写,用户完全不用关心底层fd的管理。
C代码内部的执行逻辑概括起来就是:
open(path, O_RDWR):打开设备节点,O_RDWR表示可读可写fcntl(fd, F_SETFL, FNDELAY):设置为非阻塞模式,防止读不到数据时卡死线程tcgetattr(fd, &cfg):读取当前termios配置cfsetispeed()/cfsetospeed():设置输入输出波特率cfg.c_cflag |= (CLOCAL | CREAD):忽略modem控制线,启用接收器tcsetattr(fd, TCSANOW, &cfg):立即应用配置- 构造并返回
FileDescriptor对象
注意,在flags参数传入0的时候,代码还会人为添加一段TIOCM_RTS和TIOCM_DTR的操作,拉高流控引脚,这一步是为了兼容某些必须检测到流控信号才输出数据的设备。
2.3 波特率、数据位、停止位、校验位怎么理解
经常有初学者问,波特率为什么非要设成9600、115200这种数字,我设成10000行不行?答案是:基本不行。因为通信双方约定的是在同一个“节奏”下收发数据,这个时序是用标准波特率发生器产生的。非标波特率不是完全不能设,但对方硬件大概率不支持,所以老老实实用标准值就好。
Android串口开发中,大部分设备默认9600或115200。工业仪表、电表、扫码枪这类低速设备,9600和19200用得最多;跟4G模块、定位模块、高性能传感器通信,115200甚至更高才够用。波特率一旦对不上,最常见的表现就是数据出来全是乱码,这一点在后面的排查章节我会再展开。
数据位通常是8(有的老设备用7),停止位通常是1(偶尔用2),校验位是可选的,NONE(无校验)用最多。在老式异步通信协议里,一帧数据包包含起始位、数据位、校验位、停止位。到了软件层,termios结构体对这些参数都有对应的宏定义,比如CS8表示8位数据、PARENB表示启用校验等。SerialPort库源码里其实只开放了波特率这个参数给上层,数据位、校验位这些是通过宏定义写死的,如果你的设备需要7位数据位或偶校验,就得到C代码里改。这也是为什么我强调源码重要:很多定制化需求,不掌握C代码根本实现不了。
3. demo实操过程与原理解析
3.1 串口路径与权限的准备
拿到一个烧好系统的Android设备,第一步就是确认串口节点是否存在、当前应用有没有权限访问。串口设备节点一般归属root或system用户、dialout组等,第三方App默认是没权限直接open的。所以商用项目里通常会把设备固件里对应节点的权限改一下,或者直接让应用跑在系统签名状态。如果是自己调试,简单粗暴的办法是adb shell下执行:
adb root adb remount adb shell chmod 666 /dev/ttyS1不过chmod只对当前开机状态有效,重启后权限会被重置。长久之计是写一份ueventd.rc文件或者通过init脚本,开机时自动设置串口权限。这一步很多新手容易忽略,导致明明代码没问题,却总是报Permission denied,然后一头扎进代码里排查半天——其实问题根本不在代码。
另外,如果你使用的设备已经root了,也可以让App在初始化时通过su命令动态修改权限。但这类方案能商用吗?我的建议是能不用就不用。正规做法还是协调板卡方在固件层解决权限问题,或者应用设备带系统签名。
3.2 核心代码:SerialPort的封装与调用
从实际工程的角度,我会把SerialPort.java的调用流程整理成一个相对完整的模板。首先看看核心的SerialPort类源码,这个基本是社区标准写法了:
public class SerialPort { private static final String TAG = "SerialPort"; private FileDescriptor mFd; private FileInputStream mFileInputStream; private FileOutputStream mFileOutputStream; static { System.loadLibrary("serial_port"); } // JNI方法声明 private native FileDescriptor open(String path, int baudrate, int flags); public native void close(); public SerialPort(File device, int baudrate, int flags) throws SecurityException, IOException { mFd = open(device.getAbsolutePath(), baudrate, flags); if (mFd == null) { throw new IOException("串口打开失败"); } mFileInputStream = new FileInputStream(mFd); mFileOutputStream = new FileOutputStream(mFd); } public InputStream getInputStream() { return mFileInputStream; } public OutputStream getOutputStream() { return mFileOutputStream; } }静态初始化块里通过System.loadLibrary("serial_port")加载libserial_port.so文件,这个so文件就是源码中C代码编译后的产物。open()方法的native实现对上层隐藏了所有Linux底层的细节,拿到FileDescriptor之后直接包成FileInputStream/FileOutputStream,之后的编程模型就是非常普通的Java流操作,对大多数开发者来说没什么心智负担。
使用示例也很直观:
SerialPort serialPort = new SerialPort(new File("/dev/ttyS1"), 115200, 0); InputStream in = serialPort.getInputStream(); OutputStream out = serialPort.getOutputStream(); // 发送数据 out.write(new byte[]{0x01, 0x03, 0x00, 0x00, 0x00, 0x0A}); out.flush(); // 读取数据 byte[] buffer = new byte[512]; int len = in.read(buffer);这里提供一个我在实际项目中用下来的经验:不要把InputStream.read()直接丢在UI线程里,因为read()会阻塞线程直到有数据返回,一旦设备长时间没回复,UI就会卡死。正确姿势是把读操作放到独立线程或者线程池里,拿到数据后通过Handler、LiveData或者协程回调到UI层。而且要注意一点,非阻塞模式下read()返回0是正常的,表示当前内核缓冲区没有数据,并不代表串口异常,要自己做超时和异常判断。
3.3 从输入框到串口:完整收发流程实现
很多demo自带的界面都做得比较简陋:一个EditText输波特率、一个按钮打开串口、一个TextView显示数据。我们可以在demo基础上搭建一个相对完整的收发模型,大致流程是:
- 打开串口页:输入串口路径、选择波特率
- 点击“打开串口”后创建SerialPort实例,同时启动读线程
- 读线程内部不断调用inputStream.read(buffer),读到数据后转成十六进制打印到界面
- 发送区域支持输入十六进制字符串,点击发送按钮调用outputStream.write()把数据发出去
- 关闭页面前调用serialPort.close(),释放资源
关于十六进制收发,我建议所有串口调试工具都带这个能力。因为串口通信里很多协议是二进制的(比如Modbus RTU),你直接看ASCII码根本看不出个所以然来。把字节转成Hex显示,每个字节对应协议里的一个字段,排查问题效率高得多。demo里一般会提供类似的显示逻辑,如果没有,自己写一个也很快:
private static final char[] HEX_CHARS = "0123456789ABCDEF".toCharArray(); public static String bytesToHex(byte[] bytes, int length) { StringBuilder sb = new StringBuilder(length * 3); for (int i = 0; i < length; i++) { int v = bytes[i] & 0xFF; sb.append(HEX_CHARS[v >>> 4]); sb.append(HEX_CHARS[v & 0x0F]); sb.append(' '); } return sb.toString(); }3.4 数据读写线程的优雅退出问题
在实际项目中,串口读线程的退出是一个非常容易踩坑的地方。因为read()是阻塞的,直接调用线程的interrupt()很多时候根本没效果。最稳妥的方式是维护一个volatile类型的标志位,关闭串口时先置标志位为false,然后关闭fd。而Java层的close()方法对应native层的close(fd),fd一旦关闭,阻塞中的read()会立刻返回-1或者抛出异常,读线程跟着自然退出。
这里有个细节要注意:
private volatile boolean isRunning = true; private void startReadThread() { new Thread(() -> { byte[] buffer = new byte[512]; while (isRunning) { try { int size = inputStream.read(buffer); if (size > 0) { // 回调数据 } } catch (IOException e) { if (isRunning) { // 真正的异常处理 } } } }).start(); }关闭的时候先isRunning = false,再调用serialPort.close(),双保险避免线程资源泄漏。有些情况下,inputStream的read()要挂起很久才能返回一次,设备重新打开时可能因为句柄被占用一直失败,这种问题多半就是读线程没释放干净导致的。
4. 常见问题与排查技巧实录
4.1 串口打开失败:Permission denied与No such file
这是所有串口开发新手都会遇到的第一道坎。如果你的Logcat里出现Permission denied,说明串口节点存在,但当前进程没有权限打开它。处理方案就是我前面说的:要么通过root命令临时调权限,要么在固件层配置好节点权限。如果报的是No such file or directory,那你先要确认节点路径是不是写对了,很多平台的串口路径并不是/dev/ttyS0这么直白,比如高通的/dev/ttyHSL1、全志平台的/dev/ttyS1,非常容易跟Linux通用串口名字搞混。
我自己的排查习惯是,先把Android设备用adb连上电脑,然后:
adb shell su ls -l /dev/tty* cat /proc/tty/drivers这样做的好处是能直接看到设备上的真实节点路径和软链接指向。有些主板还会在/dev下做/dev/serial0这种软链接,指向实际的UART节点,这种情况直接用软链接路径也没问题。
4.2 数据收发出现乱码
这个现象基本都是波特率不匹配造成的。对方设备设置为9600,你代码里写成115200,数据看起来就是一堆歪歪扭扭的字符。这种情况先把设备端的手册拿出来,确认通信参数(波特率、数据位、停止位、校验位),再到代码里把SerialPort构造的baudrate参数改成一致。
但还有一种冷门情况:硬件引脚接反或电平不匹配。TX接TX、RX接RX是不会通的,正确接法是TX对RX、RX对TX。TTL电平的串口如果外接RS232电平设备,中间必须加电平转换芯片,直接连会烧毁IO口或者读不到数据。如果代码参数全对但就是收不到数据,可以试着用示波器或者逻辑分析仪看下TX/RX引脚是否有波形输出。没有波形说明数据没发出来,有波形但接反导致读不到,这属于硬件连接问题,跟软件没关系。
4.3 收不到数据或者数据不完整(粘包/断包)
串口数据是按字节流到达的,应用层read()每次能读到多少数据是不确定的。比如设备一次发了20个字节,但内核缓冲区可能只到了部分数据,read()就只返回了那么多。很多新手写代码时,会用一次read()的结果去解析整个协议帧,结果怎么都对不上。
正确的做法是把读到的数据先放进一个缓冲区,然后按照协议帧格式去拼帧。比如典型的Modbus帧格式是“地址+功能码+数据+CRC”,最小帧长度和最大帧长度都是固定的,你就可以按帧长做切割。更简单的方案是设定一个帧间隔超时,比如收到一个字节后,20ms内没有后续字节就认为一帧数据结束了,然后把这批数据整体抛给上层去解析。
这块处理逻辑在demo里通常是比较简单的,因为demo只是为了演示通信链路通不通。但放到实际项目中,协议解析部分一定要做细心设计,不然各种偶发性的粘包断包问题能把人磨到怀疑人生。
4.4 同一时刻多个应用访问同一个串口
这个问题在Android上很典型:如果你有一个后台服务和一个前台界面都尝试打开同一个串口节点,Linux内核层面是允许两个fd同时打开同一个设备文件的。但是串口是独占性很强的外设,参数配置和数据流会互相干扰。比如A应用把波特率设成9600,B应用改成115200,设备端的收发时序立马就乱了。
解决办法:应用层做全局单例管理,整个进程只允许一个SerialPort实例存在;跨进程的场景,则需要用Android的Service封装串口访问,其他模块通过Binder调用Service提供的接口。这样既能避免多处打开冲突,也方便做统一的数据分发。这个设计取舍在商用项目里尤其重要,demo本身往往不会涉及,但你自己项目落地时基本都会遇到。
4.5 设备节点存在但open后read一直返回0
这种情况通常出现在设置了非阻塞模式之后。SerialPort库默认采用FNDELAY(非阻塞)方式打开串口,非阻塞模式下,内核缓冲区没有数据时read()直接返回0,而不是阻塞等待。所以你会看到读线程疯狂空转,CPU占用率很高。应对方法有两个:一是在读线程里用Thread.sleep(10)或Thread.sleep(20)降低轮询频率;二是改成阻塞模式打开,但代价是线程会在read()上挂住,需要靠关闭fd来唤醒。两种方案没有绝对的好坏,个人建议用sleep轮询的方式更直观一点,因为后续处理超时逻辑会很方便。
4.6 商用级项目在demo基础上还需要补什么
对,拿到demo跑通不是终点,反而是起点。如果要做成稳定的商用产品,下面这些工作基本是少不了的:
- 错误码与异常体系:串口open失败、read超时、write失败都要有明确的错误回调,而不是仅仅打日志。
- 数据协议层:demo给出的往往是裸字节流收发,实际产品要解析业务协议,甚至要自己定义一帧数据的结构。
- 电源管理:跟手机不同,很多串口设备是整机常供电的,但Android系统可能会休眠,休眠后串口外设是否掉电、怎么唤醒,都要提前考虑。
- 日志持久化:设备在现场出了问题,你不在旁边,只能靠日志排查。串口数据收发日志建议写到本地文件并按天切割。
- 掉线自动恢复:设备异常掉线、串口被占用、线缆松动,都要有重连机制。商用系统连续运行三五个月不出故障,才是真正的门槛。
我个人在带项目的过程中,处理过太多“demo能跑,上了产线就抽风”的情况,绝大多数问题都出在异常分支处理和资源释放上。所以看到这类标题的源码包时,我会习惯性地先看它有没有处理异常、有没有释放资源、有没有线程同步——这些细节比那个炫酷的界面重要一百倍。
5. 写在最后的几个建议
关于Android串口开发,最后再分享几条我这些年沉淀下来的实操感悟。
第一,拿到串口设备先跑一遍官方demo,但我建议别在Android设备上跑。最好的验证方式是先拿PC串口调试助手直接跟目标设备通信,确认设备的通信参数、协议格式、回复内容都是正常的,再切到Android工程调试。这样等于把变量降到了最低,Android端出问题就大概率是代码问题,而不是设备参数搞错了。我见过太多人Android端调了一整天,最后发现是设备那边根本没往总线上发数据。
第二,善用逻辑分析仪。现在几十块钱的USB逻辑分析仪配上一个开源软件,就能实时抓取串口TX/RX引脚的波形。软件上显示不出来的数据,用硬件抓一遍波形立刻真相大白:是根本没发出来,还是发了但被对方吃掉,是波特率偏了,还是电气层面接触不良。这个习惯能帮你省下大量跟硬件同事扯皮的时间。
第三,如果可能,尽量把串口相关的所有代码收敛到一个模块里,不要在十几个Activity里各处new SerialPort。我在实际项目里做过一个SerialPortManager的单例,统一管理打开、关闭、数据分发和异常回调。这样即使后来要改成Service方式调用,重构成本也低很多。
串口通信没有太多高深的花活,它的难点在于“稳定”二字。工业环境下几个月不重启、数据不丢、线程不泄漏,这比跑通一个demo要难得多。但这个方向一旦吃透了,你会发现在Android设备上跟外设打交道,确实没有比串口更省心可靠的通道了。碰上协议调试问题别慌,按照通信链路一层一层排查法,从“发没发出来”到“发得对不对”到“收没收到”到“解析对不对”,总能定位到具体环节。
本文还有配套的精品资源,点击获取