欢迎光临
我们一直在努力

从零搭建灌装监控系统(八):断线检测与自动重连

断线检测与自动重连

这是「从零搭建灌装监控系统」系列第8篇。上一篇实现了 200ms 轮询循环,但 PLC 断线后只是检测到断线,不会自动恢复。这篇实现自动重连机制——3次超时阈值判定断线、串口和TCP分别重连策略、重连间隔递增、重连成功后恢复轮询。让系统在 PLC 重启或网络抖动后自动恢复,不需要人工干预。


半夜被叫起来重启软件

我朋友的系统上线跑了两天,第三天凌晨他接到电话——操作员说界面数据不刷新了。

他远程连上去一看,PLC 那边设备重启过(凌晨维护),重启完之后上位机没重连上。界面显示的还是断线前的旧数据,状态栏 PLC 指示灯是红的。

他只能远程重启软件。重启完好了,但第二天凌晨 PLC 又维护,又得重启。他说这不行,我得睡觉。

问题出在哪?PlcService 断线后没有自动重连。 轮询循环检测到断线,只是标记 IsConnected = false,但不会尝试重新连接。需要人工重启软件。

工业现场 PLC 重启、网络抖动、线缆松动是常态。上位机必须能自动恢复——检测到断线后自动重连,重连成功后继续轮询,全程不需要人工干预。


断线检测:3次超时阈值

不能一次超时就判定断线。工业现场偶尔有干扰导致一帧数据丢失,下一帧就好了。一次超时就断线重连,会导致频繁重连,反而影响正常通信。

用3次连续超时判定断线:

private static int _consecutiveTimeouts = 0;
private const int MaxTimeouts = 3;

private static async Task PollDataLoop(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
// === 连接检查 ===
if (!IsConnected || (_tcpClient != null && !IsTcpReallyConnected()))
{
if (IsConnected)
ConnectionChanged?.Invoke(null, false);

await TryReconnectAsync();
await Task.Delay(3000, token); // 重连间隔 3 秒
continue;
}

// === 读数据(带超时) ===
var readTask = ReadAllRegistersAsync();
var timeoutTask = Task.Delay(500, token);

if (await Task.WhenAny(readTask, timeoutTask) != readTask)
{
_consecutiveTimeouts++;
LogService.Warn($"读取超时 ({_consecutiveTimeouts}/{MaxTimeouts})");

if (_consecutiveTimeouts >= MaxTimeouts)
{
LogService.Warn("连续超时达阈值,判定断线");
ConnectionChanged?.Invoke(null, false);
// 强制断开,下一轮循环会触发重连
await ForceDisconnectInternal();
_consecutiveTimeouts = 0;
}
await Task.Delay(200, token);
continue;
}

// 读取成功,重置计数器
_consecutiveTimeouts = 0;
var states = await readTask;
DataReceived?.Invoke(null, states);
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
LogService.Error($"轮询异常: {ex.Message}");
await Task.Delay(1000, token);
}

await Task.Delay(200, token);
}
}

关键逻辑:

  • 每次读超时 _consecutiveTimeouts++
  • 读成功 _consecutiveTimeouts = 0(重置)
  • 达到 3 次连续超时 → 判定断线 → 触发 ConnectionChanged(false) → 强制断开 → 下一轮循环进入重连

为什么 3 次?200ms 轮询,3 次超时 = 600ms。600ms 内 PLC 没响应,基本确定断了。再短太敏感,再长恢复慢。


重连策略:串口和TCP分别处理

串口和 TCP 的重连方式不同——串口是本地设备(COM 口),TCP 是网络连接(IP:Port)。

重连策略对比

private static async Task TryReconnectAsync()
{
try
{
LogService.Info($"尝试重连… (模式: {_currentMode})");

switch (_currentMode)
{
case ConnectionMode.Serial:
await TryReconnectSerialAsync();
break;
case ConnectionMode.ModbusTcp:
await TryReconnectTcpAsync();
break;
case ConnectionMode.CustomTcp:
// 自定义 TCP 由调用方管理
break;
}
}
catch (Exception ex)
{
LogService.Warn($"重连失败: {ex.Message}");
}
}

