☰
从零搭建灌装监控系统(八):断线检测与自动重连
2026/9/29 9:57:35 网站建设 项目流程

断线检测与自动重连

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


半夜被叫起来重启软件

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

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

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

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

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


断线检测:3次超时阈值

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

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

privatestaticint_consecutiveTimeouts=0;privateconstintMaxTimeouts=3;privatestaticasyncTaskPollDataLoop(CancellationTokentoken){while(!token.IsCancellationRequested){try{// === 连接检查 ===if(!IsConnected||(_tcpClient!=null&&!IsTcpReallyConnected())){if(IsConnected)ConnectionChanged?.Invoke(null,false);awaitTryReconnectAsync();awaitTask.Delay(3000,token);// 重连间隔 3 秒continue;}// === 读数据(带超时) ===varreadTask=ReadAllRegistersAsync();vartimeoutTask=Task.Delay(500,token);if(awaitTask.WhenAny(readTask,timeoutTask)!=readTask){_consecutiveTimeouts++;LogService.Warn($"读取超时 ({_consecutiveTimeouts}/{MaxTimeouts})");if(_consecutiveTimeouts>=MaxTimeouts){LogService.Warn("连续超时达阈值,判定断线");ConnectionChanged?.Invoke(null,false);// 强制断开,下一轮循环会触发重连awaitForceDisconnectInternal();_consecutiveTimeouts=0;}awaitTask.Delay(200,token);continue;}// 读取成功,重置计数器_consecutiveTimeouts=0;varstates=awaitreadTask;DataReceived?.Invoke(null,states);}catch(OperationCanceledException){break;}catch(Exceptionex){LogService.Error($"轮询异常:{ex.Message}");awaitTask.Delay(1000,token);}awaitTask.Delay(200,token);}}

关键逻辑:

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

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


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

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

privatestaticasyncTaskTryReconnectAsync(){try{LogService.Info($"尝试重连... (模式:{_currentMode})");switch(_currentMode){caseConnectionMode.Serial:awaitTryReconnectSerialAsync();break;caseConnectionMode.ModbusTcp:awaitTryReconnectTcpAsync();break;caseConnectionMode.CustomTcp:// 自定义 TCP 由调用方管理break;}}catch(Exceptionex){LogService.Warn($"重连失败:{ex.Message}");}}

串口重连

privatestaticasyncTaskTryReconnectSerialAsync(){// 清理旧串口if(_serialPort?.IsOpen==true)_serialPort.Close();_serialPort?.Dispose();_serialPort=null;_modbusMaster=null;// 检查串口是否存在(USB 转串口可能被拔了)varavailablePorts=SerialPort.GetPortNames();if(!availablePorts.Contains(_lastPortName)){LogService.Warn($"串口{_lastPortName}不存在,可用:{string.Join(", ",availablePorts)}");return;// 不重连,等下一轮再试}// 重新打开串口_serialPort=newSerialPort(_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 重连

privatestaticasyncTaskTryReconnectTcpAsync(){// 清理旧连接_ipMaster?.Dispose();_ipMaster=null;_tcpClient?.Dispose();_tcpClient=null;// 重新连接varclient=newTcpClient();try{usingvarcts=newCancellationTokenSource(TimeSpan.FromSeconds(5));varconnectTask=client.ConnectAsync(_lastTcpIp!,_lastTcpPort);if(awaitTask.WhenAny(connectTask,Task.Delay(Timeout.Infinite,cts.Token))!=connectTask){client.Dispose();thrownewOperationCanceledException("TCP 重连超时");}awaitconnectTask;_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);awaitTryReconnectAsync();awaitTask.Delay(3000,token);// 重连间隔 3 秒continue;}

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

进阶:指数退避

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

privatestaticint_reconnectAttempts=0;privateconstintMaxReconnectInterval=30000;// 最大 30 秒// 在重连逻辑里_reconnectAttempts++;varinterval=Math.Min(3000*_reconnectAttempts,MaxReconnectInterval);awaitTask.Delay(interval,token);// 重连成功后重置_reconnectAttempts=0;

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

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


重连成功后恢复轮询

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

是

否

是

否

是

否

是

否

轮询循环

已连接?

读数据

触发断线事件

尝试重连

重连成功?

触发连接事件

等3秒

下一轮循环

读成功?

发布数据事件

超时计数+1

达3次?

判定断线

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


ConnectionChanged 事件:通知 UI

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

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

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

重连时的 UI 反馈

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

[ObservableProperty]privatestringconnectionStatus="未连接";// 订阅事件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=newSerialPort(...);// 旧的还占着 COM 口_serialPort.Open();// 失败:串口被占用// ✅ 正确:先清理再创建if(_serialPort?.IsOpen==true)_serialPort.Close();_serialPort?.Dispose();_serialPort=null;_modbusMaster=null;// 再创建新的_serialPort=newSerialPort(...);_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 执行监控。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询