
简介一套基于Java的Modbus RTU与TCP协议通信源码包面向工业自动化、设备数据采集领域的开发者和进阶学习者解决串口与网络环境下主从站数据交换问题。压缩包共47个文件约1.03MB核心类型包括Java源码、编译后的class、依赖jar包、串口动态库dll以及pom与说明文档可用于快速搭建Modbus调试环境。目前已有2134人学习下载。资源内容围绕RTU与TCP两种模式展开RTU侧涉及串口参数配置、请求帧构建和CRC校验TCP侧借助Socket与Modbus库完成网络读写。压缩包内含主站、从站示例代码及串口/TCP连接工具类并提供数据模型与业务逻辑分层方便二次开发。通过阅读这些源码可掌握Java调用第三方库完成Modbus通信的完整链路也能理解工业协议从请求封装到响应解析的每个环节对学习或实际项目均有直接参考价值。1. 上手先看这个Java 工业通信的 Modbus 双协议落地源码做工业上位机或者设备数据采集的朋友大概率都遇到过这种场景现场设备只支持 Modbus RTU 串口协议但你的平台服务跑在服务器上只能走 TCP 网络或者反过来设备支持 TCP但现场调试工具只认串口。两边都是 Modbus却因为物理层不同而互相不通这大概是工业通信里最常见的“玄学”之一。这份源码解决的正是这个痛点一套 Java 代码里同时实现 Modbus RTU 串口通信和 Modbus TCP 网络通信开发者拿到手就能对着真实设备或模拟从站跑通读写寄存器、线圈、离散输入的完整链路。项目面向的是需要做设备对接、数据采集网关、产线监控系统开发的 Java 工程师。无论你是刚接触 Modbus 协议栈的新手还是已经在写业务代码但被 CRC 校验、字节序、功能码这些细节折磨过的熟手这份源码都能提供一个可以直接运行的参考实现。它不像那些只贴片段的技术博客而是一个能编译、能启动、能连真实设备做读写操作的工程代码。从串口参数配置到 TCP 报文封装从 CRC16 计算到异常码处理底层细节都在代码里摊开了。接下来我按一个完整项目该有的结构把这份资源从技术选型、工程结构、核心实现到运行方式、改造方向一层层拆给你看。2. 技术底座与工程结构为什么是 Netty 加串口框架的组合2.1 技术选型TCP 链路交给 NettyRTU 串口交给 jSerialComm先看依赖和框架选型。Modbus TCP 本质上是 Modbus 协议跑在 TCP/IP 之上报文里多了个 MBAP 头处理起来比裸 Socket 复杂的地方在于粘包拆包、请求响应超时管理、并发访问同一个从站的时序控制。项目里 TCP 部分基于 Netty 实现这一点符合工业通信网关的主流做法。Netty 的 ByteBuf 处理 Modbus 报文非常顺手加上它的 EventLoop 线程模型天然适合处理大量设备连接的场景。RTU 串口部分用的是 jSerialComm 库而不是老的 rxtx 或 comm3j。jSerialComm 的优势在于跨平台表现稳定Windows 和 Linux 下都不容易出现串口被占用导致 JVM 崩溃的问题。Modbus RTU 报文的计算量集中在 CRC16 校验上这个用纯 Java 手写一个工具类就够了不需要额外引入重量级库。整体依赖很轻核心就是 Netty、jSerialComm 和 slf4j 日志门面这对二次开发非常友好——不会出现“为了跑一个 Demo 要装一堆你根本用不到的依赖”的情况。2.2 目录结构Maven 标准布局加双协议模块划分工程采用标准 Maven 目录结构但包名划分上做了协议隔离。核心包大致如下com.example.modbus ├── core # 协议核心报文封装、CRC校验、功能码定义 │ ├── crc # CRC16-Modbus 计算工具 │ ├── model # 请求/响应/异常报文模型 │ └── codec # 报文编码解码器 ├── tcp # Modbus TCP 实现 │ ├── handler # Netty ChannelInboundHandler │ ├── client # TCP 客户端封装 │ └── server # TCP 服务端监听 ├── rtu # Modbus RTU 实现 │ ├── serial # 串口管理器jSerialComm 封装 │ ├── master # 主站请求发送与响应处理 │ └── slave # 从站响应逻辑 ├── common # 公共工具字节数组转换、日志 └── App.java # 启动入口演示主站采集流程├── pom.xml └── src/main/java/com/example/modbus/这个分层把协议栈和业务彻底剥离开。你改业务逻辑的时候不用碰 codec 包要增加新的功能码支持只需要在 model 里加对应报文类。项目里 demo 走的是主站Master模式也就是由 Java 程序主动发起读写请求去采集从站设备的数据这也覆盖了绝大多数设备数据采集场景。如果你要做的是从站模拟器代码结构里同样预留了 slave 包改造方向后面会细说。2.3 核心模型统一请求报文与响应报文的抽象Modbus 协议里报文结构其实很规整读线圈、读寄存器、写单个寄存器、写多个寄存器功能码不同但报文骨架一致。项目里用一个抽象基类加上功能码子类的方式来组织报文模型避免每个功能码都写一套冗余的序列化逻辑。public abstract class ModbusFrame { protected int functionCode; protected int unitId; public ModbusFrame(int functionCode, int unitId) { this.functionCode functionCode; this.unitId unitId; } public abstract byte[] toBytes(); public int getFunctionCode() { return functionCode; } public int getUnitId() { return unitId; } }这里 toBytes 是抽象方法每个子类自己负责把业务参数变成符合 Modbus 协议的字节数组。这样设计的好处是当你需要增加一个功能码时只需要新建一个子类实现它的 bol 序列化逻辑然后注册到工厂里就行完全不用碰已有的代码。3. 核心代码拆解Modbus 报文从构建到响应的完整链路3.1 CRC16-Modbus 校验RTU 链路不踩坑的关键Modbus RTU 报文里 CRC 计算是个很容易翻车的点。很多新手照着网上的 C 语言代码改成 Java结果因为无符号右移和类型转换问题算出来的校验码对不上。项目里 CRC 工具类用的是查表法性能和正确性都有保障。public final class Crc16Modbus { private static final int[] CRC_TABLE new int[256]; static { for (int i 0; i 256; i) { int crc i; for (int j 0; j 8; j) { if ((crc 0x0001) ! 0) { crc (crc 1) ^ 0xA001; } else { crc 1; } } CRC_TABLE[i] crc 0xFFFF; } } public static byte[] getCrc(byte[] data) { int crc 0xFFFF; for (byte b : data) { int index (crc ^ b) 0xFF; crc (crc 8) ^ CRC_TABLE[index]; } return new byte[]{ (byte) (crc 0xFF), (byte) ((crc 8) 0xFF) }; } }逻辑说明CRC 初始值是 0xFFFF对报文中每个字节做异或后查表最后得到的 crc 要按低字节在前、高字节在后的顺序拼进报文。这是 Modbus RTU 的字节序约定和 TCP 的 MBAP 头恰恰相反很多人在串口调试时发现报文对不上就是死在 CRC 字节序这一点上。注意这里必须用无符号位运算Java 的 byte 是有符号类型直接用位运算时容易出现负值造成的符号扩展问题项目里统一将 byte 提升为 int 后再进行操作就规避了这个问题。3.2 Modbus TCP 报文封装MBAP 头加 PDU 的组合方式TCP 模式和 RTU 模式在报文层最大的区别就是多了 7 字节的 MBAP 头。MBAP 头包含事务元标识符、协议标识符、长度和单元标识符。项目里 TCP 客户端发送读保持寄存器请求时报文构建过程是这样的public byte[] buildReadHoldRegisterRequest(int startOffset, int quantity) { byte[] pdu new byte[5]; pdu[0] 0x03; // 功能码读保持寄存器 pdu[1] (byte) ((startOffset 8) 0xFF); pdu[2] (byte) (startOffset 0xFF); pdu[3] (byte) ((quantity 8) 0xFF); pdu[4] (byte) (quantity 0xFF); ByteBuffer buffer ByteBuffer.allocate(12); buffer.putShort((short) transactionId); // 事务元标识符 buffer.putShort((short) 0x0000); // 协议标识符Modbus 固定为 0 buffer.putShort((short) (pdu.length 1)); // 长度 单元标识符 PDU buffer.put((byte) unitId); // 单元标识符从站地址 buffer.put(pdu); return buffer.array(); }参数说明transactionId 是每次请求递增的序列号用于匹配响应报文协议标识符 0x0000 表示这是 Modbus 协议长度字段指从单元标识符开始到报文结尾的总长度不包含 MBAP 头本身。这里容易踩坑的地方是 length 的计算——很多新手把整个报文长度写进这个字段导致从站解析出错。标准做法是 pdu.length 1因为 pdu 里已经有 5 个字节加上 1 字节的 unitId 正好是 6从站收到后能正确算出响应长度。3.3 响应解析从 Netty 的 ByteBuf 里提取数据TCP 客户端收到响应后需要从 Netty 的 ByteBuf 中读取数据。这里要特别注意字节序问题Netty 默认是大端模式这和 Modbus 协议的高字节在前完全一致但 Java 的 DataInputStream 默认也是大端所以这块儿算是最省心的部分。下面看响应处理中的核心代码Override protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) { int transactionId msg.readUnsignedShort(); int protocolId msg.readUnsignedShort(); int length msg.readUnsignedShort(); int unitId msg.readUnsignedByte(); int functionCode msg.readUnsignedByte(); if (protocolId ! 0x0000) { throw new ModbusProtocolException(协议标识符不匹配); } // 根据功能码解析 PDU if (functionCode 0x03) { int byteCount msg.readUnsignedByte(); byte[] data new byte[byteCount]; msg.readBytes(data); // 转换为 short[]两个字节表示一个寄存器值 short[] registers new short[byteCount / 2]; for (int i 0; i data.length; i 2) { registers[i / 2] (short) ((data[i] 8) | (data[i 1] 0xFF)); } // 分发到业务回调 responseCallback.onReadRegisters(unitId, registers); } }这段代码有一处工业通信里特别重要的处理对异常响应码的判断。如果从站返回的功能码最高位为 1比如从站返回 0x83 而不是 0x03说明是异常响应此时 PDU 后跟的是异常码而非数据。项目里在解析前会先判断 functionCode 的最高位如果是异常响应就直接抛出异常并带上异常码——比如 0x02 表示非法数据地址0x03 表示非法数据值。这个细节在联调现场排查问题时能省下大量时间。3.4 RTU 串口读写串口参数、请求发送与响应接收RTU 串口通信和 TCP 的一大不同是半双工机制发送完请求后必须等待从站响应不能像 TCP 那样持续双向传输。项目里串口主站请求发送逻辑采用同步锁配合超时机制来实现。public synchronized byte[] sendRequest(byte[] request) throws IOException { byte[] crc Crc16Modbus.getCrc(request); byte[] frame new byte[request.length 2]; System.arraycopy(request, 0, frame, 0, request.length); frame[request.length] crc[0]; frame[request.length 1] crc[1]; serialPort.writeBytes(frame, frame.length); serialPort.flush(); long timeout System.currentTimeMillis() 300; ByteArrayOutputStream responseBuf new ByteArrayOutputStream(); while (System.currentTimeMillis() timeout) { if (serialPort.bytesAvailable() 0) { byte[] buffer new byte[serialPort.bytesAvailable()]; serialPort.readBytes(buffer, buffer.length); responseBuf.write(buffer); } else { Thread.yield(); } } return responseBuf.toByteArray(); }这段代码的巧妙之处在于没有用复杂的异步回调而是用同步轮询加超时控制完成一次完整的事务。参数补充说明超时时间 300 毫秒是现场调试的经验值如果从站响应速度慢或者总线负载高建议提升到 500 到 1000 毫秒。因为加了 synchronized并发情况下不同线程发起的读写请求会排队等待保证了同一串口上报文不会交叉错乱。整个数据采集系统的稳定性很大程度上就靠这层同步锁在撑。4. 避坑指南Modbus 联调中的常见问题与排查方法4.1 接线和校验都正常却收不到响应先看串口缓冲区残留这个坑我踩过不止一次。现象是程序启动后第一次发包正常从站有响应但第二次发包后就超时无响应程序似乎卡死。排查了很久最终发现是串口缓冲区里残留着上一轮响应的多余字节——第一次响应解析完后缓冲区里还有几个没有消费完的字节第二次请求发出去后程序读取到的是残留字节导致响应帧不完整。解决方法是每次发送请求前先清空串口缓冲区private void clearSerialBuffer() { while (serialPort.bytesAvailable() 0) { byte[] discard new byte[serialPort.bytesAvailable()]; serialPort.readBytes(discard, discard.length); } }这行操作虽然看起来不起眼但实际联调中能省掉大量排查时间。从那以后我每次写串口通信代码都会在请求发送前强制走一遍清空缓冲区的流程。4.2 TCP 粘包导致响应解析错乱Modbus TCP 走以太网时如果从站连续快速返回多个响应TCP 层可能把它们粘在一个数据包里也可能把一个响应拆在两个包里。Netty 的默认 ChannelHandler 是直接透传 ByteBuf 的所以必须在 pipeline 里加一个 LengthFieldBasedFrameDecoder 来做粘包拆包。pipeline.addLast(new LengthFieldBasedFrameDecoder(256, 4, 2));这个解码器是根据 MBAP 头里长度字段自动拆包的。参数说明maxFrameLength 设为 256 表示单帧最大长度lengthFieldOffset4 表示长度字段从第 5 字节开始前 4 字节是事务元和协议标识符lengthFieldLength2 表示长度字段本身占 2 字节。加了这行之后channelRead0 里收到的就是完整的一帧 Modbus 报文不会再出现半包残包的问题。如果遇到响应解析错乱、寄存器值对不上第一反应就该查这里。4.3 寄存器值换算成浮点数时的大坑字节序和 IEEE 754Modbus 寄存器存储浮点数时通常是 4 个字节表示一个 FLOAT但不同厂家设备的寄存器顺序可能完全不同。有的设备是前两个寄存器存高位有的是低位在前。项目里提供了一个小工具类来处理这个换算避免不同厂家设备的“浮点数排版玄学”。public static float bytesToFloat(byte[] data, int offset, ByteOrder order) { int intBits 0; if (order ByteOrder.BIG_ENDIAN) { intBits ((data[offset] 0xFF) 24) | ((data[offset 1] 0xFF) 16) | ((data[offset 2] 0xFF) 8) | (data[offset 3] 0xFF); } else { intBits ((data[offset 3] 0xFF) 24) | ((data[offset 2] 0xFF) 16) | ((data[offset 1] 0xFF) 8) | (data[offset] 0xFF); } return Float.intBitsToFloat(intBits); }遇到读上来的浮点数是一个天文数字或者 0.0 但设备实际有值优先怀疑字节序不要先怀疑设备坏了。这个函数在产线调试中帮了大忙建议你保留。4.4 地址偏移混淆协议地址和实际地址差 1Modbus 协议里的地址起始值是 0但很多设备说明书里用 1 起始的编号。比如说明书说“保持寄存器地址 40001”但实际报文里的地址应该是 0。这个偏移搞错读出来的数据要么是错的要么干脆报非法地址异常。排查方式很简单——抓包比对说明书或者用一个已知值的从站模拟器做回归验证。4.5 单元标识符Unit ID不一致导致多从站总线上数据错乱一条 RS485 总线上挂了多个从站每个从站有不同的地址。如果 Java 主站填的 unitId 是 1但目标从站地址是 5从站直接忽略请求表现出来就是超时假死。会翻车的现场是请求写对了地址但响应解析时把 unitId 写死成 0 或 1导致响应匹配不上。项目里响应解析和请求构建中 unitId 都是从同一个工厂方法传下去的从源头杜绝了不一致。5. 运行与测试让源码在实际环境里跑起来5.1 启动方式与运行依赖项目用 Maven 管理环境要求是 JDK 8 以上。运行前先确保串口没有其他程序占用TCP 端口没有被防火墙上层拦截。启动命令就是标准的 Maven 打包加 Java 运行mvn clean package -DskipTests java -jar target/modbus-demo.jar项目里内置了一个调试模式如果本机没有真实从站设备可以使用这个模式启动一个模拟从站。它占用 502 端口内部实现了读保持寄存器、读线圈、写单个寄存器等功能响应逻辑按 Modbus 标准报文返回。这样在没有硬件的情况下你也能把主站采集链路完整跑通。5.2 常用配置项串口参数、TCP 连接与扫描周期项目中有一个配置文件对应的是应用启动时加载的参数。核心配置如下配置项默认值说明com.modbus.tcp.host127.0.0.1目标从站 IPcom.modbus.tcp.port502Modbus TCP 标准端口com.modbus.rtu.portCOM1串口名称Linux 下是 /dev/ttyS0 或 /dev/ttyUSB0com.modbus.rtu.baudRate9600波特率和从站保持一致com.modbus.rtu.dataBits8数据位通常为 8com.modbus.rtu.stopBits1停止位常见的是一重com.modbus.rtu.parity0校验位0 无校验 1 奇校验 2 偶校验com.modbus.poll.enabledtrue是否开启周期采集com.modbus.poll.intervalMs1000采集周期建议不低于 500ms其中 parity 这个参数很容易被忽略但现场设备通讯对不上基本都是波特率或校验位不匹配。项目里你改串口号之后建议先用串口调试助手验证主站发的报文好处是能直观看到发出的原始字节再确认 CRC 计算正确。5.3 运行效果验证日志输出与 DataCollector 回调启动成功后控制台会看到类似这样的日志对应的是采集周期内从站返回的数据2024-11-05 10:23:45.123 INFO [nioEventLoopGroup-2-1] c.e.modbus.tcp.handler - 收到响应: transactionId12, unitId1, registers[1024, 512, 896, 32]项目中数据采集完成后通过 DataCollector 接口将结果传递给业务层。这个类就是业务处理逻辑的挂载点你可以在里面做数据入库、MQTT 上报或者页面推送接口就一个回调方法结构如下public interface DataCollector { void onRegistersRead(int unitId, int startAddress, short[] registers); void onCoilsRead(int unitId, int startAddress, boolean[] coilStatus); void onWriteComplete(int unitId, int functionCode, boolean success); }模块在收到响应后会把这个接口的每个实现类依次调用。这个设计的好处是把协议解析和业务逻辑彻底解耦你的应用逻辑不会陷入 Netty 线程中。实际跑起来时可以在这个回调里加日志计数器观察抽数率。6. 二次开发实战把示例工程改造为可用的采集服务6.1 从主站采集到多设备轮询管理项目默认的 demo 是单从站采集但工业现场一台上位机往往需要管理几十个从站。改造方向是把请求发送封装成异步任务队列每个设备对应一个连接配置用一个定时任务池轮询所有设备。我建议做一个 DeviceManager 类内部维护设备列表和各自的上次采集时间用 ScheduledExecutorService 每轮遍历设备并发送请求。伪代码可以这样搭ScheduledExecutorService scheduler Executors.newScheduledThreadPool(4); scheduler.scheduleAtFixedRate(() - { for (DeviceConfig device : deviceList) { TcpClient client clientMap.get(device.getId()); if (System.currentTimeMillis() - device.getLastPollTime() device.getPollInterval()) { client.sendReadRequest(device.getStartAddress(), device.getQuantity()); device.setLastPollTime(System.currentTimeMillis()); } } }, 0, 200, TimeUnit.MILLISECONDS);这里有几个值得注意的坑串口模式千万别多线程并发发包如果设备是 RTU 串口接入一次性创建多个线程按各自周期去读很容易出现同一个串口上报文交错导致响应错乱。TCP 模式相对宽松但也建议对同一设备只用一个 Netty Channel 顺序发包不要每轮新建连接。实践中我会先用一台模拟从站跑 1 个小时再连真实现场设备验证这样能提前发现并发时序问题。6.2 增加功能码支持以读写多个寄存器为例改造的核心就在 model 包下新增一个功能码类然后在 codec 包的解析器里增加对应的 switch 分支。如果以后要支持 0x10 写多个寄存器需要构建的请求报文包含字节数、数据区和一个校验符合 PDU 的响应解析逻辑。这一块代码会用到 ByteBuffer 来拼装寄存器字节数组整体思路和 3.2 节完全一致只不过 PDU 长度动态变化。做完后建议在模拟从站上验证写入后的回读确凿数据落盘。6.3 从站模式的改造把 Java 上位机变成一个 Modbus Slave 设备去充当网关如果你的需求恰好相反——你的 Java 程序作为从站让 PLC 或组态软件来读你的数据比如把上位机变成采集网关把数据以 Modbus 协议暴露给上游系统——那么可以用项目里的 rtu slave 包为基础重新实现一个服务端。核心逻辑是接收主站请求后直接从你的业务缓存中取出数据转换成 Modbus 响应报文。这里有一点需要注意从站模式下响应时间不能太长最好在 50 毫秒以内否则主站会报超时。因此不能直接从数据库查数据建议维护一个内存缓存表由写入线程更新缓存Modbus 服务线程只读缓存。6.4 数据落库与告警联动从裸报文到业务闭环跑通报文收发只是第一步。我一般拿到这种源码后第一步先接一个 MQTT 服务把采集值推给上层业务系统第二步接 InfluxDB 或 MySQL 做历史存储第三步才做告警联动——比如连续三次读到同一个寄存器值超过阈值就触发推送。项目的 DataCollector 接口在这里就变得非常有价值因为你可以先写一个 LoggingCollector 验证数据然后替换成 JdbcCollector再在它内部调用 MetricService 完成业务闭环。这套接口替换模式是这套代码里最顺手的一块比直接修改 core 包的解析代码风险小也更容易做单元测试。6.5 一个值得保留的小技巧请求重发机制写工业通信代码久了你会发现现场环境比想象中的糟糕偶尔会有空包、响应超时、从站重启等情况。对方从站重启时回复会丢失或者重启后暂时不响应。这时如果没有重发机制采集链路就会断在那里需要人工干预。我的习惯是在客户端加一个简单的重发策略——请求发出后如果在指定时间内没收到响应重试 3 次每次间隔 500 毫秒依然无响应才标记为设备离线并通知告警模块。那段代码大致是public void sendRequestWithRetry(ModbusFrame request, int maxRetries) { for (int i 0; i maxRetries; i) { try { responseFuture client.send(request); return; } catch (TimeoutException e) { log.warn(第{}次重试设备无响应, i 1); } } markOffline(); }注意别把重试机制做成无限循环一定要设置最大重试次数否则一个损坏的设备会让线程池积累大量任务最终拖垮整台采集程序。从那以后我每次写采集服务都会先用模拟从站跑一遍重发流程再拿去现场验证避免真到现场才发现重试逻辑有 bug。希望这份源码解析能帮你少踩几个工业通信的坑。本文还有配套的精品资源点击获取