ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Android串口通信实战:从JNI termios到产品化移植全解析

Android串口通信实战:从JNI termios到产品化移植全解析 简介面向Android开发者的串口通信完整示例项目与源代码包重点解决Android平台与外部硬件设备之间的数据交互难题。压缩包共2000个文件大小16.69MB包含Java源文件、class编译文件、XML布局与配置文件、JAR依赖库、APK安装包及SO动态库等整体工程结构完整清晰既可直接导入Android Studio学习也可运行APK进行实测。目前已有605人学习下载。资源聚焦串口初始化和参数配置涵盖端口号如/dev/ttyUSB0、波特率如9600、115200、数据位、停止位、校验位等设置并提供readData、writeData、closePort等核心方法的实现思路同时涉及Android权限申请、USB转串口适配器识别、异常处理与UI交互设计。通过阅读和运行示例开发者能够快速掌握Android串口通信的完整流程并依据实际硬件需求灵活扩展与移植。 接手过 Android 串口通信需求的开发应该都懂第一次拿到一块开发板、一个外设模块对方甩给你一份协议文档让你“用 Android 读一下串口数据”大多数人是懵的。因为 Android 应用层压根没有官方串口 API官方 SDK 里连个 SerialPort 类都没有。你搜一圈资料最后大概率会找到这个叫 SerialPort 的 Android 串口通信 demo 和源代码包也就是项目标题里那个 zip 文件。这个包其实是开源圈非常经典的 android-serialport-api 工程核心思路是用 JNI 调 Linux 底层的 termios 接口操作串口设备节点。这篇博文我会基于这个 demo 把原理、编译、代码、真机调试、产品化改造整个链路讲透适合刚接触 Android 串口通信的开发者也适合已经在做但老踩坑的同行参考。1. 先说清楚这个 demo 解决了什么核心问题1.1 Android 里没有官方串口 API串口操作的本质是操作 Linux 设备节点很多新手问的第一个问题就是Android 不是有 BluetoothSocket、UsbManager 吗为什么不能直接拿来串口通信因为这些 API 对应的都是 Android 抽象好的上层协议而串口本质上是一个 Linux 字符设备节点。你打开/dev/ttyS0对它执行 open、read、write、ioctl底层走的是 Linux 内核的 UART 驱动。Android 只是继承了 Linux 内核却没有把这块能力封装成 Java 层的标准接口。所以想从应用层访问串口路径只剩下两条要么用 JNI 直接写 C 代码调系统调用要么反射调隐藏的 API。而这个 demo 选择的做法就是前者它把最底层的 open、close、read、write、波特率设置等逻辑全部用 C 实现再通过 JNI 暴露给 Java。你说它是个轮子它确实是个轮子但这是一个帮你省掉大量 termios 配置工作的轮子。波特率、数据位、停止位、校验位这些参数在 Linux C 里是通过struct termios结构体配置的标准做法是tcgetattr拿到当前属性、cfsetispeed/cfsetospeed设置波特率、cfmakeraw设置原始模式最后tcsetattr生效。1.2 为什么直接拿这个 demo 而不是自己从零写 JNI自己写 JNI 串口库不是不行但除非你是那种遇到问题喜欢从内核开始啃的性格否则完全没有必要。这个 demo 的代码量并不大但边界情况处理得相对完整比如 open 失败返回空、波特率映射表、底层文件描述符的关闭时机等。它还把 FileDescriptor 直接包装成FileInputStream和FileOutputStream这意味着上层能像读普通文件一样读串口数据。这个设计非常讨巧也大大降低了使用门槛。还有一个很现实的原因这个工程在社区里被验证过很多年大部分底层串口通信的坑都被踩过了。你拿到一份串口协议去对接设备最怕的不是数据格式复杂而是底层的原始字节流都读不出来。用成熟库可以让你把精力集中在业务协议上而不是去调试tcsetattr某个参数导致 read 卡死的问题。如果你用的是 USB 转串口方案那另当别论那块基本走usb-serial-for-android这个库但板载 UART 串口比如 RK 系列、全志、高通平台出来的 ttyS 节点用这个 JNI 方案依然是最稳的。2. 动手编译前项目结构与 NDK 环境准备2.1 目录里的每个文件是干什么的这个 zip 解压之后你会看到两个核心模块一个是serialport-lib一个是serialport-terminal。前者是底层库工程后者是上层 Demo 界面。真正值钱的是前者目录结构大致是这样serialport-lib/ ├── android_serialport_api/ │ ├── SerialPort.java // Java 层 JNI 封装 │ └── SerialPortFinder.java // 扫描系统里的串口设备节点 └── jni/ ├── SerialPort.c // JNI C 实现 ├── SerialPort.h // JNI 头文件 ├── Android.mk // NDK 构建脚本 └── Application.mk // ABI 平台配置SerialPort.java是 Java 层入口声明了 native 方法open和close并在静态代码块里加载serial_port动态库。SerialPortFinder.java负责扫描/dev目录和/proc下的设备信息帮你列出当前系统存在哪些串口节点这个在开发调试时非常有用。SerialPort.c是核心它做三件事打开设备文件、配置 termios 参数、返回文件描述符给 Java 层。2.2 NDK 版本和 .so 产物的坑这个 demo 是老工程构建脚本用的是纯 NDK 时代的ndk-build和现在 Android Studio 默认的 CMake 构建方式不太一样。但思路是通用的你需要做的是把它编译出libserial_port.so然后放到 app 的 jniLibs 目录里。如果你是在 Android Studio 里集成最简单的方式是新建一个app/src/main/jniLibs/armeabi-v7a/目录把编译产物放进去然后在SerialPort.java里保留System.loadLibrary(serial_port)即可。这里我必须单独说一个新手最容易踩的坑ABI 匹配问题。这个工程早期版本的Application.mk通常只写了APP_ABI : armeabi甚至有些版本没写默认编出 armeabi。放到现在的 64 位设备上运行抛一个java.lang.UnsatisfiedLinkError: couldnt find libserial_port.so一点也不奇怪。解决办法是编译时指定多平台APP_ABI : armeabi-v7a arm64-v8a x86 x86_64需要留意的是如果你的应用里还有其他 JNI 库它们之间的 ABI 必须保持一致否则安装时会报INSTALL_FAILED_NO_MATCHING_ABIS。编译 .so 的时候顺手用file命令看一下产物是 32 位还是 64 位能省掉不少排查时间。另外 NDK 版本建议不要用太老的用 r20 左右配合ndk-build基本没毛病太新的 NDK 反而会移除一些过时配置导致构建脚本报错。3. 核心代码逐行拆解从 open 到 read/write3.1 SerialPort.java 的 open 流程打开串口这件事Java 层代码并不复杂核心就是调用 native 方法拿到一个FileDescriptor然后包装成输入输出流。我简化一下public class SerialPort { private FileDescriptor mFd; private FileInputStream mFileInputStream; private FileOutputStream mFileOutputStream; public SerialPort(File device, int baudrate, int flags) throws IOException { mFd open(device.getAbsolutePath(), baudrate, flags); if (mFd null) { throw new IOException(native open returns null); } mFileInputStream new FileInputStream(mFd); mFileOutputStream new FileOutputStream(mFd); } private native static FileDescriptor open(String path, int baudrate, int flags); public native void close(); static { System.loadLibrary(serial_port); } }这里有个值得注意的设计Java 层只负责传入设备路径和波特率所有底层细节全部下沉到 C 层。FileInputStream和FileOutputStream是同一个文件描述符的两个不同方向流read 走输入流write 走输出流。用流而不是用RandomAccessFile的好处是你不需要自己处理缓冲区IO 操作也符合 Java 开发者的直觉。C 层open实现里有个容易被忽略的参数就是 flags。demo 默认使用O_RDWR | O_NOCTTY | O_NDELAY其中O_NOCTTY表示如果打开的是一个终端设备不要把它当作控制终端这个必须带上否则可能影响进程信号O_NDELAY表示非阻塞打开这一步只影响 open 行为不影响后续 read。真正让 read 变成阻塞模式是在设置 termios 时通过清掉O_NONBLOCK来控制的这部分很多资料没讲清容易让人误解。3.2 阻塞读线程的正确写法串口的 read 默认是阻塞的意思是你调用inputStream.read(buffer)之后如果设备没有发数据这个线程就会一直卡在那里。所以正确姿势是单独开一个读线程在循环里执行 read拿到字节就回调上层。我见过不少新人的错误是在 UI 线程里直接 read结果界面卡死。一个标准的读线程大概长这样Thread readThread new Thread(() - { byte[] buffer new byte[512]; int size; while (isReading) { try { size mInputStream.read(buffer); if (size 0) { byte[] data new byte[size]; System.arraycopy(buffer, 0, data, 0, size); callback.onDataReceived(data); } } catch (IOException e) { break; } } }); readThread.start();这里的退出条件很关键。很多人的isReading置为 false 后线程却退不出来因为 read 还阻塞在系统调用里。正确做法是在 close 流程里先关掉输入流让阻塞中的 read 抛出 IOException进而跳出循环。我自己在封装时喜欢写一个close()方法先置标志位再关闭流然后设置线程中断这样基本能保证线程在几百毫秒内退出不会出现资源泄漏。4. 真机调试完整链路设备节点、权限、跑通 demo4.1 先找到你的串口节点/dev/ttyS* 与 /proc/tty/drivers拿到板子之后第一件事不是写代码而是搞清楚你要用的串口对应哪个设备节点。串口节点通常叫/dev/ttyS0、/dev/ttyS1等但不同平台、不同内核版本名字可能差异很大。USB 转串口芯片一般是/dev/ttyUSB0或/dev/ttyACM0。你可以在 adb shell 里执行这些命令来排查ls -l /dev/ttyS* cat /proc/tty/drivers cat /proc/tty/devices/proc/tty/drivers会列出当前内核注册了哪些串口驱动/proc/tty/devices可以看到设备节点的主次设备号。如果你用的是开发板厂商一般会在文档里说明哪个物理串口对映哪个节点如果没说明最土的办法就是把 TX、RX 短接后看有没有回显或者用SerialPortFinder把扫描结果直接打印出来。顺便提一句宿主机 Windows 通过 VMware 里的 Linux 来串口调试也有类似需求但那属于宿主机把串口映射给虚拟机本质是 VMware 的串口重定向和你 Android 端调串口是两码事不要把两边的设备节点搞混。4.2 Permission denied 的 3 种解法串口设备节点默认不属于普通应用可访问的范畴。直接在 demo 里 open/dev/ttyS0最常见的异常就是Permission denied。这个坑几乎人人都会踩而且它在日志里的表现不一定是权限字样有时候是 open 返回 null有时候是读取直接异常。解法无非三种。第一种最暴力设备有 root临时执行adb shell chmod 666 /dev/ttyS0验证代码逻辑时用可以重启失效不能用于量产。第二种适合开发板修改 init.rc 或使用udev规则在系统启动阶段把目标节点的权限改成 666或者在/system/etc/permissions里给指定用户组放开访问。第三种是正规产品化做法在系统镜像中把你的应用放进对应的用户组或者让应用拥有 root 权限再用 su 提权后 open这个一般需要整机方案定制。从源码层面看这个 demo 里也提供了动态改权限的思路如果设备支持 su可以在 open 前执行/system/bin/su -c chmod 666 /dev/ttyS0之类的命令。但我要提醒一句这种方式依赖系统 root 授权弹窗而且某些定制系统的 su 行为千奇百怪产品化时不要把它当成长期方案老老实实在固件层解决权限才靠谱。4.3 最小可用的读写测试跑通 demo 时我建议先写一个最简单的测试页面进入页面自动打开串口开一个读线程把收到的字节转成十六进制直接显示在界面上界面放两个按钮一个发送固定帧一个关闭串口。不要上来就接复杂的业务协议先把链路打通。写发送时直接调mOutputStream.write(bytes)记得 flush。但也要注意串口是低速设备某些模块对发送间隔有要求连续写两个帧不加延时设备可能把后面的数据丢掉或者把它当成同一个包处理。如果设备是 RS485 半双工模式还需要处理收发方向的切换这个一般通过 GPIO 或者串口芯片的自动化管理实现代码层发送后必须等设备处理完再切换方向。这些细节在协议文档里通常不会写但实际联调时非常影响体验。5. 从 demo 到产品级移植粘包、异常恢复与排查手册5.1 半包粘包与数据帧缓冲方案demo 只是让你读到了原始字节流但真实产品里你收到的一定是一段连续的数据流需要按协议把帧切出来。这是串口通信里最典型的业务问题粘包、半包。我常用的方案是维护一个 FIFO 缓冲区每收到一段字节就先 append 进去然后循环检查里面有没有一个完整帧。解析思路有三种固定帧头帧尾、固定长度、长度字段。最简单可靠的是“帧头 长度 数据 校验”的协议判断逻辑大致是ByteArrayOutputStream buffer new ByteArrayOutputStream(); void onRawData(byte[] chunk) { buffer.write(chunk, 0, chunk.length); byte[] all buffer.toByteArray(); int pos 0; while (pos all.length) { // 找帧头 if (all[pos] ! (byte) 0xAA) { pos; continue; } if (pos 1 all.length) break; // 缺帧头第二个字节等下次 if (all[pos 1] ! (byte) 0x55) { pos 2; continue; } if (pos 3 all.length) break; // 长度字段未收全 int len (all[pos 2] 0xFF); if (pos 3 len all.length) break; // 数据未收全 byte[] frame Arrays.copyOfRange(all, pos, pos 3 len); handleFrame(frame); pos 3 len; } if (pos 0) { byte[] remain Arrays.copyOfRange(all, pos, all.length); buffer.reset(); buffer.write(remain, 0, remain.length); } }半包处理的核心是“数据没凑够就先攒着”粘包处理的核心是“一帧取完后把剩余部分继续解析”。这里没有银弹多少要结合你的协议特点来写但思路是一样的。5.2 异常断开自动重连串口是物理链路拔插、休眠、外部干扰都可能导致文件描述符异常。产品级代码不能出现“读线程静默退出用户重启 app 才能恢复”的尴尬情况。我的做法是把串口模块封装成一个状态机空闲、打开、运行、重连。读线程遇到 IOException 时不直接结束而是先进入关闭清理流程然后尝试重连。重连前要判断设备节点是否还存在文件是否可写以及距离上次失败的时间间隔不能让它在设备真正离线时十几秒就狂刷报错日志。常见的重连间隔是 2 秒、5 秒、30 秒递增最多试几次后转人工按钮。另外Android 系统休眠时串口设备的电源可能被断开这时候可以在应用层申请唤醒锁或者在业务上先发送唤醒指令确认设备响应后再进入完整协议交互。这个话题很大但串口通信做产品化迟早要面对。5.3 常见问题排查速查表我在这个项目上踩过的坑不少整理成一个速查表按症状排查会快很多症状可能原因解决思路open 返回 null / FileNotFound设备节点不存在用/proc/tty/drivers确认节点名称Permission denied节点权限不足固件层放开权限或 su 提权读线程一读就退出流被提前 close 或 fd 异常检查 close 流程线程异常日志乱码波特率不一致确认双方波特率、数据位、停止位完全一致偶尔乱码接线电平问题或干扰检查 TX/RX 是否接反共地缩短杜邦线发出去没响应半双工方向未切换RS485 场景检查收发切换 GPIOUnsatisfiedLinkErrorSo 文件 ABI 不匹配补 arm64-v8a或用 abiFilters 对齐粘包/半包没做数据帧缓冲按帧头长度校验循环解析缓冲区休眠唤醒后读不到数据串口外设断电申请唤醒锁或重新初始化串口最后再说一个我自己的使用习惯。每次调 Android 串口前我都会先用电脑上的串口助手确认外设的发送逻辑和帧格式再回到板子上联调。因为串口这类的排错链路必须从源头开始一级一级验证先确认硬件、再确认系统节点、最后确认应用代码顺序错了会浪费大量时间。这样做之后基本不会出现对着 Android 日志猜半天、最后发现是硬件根本没发数据的情况。本文还有配套的精品资源点击获取
返回列表