资讯动态

Java实现Modbus RTU与TCP双协议栈工程实践

发布时间:2026/10/20 11:31:51 来源:尧图企业网站定制
简介本资源是一套基于Java实现Modbus协议通信的完整开源源码工程面向工业自动化开发工程师、嵌入式系统集成人员及学习串口与网络通信的中高级Java开发者解决设备层数据采集、主从站交互、跨介质RS485串口/以太网协议适配等实际工程问题。压缩包共47个文件涵盖30个XML配置与构建文件如pom.xml、README.md、3个核心jar库modbus4J.jar、RXTXcomm.jar、seroUtils.jar、2个Java主程序源码、2个Windows串口驱动DLLrxtxSerial.dll等以及LICENSE、说明TXT等辅助文件整体仅1.03MB轻量易集成。已有2134人学习下载说明其在教学演示与快速原型开发中具备较高实用认可度。读者可直接运行主从站示例代码掌握RTU串口参数配置波特率、校验位、TCP连接管理、寄存器读写逻辑封装及JModbusRXTX协同调用的关键实践同时获得清晰分层的工程结构参考——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不手写NIO2.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 ClosedJSSC的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单元ID1Netty的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 ChannelInitializerSocketChannel() { 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-reversedModbus RTU强制要求用反向多项式。如果用错成0x8005校验值永远对不上PLC返回的帧。我见过三个项目栽在这儿——PLC明明返回了正确数据Java端因CRC错直接丢弃。3.2 TCP MBAP头封装事务ID不能自增得随请求变Modbus TCP的MBAP头7字节中transaction ID2字节是客户端自定义的用于匹配请求/响应。项目里TcpRequestEncoder这样生成// src/main/java/com/example/modbus/tcp/TcpRequestEncoder.java public class TcpRequestEncoder implements MessageToByteEncoderModbusRequest { 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长度功能码数据 1unit 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/TCPcore/包下的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.mainClasscom.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/ttyUSB0Linux或mode COM3Windows确认当前波特率。项目默认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固定000 06是长度6字节PDUff 03 00 00 00 0aff是unit ID25503是功能码读保持寄存器响应帧是否返回若无响应说明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最小响应帧unitIDfuncdatacrc5字节 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中address0。解决在UI层做转换或文档明确标注“代码中地址HMI地址-1”。5.4 现象多线程并发调用TcpMaster.send()时偶发ClosedChannelException原因TcpMaster未对Channel加锁两个线程同时调用channel.writeAndFlush()第二个线程在第一个尚未完成时就关闭了Channel。解决在TcpMaster.send()方法上加synchronized或改用Channel.writeAndFlush().addListener()异步回调。5.5 现象QModMaster作为从站时TCP请求返回Illegal Function0x01原因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 ListResponseObserver 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回包→响应解码”全链路验证哪怕只是加一个字节。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