拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Android RS485 Modbus RTU实战:避开android-serialport-api两大深坑

1. 项目缘起与整体设计思路1.1 为什么要在 Android 上折腾 485 串口先说背景。我手头有个工业场景的项目需要让一台 Android 平板作为上位机通过 RS485 总线去轮询控制一批 Modbus RTU 从站设备。这些从站是典型的工业板卡支持标准的 Modbus RTU 协议波特率 96008 位数据位无校验1 位停止位。Android 平板通过一个 USB 转 485 的转换器接入总线A/B 差分线接好终端电阻该加的加上。听起来很简单对吧PC 上用 Modbus Poll 几分钟就能跑通的事情搬到 Android 上却让我结结实实踩了两个大坑。这篇文章就把整个实战过程拆开讲清楚包括 android-serialport-api 这个老牌库的两个隐蔽陷阱以及最终如何实现稳定的 Modbus 锁板通信。如果你也在做 Android RS485 Modbus 的组合或者正准备入这个坑这篇内容能帮你省下至少两天的调试时间。1.2 技术选型的考量与取舍Android 本身没有开放标准的高层串口 API要操作串口只有两条路一是走 Android SDK 里的UsbManager做 USB Host 通信二是用 JNI 直接操作/dev/tty*设备节点。前者需要处理 USB 权限、端点配置、CDC/FTDI/CH340 等芯片差异后者需要 root 权限或者系统签名。android-serialport-api 这个库走的是第二条路它通过 JNI 调用 Linux 的open()、read()、write()、ioctl()等系统调用直接操作串口设备文件。这个库虽然年代久远但在工业 Android 设备上依然是主流选择原因很简单工业平板通常有 root 权限或者本身就是定制 ROM/dev/ttyS*或/dev/ttyUSB*节点是直接可用的。我选它的理由也很实际项目用的工业平板是定制 Android 系统已经预置了串口驱动/dev/ttyS3就是板载的 485 口。用 android-serialport-api 直接打开这个节点比走 USB Host 方案少了一层 USB 协议栈的折腾延迟也更低。但这里有个前提认知android-serialport-api 只是一个串口读写库它不包含任何 Modbus 协议实现。Modbus RTU 的帧组装、CRC 校验、超时重试、异常处理全部要自己写。这一点如果没搞清楚后面会走很多弯路。1.3 整体架构设计整个通信链路的架构是这样的应用层Modbus 主站逻辑负责构造请求帧、解析响应帧、管理轮询队列协议层Modbus RTU 编解码包括功能码处理、CRC16 校验、异常码解析串口层android-serialport-api 提供的SerialPort和SerialPortFinder类系统层Linux 串口设备节点/dev/ttyS3通过 JNI 调用系统调用物理层RS485 差分信号A/B 线终端电阻屏蔽接地这个分层看起来清晰但实际调试时问题往往出在层与层之间的边界上。比如串口层的超时设置会直接影响协议层的重试逻辑物理层的信号质量会表现为协议层的 CRC 错误。后面我会逐一拆解。2. android-serialport-api 的两个深坑实录2.1 第一个坑read() 方法的阻塞行为与超时陷阱这是最坑的一个问题没有之一。android-serialport-api 的SerialPort类里有个getInputStream()方法返回的是一个FileInputStream。很多人包括最初的我会想当然地认为既然设置了setSoTimeout()那么read()就应该在超时后返回 -1 或者抛异常。但事实是这个库的read()根本不理会setSoTimeout()的设置。我当时的代码是这样的// 错误示范 SerialPort serialPort new SerialPort(new File(/dev/ttyS3), 9600, 0); InputStream in serialPort.getInputStream(); serialPort.setSoTimeout(500); // 以为设置了 500ms 超时 byte[] buffer new byte[256]; int len in.read(buffer); // 这里会一直阻塞结果就是当从站设备没有响应时in.read(buffer)会永远阻塞在那里整个轮询线程卡死。我的 Modbus 轮询逻辑直接瘫痪界面上什么数据都刷不出来。为什么会这样因为 android-serialport-api 的 JNI 层在打开串口时并没有把VMIN和VTIME这两个 termios 参数设置正确。默认情况下VMIN1、VTIME0意味着read()会一直等到至少读到 1 个字节才返回如果没有数据就无限等待。setSoTimeout()设置的是 Java 层的 socket 超时对文件描述符的read()系统调用没有任何影响。解决方案不能依赖read()的阻塞行为来做超时控制。正确的做法是在 JNI 层或者通过反射修改 termios 参数设置VMIN0、VTIME5即 500ms 超时或者更简单粗暴用一个独立的读取线程 available()轮询 超时中断我最终采用的是第二种方案因为修改 JNI 层需要重新编译 so 库而工业平板的 ABI 可能不止一种。具体实现// 正确的读取方式带超时的轮询读取 public byte[] readWithTimeout(InputStream in, int expectedLen, long timeoutMs) throws IOException { byte[] result new byte[expectedLen]; int totalRead 0; long startTime System.currentTimeMillis(); while (totalRead expectedLen) { if (System.currentTimeMillis() - startTime timeoutMs) { // 超时返回已读取的部分 break; } int available in.available(); if (available 0) { int toRead Math.min(available, expectedLen - totalRead); int read in.read(result, totalRead, toRead); if (read 0) { totalRead read; startTime System.currentTimeMillis(); // 收到数据后重置超时 } } else { try { Thread.sleep(10); // 避免忙等 } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } } if (totalRead 0) { return null; // 完全没有数据 } return Arrays.copyOf(result, totalRead); }注意in.available()在串口场景下返回的是内核缓冲区中已就绪的字节数这个值可能不准确但对于 Modbus 这种短帧通信来说足够用了。关键是不要用read()的阻塞来等数据。这个坑让我卡了整整一个下午因为现象很迷惑单独测试读写是通的一旦从站不响应就整个线程挂死。后来用jstack抓线程栈才发现卡在read()的 native 调用上。2.2 第二个坑close() 之后的资源释放与文件描述符泄漏第二个坑更隐蔽是在长时间运行后才暴露出来的。android-serialport-api 的SerialPort.close()方法实现是这样的public void close() { try { if (mInputStream ! null) { mInputStream.close(); } if (mOutputStream ! null) { mOutputStream.close(); } } catch (IOException e) { // 忽略异常 } }看起来没问题对吧但问题在于它只关闭了 Java 层的流没有关闭 JNI 层打开的文件描述符。在 JNI 的open()实现中会调用open(/dev/ttyS3, O_RDWR | O_NOCTTY | O_NDELAY)返回一个 fd。这个 fd 在close()时并没有被close(fd)系统调用释放。每次打开-关闭串口就会泄漏一个文件描述符。我的应用需要定期重连串口比如从站设备重启后结果运行几个小时后应用崩溃日志显示Too many open files。用lsof查看进程的文件描述符发现/dev/ttyS3被打开了上百次。解决方案有两个办法。第一个办法是修改 JNI 层的close()实现确保调用close(fd)。这需要重新编译 so 库如果你有源码和 NDK 环境这是最彻底的方案。第二个办法是在 Java 层做规避不要频繁打开关闭串口保持一个长连接。如果确实需要重连在关闭后手动触发 GC 并等待一段时间让文件描述符有机会被回收。但这个办法不可靠只是缓解。我最终采用的是第一个办法因为项目对稳定性要求高。修改后的 JNIclose()实现JNIEXPORT void JNICALL Java_android_serialport_SerialPort_close (JNIEnv *env, jobject thiz) { jclass clazz (*env)-GetObjectClass(env, thiz); jfieldID field_fd (*env)-GetFieldID(env, clazz, mFd, Ljava/io/FileDescriptor;); jobject fd (*env)-GetObjectField(env, thiz, field_fd); jfieldID field_descriptor (*env)-GetFieldID(env, (*env)-FindClass(env, java/io/FileDescriptor), descriptor, I); jint descriptor (*env)-GetIntField(env, fd, field_descriptor); if (descriptor 0) { close(descriptor); // 关键真正关闭文件描述符 } }提示如果你没有 NDK 编译条件可以在 Java 层用反射拿到FileDescriptor的descriptor字段然后通过 JNI 调用close()。但这需要额外的 JNI 封装不如直接改库源码。这两个坑是 android-serialport-api 最典型的两个问题网上很多教程都没有提到。第一个坑导致通信不稳定第二个坑导致长时间运行崩溃。解决了这两个串口层才算真正可用。3. Modbus RTU 协议实现的核心细节3.1 帧结构与 CRC16 校验的坑Modbus RTU 的帧结构本身不复杂字段长度说明从站地址1 字节1-2470 为广播功能码1 字节如 0x03 读保持寄存器数据域N 字节取决于功能码CRC162 字节低字节在前高字节在后但 CRC16 的计算有个容易搞错的地方Modbus 用的是 CRC-16/MODBUS 变体多项式 0xA001反向的 0x8005初始值 0xFFFF结果异或 0x0000输入输出均反射。我最初从网上抄了一个 CRC16 实现结果一直校验失败。后来对比发现那个实现用的是 CRC-16/CCITT多项式 0x1021完全不对。正确的实现public static int crc16(byte[] data, int offset, int length) { int crc 0xFFFF; for (int i offset; i offset 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 0xFFFF; }注意返回值的字节序Modbus RTU 帧中CRC 低字节在前高字节在后。所以组装帧的时候int crc crc16(frame, 0, frame.length - 2); frame[frame.length - 2] (byte) (crc 0xFF); // 低字节 frame[frame.length - 1] (byte) ((crc 8) 0xFF); // 高字节这个字节序问题也让我调试了一会儿因为用 Modbus Poll 抓包看 CRC 是对的但自己算出来对不上其实就是高低字节反了。3.2 超时与重试策略的设计Modbus RTU 是主从问答式协议主站发请求从站响应。超时设置直接决定了通信效率和稳定性。在 9600 波特率下一个字节的传输时间是1 字节 起始位(1) 数据位(8) 校验位(0) 停止位(1) 10 位 9600 波特率下1 位时间 1/9600 ≈ 0.104ms 1 字节时间 10 × 0.104 ≈ 1.04ms一个典型的读保持寄存器请求帧是 8 字节响应帧是 7 字节1 个寄存器。加上从站的处理时间通常几毫秒到几十毫秒一个完整的问答周期大约 20-50ms。所以超时设置不能太短否则正常响应也会被判定为超时。我设置的是500ms这是工业场景下比较稳妥的值。如果从站设备响应慢可以适当放宽到 1000ms。重试策略我采用的是3 次重试每次重试间隔 100ms。如果 3 次都失败标记该从站为离线跳过它继续轮询下一个。这样可以避免一个故障从站拖垮整个轮询队列。public ModbusResponse sendRequest(byte[] request, int expectedResponseLen) { for (int retry 0; retry MAX_RETRY; retry) { try { // 清空输入缓冲区 clearInputBuffer(); // 发送请求 outputStream.write(request); outputStream.flush(); // 读取响应 byte[] response readWithTimeout(inputStream, expectedResponseLen, TIMEOUT_MS); if (response null || response.length 5) { continue; // 响应太短重试 } // 校验 CRC int crcCalc crc16(response, 0, response.length - 2); int crcRecv (response[response.length - 2] 0xFF) | ((response[response.length - 1] 0xFF) 8); if (crcCalc ! crcRecv) { continue; // CRC 错误重试 } return parseResponse(response); } catch (IOException e) { // IO 异常重试 } try { Thread.sleep(RETRY_INTERVAL_MS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } return null; // 全部重试失败 }实操心得clearInputBuffer()很重要。在发送新请求之前把输入缓冲区里的残留数据读掉避免上一次的超时响应混进来导致 CRC 错误。实现方式就是循环available()并read()直到没有数据。3.3 异常响应码的处理Modbus 从站返回异常时功能码的最高位会被置 1。比如读保持寄存器是 0x03异常响应就是 0x83。异常帧的结构是字段长度说明从站地址1 字节同请求功能码1 字节原功能码 0x80异常码1 字节见下表CRC162 字节校验常见的异常码异常码名称含义0x01非法功能从站不支持该功能码0x02非法数据地址寄存器地址超出范围0x03非法数据值数据域的值不合法0x04从站设备故障从站处理时发生错误0x05确认从站已接受请求正在处理0x06从站设备忙从站正在处理长任务解析响应时先判断功能码是否大于 0x80如果是说明是异常响应读取第 3 个字节作为异常码然后根据异常码做相应处理。比如 0x02 非法数据地址说明请求的寄存器地址不对需要检查配置0x06 从站设备忙可以稍后重试。我在代码里对异常响应做了分类处理可重试的异常0x05、0x06会触发重试不可重试的异常0x01、0x02、0x03会记录日志并跳过该请求。4. RS485 物理层与锁板通信实战4.1 RS485 硬件连接的注意事项RS485 是差分信号A 和 B 两根线传输相反的信号。接线时要注意A 接 AB 接 B。不同厂家的标注可能相反有的标 A/B有的标 D/D-有的标 485/485-。如果通信不上先把 A/B 对调试试。终端电阻。RS485 总线两端需要各接一个 120Ω 的终端电阻中间节点不接。如果总线很短几米以内不接也能通但长距离或高波特率时必须接。屏蔽层接地。如果用的是屏蔽双绞线屏蔽层要单点接地不要两端都接否则会形成地环路。共地。RS485 虽然差分传输但两端设备的地电位差不能太大否则会损坏收发器。如果距离远建议用隔离型 485 转换器。我用的工业平板自带 485 口接线端子是标准的 A/B/GND 三线。从站设备是板卡也是 A/B/GND。总线长度大约 10 米两端各接了一个 120Ω 电阻。4.2 锁板通信的场景与挑战锁板这个词在工业场景里通常指的是对某个控制板进行独占式通信确保在关键操作期间没有其他主站干扰。我的场景是Android 平板作为唯一主站轮询 8 个从站板卡每个板卡有 16 个保持寄存器需要读取。挑战在于轮询周期要短。8 个从站 × 16 个寄存器如果每个从站读一次按 9600 波特率算一个完整轮询周期大约 8 × 50ms 400ms。加上重试和间隔实际约 500ms。这个周期对于大多数工业监控场景够用了。不能丢帧。工业现场电磁干扰大偶发的 CRC 错误必须通过重试机制弥补。异常要能恢复。某个从站掉线后不能影响其他从站的轮询。掉线从站要能被检测到恢复后能自动重新加入轮询。我的实现方案是用一个独立的轮询线程维护一个从站列表每个从站有在线/离线状态。轮询时跳过离线从站但每隔一段时间比如 10 个周期尝试一次离线从站如果响应正常就标记为在线。// 轮询线程核心逻辑 while (isRunning) { for (SlaveDevice slave : slaveList) { if (!slave.isOnline() !slave.shouldRetry()) { continue; // 离线且未到重试时间跳过 } ModbusRequest request buildReadHoldingRegistersRequest( slave.getAddress(), 0, 16); ModbusResponse response sendRequest(request.toBytes(), 37); if (response ! null !response.isException()) { slave.setOnline(true); slave.updateData(response.getRegisterValues()); } else { slave.incrementFailureCount(); if (slave.getFailureCount() MAX_FAILURE) { slave.setOnline(false); } } Thread.sleep(POLL_INTERVAL_MS); // 从站间隔 } Thread.sleep(CYCLE_INTERVAL_MS); // 周期间隔 }4.3 数据解析与界面刷新读取到的寄存器数据是 16 位的无符号整数需要根据实际业务转换为有意义的数值。比如温度值可能是寄存器值 / 10.0压力值可能是寄存器值 * 0.01。这些转换系数要和从站设备的文档对应。界面刷新我用的是HandlerpostDelayed每 500ms 刷新一次 UI。注意不要在轮询线程里直接操作 UI否则会崩溃。数据通过ConcurrentLinkedQueue或者AtomicReference传递给 UI 线程。// 轮询线程中更新数据 latestData.set(slaveDataMap); // UI 线程中读取 handler.postDelayed(new Runnable() { Override public void run() { MapInteger, int[] data latestData.get(); updateUI(data); handler.postDelayed(this, 500); } }, 500);注意latestData用AtomicReference包装保证多线程可见性。不要用普通的HashMap直接跨线程访问会有并发问题。5. 常见问题与排查技巧实录5.1 通信完全不通的排查步骤当你发现 Android 发出去的请求没有任何响应时按以下顺序排查确认串口设备节点是否正确。用adb shell进入设备ls -l /dev/tty*查看可用的串口节点。工业平板通常是/dev/ttyS0到/dev/ttyS3USB 转串口是/dev/ttyUSB0。确认权限。ls -l看节点权限如果是crw-rw----且属主不是你的应用需要 root 或者修改权限。工业平板通常已经放开了权限。确认波特率等参数。9600、8N1 是最常见的但有些设备用 19200 或 115200。用示波器或者逻辑分析仪抓一下波形最直接。确认 A/B 线没有接反。这是最常见的硬件问题对调一下试试。确认从站地址和功能码。用 Modbus Poll 在 PC 上先测试确认从站能正常响应再换到 Android 上。5.2 CRC 错误频繁出现的处理如果通信能通但 CRC 错误率很高通常是以下原因现象可能原因解决方法偶发 CRC 错误电磁干扰加屏蔽层远离变频器连续 CRC 错误波特率不匹配确认两端波特率一致特定从站 CRC 错误该从站硬件问题检查该从站的 485 收发器长距离 CRC 错误信号衰减加终端电阻降低波特率我在现场遇到过一次某个从站的 CRC 错误率特别高其他从站都正常。后来发现是那个从站的 485 接线端子松动重新压接后问题消失。所以硬件问题往往表现为软件现象排查时要软硬结合。5.3 应用长时间运行后崩溃的处理如果应用运行几小时后崩溃日志里有Too many open files或者OutOfMemoryError大概率是资源泄漏。检查以下几点串口是否频繁打开关闭尽量保持长连接。输入输出流是否在每次请求后都正确关闭不要重复创建流。轮询线程是否有退出机制应用退到后台时要暂停轮询回到前台再恢复。数据缓存是否有上限不要无限累积历史数据。我最终的做法是串口在应用启动时打开退出时关闭中间不重连。如果串口异常捕获异常后重建连接但限制重建频率比如最多每分钟一次。5.4 常见问题速查表问题排查方向快速验证完全无响应接线、权限、设备节点用 PC 的 Modbus Poll 测试响应超时波特率、从站地址降低波特率到 9600 试试CRC 错误干扰、终端电阻缩短总线长度数据错位字节序、寄存器映射对照从站文档逐字节核对应用崩溃资源泄漏、内存用 Android Profiler 监控轮询卡顿超时设置、线程阻塞检查 read() 是否阻塞实操心得调试 Modbus 时一定要有一个可靠的参考工具。我习惯用 PC 上的 Modbus Poll 先跑通确认从站和接线没问题再切换到 Android 上调试。这样可以排除硬件问题专注于软件逻辑。6. 性能优化与稳定性加固6.1 轮询效率的优化8 个从站、每个 16 个寄存器如果逐个读取效率不高。Modbus 支持一次读取多个连续寄存器最多 125 个。所以可以把多个从站的数据合并读取吗不行Modbus 的从站地址是帧的一部分一次请求只能针对一个从站。但可以在一个从站内合并读取。比如从站有 16 个寄存器一次读 16 个比读 4 次每次 4 个要快。我的实现就是每个从站一次读 16 个寄存器。另外轮询间隔可以动态调整。如果所有从站都在线且响应正常可以缩短间隔到 200ms如果有从站离线可以放宽到 1000ms减少无效等待。6.2 异常恢复机制工业现场设备可能因为各种原因重启或掉线通信程序必须具备自恢复能力。我的做法是从站级恢复离线从站每隔 10 个轮询周期尝试一次成功则恢复在线。串口级恢复如果连续 10 次请求都失败所有从站认为串口异常关闭并重新打开串口。应用级恢复如果串口重开也失败记录日志并提示用户检查硬件。// 串口级恢复 if (consecutiveFailures 10) { closeSerialPort(); Thread.sleep(1000); openSerialPort(); consecutiveFailures 0; }6.3 日志与监控调试和运维离不开日志。我在关键节点都加了日志每次请求和响应的原始字节十六进制CRC 校验结果超时和重试次数从站在线状态变化串口打开关闭事件日志用Log.d输出到 Logcat同时写入文件方便现场排查。注意日志量不要太大否则会影响性能。可以设置日志级别正常运行时只记录错误和状态变化调试时打开详细日志。public static void logFrame(String tag, byte[] frame) { StringBuilder sb new StringBuilder(); for (byte b : frame) { sb.append(String.format(%02X , b)); } Log.d(tag, sb.toString()); }提示十六进制日志是调试 Modbus 最有效的工具。把请求和响应的十六进制打印出来对照协议文档逐字节分析大部分问题都能定位。7. 我踩过的坑与最终方案总结回头看这个项目android-serialport-api 的两个坑是最耗时的read()阻塞不理会超时设置close()不释放文件描述符。这两个问题在官方文档和大多数教程里都没有提到只有实际踩过才知道。Modbus RTU 协议本身不复杂但细节很多CRC 字节序、异常码处理、超时重试策略每一个都需要仔细对待。RS485 物理层的问题往往表现为软件现象排查时要软硬结合。最终我的方案是修改 android-serialport-api 的 JNI 层修复close()的文件描述符泄漏在 Java 层用带超时的轮询读取替代阻塞read()Modbus 协议层实现完整的 CRC 校验、异常处理和重试机制轮询线程独立运行支持从站级和串口级的异常恢复。这套方案在工业现场连续运行了几个月没有出现崩溃或通信中断。轮询周期稳定在 500ms 左右CRC 错误率低于 0.1%从站掉线后能自动恢复。如果你也在做类似的项目我的建议是先把 PC 上的 Modbus Poll 跑通确认硬件和从站没问题然后在 Android 上先用简单的读写测试串口层最后再实现完整的 Modbus 协议。不要一上来就写完整逻辑分层调试效率更高。另外android-serialport-api 这个库虽然老但在工业 Android 场景下依然能用。关键是要理解它的局限性该改 JNI 就改 JNI不要指望 Java 层能解决所有问题。串口通信本来就是靠近系统底层的活该深入的时候就得深入。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门