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:
17:26:ruoyi-common/src/main/java/com/ruoyi/common/constant/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(只读)
62:78:ruoyi-common/src/main/java/com/ruoyi/common/constant/PlcOffsetConstants.java
// 控制寄存器 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_HOMING
1.3 长连接 vs 按需连接(关键坑)
- modbus4j 的
createTcpMaster(params, true)第二个参数true= 启用长连接(keep-alive)。 master.init()只是初始化对象,并不真正建立 TCP 连接 ;真正的connect发生在第一次send(读/写)时。- 因此
master.isInitialized()只表示"是否调用过 init",不代表 TCP 链路仍然存活 。断网后旧 master 仍可能isInitialized()==true,要等到实际读写抛异常才会被发现。 - 本项目
getMaster的关键参数:
57:70:ruoyi-common/src/main/java/com/ruoyi/common/utils/Modbus4jUtils.java
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)。
46:50:ruoyi-common/src/main/java/com/ruoyi/common/component/ModbusMasterPool.java
// 有效性检查:仅判断"是否 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 周期性采集 + 重连冷却(核心定时任务)
91:107:ruoyi-quartz/src/main/java/com/ruoyi/quartz/task/GetModbusTCPDataTask.java
// 读取重连冷却时间(单位:秒),配置项 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;
}
109:119:ruoyi-quartz/src/main/java/com/ruoyi/quartz/task/GetModbusTCPDataTask.java
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);
}
298:310:ruoyi-quartz/src/main/java/com/ruoyi/quartz/task/GetModbusTCPDataTask.java
} catch (Exception e) {
System.out.println(" 通信失败: 网络或端口不可达 - " + e.getMessage());
// 记录失败时间,进入 30s 重连冷却(冷却期内该设备将被跳过,不再尝试建连)
lastReconnectFail.put(deviceKey, System.currentTimeMillis());
modbusMasterPool.destroyMaster(deviceKey); // 销毁无效连接
}
3.3 后端状态判定(离线)
BusDeviceServiceImpl.listByShedIdWithStatus:从 Redis 取实时数据,取不到 ⇒ status="离线"。
148:165:ruoyi-system/src/main/java/com/ruoyi/system/service/impl/BusDeviceServiceImpl.java
// 从 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 并重试一次,确保用户点按钮时能即时重建连接(不受采集冷却影响):
61:78:ruoyi-common/src/main/java/com/ruoyi/common/component/ModbusMasterPool.java
public Boolean writeToDevice(...) {
ModbusMaster master = getOrCreateMaster(device);
if (!checkMasterValid(master)) {
master = rebuildMaster(device); // 写操作即时重建,无需等待冷却
}
try {
// 读-改-写 置位对应 bit ...
} catch (Exception e) {
// 失败再 rebuild 重试一次
}
}
四、API 说明
4.1 Modbus4jUtils(读/写工具,见 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.2 ModbusMasterPool(连接池)
| 方法 | 说明 |
|---|---|
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:
13:19: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:
772:772: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)。
456:462: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>
504:510:ruoyi-ui/src/views/index.vue
<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 客户端 + 重连冷却 + 定时读取骨架。
java
import com.serotonin.modbus4j.*;
import com.serotonin.modbus4j.ip.IpParameters;
import com.serotonin.modbus4j.locator.BaseLocator;
public class ModbusTcpClient {
private final String host;
private final int port;
private final int slaveId;
private final long reconnectIntervalMs; // 重连冷却(毫秒)
private ModbusMaster master;
private long lastConnectFailAt = 0;
public ModbusTcpClient(String host, int port, int slaveId, long reconnectIntervalMs) {
this.host = host; this.port = port; this.slaveId = slaveId;
this.reconnectIntervalMs = reconnectIntervalMs;
}
/** 确保已连:冷却期内直接返回 false,避免频繁建连 */
public synchronized boolean ensureConnected() {
if (master != null && master.isInitialized()) return true;
long now = System.currentTimeMillis();
if (now - lastConnectFailAt < reconnectIntervalMs) return false;
try {
IpParameters p = new IpParameters();
p.setHost(host); p.setPort(port);
master = new ModbusFactory().createTcpMaster(p, true);
master.setTimeout(2000);
master.setRetries(1);
master.init();
lastConnectFailAt = 0; // 连接成功,清除冷却
return true;
} catch (Exception e) {
lastConnectFailAt = now; // 进入冷却
master = null;
return false;
}
}
/** 安全读单个 16 位保持寄存器(4x,offset 从 0 起) */
public Integer readHoldingU16(int offset) {
try {
if (!ensureConnected()) return null;
BaseLocator<Number> loc = BaseLocator.holdingRegister(
slaveId, offset, com.serotonin.modbus4j.code.DataType.TWO_BYTE_INT_UNSIGNED);
Number n = master.getValue(loc);
return n == null ? null : n.intValue();
} catch (Exception e) {
drop(); // 读失败,丢弃连接,下次冷却后才重建
return null;
}
}
/** 写单个线圈(bool),返回是否成功 */
public boolean writeCoil(int offset, boolean value) {
try {
if (!ensureConnected()) return false;
WriteCoilRequest req = new WriteCoilRequest(slaveId, offset, value);
WriteCoilResponse res = (WriteCoilResponse) master.send(req);
return !res.isException();
} catch (Exception e) {
drop();
return false;
}
}
/** 丢弃连接(仅置空,下次 ensureConnected 再建) */
public synchronized void drop() {
try { if (master != null) master.destroy(); } catch (Exception ignored) {}
master = null;
}
}
java
// 定时读取调度:每 READ_PERIOD 读一次;离线设备每 RECONNECT_INTERVAL 才重连一次
import java.util.concurrent.*;
public class PollingDemo {
static final long READ_PERIOD_MS = 3000; // 读取频率:3 秒
static final long RECONNECT_INTERVAL_MS = 30000; // 重连冷却:30 秒
public static void main(String[] args) {
ModbusTcpClient client = new ModbusTcpClient("192.168.1.10", 502, 1, RECONNECT_INTERVAL_MS);
ScheduledExecutorService sch = Executors.newSingleThreadScheduledExecutor();
sch.scheduleAtFixedRate(() -> {
Integer v = client.readHoldingU16(0); // 读控制/状态寄存器
if (v == null) {
System.out.println("本次未读取(设备离线或处于重连冷却)");
} else {
System.out.println("寄存器0 = " + v);
}
}, 0, READ_PERIOD_MS, TimeUnit.MILLISECONDS);
}
}
6.3 与业务无关的通用前端示例(Vue,状态展示 + 离线禁用)
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 === '离线' |