简介:面向需要在Java项目中集成Modbus RTU串口通信与Modbus TCP网络通信的开发者,这套代码覆盖工业自动化场景下的寄存器读写、主从站交互,适合设备数据采集、网关程序等应用。压缩包共47个文件,大小仅1.03MB,包含Java核心源码、可用的RXTX与modbus4j依赖jar包、Windows串口动态库、Maven工程配置及说明文档,目录结构清晰。已有2133人浏览学习。源码基于modbus4j和RXTX封装,提供串口参数配置、TCP连接管理、请求响应解析等可直接复用模块,对RTU二进制帧格式与TCP报文封装均有完整演示。同时附带主站与从站调用示例和异常处理逻辑,可帮助快速搭建Modbus调试工具或嵌入业务系统,从协议帧解析到通信链路搭建均有清晰代码参照,减少底层协议实现成本。
1. Modbus RTU和TCP:先搞懂这两个协议,代码才不会白写
1.1 报文结构的本质区别
做工业数据采集这行,Java对接设备是绕不开Modbus的。前阵子接了一个冷库环境监测项目,一边是温湿度传感器,只支持Modbus RTU,走RS485串口;一边是冷库PLC,支持Modbus TCP。用Java写一套通讯源码同时搞定这两种协议,我大概用了半天时间理清协议差异,剩下时间全在调设备参数。
先说RTU和TCP最核心的区别:报文结构。Modbus RTU走串口,一帧报文由从站地址(1字节)、功能码(1字节)、数据(N字节)、CRC16校验(2字节)组成,CRC排在报文尾部,低字节在前。因为串口是异步通信,没有TCP那样的可靠传输机制,所以必须靠CRC校验保证一个字节都不错。
Modbus TCP走以太网,报文前面多了一个MBAP头:事务标识符(2字节)、协议标识符(2字节)、长度(2字节),后面跟着单元标识符(1字节,其实就是原来的从站地址)、功能码和数据。TCP报文没有CRC,因为TCP/IP协议栈已经把可靠传输和校验做掉了。这个区别直接影响代码设计:RTU通讯你要自己拼校验、自己处理粘包,TCP通讯只要按照MBAP头的长度字段切包就行。
1.2 寄存器模型与功能码:Modbus到底在“说”什么
Modbus把设备里的数据抽象成了四张表:线圈(Coil)是可读可写的位,离散输入(Discrete Input)是只读的位,输入寄存器(Input Register)是只读的16位整数,保持寄存器(Holding Register)是可读可写的16位整数。绝大多数采集场景,你读的就是保持寄存器或输入寄存器,控制场景写的是线圈或保持寄存器。
功能码是设备听得懂的“动词”。0x01读线圈,0x02读离散输入,0x03读保持寄存器,0x04读输入寄存器,0x05写单个线圈,0x06写单个寄存器,0x0F写多个线圈,0x10写多个寄存器。我经手的项目里,90%以上只需要用到0x03和0x04,偶尔配合0x06和0x10做控制。
这里有个容易搞错的地方:寄存器地址是从0开始编号还是从1开始?文档上写“40001”,这个是PLC时代遗留的Modicon地址格式,4开头代表保持寄存器,实际报文里的地址是40001-40001=0。很多人在这一步栽过,明明文档写地址是40001,代码里填了40001,结果设备返回非法数据地址,其实就是没减这个偏移。
1.3 项目选型:什么时候用RTU,什么时候用TCP
协议选型不是一个单选题。按我的经验,判断标准大概就三条:设备本身支持什么、现场布线条件允不允许、项目预算有多少。老设备、防爆区仪表、距离远但点位分散,基本都是RS485走RTU,因为线缆便宜、布设简单,理论传输距离能到1200米。新上的控制柜、数据量大的设备、需要频繁读写的场景,直接以太网走TCP,省掉一堆串口转接的麻烦,故障排查也容易得多。
还有一点容易被忽略:RS485总线上是半双工通信,所有从站共用一条线,轮询周期和从站数量直接挂钩,从站多了会很慢。TCP每个设备独立连接,没有总线冲突问题,轮询频率可以做得更高。所以我的建议是:有条件就TCP,没条件就老老实实RTU,不要为了“高大上”硬把RTU设备转成TCP,多一个转换环节就多一个故障点。
2. Java的Modbus库怎么选:一次真实的选型记录
2.1 四个候选方案横向对比
Java生态里的Modbus库不算多,但真正动手选的时候还是花了一些时间。我对比过四个方向:
| 方案 | 协议支持 | 维护状态 | API风格 | 串口支持 | 适用场景 |
|---|---|---|---|---|---|
| jamod | RTU/TCP/ASCII | 基本停更 | 老旧,字节序处理有坑 | 依赖RXTX | 学习协议原理 |
| modbus4j | RTU/TCP/ASCII | 维护一般 | 功能全但臃肿,依赖多 | 自带串口实现 | 功能要求比较全的场景 |
| jlibmodbus | RTU/TCP | 持续更新 | 简洁清晰 | 基于jSerialComm,跨平台 | 大多数生产项目 |
| 自研Netty实现 | 可控 | 自己维护 | 自由 | 需自己接串口框架 | 协议二次定制需求多 |
2.2 我选择jlibmodbus的理由
最终我选了jlibmodbus。原因很直接:第一,API设计得像在用Modbus协议而不是在用某个框架,createModbusMasterTCP、createModbusMasterRTU这种命名一看就懂,省去读半天文档的功夫。第二,串口底层用的jSerialComm,Windows、Linux都能跑,我在Windows上调试完,部署到Ubuntu服务器上没有改一行代码。第三,项目还在维护,遇到问题能在社区找到讨论,这个对生产项目太重要了。
modbus4j其实功能更全,但依赖太重,集成进来增加不少包体积,而且它的线程模型有点儿绕,出了问题不好定位。jamod我看网上还有人用,但代码风格真的过时了,处理异常和超时的方式很原始,我调试时被它内部逻辑坑过一次,就直接放弃了。
注意:jlibmodbus采用的许可证需要关注一下。如果是开源项目没问题,商业闭源项目要注意许可证条款,必要时购买商业授权,别等上线了才想起这事。
2.3 先封装一个MasterClient,隔离协议差异
选好库之后,我做的第一件事不是直接写业务代码,而是封装一个MasterClient,把RTU和TCP的差异全部隔离在内部。这样上层业务根本不用关心设备走的是串口还是网口,换协议只改一行工厂方法。
public class ModbusMasterClient { private ModbusMaster master; // TCP连接:ip和端口 public void connectTcp(String ip, int port) throws ModbusIOException { master = ModbusMasterFactory.createModbusMasterTCP(ip, port, true); master.setTimeout(3000); master.connect(); } // RTU连接:串口号、波特率、数据位、停止位、校验位 public void connectRtu(String portName, int baudRate, int dataBits, int stopBits, int parity) throws ModbusIOException { SerialPortBuilder builder = SerialPortBuilder.newBuilder(portName) .setBaudRate(baudRate) .setDataBits(dataBits) .setStopBits(stopBits) .setParity(parity); master = ModbusMasterFactory.createModbusMasterRTU(builder); master.setTimeout(3000); master.connect(); } public int[] readHoldingRegisters(int slaveId, int start, int count) throws ModbusIOException { return master.readHoldingRegisters(slaveId, start, count); } public void writeSingleRegister(int slaveId, int offset, int value) throws ModbusIOException { master.writeSingleRegister(slaveId, offset, value); } public void disconnect() throws ModbusIOException { if (master != null) { master.disconnect(); } } }这个封装看着简单,但能省掉后面很多事。业务层拿到的是统一的读接口,设备地址、寄存器地址、数据类型全配置化,后续新增设备就是加一条配置的事,不用改代码。
3. Modbus TCP通讯源码:连接、读取、写入的完整实现
3.1 基于jlibmodbus跑通主从通讯的完整代码
TCP通讯相对省心,不用管串口参数和CRC,接上就能跑。下面是完整的最小示例,我习惯把它作为任何新项目的起点,先验证链路通不通,再往上加业务逻辑。
public class ModbusTcpDemo { public static void main(String[] args) { ModbusMaster master = null; try { // 设备IP和端口,PLC一般是502 master = ModbusMasterFactory.createModbusMasterTCP("192.168.1.10", 502, true); master.setTimeout(3000); master.connect(); int slaveId = 1; // 从地址0开始读10个保持寄存器 int[] values = master.readHoldingRegisters(slaveId, 0, 10); for (int i = 0; i < values.length; i++) { System.out.println("寄存器[" + i + "] = " + values[i]); } // 写单个寄存器,寄存器100写值10 master.writeSingleRegister(slaveId, 100, 10); // 写多个寄存器,寄存器100起写1、2、3 master.writeMultipleRegisters(slaveId, 100, new int[]{1, 2, 3}); } catch (ModbusIOException e) { e.printStackTrace(); } finally { if (master != null) { try { master.disconnect(); } catch (ModbusIOException ignored) { } } } } }这段代码跑通之后,再去看Wireshark抓包,能看到每次请求响应的事务ID是成对出现的。设备返回的报文里,功能码和数据区就是对应的寄存器值。
3.2 事务ID、超时与重连:TCP通讯的三个关键细节
Modbus TCP的MBAP头里有两个地方特别值得注意。第一个是事务标识符,每次请求都要自增,用于关联请求和响应。单线程同步调用时,即使事务ID不变也能凑合用,但一旦上了多线程或者快速轮询,响应和请求容易错配,数据读出来是乱的。我一般会在底层封装里加一个AtomicInteger,每发一次请求自增一次,从根上杜绝这个问题。
第二个是连接超时和读超时要分开设置。jlibmodbus的setTimeout设置的是Socket读超时,如果设备挂了,读操作会一直阻塞到超时,这个值不能设太大,我一般设3000毫秒。连接超时更关键,TCP连接建立时如果对端IP不通,默认可能要等几十秒,我建议在创建Socket的地方单独调短连接超时,别等到用户来反馈“页面转圈半天”。
重连也是一个容易被忽视的点。设备重启、网线松动都会导致连接断开,生产环境必须有重连机制。我的做法是:捕获到SocketException或ModbusIOException时,先disconnect再重新connect,中间加一个可配置的重试间隔。很多人直接重新connect不先disconnect,导致端口资源被残留连接占着,第二次连接一直抛BindException,这个坑很经典。
3.3 多线程轮询下最容易踩的并发坑
TCP连接建立之后,看起来可以随便调了,但modbus4j和jlibmodbus的ModbusMaster内部并不是所有方法都线程安全的。我有一次项目里同时开三个线程分别读温度、湿度、设备状态,结果读回来的数据偶尔会串,排查了很久才发现是Master对象被多线程并发调用,响应的解析上下文被互相覆盖了。
解决办法有两种:要么用一个线程池把请求串行化,所有寄存器的读取都走同一个调度线程;要么每个线程持有独立的ModbusMaster连接,牺牲一点TCP连接数换并发。对于大多数采集场景,串行化就够了,PLC和传感器的响应时间本来就在毫秒级,没必要为了一点并发把程序搞复杂。如果非要用多连接,注意控制连接数,设备端的连接池一般不大,连接太多会把设备搞崩。
4. Modbus RTU串口通讯:帧解析、CRC校验与实测踩坑
4.1 串口环境准备:Windows与Linux的差异
RTU通讯的第一步不是写代码,而是把串口环境理清楚。Windows下很简单,设备管理器里看COM口号,插个USB转485就能看到COM3还是COM4。Linux下要稍微折腾一下:插上转换器后执行ls /dev/ttyUSB*,通常会看到ttyUSB0;如果没权限,要么用chmod 777 /dev/ttyUSB0临时解决,要么把用户加入dialout组。我强烈建议用后者,chmod重启之后就失效了,而且权限给太宽也不安全。
串口参数必须和设备完全一致,波特率、数据位、停止位、校验位,这四个参数任何一个不匹配都会导致通讯失败或乱码。绝大多数设备默认是9600、8、1、None,但也有老仪表喜欢用19200或者偶校验。拿到一台新设备,第一件事就是看铭牌或说明书上的默认参数,很多“通讯不上”的案例都是参数没配对。
4.2 自己拼一帧RTU报文:从字节到CRC
理解RTU协议最好的方式是自己拼一帧报文。读保持寄存器的RTU帧格式是:从站地址(1字节)+ 功能码0x03 + 起始地址(2字节)+ 寄存器数量(2字节)+ CRC16(2字节),总共8字节。
public class ModbusRtuFrame { // 构建读保持寄存器请求帧 public static byte[] buildReadHoldingRegisters(byte slaveId, int startAddr, int quantity) { byte[] frame = new byte[8]; frame[0] = slaveId; frame[1] = 0x03; // 功能码:读保持寄存器 frame[2] = (byte) ((startAddr >> 8) & 0xFF); frame[3] = (byte) (startAddr & 0xFF); frame[4] = (byte) ((quantity >> 8) & 0xFF); frame[5] = (byte) (quantity & 0xFF); byte[] crc = calculateCrc(frame, 6); frame[6] = crc[0]; frame[7] = crc[1]; return frame; } }设备返回的响应帧格式是:从站地址 + 功能码 + 字节数 + 数据 + CRC。解析的时候一定要先校验CRC再取数据,顺序反了的话,噪声数据会被当成有效数据用。我做了一个简单的校验方法,返回的字节数必须等于1(功能码)+1(字节数)+N(数据)+2(CRC),不满足就直接丢弃,等待下一帧。
4.3 CRC16计算的完整实现与字节序陷阱
CRC16是Modbus RTU的底层保障,算法本身不难,但字节序这个坑非常隐蔽。计算出来的CRC要低位字节在前、高位字节在后,很多第一次写的人会把高低字节放反,结果设备一直不应答。
public static byte[] 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 = crc >> 1; } } } return new byte[]{(byte) (crc & 0xFF), (byte) ((crc >> 8) & 0xFF)}; }如果你用现成库,CRCHigh、CRCLow的顺序由库内部处理,一般不用操心。但如果你像我一样喜欢自己封装串口帧解析,这个顺序一定要记住:CRC的低字节是帧的倒数第二个字节,高字节是倒数第一个字节。
4.4 我踩过的RTU通讯坑
第一个坑是USB转485转换器的质量。便宜的转换器在波特率9600以上容易出现丢字节,表现是数据偶尔能读出来,偶尔读出来的值明显不对。后来把波特率降到9600、换了一根品牌的转换器,问题就消失了。工业现场强烈建议用工业级的USB转485隔离器,贵一点但省心。
第二个坑是从站地址。Modbus规定地址0是广播地址,设备不应答。有一次我代码里从站地址填了0,设备一直没反应,排查了很久才发现是这里的问题。还有一次厂商的设备地址是两位数,我当成十六进制填进去,解析出来完全不是同一个地址,设备和PC端工具都对不上。
第三个坑是设备响应时间。有些老设备的响应时间能达到几十甚至上百毫秒,请求发出去之后要等一会儿才能读到响应。如果代码里发完请求立刻读串口,会读到空。我在发请求后会加一个小的等待时间,同时轮询读取串口缓冲区直到读满一帧或超时,而不是只读一次。
第四个坑是RS485总线的半双工特性。同一时刻只能有一个设备发送数据,如果主站一边发请求一边从从站读响应,总线就乱了。写代码时一定要严格保证“发完请求再收响应”的顺序,不要在收响应之前又发下一个请求。
5. 寄存器数据解析与生产环境排查经验
5.1 int32、float与位打包:数据转换全搞懂
寄存器是16位的,但设备里的真实数据经常是32位浮点数或者32位整数,这样就涉及到两个寄存器拼接。拼接顺序因厂商而异,有些是高字在前,有些是低字在前。我用下面这个工具类统一处理,靠一个布尔参数控制顺序。
public class ModbusDataConverter { // 两个寄存器转int32,lowFirst为true表示低16位在前 public static int toInt32(int reg1, int reg2, boolean lowFirst) { if (lowFirst) { return ((reg2 & 0xFFFF) << 16) | (reg1 & 0xFFFF); } return ((reg1 & 0xFFFF) << 16) | (reg2 & 0xFFFF); } // 两个寄存器转float,内部先转int32再按IEEE 754解释 public static float toFloat(int reg1, int reg2, boolean lowFirst) { int intValue = toInt32(reg1, reg2, lowFirst); return Float.intBitsToFloat(intValue); } // 从16位寄存器的第bit位取值(用于开关量打包) public static boolean getBit(int registerValue, int bit) { return ((registerValue >> bit) & 0x01) == 1; } }如果你用jlibmodbus这类现成库,它一般只返回int[],不带数据类型转换。所以这一步是逃不掉的,必须自己做。我建议新建一个工具类,把所有常用转换集中放一起,不同厂商、不同设备的寄存器表在配置里注明字节序,读取后统一走这个转换器。
提示:转换float时,寄存器进int的位运算容易出错,因为Java的int是带符号的,寄存器值如果超过0x7FFF会被当成负数。所以拼接时一定要加上
& 0xFFFF把高位清零,否则负数参与左移运算,结果会完全不对。
5.2 “通讯不上”的标准排查链路
通讯类问题排查,最忌讳漫无目的地乱试。我总结了一条适用于RTU和TCP的排查链路:
- 先搞清楚是谁的问题:用Modbus Poll或Modbus Slave这类调试工具,PC直接连设备或PLC验证链路。工具能读到,说明设备和通讯链路没问题,问题在代码;工具也读不到,先查硬件和参数。
- 抓包确认报文:TCP用Wireshark直接抓包看请求响应帧;RTU用串口监听工具或调试工具的日志功能,确认主站发出去的帧格式对不对。
- 检查参数一致性:从站地址、波特率、数据位、停止位、校验位、寄存器起始地址,逐项和设备手册比对。
- 看设备返回的异常码:功能码如果带了0x80前缀,例如0x83,说明设备接收到了请求但拒绝执行。异常码01是非法功能码,02是非法数据地址,03是非法数据值,这些信息比盲猜有用得多。
- 验证同一份代码能不能读别的设备:换一个已知正常的设备,排除代码自身的bug。
我遇到过一次怎么都连不上,折腾到最后发现是PLC的程序里根本没配置Modbus从站服务,设备默认是关闭的。所以排查到第4、5步还不能解决时,回头看看设备侧的配置,也很关键。
5.3 轮询策略、配置化设计与实用建议
生产环境的Modbus采集,代码会一直跑,稳定性比功能重要。轮询频率要保守:RTU半双工总线上,一个轮询周期建议500毫秒以上,给设备留足响应时间;TCP可以快一点,但也要考虑设备侧的处理能力,我的经验是不要低于200毫秒,否则既增加网络负担,也容易触发设备侧的拒绝响应。
把设备和寄存器信息配置化,是我反复强调的一个点。地址、协议类型、数据类型、字节序这些全部放到配置文件或数据库里,应用启动时加载成对象。后面加设备不用改代码,发版流程也不用走一趟。这个习惯帮我至少节省了上百次返工。
最后再分享一个小技巧:日志里不要把读到的值直接打出来,把每次请求的原始字节帧和响应的原始字节帧都打出来,用十六进制字符串。很多异常情况光看数值是看不出来的,但原始帧能立刻定位是地址不对、CRC不对还是解析顺序错了。用Java做Modbus通讯,能碰到的问题翻来覆去就那么几个,原始帧日志能帮你少熬好几个夜。
本文还有配套的精品资源,点击获取