简介:一套基于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 链路交给 Netty,RTU 串口交给 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 表示单帧最大长度,lengthFieldOffset=4 表示长度字段从第 5 字节开始(前 4 字节是事务元和协议标识符),lengthFieldLength=2 表示长度字段本身占 2 字节。加了这行之后,channelRead0 里收到的就是完整的一帧 Modbus 报文,不会再出现半包残包的问题。如果遇到响应解析错乱、寄存器值对不上,第一反应就该查这里。
4.3 寄存器值换算成浮点数时的大坑:字节序和 IEEE 754
Modbus 寄存器存储浮点数时,通常是 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 地址偏移混淆:协议地址和实际地址差 1
Modbus 协议里的地址起始值是 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.host | 127.0.0.1 | 目标从站 IP |
| com.modbus.tcp.port | 502 | Modbus TCP 标准端口 |
| com.modbus.rtu.port | COM1 | 串口名称,Linux 下是 /dev/ttyS0 或 /dev/ttyUSB0 |
| com.modbus.rtu.baudRate | 9600 | 波特率,和从站保持一致 |
| com.modbus.rtu.dataBits | 8 | 数据位,通常为 8 |
| com.modbus.rtu.stopBits | 1 | 停止位,常见的是一重 |
| com.modbus.rtu.parity | 0 | 校验位:0 无校验 1 奇校验 2 偶校验 |
| com.modbus.poll.enabled | true | 是否开启周期采集 |
| com.modbus.poll.intervalMs | 1000 | 采集周期,建议不低于 500ms |
其中 parity 这个参数很容易被忽略,但现场设备通讯对不上基本都是波特率或校验位不匹配。项目里你改串口号之后建议先用串口调试助手验证主站发的报文,好处是能直观看到发出的原始字节,再确认 CRC 计算正确。
5.3 运行效果验证:日志输出与 DataCollector 回调
启动成功后控制台会看到类似这样的日志,对应的是采集周期内从站返回的数据:
2024-11-05 10:23:45.123 INFO [nioEventLoopGroup-2-1] c.e.modbus.tcp.handler - 收到响应: transactionId=12, unitId=1, 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。希望这份源码解析能帮你少踩几个工业通信的坑。
本文还有配套的精品资源,点击获取