☰
Android串口通信实战:从源码拆解到设备联调的关键路径
2026/10/14 19:22:22 网站建设 项目流程

简介:面向安卓开发者的串口通信工具完整源码包,主体解决移动设备与全球定位系统、单片机等外部串口设备的对接问题,适合需要实现硬件通信的嵌入式应用开发者以及进阶安卓工程师参考。压缩包共含四十三个文件,涵盖七个核心业务逻辑源码、六个底层硬件访问动态库、三个第三方库文件,以及配置资源、构建脚本与界面素材等,压缩后整体约四百六十九千字节,结构清晰便于按模块查阅。该工程已有超过一千七百九十人学习,具备一定参考热度。源码完整演示了串口通信的关键环节:从安卓六点零运行时权限申请、第三方串口接口封装,到波特率数据位校验位等参数配置、数据发送与回调接收,再到后台线程管理、异常捕获、设备枚举与连接状态变更提示,均有具体实现。项目同时包含原生层代码与完整工程配置,可帮助开发者快速吃透串口读写原理,并在此基础上改造出生产可用的通信工具。

1. Android 串口通信实战:从源码拆解到设备联调的关键路径

做 Android 开发的人遇到串口通信需求,第一反应往往是找现成源码,但下载下来能直接跑通的少之又少。这套 Android 串口工具源码的价值在于,它把设备节点枚举、权限处理、native 层 termios 配置、数据读取线程这几个串口开发的核心环节都封装好了,直接给你一条从打开设备到收发数据的完整链路。适合正在做工业设备对接、物联网网关、外设控制类 App 的开发者,也适合那些刚接手串口项目、对底层原理还不够熟的从业者。我拆完这套源码后发现,真正卡住大多数人的不是代码本身,而是对串口参数配置、权限模型和读取机制的理解,这篇笔记就把这些内容逐一展开。

2. 串口通信的基本盘:帧格式、波特率与 android 侧权限模型

2.1 先搞懂串口帧格式:数据位、校验位、停止位怎么配

串口通信看起来是两个设备之间一根线收发数据,但这条线上传递的是异步串行数据,收发双方必须按同一套帧格式来解析比特流。一个完整的串口帧由起始位、数据位、可选校验位、停止位组成。默认配置是 8 数据位、无校验、1 停止位,即 8N1,这也是绝大多数设备出厂固件默认值。

在资源里的协议文档中,设备端遵循的是标准 UART 帧格式,波特率 9600 或 115200,8N1。若你的外设文档写的是 7E1 或 8O1,就需要在打开串口时同步修改数据位、校验位、停止位这三个参数。参数不匹配时最常见的现象是能打开串口但收上来的数据全是乱码,或是设备完全无应答。需要特别注意的是,校验位分奇校验(ODD)、偶校验(EVEN)与无校验,选择哪种取决于外设文档定义,上层应用无法通过软件自动探测。

2.2 波特率选型:能跑多快不只看数字,还要看线材与驱动能力

波特率决定了每秒传输多少个比特,常见选型有 9600、19200、38400、57600、115200。绝大多数 Android 串口外设支持 115200,这个速率在短距离、低干扰环境下很稳定,也是很多开发板与工业模块的默认值。但这不意味着 115200 一定是最优解,当线材较长或现场有电机、变频器等强干扰源时,需要降速到 9600 以保证误码率可控。

源码里通过serial_port.setBaudRate(115200)这样的调用来设置波特率,底层会将这些数值转为 Linux termios 结构中的速度码。常见的 termios 速度码包括 B9600、B19200、B38400、B57600、B115200,android_serialport_api 库内部已经做了映射。如果你需要自定义波特率,比如 4800 或 250000,就得在 native 层自行扩展速度码映射表,否则会打开失败或按错误速率通信。

2.3 Android 侧权限模型:为什么设备节点打开了还是读不到数据

