SpringBoot+Vue通过ModbusTCP协议实现PLC 设备连接、重连实时控制
SpringBoot+Vue通过ModbusTCP协议实现PLC 设备连接、重连实时控制
一、核心概念
1.1 Modbus TCP 基础
| 概念 | 说明 |
|---|---|
| 主站 / 从站 | 后端程序是Modbus 主站(Master),PLC 是从站(Slave)。一个主站可轮询多台从站。 |
| 从站地址 slaveId | PLC 的站号(如1)。读写 API 必传。 |
| 功能码 | 01读线圈、02读离散输入、03读保持寄存器、04读输入寄存器、05/06写单线圈/单寄存器、15/16写多。 |
| 寄存器类型 | 4x 保持寄存器(Holding Register)可读可写;3x 输入寄存器只读。本文业务控制全部走 4x。 |
| 地址偏移换算 | 标准 Modbus 地址40001⇒ modbus4j 偏移量0;其余 = 地址 − 40001。例如40003⇒ 偏移2。 |
1.2 寄存器与 bit 位映射(对接表约定)
- 布尔量(启停、摇摆、故障位)不是单独寄存器,而是分布在一个 16 位寄存器内的不同 bit。
- 模拟量/参数多为32 位(Real / Dint),占用连续 2 个保持寄存器(高字 + 低字)。
- 写控制位必须采用读-改-写(read-modify-write),否则会覆盖同一寄存器内其它 bit。
- 本项目常量集中在
PlcOffsetConstants.java:
public class PlcOffsetConstants { public static final int REG_CONTROL = 0; // 控制寄存器 40001(读/写) public static final int REG_STATUS = 1; // 状态/限位 40002(只读) public static final int REG_FAULT = 2; // 故障 40003(只读)// 控制寄存器 40001 的 bit(V4000/V4001,读/写) public static final int BIT_REMOTE_START_STOP = 0; // V4000.0 远程启停 public static final int BIT_REMOTE_SWING = 1; // V4000.1 远程摇摆 // ... BIT_REMOTE_LOCATE / JOG_LEFT / JOG_RIGHT / JOG_UP / JOG_DOWN / FAULT_RESET / SERVO_HOMING1.3 长连接 vs 按需连接(关键坑)
- modbus4j 的
createTcpMaster(params, true)第二个参数true=启用长连接(keep-alive)。 master.init()只是初始化对象,并不真正建立 TCP 连接;真正的connect发生在第一次send(读/写)时。- 因此
master.isInitialized()只表示“是否调用过 init”,不代表 TCP 链路仍然存活。断网后旧 master 仍可能isInitialized()==true,要等到实际读写抛异常才会被发现。 - 本项目
getMaster的关键参数:
public static ModbusMaster getMaster(String ip,int port) throws ModbusInitException { IpParameters params = new IpParameters(); params.setHost(ip); params.setPort(port); ModbusMaster master = modbusFactory.createTcpMaster(params, true); // TCP 协议 + 长连接 master.setTimeout(2000); // 超时 2s master.setRetries(1); // 失败重试 1 次 master.init(); return master; }注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
二、架构设计
┌────────────┐ 定时(3s) ┌──────────────────────────┐ │ Quartz任务 │─────────────▶│ GetModbusTCPDataTask │ │ getModbus │ │ · 取每台设备连接(连接池) │ │ TCPDataTask │ │ · 有效性检查/重建 │ └────────────┘ │ · 读保持寄存器(实时数据) │ │ · 断线→记录冷却→跳过重连 │ └───────────┬──────────────┘ │ 写入 ▼ ┌──────────────────────────┐ │ Redis (key=plc:xxx) │ 实时数据缓存 └───────────┬──────────────┘ │ 读取 ┌────────────┐ HTTP GET ┌───────────▼──────────────┐ │ 前端 Vue │◀────────────│ BusDeviceServiceImpl │ │ 设备卡片/ │ listByShed │ listByShedIdWithStatus │ │ 状态/禁用 │ WithStatus │ · 拼装设备+Redis实时状态 │ └────────────┘ │ · 无实时数据→状态"离线" │ └──────────────────────────┘2.1 关键设计决策
- 连接池
ModbusMasterPool:按plcAddress缓存 master,避免每次操作都新建连接;提供getOrCreateMaster / checkMasterValid / rebuildMaster / destroyMaster。 - 连接与读写为解耦:
getMaster只建连;读用readHoldingRegisterU16Safe(吞异常,单点失败不影响整体轮询);写用writeToDevice(写前先checkMasterValid,失效则重建)。 - 重连冷却(本文重点):采集任务每 3 秒跑一次,但离线设备不会每 3 秒都去建连——一旦失败,记录失败时间,
sys_config.plc.reconnect.interval(秒,默认 30)内直接跳过该设备的读取与重连,冷却期满再试一次。在线设备读取频率完全不受影响。 - 实时数据走 Redis:采集结果入 Redis,接口查询时再拼装,前端无需直连 PLC。
- “离线”由后端判定:
listByShedIdWithStatus在 Redis 中查不到实时数据即置status="离线",前端据此显示并禁用操作。
三、后端实现流程
3.1 发起连接(连接池)
ModbusMasterPool.getOrCreateMaster(device):池里有且isInitialized()就用,否则createNewMaster(调用Modbus4jUtils.getMaster)。
// 有效性检查:仅判断“是否 init 过”,不代表链路存活(见 1.3) public boolean checkMasterValid(ModbusMaster master) { if (master.isInitialized()) return true; try { master.init(); return true; } catch (Exception e) { return false; } }3.2 周期性采集 + 重连冷却(核心定时任务)
// 读取重连冷却时间(单位:秒),配置项 plc.reconnect.interval,默认 30 秒 final long reconnectIntervalMs; { long sec = RECONNECT_INTERVAL_SECONDS_DEFAULT; // 30 String intervalStr = sysConfigService.selectConfigByKey("plc.reconnect.interval"); if (intervalStr != null && !intervalStr.trim().isEmpty()) { try { sec = Long.parseLong(intervalStr.trim()); } catch (NumberFormatException e) { sec = RECONNECT_INTERVAL_SECONDS_DEFAULT; } } if (sec < 1) sec = RECONNECT_INTERVAL_SECONDS_DEFAULT; reconnectIntervalMs = sec * 1000L; }deviceList.parallelStream().forEach(device -> { String deviceKey = device.getPlcAddress(); // 重连限频:离线设备进入冷却,期间不读不重连 Long lastReconnectFailAt = lastReconnectFail.get(deviceKey); if (lastReconnectFailAt != null && (System.currentTimeMillis() - lastReconnectFailAt) < reconnectIntervalMs) { return; } ModbusMaster master = null; try { master = modbusMasterPool.getOrCreateMaster(device); if (!modbusMasterPool.checkMasterValid(master)) { master = modbusMasterPool.rebuildMaster(device); }} catch (Exception e) { System.out.println(" 通信失败: 网络或端口不可达 - " + e.getMessage()); // 记录失败时间,进入 30s 重连冷却(冷却期内该设备将被跳过,不再尝试建连) lastReconnectFail.put(deviceKey, System.currentTimeMillis()); modbusMasterPool.destroyMaster(deviceKey); // 销毁无效连接 }3.3 后端状态判定(离线)
BusDeviceServiceImpl.listByShedIdWithStatus:从 Redis 取实时数据,取不到 ⇒status="离线"。
// 从 Redis 取实时数据 RealTimeDevice realTimeDevice = redisCache.getPlcDeviceListFromRedis() .stream().filter(...).findFirst().orElse(null); BusDeviceDetailStatuslVo vo = new BusDeviceDetailStatuslVo(); // ... if (realTimeDevice == null) { device.setStatus("离线"); // PLC 无实时数据 → 离线 } else { device.setStatus(PlcStatusEnum.getInfo(realTimeDevice.getWorkStatus())); // 运行/停止 }3.4 写入(控制下发)
writeToDevice写前先checkMasterValid,失效则rebuildMaster并重试一次,确保用户点按钮时能即时重建连接(不受采集冷却影响):
public Boolean writeToDevice(...) { ModbusMaster master = getOrCreateMaster(device); if (!checkMasterValid(master)) { master = rebuildMaster(device); // 写操作即时重建,无需等待冷却 } try { // 读-改-写 置位对应 bit ... } catch (Exception e) { // 失败再 rebuild 重试一次 } }四、API 说明
4.1Modbus4jUtils(读/写工具,见Modbus4jUtils.java)
| 方法 | 功能 | 异常 |
|---|---|---|
getMaster(ip, port) | 创建并 init 一个 TCP 长连接 master | ModbusInitException |
readCoilStatus(m, slaveId, offset) | 读 01 线圈(bool) | Transport / ErrorResponse |
readInputStatus(m, slaveId, offset) | 读 02 离散输入(bool) | 同上 |
readHoldingRegister(m, slaveId, offset, dataType) | 读 03 保持寄存器(Number) | 同上 |
readInputRegisters(m, slaveId, offset, dataType) | 读 04 输入寄存器 | 同上 |
readHoldingRegisterU16(m, slaveId, offset) | 读单个无符号 16 位(4x) | 同上 |
readHoldingRegister32(m, slaveId, offset, dataType) | 读 32 位(Dint/Real) | 同上 |
writeCoil(m, slaveId, offset, value) | 写 01 线圈(bool) | Transport(含异常码) |
writeHoldingRegister(m, slaveId, offset, value) | 写 06 单寄存器(short) | 同上 |
writeHoldingRegisterBit(m, slaveId, regOff, bitIdx, value) | 读-改-写置位 4x 寄存器某 bit | 同上 |
writeHoldingRegisterInt32(m, slaveId, offset, value) | 写 32 位整数(高/低字) | 同上 |
readHoldingRegisterU16Safe(...)/...32Safe(...) | 安全读(吞异常返回 null),用于轮询 | 不抛 |
4.2ModbusMasterPool(连接池)
| 方法 | 说明 |
|---|---|
getOrCreateMaster(device) | 取池中连接,无则新建(失败抛异常) |
checkMasterValid(master) | isInitialized()为真即返回 true(不保证链路存活) |
rebuildMaster(device) | 销毁旧连接并从池移除,重新createNewMaster |
destroyMaster(deviceKey) | 从池移除无效连接(仅移除,不立即重建) |
writeToDevice(...) | 写前校验/重建并重试,供控制下发 |
4.3 后端 REST 接口(前端调用)
| 接口 | 方法/URL | 说明 |
|---|---|---|
| 带状态设备列表 | GET /system/device/listByShedIdWithStatus | 返回设备 + Redis 实时状态(含status="运行/停止/离线") |
| 设备列表 | GET /system/device/listByShedId | 不含实时状态 |
| 视频流代理等 | 见其它文档 | — |
前端调用封装见ruoyi-ui/src/api/system/device.js:
export function listByShedIdWithStatus(query) { return request({ url: '/system/device/listByShedIdWithStatus', method: 'get', params: query }) }五、配置说明
重连冷却间隔由sys_config配置项plc.reconnect.interval控制,单位:秒,默认 30。
- 不配置 / 值非法 / <1 ⇒ 代码兜底默认30 秒。
- 在「系统管理 → 参数设置」改为
60,PLC 离线重连频率即变为 60 秒一次(在线设备仍 3 秒读取)。 - 默认值已写入
sql/全量sql备份/yingyan20260716.sql:
INSERT INTO `sys_config` VALUES (102, 'PLC重连冷却间隔(秒)', 'plc.reconnect.interval', '30', 'Y', 'admin', '2026-07-19 00:00:00', '', NULL, 'PLC离线后重连冷却时长,单位:秒,默认30秒。冷却期内不读取、不重连');六、前后端代码示例
6.1 本项目实际示例
前端设备卡片:状态标签 + 离线禁用操作控件(ruoyi-ui/src/views/index.vue)。
<el-tag :type=" (device.status === '运行' || device.status === '运行中') ? 'success' : device.status === '故障' ? 'danger' : (device.status === '离线' || device.status === '停止') ? 'info' : 'warning' " >{{ device.status }}</el-tag><el-switch v-model="device.sprayStatus" :disabled="device.status === '故障' || device.status === '离线'" size="mini" @change="handleSprayChange(device)" ></el-switch> <span :style="{ fontSize:'15px', fontWeight:'700', color: device.sprayStatus ? '#13ce66' : '#ff7875' }"> {{ device.sprayStatus ? '开启' : '关闭' }} </span>6.2 与业务无关的通用后端示例(可复用)
一个极简、无业务字段的 Modbus TCP 客户端 + 重连冷却 + 定时读取骨架。
importcom.serotonin.modbus4j.*;importcom.serotonin.modbus4j.ip.IpParameters;importcom.serotonin.modbus4j.locator.BaseLocator;publicclassModbusTcpClient{privatefinalStringhost;privatefinalintport;privatefinalintslaveId;privatefinallongreconnectIntervalMs;// 重连冷却(毫秒)privateModbusMastermaster;privatelonglastConnectFailAt=0;publicModbusTcpClient(Stringhost,intport,intslaveId,longreconnectIntervalMs){this.host=host;this.port=port;this.slaveId=slaveId;this.reconnectIntervalMs=reconnectIntervalMs;}/** 确保已连:冷却期内直接返回 false,避免频繁建连 */publicsynchronizedbooleanensureConnected(){if(master!=null&&master.isInitialized())returntrue;longnow=System.currentTimeMillis();if(now-lastConnectFailAt<reconnectIntervalMs)returnfalse;try{IpParametersp=newIpParameters();p.setHost(host);p.setPort(port);master=newModbusFactory().createTcpMaster(p,true);master.setTimeout(2000);master.setRetries(1);master.init();lastConnectFailAt=0;// 连接成功,清除冷却returntrue;}catch(Exceptione){lastConnectFailAt=now;// 进入冷却master=null;returnfalse;}}/** 安全读单个 16 位保持寄存器(4x,offset 从 0 起) */publicIntegerreadHoldingU16(intoffset){try{if(!ensureConnected())returnnull;BaseLocator<Number>loc=BaseLocator.holdingRegister(slaveId,offset,com.serotonin.modbus4j.code.DataType.TWO_BYTE_INT_UNSIGNED);Numbern=master.getValue(loc);returnn==null?null:n.intValue();}catch(Exceptione){drop();// 读失败,丢弃连接,下次冷却后才重建returnnull;}}/** 写单个线圈(bool),返回是否成功 */publicbooleanwriteCoil(intoffset,booleanvalue){try{if(!ensureConnected())returnfalse;WriteCoilRequestreq=newWriteCoilRequest(slaveId,offset,value);WriteCoilResponseres=(WriteCoilResponse)master.send(req);return!res.isException();}catch(Exceptione){drop();returnfalse;}}/** 丢弃连接(仅置空,下次 ensureConnected 再建) */publicsynchronizedvoiddrop(){try{if(master!=null)master.destroy();}catch(Exceptionignored){}master=null;}}// 定时读取调度:每 READ_PERIOD 读一次;离线设备每 RECONNECT_INTERVAL 才重连一次importjava.util.concurrent.*;publicclassPollingDemo{staticfinallongREAD_PERIOD_MS=3000;// 读取频率:3 秒staticfinallongRECONNECT_INTERVAL_MS=30000;// 重连冷却:30 秒publicstaticvoidmain(String[]args){ModbusTcpClientclient=newModbusTcpClient("192.168.1.10",502,1,RECONNECT_INTERVAL_MS);ScheduledExecutorServicesch=Executors.newSingleThreadScheduledExecutor();sch.scheduleAtFixedRate(()->{Integerv=client.readHoldingU16(0);// 读控制/状态寄存器if(v==null){System.out.println("本次未读取(设备离线或处于重连冷却)");}else{System.out.println("寄存器0 = "+v);}},0,READ_PERIOD_MS,TimeUnit.MILLISECONDS);}}6.3 与业务无关的通用前端示例(Vue,状态展示 + 离线禁用)
<template> <div class="device-card"> <el-tag :type="statusType">{{ device.status }}</el-tag> <el-switch v-model="device.on" :disabled="!online" @change="onControl" ></el-switch> <span :style="{ color: device.on ? '#13ce66' : '#ff7875' }"> {{ device.on ? '开启' : '关闭' }} </span> </div> </template> <script> export default { props: { device: Object }, computed: { // 在线 = 状态为 online;其它(offline/fault/stop)按业务定义 online() { return this.device.status === 'online'; }, statusType() { if (this.online) return 'success'; if (this.device.status === 'fault') return 'danger'; return 'info'; // offline / stop } }, methods: { onControl(val) { // 调后端控制接口(通用:传设备标识 + 开关值) this.$api.control({ id: this.device.id, on: val }); } } }; </script>通用约定:后端状态枚举建议用
online/offline/fault(或running/stopped/offline),前端只认这几个字符串即可,与具体 PLC 点位无关。本项目用的是运行/停止/离线/故障,前端做兼容即可。
七、排障与常见问题
| 现象 | 原因 | 解决 |
|---|---|---|
| 读取偶发黑屏/数据为 null 但过会儿恢复 | isInitialized()误判链路存活,实际已断 | 读写抛异常后destroyMaster,下一周期重建 |
| 离线设备疯狂重连(日志刷屏) | 每 3 秒采集都尝试建连 | 开启重连冷却(本文方案),plc.reconnect.interval调大 |
改了plc.reconnect.interval不生效 | 运行库无该配置行 | 在sys_config插入该 key,或依赖代码 30 秒兜底 |
| 写入控制位把其它功能弄乱 | 直接写整个寄存器覆盖 bit | 改用读-改-写(writeHoldingRegisterBit) |
| 前端状态不显示"离线" | 后端无实时数据时应返回"离线" | 检查 Redis 是否有该设备实时数据 |
| 离线时操作按钮仍可用 | 未禁用 | :disabled加上status === '离线' |