串口重连

private static async Task TryReconnectSerialAsync()
{
// 清理旧串口
if (_serialPort?.IsOpen == true) _serialPort.Close();
_serialPort?.Dispose();
_serialPort = null;
_modbusMaster = null;

// 检查串口是否存在(USB 转串口可能被拔了)
var availablePorts = SerialPort.GetPortNames();
if (!availablePorts.Contains(_lastPortName))
{
LogService.Warn($"串口 {_lastPortName} 不存在,可用: {string.Join(", ", availablePorts)}");
return; // 不重连,等下一轮再试
}

// 重新打开串口
_serialPort = new SerialPort(_lastPortName!, _lastBaudRate,
_lastParity, _lastDataBits, _lastStopBits);
_serialPort.Open();
_modbusMaster = ModbusSerialMaster.CreateRtu(_serialPort);
_modbusMaster.Transport.ReadTimeout = 1000;
_modbusMaster.Transport.WriteTimeout = 1000;

ConnectionChanged?.Invoke(null, true);
LogService.Info($"串口重连成功: {_lastPortName}");
}

串口重连的特殊情况——USB 转串口可能被拔了。SerialPort.GetPortNames() 列出可用串口,如果配置的 COM 口不在列表里,说明 USB 被拔了,等下一轮再试(用户可能插回来)。

TCP 重连

private static async Task TryReconnectTcpAsync()
{
// 清理旧连接
_ipMaster?.Dispose();
_ipMaster = null;
_tcpClient?.Dispose();
_tcpClient = null;

// 重新连接
var client = new TcpClient();
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var connectTask = client.ConnectAsync(_lastTcpIp!, _lastTcpPort);

if (await Task.WhenAny(connectTask, Task.Delay(Timeout.Infinite, cts.Token)) != connectTask)
{
client.Dispose();
throw new OperationCanceledException("TCP 重连超时");
}
await connectTask;

_ipMaster = ModbusIpMaster.CreateIp(client);
_ipMaster.Transport.ReadTimeout = 1000;
_ipMaster.Transport.WriteTimeout = 1000;
client.Client.ReceiveTimeout = 1000;
client.Client.SendTimeout = 1000;
_tcpClient = client;

ConnectionChanged?.Invoke(null, true);
LogService.Info($"TCP 重连成功: {_lastTcpIp}:{_lastTcpPort}");
}
catch
{
client.Dispose();
throw;
}
}

TCP 重连和初始连接逻辑一样——5 秒超时,成功后创建 Master。区别是重连用的是保存的 _lastTcpIp/_lastTcpPort,不是传入参数。


重连间隔:不要疯狂重连

重连不能太频繁——PLC 还没启动完就重连,肯定失败。失败后立即重试,又失败……形成重连风暴,日志刷屏,CPU 飙高。

用固定间隔 + 上限策略:

// 在 PollDataLoop 里
if (!IsConnected || (_tcpClient != null && !IsTcpReallyConnected()))
{
if (IsConnected) ConnectionChanged?.Invoke(null, false);

await TryReconnectAsync();
await Task.Delay(3000, token); // 重连间隔 3 秒
continue;
}

3 秒间隔够用——PLC 重启通常 10-30 秒,3 秒一次重试,最多 10 次就重连上。比 1 秒间隔少 2/3 的重连次数,日志不刷屏。

进阶:指数退避

如果想要更智能的重连间隔,用指数退避——失败次数越多,间隔越长:

private static int _reconnectAttempts = 0;
private const int MaxReconnectInterval = 30000; // 最大 30 秒

// 在重连逻辑里
_reconnectAttempts++;
var interval = Math.Min(3000 * _reconnectAttempts, MaxReconnectInterval);
await Task.Delay(interval, token);

// 重连成功后重置
_reconnectAttempts = 0;

第 1 次失败等 3 秒,第 2 次 6 秒,第 3 次 9 秒……最多 30 秒。避免长时间断线时无意义的频繁重连。

这个项目用的是固定 3 秒间隔,简单够用。灌装线 PLC 重启很快(10 秒内),不需要指数退避。


重连成功后恢复轮询