Android 系统基于 Linux 内核,串口设备节点通常位于/dev/ttyS0、/dev/ttyMT1或/dev/ttymxc0。普通应用没有直接访问这些节点的权限,这就是很多人把源码编译通过、串口也能打开,但 read 返回空或直接抛异常的根本原因。标准做法是在初始化串口时,通过File.setReadable(true)与File.setWritable(true)修改节点权限,但这要求应用具备 root 权限。

国内绝大多数定制 Android 设备出厂时已对常用串口节点做了软链映射或权限放开,比如将/dev/ttyS1软链到/dev/ttymxc1,并配置 ueventd 规则。源码中的 SerialPortFinder 组件会扫描/dev目录下的 tty 设备并枚举出可用串口列表,这比硬编码设备路径要稳妥得多。但注意,扫描结果依赖设备节点的权限与 SELinux 策略,某些 Android 5.1 以上设备即使节点存在也无法在 Java 层直接 read,必须额外处理 SELinux 策略,这是另一个高频坑点。

3. 源码模块拆解:从打开设备到环形缓冲读取的完整链路

3.1 SerialPort 核心类: open 方法的参数与返回逻辑

源码的核心是一个封装了 native 调用的 SerialPort 类,它的 open 方法接收设备绝对路径、波特率与 flags 参数。调用流程是先 new File(path) 并对文件设置读写权限,然后通过 jni 调用 native open 函数获得文件描述符 fd,最后用该 fd 构造 FileInputStream 与 FileOutputStream。部分实现还支持 flags 参数,用于传递 termios 配置中的原始模式标志。

