做工厂数据采集这些年,我最怕听到一句话:“设备是某家的,你直接连一下就行。”对方嘴里的“直接连”,往往意味着现场至少有五种协议、三种串口转以太网盒子、一本没人能看懂的寄存器表。Modbus TCP 已经是里面最好说话的一个了。这次专门抽时间把 Apache PLC4X 的 Modbus TCP 驱动从头到尾学了一遍,踩了不少坑,这篇是学习记录第一篇。如果你也是 Java 背景、需要快速把 PLC/网关的数据接到上层系统,而不是从零写 Modbus 协议栈,这篇应该能帮你省下至少一个下午的试错时间。
1. 为什么我拖到现在才认真学 Apache PLC4X
1.1 工厂里的协议“方言”问题
先说说背景。我之前做过好几个 IoT 项目,设备侧用得最多的还是 Modbus RTU/TCP,偶尔碰到西门子 S7 和三菱 MC 协议。一开始的想法很简单:哪个设备用什么协议,就写哪个协议的采集模块。于是项目里出现了一堆散装代码,Modbus 是一套封装,S7 是另一套封装,内部数据结构还不一样。后来接新设备时,哪怕只是加一个点位,也要过一遍那套私有代码,维护成本越来越高。
Apache PLC4X 进入视野其实很早,但一开始印象停留在“Apache 孵化项目”,觉得可能不稳定,就一直没认真看。直到有次要做技术选型,对比了各种开源库,才发现 PLC4X 的定位不是“某个协议的SDK”,而是一套统一访问 PLC 的客户端框架。它把 Modbus、S7、BTCORE、OPC UA、KNX 这些协议的差异封在驱动层,上层拿到的永远是同一个PlcReadResponse模型。
1.2 PLC4X 解决的是“最后一公里”接入问题
真正让我决定掏时间学它的点,是这套API设计。一个 Java 后端接 PLC,传统方式是自己维护长连接、处理心跳、拼报文、拆报文,还要自己做断线重连。这些工作本身不难,但烦,且每个协议都要做一遍。
PLC4X 的价值在于:它把“连接管理”“读写请求”“数据类型转换”“报文解析”这些脏活都收进驱动里。对应用开发来说,你只需要:
- 用一段连接字符串描述“去连哪个设备、走什么协议”;
- 用统一的
PlcConnection发起读、写、订阅; - 拿到的结果都是结构化的
List<Object>或Map<String, Object>。
我这次的实际场景是接一台工业网关,网关上跑的是 Modbus Slave,里面放了温度和压力等寄存器数据。传统做法是写个轮询线程,用 Netty 自己搞 TCP 客户端。用 PLC4X 之后,连接和报文的琐碎工作全被封装掉了,代码量肉眼可见减少。
1.3 它不适合什么人用
也要泼盆冷水。PLC4X 不适合所有场景。
如果你需要微秒级的实时读写,或者要跑在资源极少的 MCU 上,那它不合适。它毕竟是 JVM 世界的库,适合做上位机、边缘网关、数据采集服务这类场景。再就是,如果你需要往 PLC 里写控制逻辑,建议还是用原生协议库或厂商驱动,PLC4X 的写操作虽然支持,但重点是采集而非精确控制。
我这篇就说读,写和订阅留到后面的学习记录。
2. 上手之前,先把 Modbus TCP 和 PLC4X 的分工弄清楚
2.1 Modbus TCP 在网络上到底发了什么
不懂网络层细节也能用 PLC4X,但懂一点排错效率完全不样。Modbus TCP 报文由两部分组成:
- MBAP 头:包含事务处理标识符、协议标识符、长度和单元标识符(Unit Identifier)。其中单元标识符对应 Modbus 传统意义上的从站地址,TCP 场景下多数设备固定为 1。
- PDU:功能码 + 数据部分。读保持寄存器的功能码是 0x03,读线圈是 0x01,写单个保持寄存器是 0x06,写多个是 0x10。
举个例子。读从站地址 1 的保持寄存器,从协议地址 1000 开始读 2 个寄存器,请求大约是这样的:
事务标识符 1 -> 0x0001 协议标识符 0 -> 0x0000 后续字节长度 6 -> 0x0006 单元标识符 1 -> 0x01 功能码 0x03 -> 0x03 起始寄存器地址 1000 -> 0x03E8 寄存器数量 2 -> 0x0002响应里每个寄存器占 2 字节。两个寄存器拼一个 32 位整数是常见的,拼法有ABCD、CDAB、BADC、DCBA四种,这就是后面字节序坑的根源。
2.2 PLC4X 的协议栈是分层的
PLC4X 不是把 Modbus 代码一整坨封装起来。它的抽象大概分四层:
Transport:负责 TCP/UDP/串口等物理通道通信。Protocol:负责 Modbus 报文编解码。Driver:把上层的读写请求翻译成协议动作。API:统一暴露PlcConnection、PlcReadRequest、PlcReadResponse。
分层的好处是,排障时可以明确知道问题在哪一层。连不上是 Transport 层;报文发了没回是 Protocol 或对端设备问题;收到数据但解析不对则大概率是 Driver/地址配置问题。
2.3 寻址三要素:从站、寄存器地址、数据类型
用 PLC4X 写读点位前,脑子里要始终有三个东西:
- 从站地址:连接串里的
unit-identifier,对应 MBAP 头里的单元标识符; - 寄存器区域和地址:Modbus 有线圈、离散输入、保持寄存器、输入寄存器四类区域,PLC4X 里通过不同前缀表达;
- 数据类型:相同地址,按 INT、UINT、DINT、FLOAT 解释结果完全不同。
常用区域的表达方式大概是这样的:
| Modbus 区域 | 功能码 | PLC4X 地址示例 | 典型数据类型 |
|---|---|---|---|
| 线圈 | 0x01/0x05 | coil:1 | BOOL |
| 离散输入 | 0x02 | discrete-input:1 | BOOL |
| 输入寄存器 | 0x04 | input-register:1 | INT/UINT/FLOAT |
| 保持寄存器 | 0x03/0x06/0x10 | holding-register:1 | INT/UINT/FLOAT |
PLC4X 地址语法里冒号后面是协议地址,比如holding-register:1000就是协议地址 1000 的保持寄存器。要注意,很多设备资料给的是 40001 这种 PLC 风格地址,40001 对应协议地址 0,前面差 1,这个后面专门说。
3. 最小工程:Maven 依赖和连接工厂
3.1 版本怎么选,依赖加哪些
我用的版本是0.12.0。这个版本在 Maven Central 上有正式发布,依赖也干净。如果你只是做 Modbus TCP 采集,最核心的依赖就一个:
<dependency> <groupId>org.apache.plc4x</groupId> <artifactId>plc4j-driver-modbus</artifactId> <version>0.12.0</version> </dependency>它会把plc4j-api、plc4j-transport-tcp、plc4j-protocol-modbus等相关模块传递进来。如果需要在日志里看到报文级别的信息,再加 SLF4J 的绑定,我用的是 Log4j2:
<dependency> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-slf4j2-impl</artifactId> <version>2.20.0</version> </dependency>这里提一个容易踩的坑:PLC4X 的 API 在不同版本之间出现过包名调整。网上很多文章是基于旧版本写的,导入org.apache.plc4x.java.modbus之类的包可能在新版本看不到。建议始终以依赖里实际拉下来的 jar 为准,IDE 里直接看包结构。
3.2 连接字符串是最大的“隐形配置”
PLC4X 不需要一大堆Properties,所有关键参数都在连接字符串里。我这次用的连接串是这样的:
modbus-tcp://192.168.31.50:502?unit-identifier=1&request-timeout=2000拆开看:
modbus-tcp:协议驱动前缀;192.168.31.50:502:设备 IP 和端口;unit-identifier=1:从站地址,对应 Modbus 的 Unit ID;request-timeout=2000:每次请求超时时间,单位毫秒。
注意一个细节:参数名不要随便抄。网上有些文章写的是Unit-Identifier,大小写混着来,实测部分版本的 PLC4X 解析参数时区分大小写,写错了不会报“参数错误”,而是直接使用默认值。最稳妥的方法是把官方示例里的连接串原样复制改 IP。
3.3 连接管理:一个 Manager 到底
PLC4X 提供了PlcDriverManager来获取连接:
import org.apache.plc4x.java.PlcDriverManager; import org.apache.plc4x.java.api.PlcConnection; PlcDriverManager driverManager = PlcDriverManager.getDefault(); PlcConnection connection = driverManager.getConnection("modbus-tcp://192.168.31.50:502?unit-identifier=1&request-timeout=2000");这个 Manager 建议做成全局单例,因为内部有协议注册和连接复用逻辑。每次getConnection都拿新PlcDriverManager也不是不能用,但没必要,多个连接串并发获取时容易浪费资源。
拿到PlcConnection后,第一件事不是读数据,而是确认连接是否真的建立:
if (!connection.isConnected()) { // 连接还没有真正建立,需要主动 ping 或重连 }实际使用中,设备断电、网络断开会触发PlcException。PLC4X 不会自动无限重连,所以上层要自己做断线重连策略。我比较土的做法是启动一个定时任务,每隔 10 秒检查一次连接,断开了就重新getConnection。
4. 第一段成功代码:把网关里的温度读数取出来
4.1 场景设定
我这边有一台用于环境监控的网关,它对外提供 Modbus TCP Server 接口。设备文档上写着:
- 保持寄存器地址 100(协议地址)存放温度值,类型 INT,单位 0.1 摄氏度;
- 保持寄存器地址 101 存放湿度值,类型 UINT,单位 0.1%;
- 保持寄存器地址 102 存放设备状态,0 表示正常,1 表示告警,类型 UINT。
注意,文档里“地址 100”在大多数 Modbus 工具里显示为 100,很多老工程师会习惯性写成 40101,但 PLC4X 里使用协议地址,即holding-register:100。
4.2 完整读取代码
下面这段代码是我实际跑通的最小逻辑。先读 3 个保持寄存器:
import org.apache.plc4x.java.PlcDriverManager; import org.apache.plc4x.java.api.PlcConnection; import org.apache.plc4x.java.api.exceptions.PlcException; import org.apache.plc4x.java.api.messages.PlcReadRequest; import org.apache.plc4x.java.api.messages.PlcReadResponse; import java.util.concurrent.TimeUnit; public class ModbusTcpReadDemo { public static void main(String[] args) throws Exception { PlcDriverManager driverManager = PlcDriverManager.getDefault(); String connectionUrl = "modbus-tcp://192.168.31.50:502" + "?unit-identifier=1&request-timeout=2000"; try (PlcConnection connection = driverManager.getConnection(connectionUrl)) { if (!connection.isConnected()) { throw new IllegalStateException("连接建立失败"); } PlcReadRequest.Builder builder = connection.readRequestBuilder(); builder.addItem("temperature", "holding-register:100?datatype=INT"); builder.addItem("humidity", "holding-register:101?datatype=UINT"); builder.addItem("deviceStatus", "holding-register:102?datatype=UINT"); PlcReadRequest readRequest = builder.build(); // execute() 返回的是 CompletableFuture,这里手动设超时 PlcReadResponse response = readRequest.execute().get(5, TimeUnit.SECONDS); if (response.getResponseCode() != PlcResponseCode.OK) { System.err.println("请求异常: " + response.getResponseCode()); return; } int temperatureRaw = response.getInt("temperature"); int humidityRaw = response.getInt("humidity"); int statusRaw = response.getInt("deviceStatus"); System.out.println("温度原始值: " + temperatureRaw); System.out.println("湿度原始值: " + humidityRaw); System.out.println("设备状态: " + statusRaw); } } }印象里第一次跑通时,打印出来的温度值是236,我盯着这个数看了好一会儿才意识到,0.1 摄氏度分辨率,实际温度就是 23.6 度。这种由设备文档定义的缩放关系,PLC4X 不会替你处理,代码里得自己乘除。
4.3 响应对象里藏着哪些信息
PlcReadResponse除了能按 item 名称取整型,还提供了很多查询方法:
getResponseCode(String tagName):查看单个位点是否读取成功;getValue(String tagName):返回Object类型;getString("temperature"):方便直接打日志;- 如果某个寄存器读取失败或该点位不存在,
value可能是null,而不是抛异常。
我在代码里单独判断了整体getResponseCode(),但更严谨的做法是对每个 tag 都单独判断:
if (response.getResponseCode("temperature") != PlcResponseCode.OK) { // 温度通道失败,单独处理 }因为一次请求里,三个寄存器可能只有两个成功。在实际项目中,这种局部失败非常常见,千万别默认“要么全成功,要么全失败”。
4.4 缩放转换和字段规整
设备端的数据格式和业务侧需要的数据格式往往不一致。我在代码里把“读数、缩放转换、单位处理”封了一个小工具:
float temperatureCelsius = temperatureRaw / 10.0f; float humidityPercent = humidityRaw / 10.0f;同时定义了一个简单的点位结果类:
public class MonitorPointValue { private final int rawValue; private final Object value; private final String unit; }这样做的原因很简单:后期接入消息队列或数据库时,直接把封装好的对象序列化丢出去,采集逻辑和业务逻辑分离。PLC4X 毕竟解决的是“能不能读到”,而“读到的数据怎么变成业务数据”要靠自己那一层规约去兜住。
5. 踩坑合集:五天里最耗时的排障记录
5.1 连接串参数名大小写,差一个字母就连不上默认从站
第一天我被一个现象困住:连接完全不报错,isConnected()也返回 true,但读取全是PLCVALUE_RESPONSE_CODE之类的找不到地址或者超时。最后排查发现,连接串里我把参数写成了Unit-Identifier=1,而代码实际支持的是全小写unit-identifier=1。
关键让我困惑的点在于:大小写写错之后,PLC4X 会静默使用默认值,不抛异常。默认 Unit ID 通常就是 1,所以如果你设备也从站地址是 1,根本看不出问题;但我第一台测试设备恰好是从站 2,于是所有请求都发给了从站 1,对方自然没反应。
所以,连接串、参数名这类配置,尽量从官方文档或示例里复制,别自己“按记忆补全”。
5.2 寄存器起始地址差 1 的行业老毛病
做 Modbus 采集的人几乎都被“地址偏码”坑过。设备文档写的是 40001,很多老驱动也接受 40001,但在裸协议层面,40001 对应的其实是地址 0。PLC4X 里holding-register:100就是协议地址 100,不存在“4 开头”的偏码。
如果你按 40001 去填holding-register:40001,读到的很可能不是想要的寄存器。我第二次排查就是这个问题:现场设备文档写温度在 40101,我填了holding-register:40101,读出来的数据莫名其妙地大。后来看到协议抓包才发现,实际上请求的是协议地址 40101,而不是文档作者以为的 101。
现在的习惯是:拿到设备文档先确认是哪种地址规范。如果只有 40001 这种 PLC 风格地址,先用 Modbus Poll 之类的工具读一下原始协议地址,确认映射关系再写进 PLC4X 配置。
5.3 字节序陷阱:FLOAT 读出来是天文数字
第三个坑最有意思。设备文档写某状态寄存器是 FLOAT 类型,用datatype=FLOAT去读,值打印出来是-167416.4这种明显不对的数。用 Modbus Poll 同样的地址、同样的 FLOAT 解出来却是-0.72。
这就是字节序问题。Modbus 的 4 字节数据由两个寄存器组成,哪一半在前、哪一半在后,不同厂商的PLC 实现可能是反的。PLC4X 在datatype=FLOAT上按标准大端字节序解析,而我那台网关比较特殊,用的实际上是FLOAT+ 字交换,也就是说两个寄存器的顺序要互换。
PLC4X 0.12.0 的 Modbus 驱动在地址语法里支持额外参数,例如:
builder.addItem("pressure", "holding-register:200?datatype=FLOAT&byte-order=BIG_ENDIAN");byte-order参数可以控制解析顺序。如果遇到读出来数值离谱,先别怀疑是网络问题,用 Modbus Poll 或者自己抓包确认原始两个寄存器的字节序,再决定是否需要交换。
遇到这种情况,我自己的排查链路是:
- 先用一个固定值功能写死一个寄存器,比如写 0x3F800000,这在 IEEE 754 里正好等于 1.0;
- 再从 PLC4X 读出来,看结果是 1.0 还是 3.8195006e-38 这类怪数;
- 根据怪数特征判断是字节序反了还是寄存器的字序反了;
- 然后调整
byte-order参数或修改读写地址。
这个方法不用依赖抓包工具,特别适合现场。
5.4 超时设置要与设备扫描周期匹配
第一次接入一台老旧 PLC 时,我设置request-timeout=500,结果每隔几秒就抛一次超时。一开始以为是网络拥塞,后来才知道是老设备的扫描周期本身就超过 500 毫秒,处理不了频繁请求。
Modbus 是主从请求响应模式,主站发一个请求后必须等从站响应,从站如果扫描周期太慢,响应就慢。PLC4X 的请求超时时间如果比设备响应周期还短,就会提前判定失败。而连接本身是好的,重试又会让设备更忙,形成恶性循环。
解决方式就是放宽超时,以及降低轮询频率。我现在一般把请求超时设为 2000 毫秒,轮询周期设置在 3 秒以上。高速采集场景建议走 PLC4X 的订阅机制,而不是无限发请求。
5.5 日志里一堆异常,程序却还在“正常”运行
使用过程中还有个小插曲:程序一直跑着,日志却不停刷PlcException。我一开始以为连接崩了,结果一测还能读到数据。
后来才明白,PLC4X 里有些异常是“非致命”的,比如单个寄存器读取失败,响应对象里会带局部错误码,但不会让整个进程退出。如果你的业务代码没有检查单个点位状态,而是默认所有点位都有值,那么一部分数据实际已经丢了,程序却假装还在正常工作。
我的对策是在采集结果落地前加一个过滤:
if (response.getResponseCode(tagName) != PlcResponseCode.OK) { monitorLog.warn("点位读取失败: {}", tagName); continue; }这样至少不会把脏数据写进数据库。如果连续失败次数超过阈值,再触发连接重建和告警。
6. 后续我打算怎么扩展和深入
6.1 写操作:不是每个坑都要用原生协议趟一遍
PLC4X 也支持写操作,连接串和读一样,只是把 read 换成 write:
PlcWriteRequest.Builder builder = connection.writeRequestBuilder(); builder.addItem("setTemp", "holding-register:300?datatype=INT", 100); PlcWriteResponse response = builder.build().execute().get();不过要提醒自己:写操作涉及设备安全,测试时一定要对着模拟器或无关紧要的寄存器写。工业现场写错了轻则数据异常,重则影响设备运行,这个部分我打算用下一篇文章专门记录。
6.2 与上层系统打通:不只是“能读出来”
当前阶段我主要是把数据读出来后入库、推消息队列。后面如果想更进一步,可以考虑和 Apache IoTDB 这类时序数据库打通,PLC4X 本身就有相关的集成模块和示例。时序库在工业数据存盘、压缩、聚合上比普通关系库省心太多。
6.3 学习路线建议(给刚接触的人)
如果问我从哪儿开始,我会建议按这个顺序走:
- 先搞清楚设备侧的协议地址、功能码和字节序;
- 用 Modbus 模拟器或真实设备跑一个最简单的读 Demo;
- 再看 PLC4X 的连接字符串文档,不要一上来就套用复杂参数;
- 用日志或抓包工具验证发出的报文是否符合设备文档;
- 最后再碰写操作和订阅。
这也是我接下来写第二篇、第三篇记录的大纲。学习记录的形式对我来说,不仅是输出,更是把现场踩过的坑整理成可复用的经验。这篇就当“第一课”,后面再遇到新坑,我再继续记。