断线检测与自动重连
这是「从零搭建灌装监控系统」系列第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 执行监控。