public SerialPort(File device, int baudRate, int flags) throws IOException { this.device = device; if (!device.canRead() || !device.canWrite()) { try { Process su = Runtime.getRuntime().exec("/system/bin/su"); // 通过 root 权限修改节点读写权限 su.getOutputStream().write(("chmod 666 " + device.getAbsolutePath() + "\n").getBytes()); su.getOutputStream().write("exit\n".getBytes()); su.waitFor(); } catch (Exception e) { throw new SecurityException("串口节点权限修改失败,请检查 root 权限或设备节点映射"); } } fd = open(device.getAbsolutePath(), baudRate, flags); if (fd == -1) { throw new IOException("native open 返回 -1,设备可能被占用或路径不存在"); } FileInputStream fis = new FileInputStream(fd); FileOutputStream fos = new FileOutputStream(fd); }

这段代码的关键在于先检查节点权限,再执行 native open。很多定制设备在/system/bin/su这一层做了限制,如果设备的 su 路径不同,比如是/system/xbin/su,就需要在代码里做路径探测,否则权限修改失败会直接抛 SecurityException。另一个容易忽略的地方是new FileInputStream(fd)用文件描述符构造流,而不是用路径重新打开,这是为了避免设备被重复打开导致 ioctl 冲突。

3.2 native 层 open 的实现:termios 配置与原始模式

native 函数 open 是串口初始化真正的执行者,它负责打开设备节点并配置 termios 结构体。底层代码会先调用标准 C 的 open 函数获得 fd,然后使用 tcgetattr 获取当前终端参数,再修改 c_cflag、c_iflag、c_lflag 等字段。串口通信必须设置为原始模式,即关闭 ICANON、ECHO、ISIG 等标志,否则数据会被终端驱动进行行缓冲和回显处理,导致读取到的内容与设备发送的不一致。

static jint open(JNIEnv *env, jobject thiz, jstring path, jint baudrate, jint flags) { int fd = -1; int speed = getBaudRateConstant(baudrate); // 打开串口设备,O_RDWR | O_NOCTTY | O_NDELAY 避免阻塞 fd = open(path_utf, O_RDWR | O_NOCTTY | O_NDELAY); struct termios cfg; tcgetattr(fd, &cfg); cfmakeraw(&cfg); cfsetispeed(&cfg, speed); cfsetospeed(&cfg, speed); cfg.c_cflag |= (CLOCAL | CREAD); cfg.c_cflag &= ~CSIZE; cfg.c_cflag |= CS8; cfg.c_cflag &= ~PARENB; cfg.c_cflag &= ~CSTOPB; tcsetattr(fd, TCSANOW, &cfg); return fd; }

这里有两个细节值得展开。其一是O_NDELAY,它表示打开设备时不需要等待 DCD 信号,某些设备如果未接数据线或对端未就绪,没有这个标志会直接阻塞导致 open 超时。其二是cfmakeraw与后续手动设置 c_cflag 的组合,部分源码会保留 cfmakeraw 后自行赋值数据位与校验位,若外设需求不是 8N1,需要在 native 层同步修改 CSIZE、PARENB、CSTOPB 三组掩码,否则 Java 层传入的数据位参数不会生效。这个坑在实际项目中很常见:应用层配置了 7E1,但 native 层写死为 8N1,结果设备响应异常。

3.3 环形缓冲与读取线程:为什么收不到完整一包数据

数据接收是串口工具最核心的部分。源码中通常采用独立读取线程 + 环形缓冲区(RingBuffer)的方案。读取线程不断从 FileInputStream 读取字节并写入 RingBuffer,业务层在需要时从 RingBuffer 中取数解析。相比每来一帧数据就回调一次,环形缓冲能有效应对粘包与半包问题,尤其是设备连续上报的传感器数据流。

class SerialReadThread extends Thread { private final InputStream inputStream; private final RingBuffer ringBuffer; private volatile boolean running = true; @Override public void run() { byte[] buffer = new byte[512]; while (running) { try { int len = inputStream.read(buffer); if (len > 0) { ringBuffer.put(buffer, 0, len); // 通知业务层有新数据到达 dataAvailableListener.onDataAvailable(); } } catch (IOException e) { // 读取异常时暂停线程并等待重连 running = false; } } } }

读取线程的read(buffer)是阻塞式读取,当串口没有数据时会一直阻塞在系统调用上。这个设计的好处是 CPU 占用率极低,坏处是关闭串口时直接中断线程可能无法解除 read 阻塞,因此常规做法是先关闭 FileInputStream,让 read 返回异常再退出循环。Buffer 大小选 512 字节是经验值,如果你对接的设备单包数据超过 512 字节,需要调大,否则一包数据会被拆到两次读取中,解析时就会出现残缺帧。RingBuffer 的容量也建议预设为单包最大长度的两倍以上,避免写入速度高于消费速度时丢数据。

4. 移植到实际项目:代码集成、协议解析与界面联调

4.1 把串口库集成进项目的三个步骤与常见错误

把源码中的 serialport 模块集成到自己的 Android 工程,不是简单把 java 文件拷进去就完事。常见做法是把serialport作为独立 Library Module 引入,并将 native 层编译好的 so 文件按 armeabi-v7a、arm64-v8a 分别放入对应 ABI 目录。这里最常见的错误是把 x86 的 so 放到 arm 设备上,导致运行时 UnsatisfiedLinkError。

# 进入源码 native 目录,用 ndk-build 编译 so 库 cd serialport/src/main/jni # 指定 Android NDK 路径 export ANDROID_NDK_HOME=/opt/android-ndk ndk-build APP_ABI=armeabi-v7a,arm64-v8a # 编译产物拷贝到 jniLibs 目录 cp -r libs/* ../jniLibs/

编译时建议同时输出 armeabi-v7a 与 arm64-v8a 两个版本,虽然 64 位设备能兼容 32 位 so,但部分定制系统已强制 64 位进程,不提供 arm64 库会直接闪退。如果是在 Android Studio 中用 CMake 构建,需要在 build.gradle 中配置 externalNativeBuild 的 abiFilters,使最终 apk 只包含需要的 ABI 以缩小体积。

4.2 十六进制收发与 CRC 校验:解决设备协议解析的最后一公里

拿到串口原始字节流后,通常要对设备协议进行帧解析。大多数工业设备协议采用帧头、地址码、功能码、数据域、校验码、帧尾的结构,其中校验常用 CRC16-Modbus 或 CRC16-CCITT。源码或配套文档中若包含协议解析示例,一般会提供 CRC16 查表算法,查表法比逐位计算法快得多,适合在 Android 设备上高频解析。

uint16_t crc16_modbus(uint8_t *data, uint16_t len) { uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < len; i++) { crc ^= data[i]; for (uint8_t j = 0; j < 8; j++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; }

代码里crc初始值为 0xFFFF,这是 Modbus 协议标准要求。多项式 0xA001 是 0x8005 的反转形式,逐位计算法理解起来直观,但若每帧数据几百字节且刷新频率 50Hz,Android 的 Java 层做逐位 CRC 会带来明显 CPU 开销,更推荐把这段逻辑放在 JNI 层或用查表法。与之配套的还有十六进制字符串与字节数组互转工具,调试串口时通常要同时支持字符串和 Hex 两种显示方式,因为设备返回的数据可能是不可见字符。

4.3 打开串口白屏或闪退:检查清单与时序问题

很多人在布局中直接放置一个 SerialPort 操作面板,进入页面就尝试打开串口,在低端设备上会出现页面卡顿甚至白屏。这是因为串口打开与初始读取可能涉及权限申请、su 进程等待等耗时操作,如果在主线程执行,会在系统无响应后触发 ANR。正确的做法是把 open 操作放入工作线程,打开成功后通过 Handler 或回调更新界面。

new Thread(() -> { try { serialPort = new SerialPort(new File("/dev/ttyS1"), 115200, 0); runOnUiThread(() -> openStatusTextView.setText("串口打开成功")); } catch (IOException e) { runOnUiThread(() -> openStatusTextView.setText("打开失败: " + e.getMessage())); } }).start();

这里把串口对象设为类成员变量而不是局部变量,是为了避免线程执行完对象被回收导致 native 层 fd 泄漏。另一个容易忽略的点是串口关闭时要把 FileInputStream 和 FileOutputStream 都置空并调用 close,否则再次打开同一节点时会报设备或资源忙。判断是否真正释放,可以连续执行打开、关闭、再打开三次,第三次仍失败基本可以断定 fd 没有正确关闭。

5. 避坑指南:Android 串口开发五个高频翻车现场

5.1 节点权限已是 666 却仍然打不开,实际是 SELinux 拦截

现象上,ls -l /dev/ttyS1显示权限是 crw-rw-rw-,应用也有 root 权限,但 open 仍然返回 permission denied。原因在于 Android 5.0 之后全面启用了 SELinux enforcing 模式,即使 DAC 权限允许,MAC 策略也会拦截非系统域对串口节点的访问。解决方法是关闭 SELinux 或编写针对性的 te 策略,但关闭 SELinux 在正式产品上不可取,建议在有 root 权限的调试阶段先执行setenforce 0确认问题根因,再决定是否在 init.rc 中对串口节点做 label 配置。

5.2 使用 8250 驱动设备时,read 偶尔返回 0 导致丢包

现象上,读取线程read()返回 0,但设备明明在发送数据。原因在部分老的内核驱动中,串口接收 FIFO 触发阈值设置不当,或者上层 read 调用时驱动还没来得及把 FIFO 数据搬入 tty 缓冲区。解决方法是让读取线程在返回 0 时不要立即退出,而是 sleep 10ms 后继续从同一个流中读取,多数情况下下一轮 read 就能拿到完整数据。如果丢包是持续性的,则需要检查波特率是否超过该设备的实际最高稳定速率。

5.3 用 USB 转串口芯片时,设备节点会在断电后漂移

现象上,开发时用/dev/ttyUSB0,设备重启后变成了/dev/ttyUSB1,应用打开失败。原因在于 Linux 内核枚举 USB 设备时,ttyUSB 节点编号不保证固定,与插入顺序有关。解决方案是不要硬编码节点路径,而是通过源码中的 SerialPortFinder 根据驱动名称或 vendorId、productId 来识别设备。更稳妥的做法是在应用启动时扫描一次可用串口列表,让用户选择或按设备硬件标识符自动匹配,我在实际项目中就遇到过同一台设备因 USB HUB 供电不稳导致节点漂移的情况,硬编码路径在出厂后维护成本极高。

5.4 串口能打开能收数据,但发送出去的指令设备不响应

现象是收数据正常,说明波特率与帧格式是对的,但设备对指令无响应。此时先排查发送时的字节序与帧结构,比如设备要求先发高位字节,而代码按低位在前推帧。另一个更隐蔽的原因是FileOutputStream.write(byte[])只是写入系统缓冲区,没有立即 flush。虽然 FileOutputStream 的 flush 是空实现,但某些定制 ROM 的 JNI 实现可能做了用户态缓冲,需要在写入完成后调用fos.flush(),再Thread.sleep(20)等待设备处理,尤其是对讲模块和 GPS 模块这类响应时序敏感的设备。

5.5 串口调试一切正常,打包正式 APK 后无法读写

现象是 debug 包在开发板上读写串口都正常,release 包装上去后打开失败。原因通常是正式包的签名或进程 UID 与开发板内置白名单不匹配,部分定制系统只在系统应用白名单中放行了已签名应用对串口节点的访问。解决方法是把 APK 作为系统应用预置进系统,或在 framework 层针对特定 UID 放行串口访问。这是产品化过程中最容易被忽略的一环,源码 demo 跑通离真正上线还有一段系统适配的路要走。

6. 串口调试技巧进阶:用 RTS/CTS 流控排查数据丢失与回声

当收发数据出现偶发丢失或错位时,很多人会反复调波特率、换线材,却忽略了硬件流控这一层。串口通信除了 TX 和 RX 两条数据线,还有 RTS/CTS 与 DTR/DSR 两组握手信号线。源码里 open 方法最后一个参数 flags 就是为这些流控配置预留的入口,例如flags = 0表示关闭流控,flags中指定CRTSCTS则启用硬件流控。启用硬件流控后,接收端通过拉低 RTS 通知对端暂停发送,避免接收缓冲区溢出。

if (flags & CRTSCTS) { cfg.c_cflag |= CRTSCTS; } else { cfg.c_cflag &= ~CRTSCTS; }

实际调试中我通常建议关闭硬件流控除非设备文档明确要求。因为在 Android 侧,流控信号的处理依赖内核驱动对 modem 信号线的模拟,很多开发板虽然把 RTS/CTS 引脚引出了,但驱动层面并没有真正控制这两根线,启用后反而会把数据线上的正常电平变化误判为流控状态。排查办法是在设备上执行stty -F /dev/ttyS1 -a查看当前流控标志,若 clocal 未开启且 CTS 信号不稳定,需要先确认驱动是否完整支持。

回声问题同样值得单独说。某些双向透传设备在调试模式下会把发送的数据原样返回,导致应用发出的指令又出现在接收缓冲区里,如果协议解析没有按帧头做状态机隔离,会把回声当成设备的主动上报数据,进而产生错误的控制逻辑。排查方法是先向设备发送一条无业务含义的空帧,比如00 00 00 00,看接收端是否在短时间内收到完全相同的数据,收到即可确认回声。此时需要在解析层过滤本地发送副本,或者确认设备端关闭调试模式。

从那以后,我每次接到新的串口设备,都会先按这套流程走一遍:确认权限与 SELinux 状态,扫描节点并确认驱动名,op 一个最小测试页验证原始收发的字节内容,再开始写协议解析。收不到数据先查硬件流控和回声,乱码再回头核对波特率与帧格式。这套习惯帮我避开了绝大多数串口联调里的隐藏问题,也让我把这份源码的价值发挥到了最大化。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询