简介:面向需要在Android平台实现串口通信的开发者,这份源码示例演示了如何借助JNI完成底层硬件访问,从权限声明、SerialPort类封装到后台线程读写、异常处理均有覆盖,适合嵌入式、物联网和硬件交互等场景参考。压缩包共105个文件,约4.81MB,主要包含Java源码、C/H/JNI实现、so动态库、Android工程配置以及构建产物,目录覆盖底层封装、应用调用与调试输出,可对照学习Java层与Native层的调用关系,也可提取关键类嵌入自己的项目。已有169人学习/下载。资源重点展示了串口打开、关闭、读写数据的封装方法,以及波特率、数据位、停止位等参数配置,同时给出权限声明和线程管理的处理思路,能帮助开发者绕开JNI调试中的常见问题,在真实设备上快速验证串口通信流程,是学习Android硬件开发的一份紧凑示例。
1. 安卓串口开发demo:JNI文件才是这个包的核心价值
拿到"安卓Android源码——串口开发的demo,里面包含JNI文件.zip"这份资源,第一件事不是解压,而是想清楚你要用它解决什么问题。Android串口开发在嵌入式设备、工业平板、车机、手持POS机这些场景里几乎是绕不开的活,但Google官方从来没有给过一套完整的串口方案,市面上流传的demo五花八门,真正能跑通的却不多。这份demo最值钱的地方,是它把JNI层做成了可以直接编译的工程结构——你在Java层调一个open()方法,底层通过JNI进入C代码,最终操作Linux的tty设备节点,整条链路是闭环的。
我见过太多人卡在同一个地方:从网上找了一个串口demo,结果jni目录下的.c文件压根编不过,或者编过了但so文件加载不出来。这份资源里带JNI源码,意味着你不只是拿到一个黑盒的so,而是能自己改波特率、改流控、改数据位,出了问题还能在C代码里加日志排查。如果你是第一次做Android串口,或者之前用别人封装好的库但遇到了无法解释的bug,这份demo就是你能动手改、能动脑查的起点。接下来我会从目录结构、编译过程、Java调用链、参数配置几个方向,把这个demo彻底拆开。
2. 先看zip里有什么:串口demo的JNI工程结构与选型逻辑
2.1 从资源包里识别三块关键内容:Activity层、JNI目录、so库
解压这份demo之后,典型的目录结构会包含一个完整的Android工程,至少能看到app模块下的src/main/java、src/main/jni(或者cpp目录)和src/main/libs(或者jniLibs)这几块。Java目录里通常放着SerialPort.java和SerialPortFinder.java,前者封装了打开串口、配置参数、关闭串口的核心方法,后者负责扫描设备上可用的串口节点。jni目录下是serial_port.c和serial_port.h,这是整个demo的灵魂——真正操作tty设备的地方。libs目录里放着编译好的libserial_port.so,按CPU架构分文件夹,常见的是armeabi-v7a和arm64-v8a。
我先说一个常常被忽略的选型问题:这个demo里的JNI实现,用的是Google官方那个著名的android_serialport_api方案。这个方案的底层逻辑是,C代码里直接调用Linux的open()、tcgetattr()、tcsetattr()、close()这些POSIX接口,不走Java层的FileDescriptor,也不依赖任何第三方库。好处是依赖链极短,只有Linux内核的tty驱动和NDK的libc,坏处是你必须理解termios那一套结构体配置,否则出了乱码你都不知道是波特率算错还是数据位设置错了。
2.2 为什么我不建议直接用成品so库,而是坚持把JNI源码编一遍
很多人在拿到demo后,第一反应是直接把libs目录下的so文件拷到自己工程里用。这个做法在紧急情况下可以理解,但不推荐。原因有三个:第一,so文件是特定NDK版本编译出来的,如果对方的NDK版本和你本地的compileSdk、minSdk不匹配,会出现UnsatisfiedLinkError,而且这个错误往往只在真机上运行时才爆出来,排查成本极高。第二,成品so没有debug日志,串口打不开的时候你根本不知道是open()失败还是tcsetattr()失败。第三,也是最实际的,你没法改波特率——标准串口库只做死了9600、115200这些常用值,如果设备需要57600或者921600,你得自己改C代码重新编译。
我一般会建议把jni目录整个保留,在app/build.gradle里配上externalNativeBuild,让Android Studio直接帮你调NDK编译。这个过程第一次跑会很痛苦,因为要装NDK和CMake,但一旦跑通,后面改参数、加日志、调试崩溃都是秒级的事情。如果你用的是Android Studio 4.0以上的版本,CMakeLists.txt的方案比老式的Android.mk更稳,demo里如果同时有这两种构建文件,优先选CMakeLists。
2.3 手把手检查jni目录里的serial_port.c:open、configure、close一条链
打开serial_port.c,核心逻辑其实就是三个函数:Java_android_serialport_SerialPort_open、Java_android_serialport_SerialPort_close,以及内部使用的configure函数。我把open链路给你捋一遍:
static int configure(int fd, int baudrate, int data_bits, int parity, int stop_bits, int flow_control) { struct termios cfg; // 先取出当前终端参数,避免直接覆盖掉默认系统设置 if (tcgetattr(fd, &cfg)) { return -1; } // 把控制模式标志清干净,重新按我们的参数来 cfmakeraw(&cfg); cfsetispeed(&cfg, getBaudFlag(baudrate)); // 输入波特率 cfsetospeed(&cfg, getBaudFlag(baudrate)); // 输出波特率 // 数据位一般是8,校验位和停止位根据设备协议来配置 cfg.c_cflag &= ~CSIZE; switch (data_bits) { case 8: cfg.c_cflag |= CS8; break; case 7: cfg.c_cflag |= CS7; break; case 6: cfg.c_cflag |= CS6; break; default: return -2; } if (parity == 'N') { cfg.c_cflag &= ~PARENB; } else if (parity == 'O') { cfg.c_cflag |= PARENB; cfg.c_cflag |= PARODD; } // 最关键的一步:把VMIN和VTIME改成0,避免read()函数阻塞住线程 cfg.c_cc[VTIME] = 0; cfg.c_cc[VMIN] = 0; tcflush(fd, TCIOFLUSH); if (tcsetattr(fd, TCSANOW, &cfg)) { return -3; } return 0; }这段代码看着不复杂,但有几个容易翻车的点。第一,cfmakeraw(&cfg)不是POSIX标准函数,是BSD扩展,NDK环境下没问题,但如果以后要移植到其他Linux平台,得自己逐位设置ICANON、ECHO这些标志位。第二,getBaudFlag()这个函数在不同芯片方案上会有差异,比如全志、瑞芯微的评估板串口通常没有问题,但有些USB转串口芯片对非标准波特率的支持很差,表现为设备能打开但收到的数据全是乱码或丢失字节。第三,tcsetattr()的TCSANOW表示立即生效,如果这时候串口还有其他线程在读,可能会把对方打懵。
2.4 JNI头文件生成与命名规范:为什么方法名不能随便改
java_android_serialport_SerialPort_open这个命名规则是JNI的硬性要求,它把Java包的.替换成_,类名和方法名直接拼接。一旦你改了Java里的包名,比如把android_serialport改成com_yourcompany_serialport,那么C函数名也必须同步改。很多人在导入demo后,发现so库加载成功了,但调用open()就直接崩,原因就是把包名改了却没改JNI函数名。
如果你用Android Studio的javah工具或者直接让编译器自动生成头文件,就不用担心这个问题。但我更常用的是一个笨办法:在C代码里故意写一个不存在的函数名,编译报错时看错误信息里JNI期望的函数名是什么,然后照着改。这个办法虽然土,但非常快,尤其当工程里有多个JNI文件、命名互相依赖的时候。
3. 把JNI编译成so:从NDK环境配置到ABI兼容的完整落地路径
3.1 创建一个可复现的CMakeLists.txt:参数说明与目录匹配
假设你以为zip里的工程可以直接打开就编译,大概率会失望。因为对方的SDK版本、NDK版本和你本地的未必一致。我更建议你新建一个空工程,然后把Java代码和jni目录拷过去,自己写CMakeLists。这样能保证编译链路是干净的,后续改任何东西你都知道影响面在哪里。我的CMakeLists通常长这样:
cmake_minimum_required(VERSION 3.10) project(serial_port_demo) # 把serial_port.c编译成动态库,名字叫serial_port add_library(serial_port SHARED src/main/jni/serial_port.c) # 找到log库,这样才能在C代码里用__android_log_print输出日志 find_library(log-lib log) # 核心操作:把so链接到log库,同时导出所有JNI符号 target_link_libraries(serial_port ${log-lib}) # 指定输出的so文件名格式,避免每次编译都带版本号后缀 set_target_properties(serial_port PROPERTIES PREFIX "" SUFFIX ".so")这段配置有三个关键点。第一,add_library里一定要写完整路径,如果你把jni目录放在了src/main/cpp,那么这里就改成src/main/cpp/serial_port.c。第二,find_library(log-lib log)是必须的,串口C代码里如果用了__android_log_print来打印调试信息(demo里通常在open失败时输出errno),不链接log库直接编译报错。第三,PREFIX设为空是因为Android的so命名不带lib前缀,但系统加载时又默认找lib前缀.so,这地方很绕,你可以在Java层直接用System.loadLibrary("serial_port"),它会自动拼上lib前缀去找。
3.2 在build.gradle里配置externalNativeBuild:版本陷阱与ABI取舍
CMakeLists准备好之后,Java工程这边的配置才是真正决定你能不能编过的地方。按现在的Android Studio惯例,app/build.gradle的android节点里要加上externalNativeBuild,指定CMakeLists的路径,同时用ndk.abiFilters限制CPU架构。我的配置如下:
android { compileSdkVersion 33 defaultConfig { applicationId "com.example.serialportdemo" minSdkVersion 21 targetSdkVersion 33 ndk { // 只保留这两种架构,不要加x86和mips,省得编出一堆用不上的so abiFilters 'armeabi-v7a', 'arm64-v8a' } externalNativeBuild { cmake { cppFlags "" arguments "-DANDROID_STL=c++_static" } } } externalNativeBuild { cmake { path "CMakeLists.txt" version "3.10.2" } } }这里最大的坑是abiFilters。如果你的设备是老的联发科方案或者某些只用armeabi架构的工控板,那必须把armeabi也加进去。但要注意,armeabi这个ABI在NDK r17之后就被移除了,如果你本地装的是新版本NDK,编armeabi会直接报错。另一个常见问题是,多个模块(比如主工程依赖了一个第三方库)各自声明了不同的ABI,最终合并时会生成所有ABI的so包,导致APK体积变大——解决办法是在所有依赖模块里统一abiFilters。
3.3 编译报错避坑:NDK未安装、C文件编码、参数类型不一致
我几乎每次给新人演示编译串口demo,都会遇到同样的三个报错。第一个是No toolchain found,原因是本机没装NDK,Android Studio虽然会自动下载,但公司内网环境经常失败。解决办法是去官网单独下载NDK版本,然后在local.properties里写死sdk.dir和ndk.dir。第二个报错是C文件里用了中文字符注释,编码不是UTF-8,编译直接乱码,这个最容易避免,所有注释一律英文或ASCII字符。第三个是JNI函数签名不一致——Java里native void close(),C里写的却是Java_..._close(JNIEnv*, jobject, int),多了个参数,链接时虽然不报错,但运行时IllegalArgumentException直接抛出来。
提示:如果你编出来的so一直加载失败,先别查代码,用
file命令看一下so的架构信息——file libserial_port.so会输出ARM aarch64之类的标识,确认和你设备的uname -m输出一致。
3.4 so文件与Java加载路径的对应关系
编译完成后,so文件会输出到app/build/intermediates/cxx/Debug目录下,而不是直接放在src/main/jniLibs里。Android Studio会在安装APK的时候自动把它们打包进lib/<abi>/。如果你的工程结构里同时存在src/main/jniLibs目录,并且里面也有一个同名so,那么这里会产生一个非常隐蔽的覆盖问题——jniLibs里的老版本so会优先被打包,你辛苦编译的新文件根本没有生效。这套玄学我踩过一次,排查了整整一下午,最后是把jniLibs里残留的老文件全部删掉才解决的。
4. 从JNI到Java:SerialPort类的封装逻辑与open方法的完整链路
4.1 SerialPort.java的核心方法:native声明与FileDescriptor的关系
打开SerialPort.java,你会发现这个类非常短,声明三四个native方法就够了。真正的复杂逻辑在C代码里,Java层只是做了权限校验和文件描述符的传递。我把核心方法贴出来:
public class SerialPort { private FileDescriptor mFd; private FileInputStream mFileInputStream; private FileOutputStream mFileOutputStream; private native FileDescriptor open(String path, int baudrate, int flags); private native void close(); public SerialPort(String device, int baudrate) throws IOException { // 核心权限检查:/dev下的串口设备通常属于root或dialout用户组 File file = new File(device); if (!file.canRead() || !file.canWrite()) { // 这里不做chmod 777的妥协,而是引导用户用root或授权管理 throw new SecurityException("Serial port permission denied"); } mFd = open(device, baudrate, 0); if (mFd == null) { throw new IOException("native open returned null"); } mFileInputStream = new FileInputStream(mFd); mFileOutputStream = new FileOutputStream(mFd); } public void closeSerialPort() { close(); if (mFileInputStream != null) { try { mFileInputStream.close(); } catch (IOException e) { // 关闭流失败时,文件描述符可能已经失效,吞掉这条异常即可 } mFileInputStream = null; } // 对称处理输出流 } }这段Java代码的关键在于,串口设备的读写被抽象成了FileInputStream和FileOutputStream。这样一来,上层业务代码不需要知道底层是串口还是一般的文件,读写方式完全统一。但你也要理解,FileInputStream(mFd)并不是直接把文件路径传进去,而是通过FileDescriptor对象复用底层文件句柄——所以C层open()返回的文件描述符必须通过JNI返回一个FileDescriptor对象,这里JNI的NewFileDescriptor函数是桥接的关键。
4.2 为什么SerialPortFinder要扫设备节点:工业场景下的枚举逻辑
串口不是像USB那样即插即用的,设备节点分布在/dev/ttyS*、/dev/ttyMT*、/dev/ttymxc*这些路径下,不同芯片的平台节点命名规律完全不同。SerialPortFinder.java就是干这个事情的,它遍历/dev目录,用File.list把所有名字匹配ttyS、ttyMT、ttyUSB等前缀的文件摘出来,然后在界面上显示成一个列表。这个方案粗暴但有效,我强烈建议你不要改这个逻辑,老老实实用。
不过有个坑:在Android 10以上的系统里,从应用层直接list/dev目录会被SELinux拦住,看到的列表可能是空的。常见做法是把设备节点列表移到init.rc里让系统启动时生成,或者app以root权限运行(很多工控设备预置了root)。如果你在调试的时候发现列表是空的,别怀疑是SerialPortFinder写错了,先去看SELinux的状态。
4.3 在MainActivity里调用SerialPort:超时与线程模型
demo里的MainActivity通常演示的是最原始的收发逻辑:界面一个输入框,一个发送按钮,一个TextView接收显示。但实际项目里你得改造,否则会卡死界面。下面是我在生产环境里验证过的调用方式:
public class SerialPortActivity extends Activity { private SerialPort mSerialPort; private ReadThread mReadThread; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // ... setContentView try { // 把波特率从layout里的EditText解析出来 int baudrate = Integer.parseInt(mBaudrateEditText.getText().toString()); mSerialPort = new SerialPort(new File("/dev/ttyS3"), baudrate); mReadThread = new ReadThread(); mReadThread.start(); } catch (IOException e) { // 设备节点不存在、权限拒绝等情况统一在这里处理 Toast.makeText(this, "串口打开失败: " + e.getMessage(), Toast.LENGTH_LONG).show(); finish(); } } private class ReadThread extends Thread { @Override public void run() { byte[] buffer = new byte[512]; // 由于C层设了VMIN=0,read()不会阻塞,所以这里不能用无限循环 while (!isInterrupted()) { try { int size = mSerialPort.getInputStream().read(buffer); if (size > 0) { // 收到数据,通过Handler或runOnUiThread更新界面 mHandler.obtainMessage(MSG_READ, size, -1, buffer).sendToTarget(); } } catch (IOException e) { // 对端关闭或设备拔出,立刻退出读线程 break; } } } } }这段代码里最容易踩的坑是while (!isInterrupted())这个循环。如果C层的VMIN and VTIME没设置好,read()会一直阻塞,线程永远不会退出,而且界面上的关闭按钮会失效。你把VTIME设为0、VMIN设为0后,read()在没有数据的时候立刻返回0或负数,循环虽然转得快但CPU占用会上去——所以我在实际项目里会加一句try { Thread.sleep(10); } catch (InterruptedException e) { break; }来把CPU降下来。
5. 波特率、数据位与流控:串口参数设置的三个必调项
5.1 正确识别目标设备的波特率与校验位
串口通信双方必须约定好波特率、数据位、停止位、校验位,这四个参数不匹配,收到的数据要么是乱码,要么是一堆时序错误导致的错帧。demo里默认是115200, 8N1(8数据位、无校验、1停止位),这是最通用的配置,很多MCU、GPS模块、指纹模组出厂都是这个参数。但如果你接的是老式工业仪表,波特率可能是9600,校验位可能是Even(偶校验),这时候必须在SerialPort构造函数里把参数传对。
我踩过一个真实案例:接一款海康的二维码扫描头,默认波特率实际是19200,但文档写的是115200,结果调试了两天,最后用示波器抓波形才发现波特率不对。这里给新手一个调试思路:如果串口没有任何数据返回,优先用短接测试——把TXD和RXD短接,自发自收,能收到说明API链路正常,问题出在设备端参数上。
5.2 如何在C代码里增加非标准波特率:乘除因子法
Linux内核的termios结构体对波特率的定义是B115200、B9600这种枚举常量,如果你想用57600或921600,需要自己定义波特率标志,然后通过cfsetispeed的BOTHER(非标准波特率)模式配合c_ispeed字段直接写数值。这个做法的代码如下:
#ifndef BOTHER #define BOTHER 0010000 #endif static speed_t getBaudFlag(int baudrate) { // 标准波特率直接走tcsetattr的枚举常量 switch (baudrate) { case 300: return B300; case 1200: return B1200; case 2400: return B2400; case 9600: return B9600; case 19200: return B19200; case 38400: return B38400; case 57600: return B57600; case 115200: return B115200; default: // 非标准波特率用BOTHER + 直接赋值方式 return BOTHER; } }调用getBaudFlag的地方也要做配套修改,如果返回的是BOTHER,那就不能走cfsetispeed,得改用cfsetispeed配合c_ospeed字段。我为了这个功能专门维护了一份serial_port.c的补丁。要注意,USB转串口芯片(比如CH340、CP2102)对非标准波特率的支持取决于驱动实现,有些芯片会主动拒绝不支持的速率,表现为tcsetattr返回0但实际通信失败。
5.3 流控参数:硬件流控RTS/CTS必须按设备文档来
很多设备的串口对接需要硬件流控,默认的demo是没有打开RTS/CTS的。如果设备文档明确写了要启用硬件流控,你需要修改configure函数:
if (flow_control == 1) { // 启动硬件流控需要同时设置CRTSCTS标志 cfg.c_cflag |= CRTSCTS; } else { cfg.c_cflag &= ~CRTSCTS; }我在这里多提一句,加CRTSCTS之后,电气连接必须把设备的RTS接到主控的CTS脚,接反了通信直接卡死。还有一个玄学:有些设备虽然用了九针串口头,但实际没接RTS/CTS线,这种情况下你开硬件流控反而会让数据一直阻塞。所以流控的真实规则是:设备文档明确说有才开,没有说就一律关闭。
5.4 从demo到产品:串口收发线程池与心跳保活策略
串口demo和时间产品之间最实际的差距,就是收发逻辑的工程化程度。生产项目里我一般会在demo基础上增加四个东西:读线程的白名单超时机制、写操作的同步锁、断线重连、收发日志文件。尤其是断线重连——USB转串口设备经常被系统休眠挂起或物理拔插,Java层的FileInputStream.read()会立即返回-1,这时候你要在IOException分支里重建SerialPort对象,并且带上重试次数的上限,避免死循环。
6. 避坑:串口打开失败、乱码、设备节点消失的排查清单
6.1 权限拒绝:Permission denied与SELinux的强制拦截
现象:调用new SerialPort("/dev/ttyS0", 115200)时报IOException,提示Permission denied。原因是app进程没有对该设备节点的读写权限。串口设备节点通常归属root或dialout用户组,普通应用访问不了。
解决思路是分两个层次弄,先看代码里有没有做权限校验,再做系统级处理。应用层就算file.setReadable(true)也只是改了Linux的文件权限位,对SELinux策略是无效的。在真机调试时,我会先adb shell看一下设备权限——ls -l /dev/ttyS0输出如果是crw-rw---- root dialout,那app确实没权限。比较省事的办法是让app跑在root进程里(比如用su拉起),或者让系统在init.rc里把设备节点权限改成0666。二选一,你说哪种适合你们系统。
6.2 打开失败但没报错:文件描述符无效与open()返回-1
现象:SerialPort构造成功,但read()永远返回-1,而且写数据后设备无响应。
原因排查要看C层的open()函数。标准实现里open是这么写的:
fd = open(path, O_RDWR | O_NOCTTY | O_NONBLOCK); if (fd == -1) { __android_log_print(ANDROID_LOG_ERROR, "SerialPort", "open failed: %s", strerror(errno)); return NULL; }如果errno是ENXIO或EBUSY,说明设备节点存在但驱动没有绑定成功。嵌入式Linux平台常见原因是设备树里没有使能对应的UART,比如你打开了/dev/ttyS3但设备树里只注册了ttyS0。这时候别在Android上层浪费时间,直接看内核日志——dmesg | grep tty,确认对应串口驱动是否加载。
6.3 乱码:波特率匹配但数据依然错的另一种可能
先排除波特率问题后,乱码还有一个被忽略的源头——电平反转。RS232的电平逻辑和TTL是反的,如果你的主板引出的是TTL电平串口,却接了一个RS232设备,直接物理连接会导致误码率高到完全无法通信。你需要一个TTL转RS232电平转换芯片(MAX3232这类),而不是靠软件去适配。另外,如果数据一直重复接收但不完整,检查一下是不是打开了硬件流控但没接CTS线,这个故障现象和信号干扰很像,很容易误判。
6.4 设备节点消失了:/dev/ttyS*在Android 10以后变得不可见
现象:串口之前能正常收发,重启后SerialPortFinder返回的列表变空,或者直接FileNotFoundException。
这里涉及的坑是Android 10的dynamic partitions和SELinux策略更新。早期Android版本下,/dev目录是直接挂载的,App通过File("/dev")遍历目录没问题;Android 10以后,/dev下的节点变成devtmpfs动态管理,App的listFiles()可能只看到部分设备。dmesg能看到完整节点,但Java层看不到。解决办法是把设备节点的白名单写死在代码里(低频变化),或者通过Runtime.exec("ls /dev/ttyS*")来列目录,底层用root权限执行命令再解析输出。
6.5 so文件加载失败:UnsatisfiedLinkError的复盘
现象:System.loadLibrary("serial_port")直接抛异常,进程崩溃。
原因主要是两个:要么是so文件不在APK的lib/<abi>/目录下,要么是so依赖的某个系统库(比如liblog.so)在目标系统上版本不兼容。后一种情况在国产工控机上特别常见,因为系统镜像被厂商裁剪过,liblog或者libcutils缺失。排查步骤是先确认so打进了APK——用unzip -l app-debug.apk | grep serial,输出里必须能看到lib/arm64-v8a/libserial_port.so。如果没有,检查abiFilters配置。如果是系统库缺失,只能把NDK链接方式从动态链接改成静态链接,在CMakeLists里加上set(CMAKE_SHARED_LINKER_FLAGS "-static-libstdc++ -static-libgcc")。
7. 进阶:把demo改造为具备数据帧处理的通用串口通信框架
到这个阶段,你已经有了能收发字节的串口通道,但字节还不是业务数据。设备吐出来的往往是一串没有边界的十六进制流,比如GPS的$GPRMC语句是ASCII明文按逗号分列,而工业Modbus协议则是带CRC校验的二进制帧。这块能力直接参考demo是学不来的,我自己有一个固定的分层设计可以分享给你。
第一层叫ByteReceiver,职责是维持一个环形缓冲区。读线程不断把字节放入缓冲区,同时触发帧解析器的push(byte)方法。第二层叫FrameParser,针对不同协议各写一个实现:文本协议以\n或\r\n为帧结束符,二进制协议按固定长度或头尾标记切割。每次push(byte)只处理一个字节,判断当前是否处于帧同步状态,不是的话继续找帧头,找到后累计长度直到匹配。第三层是EventHandler,解析出完整帧后回调到业务代码,业务层拿到的是结构化数据而不是原始字节数组。
public abstract class FrameParser { private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); public void push(byte b) { buffer.write(b); if (isFrameComplete(buffer.toByteArray())) { onFrameReceived(buffer.toByteArray()); buffer.reset(); } } protected abstract boolean isFrameComplete(byte[] frame); protected abstract void onFrameReceived(byte[] frame); }这个三段式设计的好处是,换协议时只改FrameParser,收发线程和缓冲区完全复用。如果你接的设备既有GPS又有传感器,那么并行跑两个FrameParser实例,各挂各的协议,互不干扰。另外一个习惯我强烈建议你养成:每次打开串口时把设备路径和时间戳打日志,收到的每一帧原始数据留下hex转储到文件,这样出现异常时你能回放数据,而不是凭印象猜协议哪里对不上。这个习惯帮我省了至少三次通宵排障。
最后再强调一个我在多个项目里反复踩的坑:串口通信没有后悔药,误码和丢帧是常态,重要的是能快速定位。所以SerialPort的读线程里要加一帧超时机制——如果解析过程中超过预设间隔没有收到下一个字节,就立即复位缓冲区。这样即使协议错了,系统也不会被半包数据卡死。希望这份串口开发笔记能帮你把demo真正用起来,少走点我走过的弯路。
本文还有配套的精品资源,点击获取