断线检测与自动重连
这是「从零搭建灌装监控系统」系列第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循环检测到已连接,直接读数据。
整个流程是自包含的——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 执行监控。