简介:这份资源是一套基于Android平台的串口通信工具完整源码,面向需要实现串口调试、设备通信的Android应用开发者。项目以 ComAssistant-master 为基础,覆盖了权限动态申请、串口API调用、波特率与校验位配置、数据读写、后台线程管理以及异常处理等关键开发环节,适合入门学习或作为实际项目的搭建参考。资源共43个文件,压缩包约469KB,核心部分包括Java层源码、so动态库、jni原生C代码以及XML界面配置,目录结构完整,可从上层界面一路追踪到底层串口通信实现。已有1790人在CSDN学习浏览该资源,验证了内容的实用性与参考价值。通过这份源码,开发者能够清晰理解Android串口通信的完整流程,掌握多设备枚举、连接状态管理、事件驱动收发等进阶要点,有效缩短自主二次开发与排错周期。
1. 为什么需要一份 Android 串口工具源码
做工业平板、仪器仪表或车机应用的同学,迟早会撞上这样一幕:设备上留了一个 DB9 或 RS485 接口,对方的协议手册写得像天书,但业务上又必须在 Android 系统里把这路数据收进来。市面上现成的串口调试 App 一抓一大把,能测通硬件的收尾,但没法直接嵌进自己的应用里——后台服务、协议解析、断线重连,全部得从自己的代码里长出来。这份标题里的源码,核心就是一套从应用层打开 Linux 字符设备节点、配置波特率与数据位、再以流的形式完成收发的完整工程。它解决的是「Android 应用侧怎么和外部串口设备稳定通信」这个具体问题。
它的适用对象很明确:一类是要把串口能力接进自研 App 的工程师,另一类是刚接触 Android 串口开发、想从一份可编译的工程里搞懂原理的初学者。很多开源实现把 Java 层和 C 层的职责分得很清楚——Java 层负责设备路径、权限和流接口,C 层用 termios 系列函数真正操作设备文件。对开发者来说,这份源码既是可直接引用的库,也是一份能读懂的教科书。我见过不少人拿着源码在 ARM 板子上跑通后,才发现自己在 JTAG 和权限上耗掉的时间比写业务代码还多;这篇笔记就按「原理 → 拆源码 → 集成 → 避坑」的顺序,把它讲透。
2. 串口接入 Android 的第一性原理:设备节点与 JNI 桥接
2.1 Android 串口设备节点到底藏在哪
Android 底层是 Linux 内核,串口在 Linux 里并不神秘,它就是一类字符设备文件。常见的设备节点路径有/dev/ttyS0、/dev/ttyMT0、/dev/ttyUSB0等,具体叫什么取决于 SoC 平台和物理接口类型。比如多数 x86 工控板的串口是/dev/ttyS0,不少 ARM 平台把 UART 命名为ttyMT、ttyAMA之类的前缀;如果通过 USB 转串口芯片接入,则多半是/dev/ttyUSB0。了解这一点主要是为了在代码里做设备路径的配置化,别写死一个路径就以为万事大吉。
串口作为字符设备的本质意味着:打开它要用文件操作语义,配置参数要走termios结构体,收发数据就是read()和write()。Java 层不提供直接访问/dev/路径下的设备文件的能力,所以所有开源方案都绕不开 JNI。常见做法是用 NDK 编译一个本地库,在 C 层调用open()、tcgetattr()、tcsetattr()和read()/write(),再把文件描述符传回 Java 层,由 Java 层包装成InputStream和OutputStream。这个桥接层的质量,直接决定了项目后续的稳定性。
2.2 为什么 Java 不能直接操作串口
不少刚接触的人会问:我把文件路径交给FileInputStream不就行了?实验一次就会发现不行。原因有两层:一是 SELinux 和 Android 的应用沙箱权限模型不允许普通 App 直接打开/dev/ttyS*这类节点,这点后面避坑章节再展开;二是在 Android 中直接往任意/dev/文件写数据是受系统策略管控的,即使侥幸打开,配置波特率、数据位这种操作也没有对应的 Java API。串口参数本质是对驱动层的 termios 结构做ioctl设置,这只能从原生代码完成。
所以 JNI 层的核心任务就三件:打开设备文件、配置 termios、把 fd 交给 Java 层做流式读写。打开设备的 C 代码中,open()通常会传入O_RDWR和O_NOCTTY两个标志,O_NOCTTY的意思是防止这个串口设备成为当前进程的控制终端,避免进程收到意外的终端控制信号。参数用O_NONBLOCK还是阻塞模式,取决于读线程的实现策略,后面会细说。
int fd = open(device_path, O_RDWR | O_NOCTTY | O_NDELAY); if (fd == -1) { // 打开失败,打印 errno 便于排查权限与路径问题 return -1; } // 把串口设置为阻塞模式,让读写有自然的等待语义 fcntl(fd, F_SETFL, 0);这段代码里的O_NDELAY在这里的作用是与F_SETFL搭配使用:先以非阻塞方式探测能不能打开节点,随后通过fcntl把 fd 恢复为阻塞模式。这样既避免打开阶段因设备状态异常导致挂死,又保证正式读写时能按阻塞流模型处理。很多源码就是在这一层掉了链子——非阻塞 fd 传给 Java 层后,InputStream.read()会立即返回 0 或抛出异常,导致数据读不到或 CPU 空转。如果你看到的源码里没有这一行,大概率会在实际设备上踩到这个坑。
2.3 termios 配置:波特率、数据位与校验位的 C 层写法
配置 termios 是整个源码里最容易“看着没问题但跑起来全是乱码”的部分。标准流程是先tcgetattr拿到当前配置,修改c_cflag、c_iflag、c_oflag、c_lflag,再用tcsetattr写回。常见的干净做法是先调用cfmakeraw(&options)把驱动设置为原始模式,这样不会对输入输出做回车换行转换、不会把Ctrl+C当信号处理,避免 Linux 行规程干扰二进制数据帧。
struct termios options; tcgetattr(fd, &options); cfmakeraw(&options); // 设置波特率:这里的速率宏要与 Java 层传过来的 int 值对应 cfsetispeed(&options, B9600); cfsetospeed(&options, B9600); // 数据位 8、停止位 1、无校验:最常见组合 options.c_cflag &= ~CSIZE; options.c_cflag |= CS8; options.c_cflag &= ~CSTOPB; options.c_cflag &= ~PARENB; // 启用接收,关闭硬件流控(大多数 RS232 设备不用 RTS/CTS) options.c_cflag |= CREAD | CLOCAL; options.c_cflag &= ~CRTSCTS;参数说明这一组配置的含义:CS8表示 8 个数据位;CSTOPB清零表示 1 个停止位,若置位则是 2 个停止位;PARENB清零表示无校验,置位后还要配合PARODD决定奇偶校验。CREAD必须置位,否则驱动不会把收到的数据交给应用;CLOCAL忽略调制解调器的载波信号,避免检测不到 DCD 就收不到数据。cfmakeraw之后c_iflag和c_lflag基本不用再改,这一点是很多源码里容易忽略的细节——有人只设置了c_cflag却保留了行规程的转换,结果收到的帧里时不时多一个\r或0x0A。
双向波特率不一定要一样,部分设备要求 115200 发送、9600 接收这种非对称配置。源码里一般会做成读波特率和写波特率两个参数,内部依次调用cfsetispeed和cfsetospeed。如果发现设备回数据乱码,第一件事就查这两个值有没有配对。
3. 拆解源码核心模块:读、写、关闭的生命周期管理
3.1 Java 层与本地库的对应关系
拿到一份串口工具源码,先看 Java 层的类结构,通常能整理出几个固定角色:一个持设备路径和参数并调用 native 打开方法的门面类,一个继承InputStream的读包装类,一个继承OutputStream的写包装类,再加上关闭时释放 fd 的清理逻辑。native 方法的典型声明是private native int open(String path, int baudrate, int flags),open返回的 int 就是要持有的文件描述符。
这套设计的关键在于:Java 层的流对象与底层 fd 是绑定的,流生命周期必须跟设备开关周期严格同步。常见错误是只关闭了InputStream却没关闭底层 fd,导致串口被长时间占用、其它进程无法再打开。所以源码里close()方法通常会先在 Java 层标记一个mOpen = false,再调用 nativeclose(),并且在 native 内部做了 fd 合法性检查,防止close(-1)导致崩溃。
3.2 读线程模型:阻塞读还是事件轮询
串口数据是异步到达的,所以读操作必然要放到子线程。很多源码会在打开设备后启动一个读线程,在线程里循环调用InputStream.read(buffer)。阻塞模式下,没有数据时线程会自然挂起,不占 CPU;数据到达时驱动唤醒线程完成读取。如果 C 层没有把 fd 恢复为阻塞模式,这个循环就会变成空转,CPU 占用率直线上升。
@Override public void run() { byte[] buffer = new byte[256]; while (mIsOpen) { int length; try { length = mFileInputStream.read(buffer); if (length > 0) { byte[] data = new byte[length]; System.arraycopy(buffer, 0, data, 0, length); if (mDataListener != null) { mDataListener.onDataReceived(data); } } } catch (IOException e) { if (mIsOpen) { // 只有未主动关闭时的异常才需要上报 mDataListener.onError(e); } break; } } }逻辑说明这个读循环里三个容易写错的点。第一,mIsOpen先判断再read(),但read()可能长期阻塞在设备上,所以关闭方法里要同时close()流来打断阻塞;第二,buffer大小通常 128~256 字节,对于一帧几十字节的协议足够,遇到大帧场景再调大;第三,必须把IOException掐在本地,区分是主动关闭还是异常断开,否则线程会不正常终止。参数上我一般把buffer设为 512,覆盖绝大多数传感器和工业协议的帧长度。
3.3 写操作与串口驱动的发送缓冲
写操作相对简单,直接通过OutputStream.write()完成。需要留意的点是串口驱动的发送缓冲区并不是无限大,写入速度超过设备消费能力时,底层write()会阻塞,所以业务上发送协议帧的线程不要放在 UI 线程。另一个细节是:write()成功返回并不代表对方设备真的收到了完整数据,它只表示数据进入内核发送缓冲区,对讲机式的应用还要靠应答帧确认。
public void send(byte[] data) { if (!mIsOpen || data == null || data.length == 0) { return; } try { mOutputStream.write(data); mOutputStream.flush(); } catch (IOException e) { // 写入失败时记录日志,并触发重连回调 onConnectionError(e); } }这里flush()对流本身没有意义,因为没有用户态缓冲区,但保留这个调用是为了将来替换成带缓冲的包装流时行为保持一致。send()方法最好做成同步方法,或由上层消息队列统一调度,避免多个业务线程并发write()导致帧交叉错乱。多数设备协议对帧连续性敏感,一条指令被另一条指令的字节打断,解析端必然报错。
3.4 串口参数的可配置化设计
良好的源码不会把这些参数写死在 C 代码或者 Java 类的构造方法里,而是支持读取配置或动态传入。设备路径、波特率、数据位、停止位、校验位、流控方式这六项,每一项都应当被上层参数化。常见做法是提供一个配置类或SerialPortOptions结构体,打开时一次性传入。
| 参数 | 可选值 | 缺省建议 |
|---|---|---|
| 设备路径 | /dev/ttyS0、/dev/ttyMT0、/dev/ttyUSB0 | 运行时探测 |
| 波特率 | 9600、19200、115200 等 | 按设备手册 |
| 数据位 | 5、6、7、8 | 8 |
| 停止位 | 1、2 | 1 |
| 校验位 | NONE、EVEN、ODD | NONE |
| 流控 | NONE、RTS/CTS | NONE |
表格里的参数需要两者都要覆盖到:一是 C 层的 termios 宏定义,二是 Java 层的参数映射逻辑。特别提醒,波特率只能使用内核预定义的速率值,直接传一个随意数字给底层会设置失败或得到错误速率。非标波特率(如 2400、57600 之外的奇异值)需要内核和驱动支持BOTHER机制,绝大多数商用源码不处理这种情况。
4. 把源码集成进自己的 Android 工程:从编译到第一个 Demo
4.1 准备工作:拷贝源码与本地库进工程
无论从哪获得的源码,通用集成步骤都是类似的:找到 C 层源码或预编译的.so库、把 Java 层工具类拷贝到自己的src目录、配置CMakeLists.txt或把.so放到src/main/jniLibs/<abi>/下。如果是 .so 库,需要确认包含arm64-v8a和armeabi-v7a目录,某些源码只带 v7a,在 64 位真机上可能加载失败。
android { defaultConfig { externalNativeBuild { cmake { cppFlags "" // 指定要编译支持的 ABI abiFilters "arm64-v8a", "armeabi-v7a" } } } externalNativeBuild { cmake { path "src/main/cpp/CMakeLists.txt" } } }这段配置的含义是让 Gradle 在构建时自动用 CMake 编译 C 层源码,并只生成arm64-v8a和armeabi-v7a两种指令集。真机调试时这两个 ABI 基本覆盖全部设备;如果还要支持 x86 模拟器,可以临时加一个x86_64,但发布包不推荐带上,体积变大且没有实际意义。如果源码不附带 C 源码而只有 .so,那就省去externalNativeBuild配置,直接依赖预编译库。
4.2 初始化与打开串口的最小可用代码
集成调试阶段,不要上来就套复杂的业务框架。先用最小代码把设备打开、收到一串数据、再关掉,验证硬件链路是通的,然后再往上层加协议解析。最小可用的调用逻辑一般是:声明设备路径与参数 → 实例化串口工具类 → 注册数据回调 → 打开设备。
// 1. 确定设备节点:先执行 ls /dev/ttyS* 确认实际路径 String devicePath = "/dev/ttyS0"; int baudrate = 9600; // 2. 创建配置:数据位8,停止位1,无校验 SerialPortOptions options = new SerialPortOptions.Builder() .devicePath(devicePath) .baudrate(baudrate) .dataBits(8) .stopBits(1) .parity(SerialPortOptions.PARITY_NONE) .build(); // 3. 打开设备,读线程自动启动 SerialPort serialPort = new SerialPort(options); serialPort.setDataListener(data -> { // 收到数据后投递到业务线程,不要在这里做耗时操作 Log.d("SerialPort", "recv: " + bytesToHex(data)); }); serialPort.open();逻辑说明这个代码块演示了从配置到打开的最小闭环。bytesToHex是调试期间必备的辅助方法,把字节数组转换成十六进制字符串打印出来,方便和协议手册逐字节对照。对于协议复杂的场景,建议在回调里先做帧缓冲,再按帧长度或帧头解析整包数据,避免把半包数据交给业务逻辑。打开方法内部应该先检查设备是否已打开,防止重复调用造成 fd 泄露。
4.3 帧协议封装:粘包与半包处理
串口通信最常见的协议坑是“粘包”和“半包”。设备可能一次发来多个帧,缓冲区读一次也可能只读到半帧。成熟的源码会提供一个帧缓存区,把每次读到的数据追加到缓存尾部,然后按协议格式循环解析,逐帧回调上层。
private final ByteArrayOutputStream mFrameBuffer = new ByteArrayOutputStream(); public void onDataReceived(byte[] data) { // 1. 数据进入缓存 mFrameBuffer.write(data, 0, data.length); // 2. 按帧头+长度字段循环拆包 byte[] buffer = mFrameBuffer.toByteArray(); int offset = 0; while (buffer.length - offset >= 4) { // 假设帧格式: 0xAA 0x55 len data... if ((buffer[offset] & 0xFF) == 0xAA && (buffer[offset + 1] & 0xFF) == 0x55) { int frameLength = buffer[offset + 2] & 0xFF; if (buffer.length - offset < frameLength + 3) { break; // 数据不够一帧,等下一包 } byte[] frame = Arrays.copyOfRange(buffer, offset, offset + 3 + frameLength); mFrameListener.onFrameReceived(frame); offset += 3 + frameLength; } else { offset++; // 没有帧头,逐字节跳过 } } // 3. 处理剩余未消费数据 byte[] remain = Arrays.copyOfRange(buffer, offset, buffer.length); mFrameBuffer.reset(); mFrameBuffer.write(remain, 0, remain.length); }这段代码的逻辑是典型的“缓存 + 循环拆包”模式:先把新数据追加进缓冲区,再根据定义的帧头与长度字段判断当前缓冲区里有没有完整的一帧。有就切割出来回调,没有就跳出循环等待下包数据。跳字节找帧头的分支应对异常字节,防止数据错位后整个缓存区作废。参数上,协议长度字段通常是 1 或 2 字节,如果帧长度超过 255 就要改成双字节小端序;帧头越复杂(比如 4 字节魔数),误判概率越低,但拆包代码也更繁琐。
4.4 关闭与资源释放的完整顺序
串口关闭的顺序里藏着不少 bug。一个常见的错误是先把mIsOpen置为 false 再关闭流,然后直接 join 读线程,但在某些定制系统上读线程可能深度阻塞,join 会一直卡住。更稳妥的关闭顺序是:主动设置关闭标记 → 关闭InputStream打断阻塞 → 等待读线程退出并设置超时 → 最后关闭OutputStream和底层 fd。
public void close() { if (!mIsOpen) { return; } mIsOpen = false; try { if (mFileInputStream != null) { mFileInputStream.close(); // 打断阻塞读 } } catch (IOException ignored) { } try { if (mReadThread != null) { mReadThread.join(500); // 等待线程退出,避免残留 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } try { if (mFileOutputStream != null) { mFileOutputStream.close(); } } catch (IOException ignored) { } mSerialPortNative.close(); // 释放 fd }这段关闭流程的巧妙之处在于借助InputStream.close()从外部打断阻塞的read(),从而让读线程自然退出,而不是依赖一个不确定的接口标志位。join(500)是保险策略:如果驱动异常导致流关闭后线程仍不退出,最多等 500ms,避免关设备时 UI 卡死。mSerialPortNative.close()必须在最后调用,等所有 Java 层流全部释放后再收起 fd。
5. 串口集成避坑指南:5 个最常见的翻车现场
5.1 设备节点存在但打开报 Permission denied
现象:代码里路径写的是/dev/ttyS0,文件也存在,但open()返回 -1,日志里打出的 errno 是 13。原因:Android 的 SELinux 策略默认禁止普通应用访问 tty 设备节点,即使应用声明了READ_EXTERNAL_STORAGE也无济于事。解决:如果设备已经 root,调试阶段可以用setenforce 0临时放行;量产方案通常需要在系统固件的 sepolicy 里追加允许规则,或者有系统签名权限的 App 通过系统接口申请。经验之谈是,工业平板上跑串口应用,十有八九要跟设备厂商要一个定制固件或系统授权,不要指望纯应用层绕过。
5.2 read() 一直接收不全整帧,数据总是在半路断开
现象:打印收到的字节数组,每次只有几十字节,一帧 200 字节的数据要分四五次才能收完。原因:驱动按内核缓冲区大小与中断节奏上报数据,应用侧read()不会为你凑够一帧。解决:从根上接受“读到多少算多少”的现实,把协议拆包逻辑做在应用层,用 4.3 节那种帧缓存持续拼接。另外一个隐蔽原因是缓冲区开得太小,read(buffer)的 buffer 只有 64 字节,驱动一次可用数据再多也只能取 64 字节,改大到 512 或 1024 往往能减少切包次数。
5.3 波特率设了 115200,设备回的却是乱码
现象:收发硬件连线确认无误,参数配置也检查过,设备返回的字节流完全不可读。原因:最典型的是 C 层未正确设置对应波特率的宏,或者cfsetispeed与cfsetospeed只设置了一个。另一个可能位于不太容易被想到的位置:USB 转串口芯片的驱动层默认配置和代码配置不一致,导致实际链路速率与预期不同。解决:排除法很有效——先用系统自带串口调试工具把设备调通,再用自己的源码连;确认不是硬件问题后,回到 C 代码逐行打印tcgetattr读回的c_cflag实际值,检查波特率宏是否真的生效。
5.4 串口关闭后立刻重新打开失败
现象:执行close()后 1 秒内再次open()同一路径,返回 -1,提示Device or resource busy。原因:关闭时 fd 还被子线程持有,或者驱动内部有状态残留;还有一个常见原因是close()没被完整调用,Java 层流关了但 C 层 fd 没关。解决:关闭流程严格按 4.4 节顺序执行;若仍然复现,在 close 末尾加一次usleep或让上层业务在收到关闭回调后再等 200ms 重新打开。某些底层驱动确实需要几十毫秒释放端口,这种“玄学”问题见得多了,重试机制比纠结驱动源码更省时间。
5.5 数据回调里直接更新 UI 导致界面卡死或崩溃
现象:数据量一大,界面掉帧甚至 ANR。原因:串口读线程是一个高频触发源,在回调里直接runOnUiThread刷新控件,会把每帧数据都变成一次 UI 任务,消息队列瞬间堆积。解决:读线程只负责把帧投递到业务层的单线程队列,由专门的 HandlerThread 消费,只有在真正需要刷新 UI 的业务点上才切到主线程。比较实用的模式是回调里做数据聚合,比如每 100ms 把累积数据合并发布一次,而不是一帧一刷。
6. 把串口源码变成趁手的调试工具:一个长期值得投入的小习惯
源码集成稳定之后,我建议再做一层轻量封装:把它做成一个可独立运行的工具型 Activity,或至少保留一个不依赖业务模块的调试入口。界面不用复杂,一个设备路径下拉框、一个波特率选择框、一个发送输入框、一个滚动接收日志区就够。别小看这个工具,它解决的是实际开发里特别好用的痛——协议联调时,对方的设备不可能每次都连着你电脑上的 USB 转串工具,在 Android 设备上直接调,才是现场真实链路。
一个值得借鉴的做法是把接收区做成十六进制与 ASCII 双显,并且加上行号和时间戳。工业协议里经常出现“非打印字符 + 残留旧帧”的组合,纯文本模式下几乎无法定位问题。加上时间戳后,从设备发出到应用收到的时间差一目了然,能直接判断是链路延时长还是协议交互次数太多。我会习惯性地在工具页放一个“连续发送”按钮,用来长时间压测设备在高频询问下会不会掉线——这个测试数据在量产评估里非常有说服力。
在封装时给源码补两个能力:一是把当前收发的字节数统计出来,累计计数清零;二是把每条日志同步落盘。字节统计能直观看到实际吞吐量,和理论波特率对比就能大致判断底层链路是否健康。日志落盘的价值则在于复现问题:现场设备环境复杂,等用户把出问题的 logcat 抓回来,往往已经丢了上下文,有了本地文件,排查效率能提高一倍不止。
除此之外,确认数据稳定后,再考虑把它收敛为独立 Module,串口逻辑与业务完全隔离,对外只暴露 open/send/close 三个接口。这样以后从 Android 工程切到系统定制固件或换平台,迁移成本都控制在很小的范围。这套源码对我来说更像是一个持续迭代的底座——每次遇到新硬件平台,把设备节点映射和波特率策略调整一下,剩下的稳定性保障都是现成的。希望能帮你的串口项目少走几段弯路。
本文还有配套的精品资源,点击获取