Android RS485通信避坑指南:android-serialport-api的权限与发送完成判定
1. 从一次锁板现场说起为什么 Android 上的 485 通信没那么简单去年接手一个智能柜项目主控是一块 Android 工控板下面挂了一串基于 RS485 的电子锁板走 Modbus RTU 协议。需求听起来很朴素开锁、读锁状态、批量巡检。我当时的心理活动是这不就是个串口读写吗半天搞定。结果这一搞前后折腾了将近两周其中大部分时间不是在写业务逻辑而是在跟android-serialport-api这个老库的两个深坑死磕。先把结论摆出来省得你走弯路Android 上做 RS485 通信难点从来不在 Modbus 协议本身而在于串口设备的打开方式、权限模型、以及收发时序的控制。android-serialport-api这个库网上流传最广的那份SerialPort.javaSerialPortFinder.javajni的经典组合能让你快速跑通打开串口发一条指令的 Demo但一旦进入真实项目——尤其是 RS485 半双工总线、多从站、需要可靠应答的场景——它埋的两个坑会让你怀疑人生。这篇文章面向的是正在或准备在 Android 上做 485/232 串口通信的开发者不管你是做工业网关、智能柜、门禁、还是各种带 Modbus 从站的硬件项目都能直接抄作业。我会把两个坑的现象、根因、排查链路、修复方案完整还原然后给出一套经过现场验证的 Modbus 锁板可靠通信实战代码包括 CRC 校验、超时重试、半双工收发切换、以及多从站轮询的调度策略。先给不熟悉背景的读者补一句RS485 是一种差分信号、半双工、总线型的物理层标准和 RS232 的点对点全双工完全不同。所谓半双工就是同一时刻总线上只能有一个设备在说其他设备在听。这个特性决定了 Android 端发完一帧数据后必须等发送真正完成、总线释放才能切换到接收状态去听从站的应答。而android-serialport-api恰恰在这个发送完成的判断上给了你一个非常容易踩的错觉。提示本文所有代码基于经典android-serialport-api的 JNI 方案如果你用的是usb-serial-for-android这类 USB 转串口方案坑的具体表现不同但发送完成判定和权限/设备节点这两类问题的本质是相通的思路可以借鉴。2. 深坑一串口打开成功但数据发出去石沉大海2.1 现象描述open 返回正常write 也不报错从站就是不应答第一个坑的表现极具迷惑性。代码里SerialPort构造函数正常返回getOutputStream()拿到了输出流write()调用也没抛异常日志里一切正常。但用示波器或者逻辑分析仪一挂发现 485 总线上要么根本没有差分信号要么只有一小段畸变的波形。从站自然一声不吭你的超时重试逻辑疯狂触发最后报通信失败。我当时的排查顺序是这样的先怀疑接线A/B 接反了再怀疑波特率9600 还是 115200再怀疑从站地址和功能码最后才怀疑到库本身。这个顺序其实是错的正确的排查顺序应该是从信号有没有真正发出去倒推而不是从协议层正推。因为协议层的问题至少会有波形而没波形是物理层/驱动层的问题。2.2 根因定位设备节点权限与 SELinux 的双重拦截android-serialport-api打开串口的本质是通过 JNI 调用 Linux 的open()系统调用去打开/dev/ttyS*或/dev/ttyUSB*这样的设备节点。问题在于Android 从 4.4 之后对设备节点的访问控制越来越严到了 Android 7 以后普通 App 进程根本没有权限直接 open 这些节点即使你在AndroidManifest.xml里声明了READ_EXTERNAL_STORAGE之类的权限也没用因为串口节点不属于存储权限管辖范围。具体来说拦截来自三层文件权限层/dev/ttyS1这类节点的属主通常是root:root或system:system权限位是crw-rw----普通 App 的 uid 不在允许列表里open()直接返回EACCESPermission denied。SELinux 层即使你把文件权限改成crw-rw-rw-SELinux 的untrusted_app域仍然会拦截对tty_device的访问报avc: denied。设备树/内核层部分工控板的串口默认没有使能或者被其他驱动占用比如被当成调试串口 console 占用这种情况下节点存在但打不开。android-serialport-api的经典代码里open()失败时往往只是返回一个空对象或者抛一个笼统的 IOException不会告诉你到底是哪一层拦的。这就是它第一个坑的坑之所在——错误信息不透明让你误以为是协议问题。2.3 排查链路用 adb 逐层验证权限我后来总结出一套标准排查流程用 adb 就能定位到具体是哪一层的问题# 第一步确认设备节点是否存在 adb shell ls -l /dev/ttyS* # 第二步确认当前 App 进程的 uid 和所属组 adb shell ps -A | grep 你的包名 adb shell cat /proc/pid/status | grep -i uid # 第三步手动尝试以 App 身份访问需要 root 或 run-as adb shell run-as 你的包名 ls -l /dev/ttyS1 # 第四步查看 SELinux 拒绝日志 adb shell dmesg | grep avc adb logcat | grep avc如果第一步就发现节点不存在那是内核/设备树问题得找硬件厂商。如果节点存在但第三步报 Permission denied那是文件权限问题。如果文件权限没问题但第四步有avc: denied那是 SELinux 问题。2.4 修复方案三种可行路径与选型建议针对权限问题实际项目中有三条路可走各有取舍方案做法优点缺点适用场景系统签名App 用平台签名声明android:sharedUserIdandroid.uid.system权限最彻底SELinux 域为 system_app需要厂商签名无法上应用市场自研工控板、定制 ROM修改权限脚本在init.rc或开机脚本里chmod 666 /dev/ttyS*并配置 SELinux 策略一次配置长期有效需要 root 或定制 ROM有 ROM 定制能力的项目运行时提权通过su执行 chmod或使用厂商提供的权限接口不改 ROM依赖 root稳定性差调试阶段、临时方案我们项目最终走的是系统签名 定制 SELinux 策略这条路因为工控板 ROM 是我们自己可控的。具体做法是在device/xxx/sepolicy/下新增一个tty_device的允许规则让 system_app 域可以读写ttyS*。如果你没有 ROM 定制能力那就只能跟硬件厂商要一个已经放开权限的固件或者退而求其次用 USB 转串口方案usb-serial-for-android因为 USB 设备可以通过标准的 USB 权限申请流程拿到访问权不需要动系统。注意网上有些教程教你用Runtime.getRuntime().exec(su)然后 chmod这在调试机上能跑但量产机上大概率没有 su而且每次开机都要执行非常不可靠。别把它当成正式方案。3. 深坑二发送完成判定错误半双工总线上自己撞自己3.1 现象描述单条指令能通连续发就乱码或丢应答第二个坑更隐蔽也更致命。当你只发一条指令、等一会儿再读应答时一切正常。但当你连续快速发送多条指令或者做多从站轮询时就开始出现应答数据错位、CRC 校验失败、偶尔收到自己刚发出去的数据回环、从站应答被截断。这个现象在 RS485 半双工场景下尤其明显因为半双工要求发完必须立刻切接收切换时机差几毫秒应答的头几个字节就丢了。而android-serialport-api的write()方法返回时并不代表数据已经从硬件发出去了。3.2 根因剖析OutputStream.write 的缓冲与 tcdrain 缺失这是整个问题的核心。在 Linux 串口编程里write()系统调用只是把数据拷贝到内核的发送缓冲区tty write buffer然后就返回了。数据真正被 UART 硬件逐位发送到总线上是在 write 返回之后由内核异步完成的。如果你在 write 返回后立刻去读或者立刻切换 485 收发方向就会在数据还没发完的时候打断它。标准 Linux 串口编程里判断数据真正发完要用tcdrain(fd)它会阻塞直到发送缓冲区全部清空。但android-serialport-api的经典实现里JNI 层的 write 只是简单调用了write()没有调用tcdrain()。Java 层的SerialPort也没有暴露 drain 接口。于是你在 Java 层拿到的write 完成其实只是数据进了内核缓冲区离数据上了总线还差得远。更麻烦的是很多 485 收发切换电路是硬件自动方向控制比如用 MAX13487 这类带自动收发的芯片这种情况下你不需要手动控制 DE/RE 引脚但前提是发送必须完整。如果发送被提前打断自动方向控制芯片会在数据没发完时就切回接收导致总线上的波形残缺从站收到的是坏帧。3.3 实测验证用逻辑分析仪看 write 返回与波形的时间差为了确认这个判断我用逻辑分析仪同时抓了 UART 的 TX 引脚和 485 的 A/B 差分线然后在代码里 write 返回的瞬间翻转一个 GPIO 做标记。结果非常清楚write 返回时TX 引脚上还有将近一半的数据没发出去。在 9600 波特率下发 8 个字节大约需要 8.3ms而 write 返回时只过了 3ms 左右。这 5ms 的差距在半双工切换时就是致命的。这个实测数据也解释了为什么单条指令能通——因为单条指令发完后你的代码里往往有别的耗时操作比如日志打印、UI 更新无意中给了发送完成的时间。而连续发送时这个无意中的延迟消失了问题就暴露了。3.4 修复方案在 JNI 层补上 tcdrain并封装可靠的发送接口修复思路很直接在 JNI 的 write 之后补上tcdrain()。我修改了SerialPort.c新增了一个writeAndDrain方法JNIEXPORT jint JNICALL Java_android_1serialport_1api_SerialPort_writeAndDrain (JNIEnv *env, jobject thiz, jbyteArray data, jint length) { int fd getFdFromObject(env, thiz); // 从 Java 对象取 fd jbyte *buf (*env)-GetByteArrayElements(env, data, NULL); int ret write(fd, buf, length); if (ret 0) { // 关键阻塞直到发送缓冲区清空 tcdrain(fd); } (*env)-ReleaseByteArrayElements(env, data, buf, 0); return ret; }然后在 Java 层封装一个sendAndWait方法把 write drain 合并成一个原子操作。这样调用方拿到的返回才是真正数据已上总线的信号。如果你不想改 JNI比如用的是预编译的 so 库还有一个 Java 层的补救办法根据波特率和字节数估算发送时间主动 sleep 一段。公式是发送时间(ms) (字节数 × 每字节位数) / 波特率 × 1000 每字节位数 1(起始) 8(数据) 1(停止) 10无校验比如 9600 波特率发 8 字节8 × 10 / 9600 × 1000 ≈ 8.3ms。保险起见再乘 1.5 倍余量sleep 12ms。这个方法不精确但胜在不用改底层适合快速验证。正式项目还是建议改 JNI因为 sleep 会拖慢整体轮询速度。提示tcdrain在某些 USB 转串口芯片的驱动上行为不完全可靠如果发现 drain 返回后仍有残留可以配合tcflush(fd, TCOFLUSH)清空发送缓冲但要注意 flush 会丢弃未发数据只在异常恢复时用。4. Modbus RTU 锁板通信的完整实现从帧构造到多从站调度4.1 Modbus RTU 帧结构与 CRC16 校验的坑Modbus RTU 的帧格式很简洁从站地址(1B) 功能码(1B) 数据(NB) CRC16(2B)帧与帧之间靠 3.5 个字符时间的静默间隔来分隔。锁板场景常用的功能码是0x01读线圈读锁状态、0x05写单线圈开锁/关锁、0x0F写多线圈批量控制。CRC16 是 Modbus 的标配校验多项式0xA001反向的0x8005初始值0xFFFF。这里有个新手常踩的坑CRC 的低字节在前高字节在后和很多教程里高字节在前的写法相反。我见过不止一个项目因为 CRC 字节序搞反导致从站全部不应答。public static byte[] crc16(byte[] data, int len) { int crc 0xFFFF; for (int i 0; i len; i) { crc ^ (data[i] 0xFF); for (int j 0; j 8; j) { if ((crc 0x0001) ! 0) { crc (crc 1) ^ 0xA001; } else { crc 1; } } } // 注意低字节在前 return new byte[]{(byte) (crc 0xFF), (byte) ((crc 8) 0xFF)}; }构造开锁指令从站地址 0x01开锁线圈地址 0x0000值 0xFF00 表示开public static byte[] buildWriteCoil(int slaveAddr, int coilAddr, boolean on) { byte[] frame new byte[8]; frame[0] (byte) slaveAddr; frame[1] 0x05; frame[2] (byte) (coilAddr 8); frame[3] (byte) (coilAddr 0xFF); frame[4] on ? (byte) 0xFF : 0x00; frame[5] 0x00; byte[] crc crc16(frame, 6); frame[6] crc[0]; frame[7] crc[1]; return frame; }4.2 半双工收发切换的时序控制有了前面writeAndDrain的基础收发切换就清晰了。整个流程是确保接收缓冲区干净tcflush(fd, TCIFLUSH)避免上一帧的残留干扰。调用writeAndDrain发送请求帧返回即代表数据已上总线。立即切换到接收状态如果是硬件自动方向控制这一步由硬件完成如果是软件控制 DE/RE需要在这里翻转 GPIO。按预期应答长度读取带超时。校验 CRC匹配从站地址和功能码。超时时间的设定很关键。Modbus 规范建议响应超时设为 300ms 到 1s但实际要看从站的响应速度。锁板这类设备响应通常很快我设的是200ms配合 3 次重试。超时太短会误判太长会拖慢轮询。public byte[] transact(byte[] request, int expectedLen, int timeoutMs) throws IOException { synchronized (lock) { // 半双工总线必须串行化 flushInput(); outputStream.write(request); // 如果 JNI 已补 tcdrain这里返回即发送完成 // 否则需要 sleep 估算时间 byte[] response new byte[expectedLen]; int read readWithTimeout(response, timeoutMs); if (read expectedLen) { throw new IOException(响应不完整: read / expectedLen); } return response; } }4.3 多从站轮询的调度与总线冲突避免锁板项目通常有几十个从站挂在同一条 485 总线上轮询策略直接影响整体响应速度。核心原则是同一时刻总线上只能有一个请求在飞所以必须用一个全局锁把transact串行化。我见过有人用线程池并发发请求结果总线上一堆帧撞在一起全部乱码。轮询调度我用了两种模式巡检模式按从站地址顺序逐个读状态每个从站间隔 20ms给从站处理时间一轮下来几十个从站大概几秒。优先模式开锁这类实时操作插队执行巡检线程在两次从站之间检查是否有优先任务有就先执行。private final Object busLock new Object(); private final PriorityQueueRunnable urgentTasks new PriorityQueue(); public void pollLoop() { while (running) { for (int addr 1; addr maxSlave; addr) { // 优先任务插队 Runnable urgent; synchronized (urgentTasks) { urgent urgentTasks.poll(); } if (urgent ! null) { urgent.run(); } try { readSlaveStatus(addr); } catch (IOException e) { Log.w(TAG, 从站 addr 读取失败, e); } sleepQuietly(20); } } }4.4 异常恢复CRC 错误、超时、总线卡死的处理现场环境电磁干扰大485 总线又长异常是常态。我的处理策略是分级单次 CRC 错误直接重试最多 3 次。连续 3 次失败标记该从站为疑似离线跳过它继续轮询其他从站避免一个坏从站拖垮整条总线。整条总线全部失败可能是总线卡死某个从站故障拉低了差分电平需要触发总线复位——关闭串口重新打开或者控制一个总线电源开关断电重启。这里有个经验485 总线的 A/B 线一定要加偏置电阻和终端电阻。偏置电阻通常 4.7kΩ 上拉到 A、下拉到 B保证总线空闲时有确定的电平避免浮空导致的误触发终端电阻120Ω接在总线两端抑制反射。我们项目一开始没加偏置空闲时总线上电平飘忽从站偶尔会误判成帧起始加了之后稳定很多。5. 那些文档里不会写的现场经验5.1 波特率与线长的取舍230400 不是想上就能上热词里有人问230400 波特率是否有问题这个问题很实在。RS485 的可靠通信距离和波特率是强相关的经验公式是波特率 × 线长 ≤ 10^7 到 10^8取决于线材和干扰。9600 波特率理论能跑 1000 米以上115200 大概能跑 100 米左右230400 在普通双绞线上可能只有几十米而且对终端电阻和线材质量要求很高。我们项目总线长度大概 30 米一开始想用 115200 提速实测发现误码率明显上升最后降到 19200 才稳定。别迷信高波特率稳定性永远优先于速度。如果确实需要高速那就缩短线长、用屏蔽双绞线、加磁环、做好单点接地。5.2 收发切换的硬件方案对比自动方向 vs GPIO 控制485 收发切换有两种主流硬件方案方案典型芯片优点缺点自动方向控制MAX13487、SP3485自动电路软件无需管 DE/RE简单对发送完整性要求高发送被打断会出错GPIO 手动控制MAX485、SP3485控制精确可主动控制切换时机需要占用一个 GPIO软件要管时序自动方向方案配合前面补了tcdrain的发送接口是最省心的组合。手动 GPIO 方案则需要在writeAndDrain返回后立刻翻转 GPIO翻转晚了会丢失应答头翻转早了会截断发送。我建议优先选自动方向芯片把时序问题交给硬件。5.3 调试工具链逻辑分析仪、Modbus Poll 与自研抓包调试 485 通信光看日志是不够的必须能看到总线上的实际波形和字节。我的工具链是逻辑分析仪比如几十块的那种 8 通道抓 TX/RX/DE 引脚看时序和波形判断发送是否完整、切换是否及时。Modbus Poll / Modbus Slave在 PC 端模拟主站或从站验证协议帧是否正确。用 Modbus Poll 当主站去读你的锁板能快速确认锁板本身是否正常。自研抓包在 Android 端把每次收发的原始字节 hex dump 到日志配合时间戳能还原完整的通信过程。这里提醒一句用 PC 工具调试时注意 PC 端的 USB 转 485 转换器和 Android 端的电平、接线是否一致。我踩过一次坑PC 端转换器的 A/B 定义和 Android 板子的 A/B 定义相反导致 PC 能通、Android 不通查了半天才发现是接线定义问题。5.4 一个容易被忽略的细节串口关闭与资源释放android-serialport-api的SerialPort.close()如果没被正确调用fd 会泄漏多次打开关闭后可能耗尽文件描述符导致后续 open 失败。我的做法是在onDestroy或者业务结束时用 try-finally 确保 close 被调用并且把 close 放在一个独立的线程里执行避免阻塞主线程。Override protected void onDestroy() { super.onDestroy(); if (serialPort ! null) { new Thread(() - { try { serialPort.close(); } catch (Exception e) { Log.e(TAG, 关闭串口异常, e); } }).start(); } }另外串口的输入输出流也要在 close 之前 flush 掉避免残留数据影响下次打开。这些细节看起来琐碎但在长时间运行的工控设备上任何一个泄漏都会在几天后变成莫名其妙就不通信了的故障。6. 把两个坑变成一套可复用的通信框架回过头看android-serialport-api这两个坑的本质一个是权限模型的坑Android 安全机制与 Linux 设备访问的冲突一个是发送完成判定的坑Java 流抽象与底层硬件时序的鸿沟。前者靠系统签名或 ROM 定制解决后者靠在 JNI 层补tcdrain解决。两个坑都填上之后剩下的 Modbus 协议实现、CRC 校验、多从站调度反而是相对标准的工作。我现在把这套方案封装成了一个独立的通信模块对外只暴露open()、transact()、close()三个接口内部处理了权限检查、发送 drain、超时重试、总线串行化、异常恢复。新项目接入时改一下从站地址表和功能码映射就能用。这套东西在智能柜、门禁、充电桩几个项目上复用下来稳定性比最初那版提升了不止一个档次——连续跑一个月通信失败率从最初的百分之几降到了万分之一以下。如果你正在做类似的项目我的建议是别急着写业务逻辑先把发一条指令、稳定收到应答这件事做到 100% 可靠再往上堆功能。串口通信的坑越早暴露越好越晚发现代价越大。