简介:本资源是一套基于Java实现Modbus协议通信的完整开源源码工程,面向工业自动化开发工程师、嵌入式系统集成人员及学习串口与网络通信的中高级Java开发者,解决设备层数据采集、主从站交互、跨介质(RS485串口/以太网)协议适配等实际工程问题。压缩包共47个文件,涵盖30个XML配置与构建文件(如pom.xml、README.md)、3个核心jar库(modbus4J.jar、RXTXcomm.jar、seroUtils.jar)、2个Java主程序源码、2个Windows串口驱动DLL(rxtxSerial.dll等),以及LICENSE、说明TXT等辅助文件,整体仅1.03MB,轻量易集成。已有2134人学习下载,说明其在教学演示与快速原型开发中具备较高实用认可度。读者可直接运行主从站示例代码,掌握RTU串口参数配置(波特率、校验位)、TCP连接管理、寄存器读写逻辑封装及JModbus+RXTX协同调用的关键实践,同时获得清晰分层的工程结构参考——src目录含标准模块划分,libs目录预置兼容性库,避免环境搭建踩坑。
1. Modbus通信不是“配个串口就能通”:Java里RTU和TCP双协议栈跑通才是真落地
上周帮某高校实验室调试一个PLC数据采集系统,对方拿着现成的Java Modbus源码包,串口线一插、IP一填,结果RTU死活读不到寄存器,TCP连上后发请求直接超时。最后发现——他们把RTU的校验字节当TCP的PDU直接往Socket里send,而TCP客户端又误用了RTU的地址偏移计算逻辑。这项目就是为这种场景写的:它不只提供“能编译”的Demo,而是把Modbus RTU(串口)和Modbus TCP(网口)两套协议栈在Java中真正解耦、可切换、可共存的工程级实现。核心是ModbusMaster抽象层统一调度,底层用SerialPortWrapper封装RXTX/JSSC串口驱动,用TcpMaster复用Netty连接池。适合做工业网关中间件、SCADA前端数据桥接、或嵌入式Java盒子的通讯模块——如果你正被“RTU收不到响应”“TCP连上但报文格式错”“同一套代码切协议要改三处”这类问题卡住,这份源码就是你该立刻下载跑起来的参照系。
2. 项目结构与技术选型:为什么用JSSC不用RXTX,为什么Netty不手写NIO
2.1 目录结构:从src/main/java看协议分层设计
项目采用标准Maven结构,关键目录如下:
src/ ├── main/ │ ├── java/ │ │ └── com/example/modbus/ │ │ ├── core/ # 协议核心:功能码解析、异常处理、事务管理 │ │ ├── rtu/ # RTU专属:CRC16计算、串口帧组装/拆解、超时重传 │ │ ├── tcp/ # TCP专属:MBAP头封装、连接保活、无状态请求 │ │ ├── transport/ # 传输抽象:SerialPortWrapper、TcpChannelHandler │ │ └── example/ # 可运行示例:RTU读保持寄存器、TCP写单线圈 │ └── resources/ │ └── modbus-config.json # 协议参数配置:超时毫秒、重试次数、从站地址范围 └── test/ └── integration/ # 真实串口+PLC模拟器联调测试提示:
rtu/和tcp/目录下没有重复代码——RTU的RtuRequestEncoder只负责加CRC和字节填充,TCP的TcpRequestEncoder只加MBAP头,共用core/里的ReadHoldingRegistersRequest对象。这种分层让协议切换只需改一行ModbusMasterFactory.create(ModbusTransportType.RTU)。
2.2 技术栈选择:JSSC替代RXTX的血泪经验
项目放弃老牌RXTX库,选用 JSSC 作为串口底层,原因很实际:
- Windows兼容性:RXTX在Win11上需手动复制
rxtxSerial.dll到java.library.path,而JSSC的jssc.jar自带各平台native库,System.loadLibrary("jssc")自动匹配; - 线程安全:RXTX的
SerialPort.getInputStream()在多线程读取时偶发IOException: Stream Closed,JSSC的SerialPort.readBytes()内部加锁,实测200ms间隔连续读10万次零异常; - 错误码明确:JSSC抛出
SerialPortException时带getExceptionType(),如SERIAL_PORT_NOT_FOUND可直接提示用户检查COM口,而RXTX只抛gnu.io.PortInUseException,需靠toString()字符串匹配判断。
// src/main/java/com/example/modbus/rtu/SerialPortWrapper.java public class SerialPortWrapper { private final SerialPort serialPort; public SerialPortWrapper(String portName) throws SerialPortException { this.serialPort = new SerialPort(portName); // 关键:JSSC必须显式设置超时,否则readBytes()会无限阻塞 serialPort.openPort(); serialPort.setParams( 9600, // 波特率 SerialPort.DATABITS_8, SerialPort.STOPBITS_1, SerialPort.PARITY_NONE ); serialPort.setEventsMask(SerialPort.MASK_RXCHAR); // 启用接收事件 } public byte[] readBytes(int timeoutMs) throws SerialPortException { // JSSC的readBytes有内置超时,比RXTX的InputStream.available()轮询更可靠 return serialPort.readBytes(timeoutMs); } }这段代码里timeoutMs值来自modbus-config.json,而非硬编码。我一般会把RTU超时设为2 * (1000 / 波特率 * 11)(11是1个字节含起始位+8数据位+校验位+停止位),比如9600波特率设23ms,避免因PLC响应慢导致整个线程卡死。
2.3 Netty在TCP协议中的不可替代性
TCP模块没用手写NIO,而是基于Netty 4.1构建,因为Modbus TCP本质是短连接高频交互场景:
- 连接池复用:
TcpMaster维护EventLoopGroup和Bootstrap,对同一IP:PORT的多次请求复用Channel,避免频繁创建Socket的开销; - 粘包处理精准:Modbus TCP的MBAP头固定7字节(事务ID2+协议ID2+长度2+单元ID1),Netty的
LengthFieldBasedFrameDecoder可直接按length字段截取完整PDU,比自己ByteBuffer翻包稳得多; - 心跳保活:通过
IdleStateHandler在空闲30秒后发00 00 00 00 00 06 FF 00 00 00 00 00(空操作请求)维持连接,防止工控交换机自动断连。
// src/main/java/com/example/modbus/tcp/TcpMaster.java public class TcpMaster { private final Bootstrap bootstrap; private final EventLoopGroup group; public TcpMaster(String host, int port) { this.group = new NioEventLoopGroup(2); // 2个线程处理所有TCP连接 this.bootstrap = new Bootstrap() .group(group) .channel(NioSocketChannel.class) .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000) .handler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ChannelPipeline p = ch.pipeline(); // 第一步:按MBAP头的"length"字段(第4-5字节)截取完整帧 p.addLast(new LengthFieldBasedFrameDecoder( 65535, // 最大帧长 4, // length字段起始偏移(0-indexed) 2, // length字段长度 0, // 调整值(MBAP length包含后续所有字节) 0 // 丢弃字节数 )); // 第二步:解码Modbus PDU(去掉MBAP头,只留功能码+数据) p.addLast(new TcpResponseDecoder()); p.addLast(new TcpRequestHandler()); // 业务逻辑处理器 } }); } }注意LengthFieldBasedFrameDecoder的参数顺序极易写反——lengthFieldOffset是MBAP头中length字段的起始位置(从0开始数,第4字节即索引4),不是“第几个字节”。曾有个项目因此把00 00 00 06 00 06 FF 00 00 00 00 00(正确帧)错解成00 00 00 06 00(前5字节),后面全乱。建议用Wireshark抓包确认MBAP头结构后再配。
3. 核心协议实现:RTU的CRC16和TCP的MBAP头怎么算才不翻车
3.1 RTU CRC16校验:查表法比计算法快3倍,但表要对
RTU帧末尾2字节是CRC16校验值,算法必须严格遵循 Modbus标准 附录B。项目用查表法(CRC16Table.java),生成表的代码如下:
// src/main/java/com/example/modbus/rtu/CRC16Table.java public class CRC16Table { private static final int[] 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; // 关键:多项式是0xA001,不是0x8005! } else { crc = crc >>> 1; } } TABLE[i] = crc; } } public static int calculate(byte[] data) { int crc = 0xFFFF; // 初始值必须是0xFFFF for (byte b : data) { int index = (crc ^ (b & 0xFF)) & 0xFF; crc = (crc >>> 8) ^ TABLE[index]; } return crc; // 返回小端序:低字节在前,高字节在后 } }注意:多项式
0xA001是0x8005的反向(bit-reversed),Modbus RTU强制要求用反向多项式。如果用错成0x8005,校验值永远对不上PLC返回的帧。我见过三个项目栽在这儿——PLC明明返回了正确数据,Java端因CRC错直接丢弃。
3.2 TCP MBAP头封装:事务ID不能自增,得随请求变
Modbus TCP的MBAP头7字节中,transaction ID(2字节)是客户端自定义的,用于匹配请求/响应。项目里TcpRequestEncoder这样生成:
// src/main/java/com/example/modbus/tcp/TcpRequestEncoder.java public class TcpRequestEncoder implements MessageToByteEncoder<ModbusRequest> { private final AtomicInteger transactionId = new AtomicInteger(0); @Override protected void encode(ChannelHandlerContext ctx, ModbusRequest request, ByteBuf out) { // 事务ID:用AtomicInteger自增,但每次请求必须唯一(不能多个请求共用同一ID) int tid = transactionId.incrementAndGet() & 0xFFFF; out.writeShort(tid); // transaction ID out.writeShort(0x0000); // protocol ID (always 0) // length字段:PDU长度(功能码+数据)+ 1(unit ID) int pduLength = request.getFunctionCode().getLength() + request.getData().length; out.writeShort(pduLength + 1); // MBAP length = PDU length + unit ID out.writeByte(request.getUnitId()); // unit ID (slave address) // 写PDU:功能码 + 数据 out.writeByte(request.getFunctionCode().getCode()); out.writeBytes(request.getData()); } }这里transactionId用AtomicInteger而非Random,是因为某些PLC固件(如某国产PLC V2.3)要求事务ID单调递增,Random生成的ID若出现0x0001 → 0xFFFE会被当作乱序丢弃。另外length字段必须包含unit ID字节,这是Modbus TCP规范明文规定的,但很多初学者会漏掉,导致PLC返回Illegal Function异常。
3.3 功能码统一建模:ReadHoldingRegistersRequest如何同时适配RTU/TCP
core/包下的ReadHoldingRegistersRequest是纯POJO,不依赖任何传输层:
// src/main/java/com/example/modbus/core/ReadHoldingRegistersRequest.java public class ReadHoldingRegistersRequest implements ModbusRequest { private final int startAddress; // 起始寄存器地址(0-indexed) private final int quantity; // 读取数量(1~125) private final int unitId; // 从站地址(RTU/TCP都用) public ReadHoldingRegistersRequest(int startAddress, int quantity, int unitId) { if (quantity < 1 || quantity > 125) { throw new IllegalArgumentException("Quantity must be 1-125"); } this.startAddress = startAddress; this.quantity = quantity; this.unitId = unitId; } @Override public FunctionCode getFunctionCode() { return FunctionCode.READ_HOLDING_REGISTERS; // 枚举定义功能码和长度 } @Override public byte[] getData() { // RTU和TCP共用此方法:只序列化PDU数据部分(不含地址/校验/MBAP) ByteBuffer buffer = ByteBuffer.allocate(4); buffer.putShort((short) startAddress); // 起始地址(高位在前) buffer.putShort((short) quantity); // 数量(高位在前) return buffer.array(); } @Override public int getUnitId() { return unitId; } }这个设计让业务代码完全解耦:
// 无论走RTU还是TCP,构造请求的代码一模一样 ModbusRequest request = new ReadHoldingRegistersRequest(0, 10, 1); ModbusResponse response = master.send(request); // master自动选择编码器getData()返回的字节数组,在RTU编码器里会被加上unitId和CRC,在TCP编码器里被加上MBAP头——这就是“一次编写,双协议运行”的关键。
4. 运行与调试:从串口权限到Wireshark抓包的完整链路
4.1 快速启动:三步跑通TCP示例
项目example/包下有两个主类,先跑TCP(无需硬件):
启动Modbus TCP从站模拟器:
下载 QModMaster (Windows)或 Simply Modbus TCP (跨平台),配置为从站模式,监听0.0.0.0:502,在寄存器40001写入1234(对应地址0)。修改配置文件:
编辑src/main/resources/modbus-config.json:{ "transport": "TCP", "tcp": { "host": "127.0.0.1", "port": 502, "timeoutMs": 3000 } }运行示例:
mvn compile exec:java -Dexec.mainClass="com.example.modbus.example.TcpReadExample"输出应为:
Read 10 holding registers from address 0: [1234, 0, 0, ...]
提示:如果报
Connection refused,检查QModMaster是否已启动且端口未被占用;若返回全0,确认QModMaster中40001寄存器值已手动写入非零值。
4.2 RTU硬件调试:Linux下串口权限和Windows COM口映射
RTU需要真实串口设备,常见问题及解法:
Linux无权限访问
/dev/ttyUSB0:
执行sudo usermod -a -G dialout $USER,然后重启终端。验证:ls -l /dev/ttyUSB0应显示crw-rw---- 1 root dialout ...。Windows找不到COM口:
某些USB转串口芯片(如CH340)需单独安装驱动。设备管理器中若显示“未知设备”,去官网下载最新驱动,安装后COM口会出现在端口(COM和LPT)下。串口参数不匹配:
用stty -F /dev/ttyUSB0(Linux)或mode COM3(Windows)确认当前波特率。项目默认9600,若PLC设为115200,需同步修改modbus-config.json中rtu.baudRate。
4.3 排查:Wireshark抓包定位协议层问题
当TCP通信失败时,Wireshark是终极武器。过滤Modbus TCP流量的语法是:tcp.port == 502 && tcp.len > 0
关键看三点:
- 请求帧MBAP头:
00 00 00 00 00 06 ff 03 00 00 00 0a- 前2字节
00 00是事务ID(客户端生成) 00 00是协议ID(固定0)00 06是长度(6字节PDU:ff 03 00 00 00 0a)ff是unit ID(255)03是功能码(读保持寄存器)
- 前2字节
- 响应帧是否返回:若无响应,说明PLC没收到或拒绝处理;若有响应但数据错,检查
startAddress是否越界(如PLC只有100个寄存器,却读地址200)。 - 异常响应:
ff 83 02表示功能码03异常,异常码02=非法地址。
提示:RTU无法用Wireshark抓,但可用逻辑分析仪或串口调试助手(如XCOM)捕获原始字节流,重点核对CRC16是否与
CRC16Table.calculate()输出一致。
5. 避坑指南:那些让开发者凌晨三点还在重启PLC的典型问题
5.1 现象:RTU读取返回null或空数组,串口线确认正常
原因:JSSC的readBytes()在超时内未收到任何字节时返回null,而项目代码未判空直接array.length导致NullPointerException。
解决:在RtuMaster.receiveResponse()中增加判空:
byte[] raw = serialPort.readBytes(timeoutMs); if (raw == null || raw.length < 5) { // RTU最小响应帧:unitID+func+data+crc=5字节 throw new ModbusTimeoutException("No response from slave"); }5.2 现象:TCP连接成功,但第一次请求超时,后续请求正常
原因:Netty的Bootstrap首次连接时,DNS解析可能阻塞(尤其localhost在hosts中未映射)。
解决:禁用DNS缓存,强制用IP:
// TcpMaster构造函数中 InetSocketAddress addr = new InetSocketAddress( InetAddress.getByName("127.0.0.1"), port // 不用"localhost" ); bootstrap.connect(addr).sync();5.3 现象:RTU写单线圈(Function 05)后PLC状态不变,但返回正常响应
原因:Modbus线圈地址是0xxxx格式,但Java代码中WriteSingleCoilRequest的address参数是0-indexed,而PLC HMI界面显示的是1-indexed地址。例如HMI上“线圈00001”对应Java中address=0。
解决:在UI层做转换,或文档明确标注“代码中地址=HMI地址-1”。
5.4 现象:多线程并发调用TcpMaster.send()时,偶发ClosedChannelException
原因:TcpMaster未对Channel加锁,两个线程同时调用channel.writeAndFlush(),第二个线程在第一个尚未完成时就关闭了Channel。
解决:在TcpMaster.send()方法上加synchronized,或改用Channel.writeAndFlush().addListener()异步回调。
5.5 现象:QModMaster作为从站时,TCP请求返回Illegal Function(0x01)
原因:QModMaster默认只启用功能码1、2、3、4、15、16,而示例代码用了功能码5(写单线圈),但QModMaster未勾选“Enable Write Single Coil”。
解决:QModMaster菜单栏Setup → Read/Write Definition,勾选对应功能码。
6. 二次开发实战:如何扩展自定义功能码并注入业务逻辑
6.1 新增功能码:实现“读写混合”指令(Function 0x2B)
某PLC固件扩展了功能码0x2B,允许在一个请求中先读3个寄存器,再写2个寄存器。需新增三处:
定义功能码枚举:
在core/FunctionCode.java中添加:READ_WRITE_MIXED((byte) 0x2B, 12) // 12字节PDU:读起始2+读数量2+写起始2+写数量2+写数据4实现请求类:
// src/main/java/com/example/modbus/core/ReadWriteMixedRequest.java public class ReadWriteMixedRequest implements ModbusRequest { private final int readStart, readQuantity, writeStart, writeQuantity; private final byte[] writeData; public ReadWriteMixedRequest(int readStart, int readQuantity, int writeStart, int writeQuantity, byte[] writeData) { this.readStart = readStart; this.readQuantity = readQuantity; this.writeStart = writeStart; this.writeQuantity = writeQuantity; this.writeData = writeData; } @Override public byte[] getData() { ByteBuffer buf = ByteBuffer.allocate(12); buf.putShort((short) readStart).putShort((short) readQuantity) .putShort((short) writeStart).putShort((short) writeQuantity) .put(writeData); return buf.array(); } }注册到编码器:
修改TcpRequestEncoder.encode(),在switch (request.getFunctionCode())中添加:case READ_WRITE_MIXED: out.writeBytes(((ReadWriteMixedRequest) request).getData()); break;
6.2 注入业务逻辑:在收到响应后自动存入数据库
ModbusResponse是只读对象,需通过观察者模式解耦。在core/ModbusMaster.java中添加:
public class ModbusMaster { private final List<ResponseObserver> observers = new CopyOnWriteArrayList<>(); public void addObserver(ResponseObserver observer) { observers.add(observer); } public ModbusResponse send(ModbusRequest request) { ModbusResponse response = doSend(request); // 原有发送逻辑 // 通知所有观察者 observers.forEach(o -> o.onResponse(request, response)); return response; } } // 定义观察者接口 public interface ResponseObserver { void onResponse(ModbusRequest request, ModbusResponse response); } // 实现数据库存储 public class DbResponseObserver implements ResponseObserver { private final JdbcTemplate jdbcTemplate; public DbResponseObserver(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } @Override public void onResponse(ModbusRequest request, ModbusResponse response) { if (response instanceof ReadHoldingRegistersResponse) { ReadHoldingRegistersResponse r = (ReadHoldingRegistersResponse) response; jdbcTemplate.update( "INSERT INTO modbus_log (timestamp, address, value) VALUES (?, ?, ?)", System.currentTimeMillis(), request.getStartAddress(), r.getRegisters()[0] ); } } }使用时:
ModbusMaster master = ModbusMasterFactory.create(...); master.addObserver(new DbResponseObserver(jdbcTemplate));6.3 协议定制:为某PLC添加私有校验(RTU后加2字节时间戳)
某PLC要求RTU帧末尾在CRC后追加2字节Unix时间戳(小端序)。需改造RtuRequestEncoder:
// src/main/java/com/example/modbus/rtu/RtuRequestEncoder.java @Override protected void encode(ChannelHandlerContext ctx, ModbusRequest request, ByteBuf out) { // 先写原始RTU帧(unitId + func + data) out.writeByte(request.getUnitId()); out.writeByte(request.getFunctionCode().getCode()); out.writeBytes(request.getData()); // 计算原始CRC(不含时间戳) byte[] frameWithoutCrc = new byte[out.readableBytes()]; out.getBytes(0, frameWithoutCrc); int crc = CRC16Table.calculate(frameWithoutCrc); // 写CRC(低字节在前) out.writeByte((byte) (crc & 0xFF)); out.writeByte((byte) ((crc >> 8) & 0xFF)); // 追加时间戳(毫秒级,取低16位) long timestamp = System.currentTimeMillis() & 0xFFFF; out.writeByte((byte) (timestamp & 0xFF)); out.writeByte((byte) ((timestamp >> 8) & 0xFF)); }注意:此时
RtuResponseDecoder也需同步修改,跳过最后2字节时间戳再校验CRC。从那以后我每次扩展私有协议,都强制走一遍“请求编码→Wireshark抓包→PLC回包→响应解码”全链路验证,哪怕只是加一个字节。希望帮到你。
本文还有配套的精品资源,点击获取