
SpringBootVue通过ModbusTCP协议实现PLC 设备连接、重连实时控制一、核心概念1.1 Modbus TCP 基础概念说明主站 / 从站后端程序是Modbus 主站MasterPLC 是从站Slave。一个主站可轮询多台从站。从站地址 slaveIdPLC 的站号如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.javapublic 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 的 bitV4000/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 (keyplc: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 长连接 masterModbusInitExceptionreadCoilStatus(m, slaveId, offset)读 01 线圈boolTransport / ErrorResponsereadInputStatus(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 线圈boolTransport含异常码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)销毁旧连接并从池移除重新createNewMasterdestroyMaster(deviceKey)从池移除无效连接仅移除不立即重建writeToDevice(...)写前校验/重建并重试供控制下发4.3 后端 REST 接口前端调用接口方法/URL说明带状态设备列表GET /system/device/listByShedIdWithStatus返回设备 Redis 实时状态含status运行/停止/离线设备列表GET /system/device/listByShedId不含实时状态视频流代理等见其它文档—前端调用封装见ruoyi-ui/src/api/system/device.jsexport function listByShedIdWithStatus(query) { return request({ url: /system/device/listByShedIdWithStatus, method: get, params: query }) }五、配置说明重连冷却间隔由sys_config配置项plc.reconnect.interval控制单位秒默认 30。不配置 / 值非法 / 1 ⇒ 代码兜底默认30 秒。在「系统管理 → 参数设置」改为60PLC 离线重连频率即变为 60 秒一次在线设备仍 3 秒读取。默认值已写入sql/全量sql备份/yingyan20260716.sqlINSERT 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-tagel-switch v-modeldevice.sprayStatus :disableddevice.status 故障 || device.status 离线 sizemini changehandleSprayChange(device) /el-switch span :style{ fontSize:15px, fontWeight:700, color: device.sprayStatus ? #13ce66 : #ff7875 } {{ device.sprayStatus ? 开启 : 关闭 }} /span6.2 与业务无关的通用后端示例可复用一个极简、无业务字段的 Modbus TCP 客户端 重连冷却 定时读取骨架。importcom.serotonin.modbus4j.*;importcom.serotonin.modbus4j.ip.IpParameters;importcom.serotonin.modbus4j.locator.BaseLocator;publicclassModbusTcpClient{privatefinalStringhost;privatefinalintport;privatefinalintslaveId;privatefinallongreconnectIntervalMs;// 重连冷却毫秒privateModbusMastermaster;privatelonglastConnectFailAt0;publicModbusTcpClient(Stringhost,intport,intslaveId,longreconnectIntervalMs){this.hosthost;this.portport;this.slaveIdslaveId;this.reconnectIntervalMsreconnectIntervalMs;}/** 确保已连冷却期内直接返回 false避免频繁建连 */publicsynchronizedbooleanensureConnected(){if(master!nullmaster.isInitialized())returntrue;longnowSystem.currentTimeMillis();if(now-lastConnectFailAtreconnectIntervalMs)returnfalse;try{IpParameterspnewIpParameters();p.setHost(host);p.setPort(port);masternewModbusFactory().createTcpMaster(p,true);master.setTimeout(2000);master.setRetries(1);master.init();lastConnectFailAt0;// 连接成功清除冷却returntrue;}catch(Exceptione){lastConnectFailAtnow;// 进入冷却masternull;returnfalse;}}/** 安全读单个 16 位保持寄存器4xoffset 从 0 起 */publicIntegerreadHoldingU16(intoffset){try{if(!ensureConnected())returnnull;BaseLocatorNumberlocBaseLocator.holdingRegister(slaveId,offset,com.serotonin.modbus4j.code.DataType.TWO_BYTE_INT_UNSIGNED);Numbernmaster.getValue(loc);returnnnull?null:n.intValue();}catch(Exceptione){drop();// 读失败丢弃连接下次冷却后才重建returnnull;}}/** 写单个线圈bool返回是否成功 */publicbooleanwriteCoil(intoffset,booleanvalue){try{if(!ensureConnected())returnfalse;WriteCoilRequestreqnewWriteCoilRequest(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){}masternull;}}// 定时读取调度每 READ_PERIOD 读一次离线设备每 RECONNECT_INTERVAL 才重连一次importjava.util.concurrent.*;publicclassPollingDemo{staticfinallongREAD_PERIOD_MS3000;// 读取频率3 秒staticfinallongRECONNECT_INTERVAL_MS30000;// 重连冷却30 秒publicstaticvoidmain(String[]args){ModbusTcpClientclientnewModbusTcpClient(192.168.1.10,502,1,RECONNECT_INTERVAL_MS);ScheduledExecutorServiceschExecutors.newSingleThreadScheduledExecutor();sch.scheduleAtFixedRate(()-{Integervclient.readHoldingU16(0);// 读控制/状态寄存器if(vnull){System.out.println(本次未读取设备离线或处于重连冷却);}else{System.out.println(寄存器0 v);}},0,READ_PERIOD_MS,TimeUnit.MILLISECONDS);}}6.3 与业务无关的通用前端示例Vue状态展示 离线禁用template div classdevice-card el-tag :typestatusType{{ device.status }}/el-tag el-switch v-modeldevice.on :disabled!online changeonControl /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 离线