1. 项目缘起与整体设计思路1.1 为什么要在 Android 上折腾 485 串口先说背景。我手上有个工业场景的项目需求很明确一台 Android 平板作为上位机通过 RS485 总线去控制一块伺服驱动板走 Modbus RTU 协议。听起来不复杂对吧但真正动手之后才发现Android 平台上的串口通信跟我们在 STM32 或者 PC 上玩串口完全是两码事。在 STM32 上搞 485你直接操作 UART 寄存器配好波特率、数据位、停止位再控制一个 GPIO 做收发切换就完事了。PC 上更简单插个 USB 转 485 模块用 Modbus Poll 或者自己写个 Python 脚本就能跑。但 Android 呢系统本身对串口设备节点的权限管得很死/dev/ttyS*这些节点普通应用根本碰不到除非你有 root 权限或者系统签名。这就导致了一个很尴尬的局面——你明明看到设备节点在那里但就是打不开。我选择android-serialport-api这个库原因很实际它足够轻量核心就是几个 JNI 文件加一个 Java 封装层没有引入额外的依赖编译出来的 so 库体积很小。市面上也有一些其他的方案比如usb-serial-for-android那个走的是 USB Host 通道需要设备支持 OTG 并且要处理 USB 权限弹窗对于嵌入式设备固定安装的场景来说反而更麻烦。而android-serialport-api直接操作设备节点只要权限到位打开就能用延迟也更低。这个项目适合谁参考呢如果你正在做 Android 工控平板、智能终端、自助设备这类需要跟下位机通过 485 通信的场景或者你单纯想搞清楚 Android 串口通信的底层逻辑那这篇内容应该能帮你省下不少踩坑的时间。1.2 整体方案架构与选型考量整个通信链路的架构是这样的Android 平板通过 OTG 或者板载的 UART 接口外接一个 TTL 转 485 模块然后通过 A/B 差分线接到伺服驱动板的 485 接口上。软件层面Android 端用android-serialport-api打开串口设备节点按照 Modbus RTU 的帧格式组包发送然后等待从站响应。这里有个关键选型点为什么不直接用 Modbus TCP因为现场设备只支持 RTU而且 485 总线在工业环境下的抗干扰能力和布线成本优势是实打实的。一条双绞线可以挂多个从站布线简单成本低。当然代价就是速率相对慢一些但对于伺服控制这种场景波特率 9600 或者 19200 完全够用。另一个选型点是关于 485 收发切换电路。我用的 TTL 转 485 模块是带自动收发切换的也就是说模块内部会根据发送数据的状态自动控制 DE/RE 引脚不需要 Android 端额外操作 GPIO。这一点很重要因为 Android 应用层根本没法直接控制 GPIO如果模块不带自动切换那就必须用带使能控制的电路通过串口的 RTS 引脚来控制收发方向而android-serialport-api对 RTS 的支持并不完善这就很容易出问题。注意选 TTL 转 485 模块的时候一定要确认是否支持自动收发切换。如果不支持你需要在 Android 端通过 RTS 控制收发方向而很多 Android 设备的串口驱动对 RTS 的支持并不好会导致发送完成后无法及时切回接收状态从而丢失从站的响应数据。2. android-serialport-api 的两个深坑详解2.1 第一个坑设备节点权限与路径适配android-serialport-api这个库的原理很简单它通过 JNI 调用 Linux 的open()系统调用来打开串口设备节点然后通过read()和write()进行数据收发。问题就出在这个“打开”上。在标准的 Linux 系统里串口设备节点通常是/dev/ttyS0、/dev/ttyS1这种。但在 Android 上不同厂商的设备节点命名完全不一样。我手上这台平板串口节点是/dev/ttyMT0而另一台设备上是/dev/ttyHSL0。更坑的是有些设备的节点权限是root:root 660普通应用根本打不开。我试过几种方案。第一种是直接在代码里硬编码节点路径但换一台设备就废了。第二种是遍历/dev/目录下的所有tty*节点逐个尝试打开但这样效率低而且容易误操作。最后我采用的方案是在应用启动时通过读取/proc/tty/drivers文件来获取当前系统可用的串口驱动信息然后结合设备型号做映射。具体来说/proc/tty/drivers文件里会列出所有已注册的 tty 驱动包括驱动名称、节点路径、主设备号等信息。我可以从中筛选出类型为serial的驱动然后拿到对应的节点路径。但这里还有个问题——即使拿到了路径权限不够还是打不开。关于权限如果你的设备是 root 过的可以在应用启动时通过su命令去修改节点权限比如chmod 666 /dev/ttyMT0。但这种方式依赖 root而且每次重启都要重新执行。另一种方案是让系统集成商在init.rc或者ueventd.rc里配置好节点权限让应用可以直接访问。我最终采用的是后者因为项目是定制设备系统层可以配合修改。实操心得在调试阶段可以先用adb shell进入设备用ls -l /dev/tty*查看节点权限然后用cat /proc/tty/drivers确认驱动信息。如果权限不对临时用chmod 666改一下确认通信没问题后再去推动系统层做永久配置。这样可以把软件问题和权限问题分开排查效率高很多。2.2 第二个坑JNI 层的文件描述符泄漏与线程安全这个坑更隐蔽也更致命。android-serialport-api的 JNI 层在打开串口时会创建一个文件描述符fd这个 fd 在 Java 层对应一个FileDescriptor对象。问题在于如果你在代码里多次打开同一个串口而没有正确关闭fd 就会泄漏。Linux 系统对每个进程的 fd 数量是有限制的一般是 1024 个。泄漏到一定程度后open()就会返回 -1串口再也打不开。我第一次遇到这个问题的时候现象是应用运行了大概两三天后串口突然就通信不上了重启应用又恢复正常。当时排查了很久一开始怀疑是 485 总线干扰后来怀疑是 Modbus 帧格式问题最后用lsof命令查看进程的 fd 列表才发现有一大堆/dev/ttyMT0的 fd 没有被释放。原因是我在代码里每次发送数据前都会调用SerialPort.open()但发送完成后没有调用close()。当时的想法是保持串口常开避免频繁开关带来的开销。但实际上android-serialport-api的SerialPort类并没有实现Closeable接口也没有 finalizer 来保证 fd 被释放。如果你不显式调用close()fd 就会一直挂着。修复方案很简单把SerialPort对象做成单例在应用启动时打开一次在应用退出时关闭。同时在SerialPort的封装类里加一个isOpen标志位避免重复打开。另外我还加了一个 fd 泄漏检测机制定期读取/proc/self/fd目录下的文件数量如果超过阈值就报警。线程安全是另一个问题。android-serialport-api的write()和read()方法本身不是线程安全的。如果你在一个线程里写数据同时在另一个线程里读数据就有可能出现数据错乱。我采用的方案是加一把读写锁写操作和读操作互斥。但这样又带来了新的问题——如果读操作阻塞时间太长写操作就会被卡住。后来我改成了读写分离的方案写操作走一个单独的线程通过一个阻塞队列来缓冲待发送的数据读操作走另一个线程持续读取串口数据并放入接收缓冲区。两个线程通过synchronized块来保护对SerialPort对象的访问但读写操作本身不互斥。这样既保证了线程安全又避免了读写互相阻塞。注意android-serialport-api的read()方法是阻塞的如果你在 UI 线程里调用会直接 ANR。一定要在子线程里做读写操作然后通过 Handler 或者 LiveData 把数据传回 UI 层。3. Modbus RTU 锁板通信实战3.1 Modbus RTU 帧格式与组包细节Modbus RTU 的帧格式是这样的从站地址1 字节 功能码1 字节 数据域N 字节 CRC 校验2 字节。帧与帧之间需要至少 3.5 个字符时间的静默间隔来区分。在波特率 9600 的情况下一个字符时间是 10 位1 起始位 8 数据位 1 停止位也就是大约 1.04 毫秒3.5 个字符时间就是 3.64 毫秒。我用的功能码是 0x06写单个寄存器和 0x03读保持寄存器。写单个寄存器的帧格式是从站地址 0x06 寄存器地址高字节 寄存器地址低字节 数据高字节 数据低字节 CRC 低字节 CRC 高字节。读保持寄存器的帧格式是从站地址 0x03 起始地址高字节 起始地址低字节 寄存器数量高字节 寄存器数量低字节 CRC 低字节 CRC 高字节。CRC 校验是 Modbus RTU 里最容易出错的地方。它的计算方式是初始化 CRC 为 0xFFFF然后对每个字节进行处理每个字节与 CRC 的低字节异或然后右移 8 次每次如果最低位为 1 就与 0xA001 异或。最终得到的 CRC 需要低字节在前高字节在后。我一开始就是搞反了字节序导致从站一直不响应。public static int calculateCRC(byte[] data, int length) { int crc 0xFFFF; for (int i 0; i length; i) { crc ^ (data[i] 0xFF); for (int j 0; j 8; j) { if ((crc 0x0001) ! 0) { crc (crc 1) ^ 0xA001; } else { crc 1; } } } return crc; }组包的时候先把从站地址、功能码、数据域拼成一个字节数组然后计算 CRC把 CRC 的低字节和高字节追加到数组末尾。发送的时候通过SerialPort的write()方法把整个字节数组写出去。3.2 锁板通信的超时与重试机制“锁板”这个词在工业场景里指的是从站设备处于一种锁定状态只响应特定地址的请求。我遇到的场景是伺服驱动板被配置成了地址 0x01只响应这个地址的 Modbus 请求。如果地址不对从站直接忽略不会返回任何响应。这就带来了一个问题如果 Android 端发送了请求但没收到响应怎么区分是从站没收到、从站收到了但地址不对、还是从站响应了但 Android 端没收到我的做法是设置一个响应超时时间比如 200 毫秒。发送请求后开始计时如果在 200 毫秒内没有收到完整的响应帧就认为本次通信失败触发重试。重试策略我采用的是指数退避第一次重试等待 100 毫秒第二次等待 200 毫秒第三次等待 400 毫秒最多重试 3 次。如果 3 次都失败就上报通信故障。这样做的原因是485 总线在工业环境下可能会受到瞬时干扰偶尔一两次通信失败是正常的通过重试可以恢复。但如果连续多次失败那就说明是硬件或者配置问题了再重试也没用。实操心得超时时间不要设得太短。我一开始设了 50 毫秒结果发现从站响应经常超时。后来用示波器抓了一下波形发现从站从收到请求到发出响应之间有大概 80 毫秒的处理时间。所以超时时间至少要设成从站最大处理时间的两倍以上我最终设的是 200 毫秒实测很稳。3.3 485 总线布线与隔离电路要点485 总线的布线有很多讲究。首先必须用双绞线因为双绞线可以抵消共模干扰。A 和 B 两根线要绞在一起不能分开走。其次总线的两端需要接终端电阻一般是 120 欧姆。终端电阻的作用是匹配总线阻抗消除信号反射。如果总线长度小于 50 米可以不接终端电阻但超过 50 米就一定要接。隔离电路是另一个关键点。工业现场的地电位差可能很大如果 Android 设备和伺服驱动板不共地485 总线上会有很大的共模电压轻则通信不稳定重则烧毁收发器。我用的 TTL 转 485 模块是带光耦隔离的隔离电压 2500V。这样即使两边地电位差很大也不会损坏设备。还有一个细节是 485 总线的接地。很多人以为 485 只需要 A 和 B 两根线其实还需要一根地线。这根地线不是用来传输信号的而是用来给总线上的所有设备提供一个共同的参考地。如果设备之间地电位差太大就需要用隔离模块来切断地环路。注意485 总线的 A 和 B 不要接反。虽然有些收发器有极性自适应功能但大部分标准 485 收发器是不支持的。接反了要么完全通信不上要么通信极不稳定。我一般会在接线端子旁边标注 A 和 B避免插错。4. 常见问题与排查技巧实录4.1 通信失败排查速查表现象可能原因排查方法解决方案完全无响应串口节点打不开检查/dev/tty*权限修改节点权限或推动系统层配置完全无响应A/B 线接反交换 A/B 线测试纠正接线完全无响应从站地址不对用 Modbus Poll 扫描地址修改从站地址或请求地址偶尔超时485 总线干扰示波器抓波形加终端电阻、改用屏蔽双绞线偶尔超时超时时间太短增大超时时间设为从站处理时间的 2 倍以上数据错乱读写线程冲突检查线程同步逻辑加读写锁或读写分离运行一段时间后失败fd 泄漏lsof查看 fd 数量确保串口正确关闭CRC 校验失败CRC 字节序错误检查 CRC 计算代码低字节在前高字节在后4.2 独家避坑技巧与调试心得第一个技巧是关于调试工具的。我强烈建议在开发阶段同时用 Modbus Poll 和 Modbus Slave 这两个工具。Modbus Poll 作为主站可以模拟 Android 端发送请求Modbus Slave 作为从站可以模拟伺服驱动板响应请求。这样你可以先把 Android 端的代码放一边用 PC 工具确认 485 硬件链路和 Modbus 协议没问题然后再去调 Android 端的代码。这样可以避免同时排查硬件和软件问题效率高很多。第二个技巧是关于日志的。Android 端的串口日志一定要打全包括发送的原始字节、接收的原始字节、CRC 校验结果、超时时间等。我一般会用十六进制格式打印方便对比。另外日志要写到文件里不要只打在 Logcat 里因为 Logcat 缓冲区有限时间长了会被冲掉。第三个技巧是关于 485 收发切换的。如果你的 TTL 转 485 模块不带自动切换需要用 RTS 控制收发方向那一定要注意切换时机。发送数据前先把 RTS 拉高发送模式发送完成后等最后一个字节完全移出移位寄存器再拉低接收模式。如果拉低太早最后一个字节会丢失如果拉低太晚从站的响应可能已经开始发送了会冲突。我一般会在发送完成后延时 1 个字符时间再切换。第四个技巧是关于电源的。Android 平板和 485 模块的电源一定要共地否则 485 通信会不稳定。如果 Android 平板用电池供电485 模块用外部电源供电那一定要把两者的地线连在一起。我遇到过好几次通信不稳定的情况最后发现都是地线没接好。4.3 性能优化与稳定性提升在稳定性方面我做了几件事。第一把串口读写放在单独的服务里跟 UI 生命周期解耦。这样即使 UI 退到后台串口通信也不会中断。第二加了看门狗机制定期检查串口是否还在正常工作如果发现异常就自动重启串口服务。第三加了通信质量统计记录每次通信的成功率和平均响应时间方便后期分析。在性能方面主要是减少不必要的内存分配。Modbus 帧的组包和解包都在字节数组上操作避免频繁创建对象。接收缓冲区用固定大小的环形缓冲区避免动态扩容带来的开销。发送队列用ArrayBlockingQueue容量设为 64足够缓冲短时间内的突发请求。还有一个细节是关于read()方法的。android-serialport-api的read()方法会阻塞直到有数据可读但如果你设置的超时时间太长线程会一直卡在那里。我一般会把read()放在一个循环里每次读取前先检查一下是否有数据可读如果没有就sleep一小段时间再试。这样虽然稍微增加了 CPU 占用但可以避免线程长时间阻塞。实操心得如果你的应用需要长时间运行建议定期重启串口服务。我实测下来连续运行 72 小时后串口通信的成功率会从 99.9% 降到 99% 左右虽然看起来降得不多但在工业场景下1% 的失败率已经很高了。后来我加了一个定时重启机制每 24 小时重启一次串口服务成功率就稳定在 99.9% 以上了。5. 从踩坑到填坑的完整复盘5.1 那些让我熬夜的瞬间第一个让我熬夜的问题是 fd 泄漏。当时应用跑了两天突然通信不上我以为是硬件问题换了模块、换了线、换了设备折腾了一整天。最后用lsof一看fd 数量已经到 900 多了离 1024 的上限就差一点。那一刻的心情真的是又气又笑。第二个让我熬夜的问题是 CRC 校验。我按照网上的代码写了 CRC 计算但从站一直不响应。我用 Modbus Poll 抓了正确的请求帧跟我的请求帧对比发现 CRC 字节序反了。Modbus RTU 的 CRC 是低字节在前高字节在后而我写成了高字节在前。改过来之后一次就通了。第三个让我熬夜的问题是线程冲突。我在一个线程里写数据在另一个线程里读数据结果偶尔会出现数据错乱。后来加了读写锁问题解决了但读操作经常被写操作阻塞导致响应变慢。最后改成了读写分离才彻底解决。5.2 最终稳定运行的方案总结最终稳定运行的方案是这样的串口服务作为前台服务运行保证不被系统杀掉。串口对象做成单例应用启动时打开应用退出时关闭。读写操作分别走独立的线程通过阻塞队列和环形缓冲区来交换数据。Modbus 组包和解包都在字节数组上操作避免频繁创建对象。超时时间设为 200 毫秒重试 3 次指数退避。485 模块用带光耦隔离和自动收发切换的型号总线两端接 120 欧姆终端电阻用屏蔽双绞线。这套方案在我手上的设备上连续运行了一个月通信成功率稳定在 99.9% 以上没有出现过 fd 泄漏或者线程冲突的问题。当然不同的设备和场景可能需要微调但整体思路应该是通用的。5.3 后续可以扩展的方向这个项目后续还可以往几个方向扩展。第一支持 Modbus TCP这样可以通过网络远程控制不局限于 485 总线。第二加一个 Web 配置界面方便现场调试和参数修改。第三加数据记录和分析功能把通信日志和伺服运行数据存到数据库里方便后期分析。第四支持多从站轮询一条 485 总线上挂多个伺服驱动板Android 端轮流跟每个从站通信。我个人在实际操作中的体会是Android 串口通信的难点不在协议本身而在 Android 系统的权限管理和资源管理。只要你把权限问题解决了把 fd 泄漏和线程安全问题处理好了剩下的就是标准的 Modbus 协议实现跟在其他平台上没什么区别。但恰恰是这两个问题最容易让人掉坑里。希望这篇内容能帮你少走一些弯路。