重连成功后不需要特殊处理——TryReconnectAsync 成功后 IsConnected 变 true,下一轮 PollDataLoop 循环检测到已连接,直接读数据。

#mermaid-svg-ET928t4iQ1FvMgh0{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ET928t4iQ1FvMgh0 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ET928t4iQ1FvMgh0 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ET928t4iQ1FvMgh0 .error-icon{fill:#552222;}#mermaid-svg-ET928t4iQ1FvMgh0 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ET928t4iQ1FvMgh0 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ET928t4iQ1FvMgh0 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ET928t4iQ1FvMgh0 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ET928t4iQ1FvMgh0 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ET928t4iQ1FvMgh0 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ET928t4iQ1FvMgh0 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ET928t4iQ1FvMgh0 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ET928t4iQ1FvMgh0 .marker.cross{stroke:#333333;}#mermaid-svg-ET928t4iQ1FvMgh0 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ET928t4iQ1FvMgh0 p{margin:0;}#mermaid-svg-ET928t4iQ1FvMgh0 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-ET928t4iQ1FvMgh0 .cluster-label text{fill:#333;}#mermaid-svg-ET928t4iQ1FvMgh0 .cluster-label span{color:#333;}#mermaid-svg-ET928t4iQ1FvMgh0 .cluster-label span p{background-color:transparent;}#mermaid-svg-ET928t4iQ1FvMgh0 .label text,#mermaid-svg-ET928t4iQ1FvMgh0 span{fill:#333;color:#333;}#mermaid-svg-ET928t4iQ1FvMgh0 .node rect,#mermaid-svg-ET928t4iQ1FvMgh0 .node circle,#mermaid-svg-ET928t4iQ1FvMgh0 .node ellipse,#mermaid-svg-ET928t4iQ1FvMgh0 .node polygon,#mermaid-svg-ET928t4iQ1FvMgh0 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ET928t4iQ1FvMgh0 .rough-node .label text,#mermaid-svg-ET928t4iQ1FvMgh0 .node .label text,#mermaid-svg-ET928t4iQ1FvMgh0 .image-shape .label,#mermaid-svg-ET928t4iQ1FvMgh0 .icon-shape .label{text-anchor:middle;}#mermaid-svg-ET928t4iQ1FvMgh0 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ET928t4iQ1FvMgh0 .rough-node .label,#mermaid-svg-ET928t4iQ1FvMgh0 .node .label,#mermaid-svg-ET928t4iQ1FvMgh0 .image-shape .label,#mermaid-svg-ET928t4iQ1FvMgh0 .icon-shape .label{text-align:center;}#mermaid-svg-ET928t4iQ1FvMgh0 .node.clickable{cursor:pointer;}#mermaid-svg-ET928t4iQ1FvMgh0 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ET928t4iQ1FvMgh0 .arrowheadPath{fill:#333333;}#mermaid-svg-ET928t4iQ1FvMgh0 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ET928t4iQ1FvMgh0 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ET928t4iQ1FvMgh0 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ET928t4iQ1FvMgh0 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ET928t4iQ1FvMgh0 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ET928t4iQ1FvMgh0 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ET928t4iQ1FvMgh0 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ET928t4iQ1FvMgh0 .cluster text{fill:#333;}#mermaid-svg-ET928t4iQ1FvMgh0 .cluster span{color:#333;}#mermaid-svg-ET928t4iQ1FvMgh0 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ET928t4iQ1FvMgh0 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ET928t4iQ1FvMgh0 rect.text{fill:none;stroke-width:0;}#mermaid-svg-ET928t4iQ1FvMgh0 .icon-shape,#mermaid-svg-ET928t4iQ1FvMgh0 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ET928t4iQ1FvMgh0 .icon-shape p,#mermaid-svg-ET928t4iQ1FvMgh0 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ET928t4iQ1FvMgh0 .icon-shape rect,#mermaid-svg-ET928t4iQ1FvMgh0 .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ET928t4iQ1FvMgh0 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ET928t4iQ1FvMgh0 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ET928t4iQ1FvMgh0 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

是

否

是

否

是

否

是

否

轮询循环

已连接?

读数据

触发断线事件

尝试重连

重连成功?

触发连接事件

