简介:本资源是一套面向Android开发者的工业通信实战代码包,聚焦于在PDA等移动终端上通过Modbus TCP协议与PLC设备进行稳定读写交互,特别适合工控场景下需二次开发移动端HMI的工程师及学习嵌入式通信的新手。项目基于modbus4j.jar与seroUtils.jar实现16位寄存器读写,并完整封装了浮点数转换与写入逻辑,代码源自真实产线项目,已亲测可用并规避常见连接超时、字节序错乱等坑点。压缩包共1076个文件,含20个核心Java源码、129个class字节码、116个JSON配置、51个XML布局及26个JAR依赖,另有APK安装包与调试资源,整体大小为12.01MB。目前已有934人学习下载,提供即插即用的PLC通信模块、清晰的目录分层(含工具类、协议封装、UI交互三层结构)以及可直接复用的异常处理与数据解析范例,助力开发者快速集成Modbus功能到自有Android应用中。
很多搞工控的老哥们一听到“Android连PLC”,第一反应就是“这玩意儿能用吗?稳定吗?”。我的答案是:能,而且比你想象中简单。今天这篇就基于我最近做的一个项目,完整拆解一下怎么在Android上用Modbus4j库去读写三菱、西门子等常见PLC的数据。
先说清楚这篇文适合谁:搞MES系统集成的、做设备点检APP的、或者纯粹想在产线上用手机/平板当临时HMI的朋友。只要你的PLC支持Modbus TCP协议(现在主流PLC基本都支持,或者加个网关模块就能支持),这套方案就能跑通。文章不扯太多虚的,直接把从环境搭建到读写寄存器的完整代码和踩坑记录都给你。
1. 为什么我选择Modbus4j而不是自己造轮子
在正式写代码之前,得先说清楚一个事:为什么偏偏是Modbus4j这个库。
市面上Java读写Modbus的库其实不少,比如jamod、modbuspal,但我在对比测试了一圈之后,最终留下了Modbus4j。原因很实际:
第一,这个库对Modbus协议的实现非常完整。功能码覆盖了线圈读写(01/05/15)、离散量输入(02)、保持寄存器读写(03/06/16)、输入寄存器(04),基本覆盖了工控现场所有常见场景。像三菱FX系列读变频器频率这种需求,本质就是读保持寄存器,一个函数就搞定了。
第二,它内部把Modbus TCP 和 RTU都封装好了。虽然Android设备绝大多数场景下走TCP,但保不齐你后面要加一个串口转WiFi的网关,或者直接用USB转485去连老设备,这时候RTU的支持就是刚需。
第三,基于Apache 2.0协议开源,商用完全没问题,不会有授权的坑。
再说说为什么不建议自己写Modbus协议栈。Modbus TCP报文格式虽然不复杂——事务标识符、协议标识符、长度、单元标识符加起来就8个字节的MBAP头,后面跟功能码和数据——但真正写起来,你会发现坑全在细节里:TCP粘包拆包怎么处理、超时重试机制怎么设计、异常码怎么解析、处理从站不响应的情况,这些如果没有经过大量现场验证,很容易出现“连得上但读不到数据”的灵异问题。工业现场讲的是稳定,不是炫技。
在选型这件事上,我的建议是:除非你有极其特殊的定制需求(比如非标准协议变种),否则不要自己写协议层,直接用社区验证过的成熟库。
2. 基础概念:寄存器类型与Modbus4j的核心API
动手写代码前,脑袋里必须建立一个映射关系表。PC端做Modbus开发最怕的就是地址搞错,到了Android上同样如此。
2.1 Modbus协议里那四种基础数据类型
| 数据类型 | 功能码(读) | 功能码(写) | 常见用途 |
|---|---|---|---|
| 线圈(Coil) | 01 | 05(单写)/ 15(批量写) | 启停信号、开关状态 |
| 离散输入(Discrete Input) | 02 | 不支持写 | 限位开关、按钮状态 |
| 输入寄存器(Input Register) | 04 | 不支持写 | 模拟量采集(传感器数值) |
| 保持寄存器(Holding Register) | 03 | 06(单写)/ 16(批量写) | 频率设定值、温度设定值、运行参数 |
在工控界有一个历史遗留问题要注意:PLC侧看到的地址(比如40001)和协议里的真实地址(比如0)不是一回事。Modbus协议经典的“5位数地址”规则里,3xxxx对应输入寄存器,4xxxx对应保持寄存器,而协议报文里从0开始计数。所以屏幕上显示40001,到了Modbus4j里写ReadHoldingRegistersRequest,起始地址其实是0。
2.2 Modbus4j的核心类,一句话讲清楚
ModbusFactory:工厂类,用来创建各种通信Master。就像Android里的Retrofit.Builder,一切连接的起点。TcpMaster/TcpMasterParameters:TCP主站,对应TCP连接的建立与参数配置,如IP、端口、超时、重连次数。ModbusMaster:统一的主站操作接口,读写都通过它来完成。ReadHoldingRegistersRequest/WriteRegisterRequest/WriteRegistersRequest:请求对象,每个对应一个功能码。ValueFormat:这个类实在是太好用了。它把“字节数组转成Java里的int/float/long/bool”这一步高度封装了,默认大端字节序的情况下直接拿值。
拿到这些类的概念,下面就可以撸代码了。
3. 工程搭建与核心代码实现
3.1 环境准备与依赖引入
我用的是Android Studio(当前的大版本基本都行),项目用Java还是Kotlin都能跑,但因为我后续要写一些偏底层的解析逻辑,而且对接的很多老工控项目组Java功底更扎实,所以下面示例统一用Java写。自己项目里用Kotlin的,照着逻辑翻译就行。
在app/build.gradle里加依赖:
dependencies { implementation 'com.infiniteautomation:modbus4j:3.0.3' // 如果遇到依赖冲突,加上以下排除规则 implementation('com.infiniteautomation:modbus4j:3.0.3') { exclude group: 'org.slf4j', module: 'slf4j-api' } }测试下来3.0.3版本比较稳定,老版本2.x也没问题,但API命名有些变化。如果你需要在自己电脑上先做协议验证(不写Android),直接建一个Java main方法类也能跑,这个库在纯Java环境下的表现和Android上完全一致。
权限方面,别忘在AndroidManifest.xml里加网络权限:
<uses-permission android:name="android.permission.INTERNET" />这步漏了的话,Android 9及以上直接抛异常,而且很难排查到。
3.2 初始化Master连接
import com.serotonin.modbus4j.ModbusFactory; import com.serotonin.modbus4j.ModbusMaster; import com.serotonin.modbus4j.ip.tcp.TcpMaster; import com.serotonin.modbus4j.ip.tcp.TcpMasterParameters; public class ModbusTcpClient { private ModbusMaster master; private ModbusFactory factory = new ModbusFactory(); // 关键参数:IP、端口、超时时间、重试次数 public boolean connect(String ip, int port, int timeout, int retries) { try { TcpMasterParameters params = new TcpMasterParameters(ip, port); params.setTimeout(timeout); // 毫秒,建议3000-5000 params.setRetries(retries); // 重试次数,建议2-3 master = factory.getTcpMaster(params); master.init(); return true; } catch (Exception e) { e.printStackTrace(); return false; } } }这里有两个参数很有意思,先说timeout。Modbus TCP虽然是走TCP协议,有系统级超时兜底,但如果你不设应用层超时的话,一旦PLC端IP能ping通但业务端口不响应,TCP长连接可能挂很久才报错,现场体验非常糟糕。建议超时设在3000到5000毫秒之间。太小的话PLC在忙其他任务(比如正在执行子程序或者中断服务)时,经常会误报超时。
再说retries。PLC不比服务器,它的响应时间是不稳定的,CPU扫描周期一出问题,几十毫秒内没回包很正常。我一般设2次,既不会因为单次超时误判设备离线,也不会把大量时间浪费在无意义的等待上。
3.3 读取三菱PLC的保持寄存器(最常用)
需求很典型:产线上三菱FX系列PLC,读取当前变频器频率。变频器的运行频率一般存在D寄存器,映射到Modbus的保持寄存器区域。读取代码如下:
import com.serotonin.modbus4j.msg.ReadHoldingRegistersRequest; import com.serotonin.modbus4j.msg.ReadHoldingRegistersResponse; import com.serotonin.modbus4j.base.ModbusUtils; public int readHoldingRegister(int unitId, int startOffset, int length) { try { ReadHoldingRegistersRequest request = new ReadHoldingRegistersRequest(unitId, startOffset, length); ReadHoldingRegistersResponse response = (ReadHoldingRegistersResponse) master.send(request); if (response.isException()) { // 功能码返回异常,例如从站地址错误、寄存器地址越界 return -1; } // 注意返回的是字节数组,按大端模式转int return ModbusUtils.bigEndianToInt(response.getData(), 0); } catch (Exception e) { e.printStackTrace(); return -1; } }注意unitId,这是从站地址(站号)。很多新手在这里会踩坑:三菱FX3U在PLC侧把站号设为0,但Modbus TCP协议里单元标识符有效范围是1到247,站号0会被当成广播地址,PLC直接不理你。现场配Modbus网关时,要先把PLC的站号改成1或其它非0值,协议转换模块才会正常响应。
读取多个连续寄存器的时候,把length参数传大一点就行。还有一点:一次请求的最大寄存器数量是125个(这是Modbus协议的限制),千万别一次读几千个,要分批。这个限制在标准文档里有写,但是很多山寨网关并不提示,只会返回异常码,搞不好排查半天。
3.4 写入数据到PLC(频率设定/启停控制)
写入操作是最能体现“用手机控制设备”的场景。比如通过APP把变频器目标频率直接写到PLC的D寄存器:
import com.serotonin.modbus4j.msg.WriteRegisterRequest; import com.serotonin.modbus4j.msg.WriteRegisterResponse; import com.serotonin.modbus4j.base.ModbusUtils; public boolean writeSingleRegister(int unitId, int offset, int value) { try { WriteRegisterRequest request = new WriteRegisterRequest(unitId, offset, value); WriteRegisterResponse response = (WriteRegisterResponse) master.send(request); return !response.isException(); } catch (Exception e) { e.printStackTrace(); return false; } }如果你想一次写多个寄存器(比如同时下发目标频率和加减速时间),那就用WriteRegistersRequest,传入一个寄存器的int数组。这在批量设定设备参数时非常有用。
import com.serotonin.modbus4j.msg.WriteRegistersRequest; import com.serotonin.modbus4j.msg.WriteRegistersResponse; public boolean writeMultipleRegisters(int unitId, int startOffset, int[] values) { try { WriteRegistersRequest request = new WriteRegistersRequest(unitId, startOffset, values); WriteRegistersResponse response = (WriteRegistersResponse) master.send(request); return !response.isException(); } catch (Exception e) { e.printStackTrace(); return false; } }写操作是现场事故高发区。建议在产品逻辑上强制加入二次确认对话框,同时在代码里做一个写前校验:对频率、温度等模拟量设定值,限制上下限后裁剪到合法范围内,别让一个-1或者65535这种数据直接灌到PLC里。
3.5 释放连接资源
Android设备不像PC,系统资源有限,而且一个Activity生命周期变化很可能导致底层连接异常。建议在onDestroy里做释放:
@Override protected void onDestroy() { super.onDestroy(); if (master != null) { master.destroy(); } }这个destroy()方法是这个库自己维护TCP连接状态的,不调用的话,TCP连接不会被系统自动回收,反复进入页面几次,就会出现连接数过多、所有请求超时的情况。而且这种问题在日志里看起来又是“一切正常但没响应”,非常迷惑。
4. 实际项目中的典型问题与排查记录
再好的代码,在真实工控现场也会遇到一堆“文档里没有写的”问题。我把这几个月踩过的坑整理成一份速查表,希望能帮你省下几天的排查时间。
4.1 常见问题速查
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 连接超时 | PLC的IP地址或端口错误 | ping PLC IP,用telnet测502端口 | 核对PLC侧以太网模块配置 |
| 连接超时 | Android设备与PLC不在同一网段 | 查看手机WiFi的IP地址段 | 给手机设置静态IP,确保和PLC同网段 |
| 读数据全是0 | 寄存器地址映射不对 | 在PC端先用Modbus Poll工具验证 | 对照PLC编程软件的D寄存器编号,注意起始偏移 |
| 读数据全是65535 | 寄存器不存在或该地址未分配 | 检查PLC侧数据是否已初始化 | 在PLC里给该寄存器赋初值 |
| 数据错乱(高低字节反) | 字节序不匹配 | 打印原始字节,比对大小端 | 调整库里的字节序配置 |
| 写入不生效 | 写功能码不正确 | 确认PLC支持06还是16功能码 | 单寄存器用06,多寄存器用16 |
| 偶发超时 | PLC扫描周期长,CPU忙 | 抓包看响应时间 | 加大timeout,降低轮询频率 |
| 崩溃 | 在主线程执行网络操作 | 看logcat的NetworkOnMainThreadException | 挪到子线程/协程/HandlerThread |
4.2 重点说说字节序问题
这是所有Modbus调试里最容易让人抓狂的坑,没有之一。
大端序(Big-Endian)表示高字节在前,小端序(Little-Endian)表示低字节在前。Modbus官方协议规定的是大端,但三菱、西门子都有一些特殊场景会搞出小端来,尤其中间加了个网关后,字节序就不可控了。
比如你读到的原始数据是0x01 0xF4,按大端解析就是500,按小端解析就是0xF401 = 62465。一个正常,一个疯掉。
解决思路很简单:打印原始字节,然后肉眼判断哪个值合理。
byte[] data = response.getData(); for (byte b : data) { System.out.printf("%02X ", b); }如果发现高低字节是反的,做一次字节交换就行:
public static int swapByteOrder(int value) { return ((value & 0xFF) << 8) | ((value >> 8) & 0xFF); }有些PLC厂商会把32位浮点数分两个字存储,而两个字的顺序也可能是反的(字序与字节序两个维度)。这种情况下建议自己写一个解析器,手动拼装两个寄存器后,再按IEEE 754格式转float。Modbus4j里也有LittleEndian的寄存器配置,但遇到混合字节序的场景,自己拼字节反而更可控。
4.3 Android端特有的坑:主线程网络操作和生命周期管理
这里必须强调一个Android开发的基本红线:不能在主线程里直接调用master.send()。Android从4.0开始就强制禁止在主线程做网络访问,直接抛NetworkOnMainThreadException。就算不抛异常,你也会把UI线程卡到ANR,用户看到的就是“手机死机了”。
我项目里的做法是用一个单线程的ExecutorService来管理所有Modbus请求。这样可以保证所有读写操作按顺序执行,不会出现两个请求同时发出去把PLC搞糊涂的情况:
private ExecutorService modbusExecutor = Executors.newSingleThreadExecutor(); public void postModbusTask(Runnable task) { modbusExecutor.execute(task); }然后在子线程里处理好数据,通过runOnUiThread或Handler把结果抛回UI线程刷新界面。
页面的生命周期也要小心。如果用户在读写过程中退出了页面,而子线程还在执行网络操作,很可能把连接状态搞乱。所以我在MainActivity里维护一个volatile boolean isActive标志位,在子线程回调前先检查一下页面还活着没,避免一个已经销毁的Activity去执行UI更新。
4.4 多设备场景下的设备地址管理
有一个需求很常见:一个App要管理多条产线的十几套PLC。这时Modbus协议里的unitId就是最重要的路由信息。每一套PLC的寄存器数据,都要跟它的IP和unitId一起维护。我的做法是封装一个设备对象:
public class PlcDevice { public String deviceName; public String ipAddress; public int port; public int unitId; public int[] registerAddresses; // 该设备需要监控的寄存器地址表 }这样在界面上切换设备时,只需要重新创建TcpMasterParameters指向对应IP,再重新初始化即可。但要注意:Modbus4j的Master实例和TCP连接是绑定的,如果你有十几台设备需要同时采集数据,就要创建多个Master实例,每个Master维护一路TCP连接,并用各自的Executor去调度请求。
连接数量多了以后,建议加一个连接池管理,超过一定空闲时间自动destroy(),下次用到时再init()。省电,也避免手机端的TCP连接数被打满。
5. 断线重连与稳定性优化
工控场景里最怕的就是“APP连着一半突然断了”,所以断线重连机制必须一开始就设计好,而不是等现场出问题再补。
5.1 心跳检测机制
Modbus TCP本身没有“心跳包”这一说法,实际工程上就是用定期读寄存器来间接保活。我一般是每隔5秒发一次读操作(读一个或几个寄存器都行,关键是产生通信流量),连续3次无响应就判定设备离线,然后进入重连流程。
private void startHeartbeat() { heartbeatRunnable = new Runnable() { @Override public void run() { if (isConnected()) { int value = readHoldingRegister(unitId, heartbeatAddress, 1); if (value == -1) { offlineCount++; if (offlineCount >= 3) { // 判定离线,触发重连 reconnect(); } } else { offlineCount = 0; } } heartBeatHandler.postDelayed(this, 5000); } }; heartBeatHandler.postDelayed(heartbeatRunnable, 5000); }注意心跳间隔别太短,5秒是比较保守的。太频繁的话,一方面会增加PLC那边的通信负担,另一方面手机也会更耗电。有时候PLC厂家看到你每秒刷屏式地读数据,还会怀疑你的系统是不是有异常,现场沟通起来很尴尬。
5.2 自动重连与退避策略
重连不能太耿直。如果PLC停电了半小时,你的App每两秒重试一次,手机电池会哭的。我用了一个简单的指数退避策略:
- 第1次重试间隔:3秒
- 第2次:6秒
- 第3次:12秒
- 最大值封顶:30秒
- 一旦重连成功,立即重置间隔
这样既能在PLC恢复后快速感知,又不会在长时间离线时浪费资源和电量。
5.3 日志记录是保命手段
再说一个经验:在Android上做Modbus调试,如果没有日志记录,你基本就是瞎子。你要把每一次请求的:时间戳、目标IP、功能码、寄存器地址、读写长度、返回结果,全部写到本地文件里去。现场出了问题,通过日志能快速定位是App问题、网络问题还是PLC问题。
我实现了一个简单的FileLogger,每次读写操作后追加一行日志,按天滚动保存。虽然看起来土,但真的很救命。
6. 补充:当PLC不支持Modbus TCP时怎么处理
不是所有PLC都自带Modbus TCP接口的。三菱FX2N这种老古董,本身只带RS485串口,如果你非要用Android去连它,有两个办法:
第一种,加一个串口转WiFi/串口转以太网的网关模块。市面上很多工业级网关(比如有人物联网、USR IOT系列)都自带Modbus TCP转Modbus RTU功能。Android发送Modbus TCP报文,网关收到后自动转成RTU格式,通过RS485总线发给PLC。这种方式对App代码完全透明,读写逻辑一个字都不用改。
第二种,串口直连。Android设备通过OTG接一个USB转RS485模块,然后直接用Modbus4j的RTU模式去通信。实现上要处理串口打开、波特率设置、数据位/停止位/校验位配置,代码量会多一点,而且相对TCP来说,串口通信的调试难度更大。我建议能走TCP就走TCP,实在要用串口再上这个方案。
无论哪种方式,PLC侧需要预先做好配置:站号不能为0、波特率/校验方式要和网关侧一致、需要映射的D寄存器和M线圈都要在PLC程序里准备好数据。
7. 写在最后的几个小建议
根据我个人经验,还有几件小事值得多啰嗦几句。
第一,上现场前一定先在办公室用模拟器或PC端工具把通信逻辑验证好。我常用的是Modbus Poll(PC端测试软件)和一套虚拟从站模拟器,先在PC上把读写逻辑跑通,再往Android上搬。直接拿真机到现场调试,一旦出问题,你连“到底是硬件问题还是代码问题”都分不清。
第二,界面上的数据刷新频率不要超过2Hz。人眼完全看不出10Hz和2Hz刷新率的区别,但PLC那边可能已经被你的高频读请求拖累了。尤其是还要兼顾多台设备时,更要把轮询周期做成分段式,避免所有设备都在同一个时间点发请求,造成瞬间流量风暴。
第三,考虑一套离线模式。Android设备本身有本地数据库(比如SQLite),把最近一次读到的PLC数据缓存下来。这样万一断网断连,界面还能显示历史数据,至少用户可以知道“上一次正常时设备的状态是什么”,这对于现场排障有很大帮助。
第四,如果可以的话,把PLC读写封装在自己团队内部的一个独立Module里,与UI层彻底隔离。这样后续如果换通信库、加设备类型,都不需要动界面代码。我早期为了赶进度把通信逻辑和Activity混在一起写,后面加需求时吃了不少苦头,重构花的时间比重写还要多。
Modbus4j这个库虽然不算非常活跃,但胜在稳定,配合Android做移动端的工控监控完全够用。像手机远程看一下产量、改一下频率设定、点一下启动停止,这种需求它都能稳稳扛住。如果你正在评估类似方案,希望这篇能够给你省下几个加班的晚上。
本文还有配套的精品资源,点击获取