
简介面向需要在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-400010。很多人在这一步栽过明明文档写地址是40001代码里填了40001结果设备返回非法数据地址其实就是没减这个偏移。1.3 项目选型什么时候用RTU什么时候用TCP协议选型不是一个单选题。按我的经验判断标准大概就三条设备本身支持什么、现场布线条件允不允许、项目预算有多少。老设备、防爆区仪表、距离远但点位分散基本都是RS485走RTU因为线缆便宜、布设简单理论传输距离能到1200米。新上的控制柜、数据量大的设备、需要频繁读写的场景直接以太网走TCP省掉一堆串口转接的麻烦故障排查也容易得多。还有一点容易被忽略RS485总线上是半双工通信所有从站共用一条线轮询周期和从站数量直接挂钩从站多了会很慢。TCP每个设备独立连接没有总线冲突问题轮询频率可以做得更高。所以我的建议是有条件就TCP没条件就老老实实RTU不要为了“高大上”硬把RTU设备转成TCP多一个转换环节就多一个故障点。2. Java的Modbus库怎么选一次真实的选型记录2.1 四个候选方案横向对比Java生态里的Modbus库不算多但真正动手选的时候还是花了一些时间。我对比过四个方向方案协议支持维护状态API风格串口支持适用场景jamodRTU/TCP/ASCII基本停更老旧字节序处理有坑依赖RXTX学习协议原理modbus4jRTU/TCP/ASCII维护一般功能全但臃肿依赖多自带串口实现功能要求比较全的场景jlibmodbusRTU/TCP持续更新简洁清晰基于jSerialComm跨平台大多数生产项目自研Netty实现可控自己维护自由需自己接串口框架协议二次定制需求多2.2 我选择jlibmodbus的理由最终我选了jlibmodbus。原因很直接第一API设计得像在用Modbus协议而不是在用某个框架createModbusMasterTCP、createModbusMasterRTU这种命名一看就懂省去读半天文档的功夫。第二串口底层用的jSerialCommWindows、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字节 CRC162字节总共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数据2CRC不满足就直接丢弃等待下一帧。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 { // 两个寄存器转int32lowFirst为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通讯能碰到的问题翻来覆去就那么几个原始帧日志能帮你少熬好几个夜。本文还有配套的精品资源点击获取