等3秒

下一轮循环

读成功?

发布数据事件

超时计数+1

达3次?

判定断线

整个流程是自包含的——PollDataLoop 一直在跑,断线时自动重连,重连后自动恢复,不需要外部干预。


ConnectionChanged 事件:通知 UI

断线和重连都通过 ConnectionChanged 事件通知 ViewModel:

// MainWindowViewModel 订阅
public MainWindowViewModel(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
PlcService.ConnectionChanged += (s, connected) =>
{
// 切回 UI 线程更新绑定属性
Application.Current.Dispatcher.Invoke(() =>
{
IsPlcConnected = connected;
});
};
}

UI 上的状态指示灯绑定 IsPlcConnected,断线变红,重连变绿。操作员一眼看到连接状态。

重连时的 UI 反馈

光变红不够,最好告诉操作员"正在重连"。加个中间状态:

[ObservableProperty]
private string connectionStatus = "未连接";

// 订阅事件
PlcService.ConnectionChanged += (s, connected) =>
{
Application.Current.Dispatcher.Invoke(() =>
{
IsPlcConnected = connected;
ConnectionStatus = connected ? "已连接" : "断线,正在重连…";
});
};

UI 上显示 ConnectionStatus,断线时显示"断线,正在重连…“,重连成功显示"已连接”。操作员知道系统在自动恢复,不是死机了。


踩坑记录

坑现象原因解决
重连风暴 日志刷屏,CPU 飙高 重连失败立即重试,无间隔 重连间隔 3 秒,或指数退避
串口重连失败 串口不存在异常 USB 转串口被拔了 GetPortNames 检查,不存在就跳过
重连后数据不对 读到全 0 或全 FF 旧 Master 没释放,新 Master 用了旧连接 重连前先 Dispose 旧 Master 和连接
断线判定太敏感 偶尔断线又恢复 1次超时就判定断线 3次连续超时才判定
重连不退出 程序关了还在重连 重连逻辑没响应 CancellationToken 重连里的 await 都传 token

第三个坑很恶心。我朋友第一次写重连,只 new 了新 SerialPort,没 Dispose 旧的。结果旧串口还占着 COM 口,新串口 Open 失败(串口被占用)。必须先 Close + Dispose 旧的,再创建新的。

// ❌ 错误:没清理旧连接
_serialPort = new SerialPort(...); // 旧的还占着 COM 口
_serialPort.Open(); // 失败:串口被占用

// ✅ 正确:先清理再创建
if (_serialPort?.IsOpen == true) _serialPort.Close();
_serialPort?.Dispose();
_serialPort = null;
_modbusMaster = null;
// 再创建新的
_serialPort = new SerialPort(...);
_serialPort.Open();


本篇小结

知识点关键代码
3次超时阈值 _consecutiveTimeouts >= 3 判定断线
串口重连 Close+Dispose 旧 → 检查端口存在 → new SerialPort
TCP 重连 Dispose 旧 → new TcpClient → 5秒超时连接
重连间隔 Task.Delay(3000, token) 固定间隔
指数退避 Math.Min(3000 * attempts, 30000)
串口不存在检测 SerialPort.GetPortNames().Contains(port)
TCP 假连接检测 Socket.Poll(1, SelectMode.SelectRead)
UI 状态反馈 ConnectionChanged 事件 + Dispatcher 切线程

从断线就崩,到 3 次超时判定 + 自动重连 + UI 反馈——PLC 重启或网络抖动后,系统自动恢复,操作员看到"正在重连"知道系统在工作,不需要人工重启软件。

至此,Modbus 通信部分(05-08 篇)全部完成。下一篇开始讲数据持久化——把读上来的生产数据存到 SQLite,用 FreeSql ORM 自动建表。


下期预告

第9篇:SQLite 与 FreeSql ORM

我们将引入 SQLite 数据库 + FreeSql ORM,实现生产记录、报警记录、日志的持久化。涉及 DbProvider 封装、双检锁单例、AutoSyncStructure 自动建表、SQL 执行监控。

赞(0)
未经允许不得转载:171主机测评 » 从零搭建灌装监控系统(八):断线检测与自动重连
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址