本文记录了我学习用 C# 通过 Modbus RTU 协议与 PLC 通信的过程。内容涵盖RtuConnect类的设计思路、关键代码解析、线程安全的考虑、连接状态监控以及优雅的程序退出机制。一、RtuConnect 类的整体结构RtuConnect是一个密封类sealed负责管理与 PLC 的串口通信内部采用单例模式确保整个程序只有一个通信主站实例。publicsealedclassRtuConnect{// 单例实现privatestaticreadonlyLazyRtuConnect_instancenewLazyRtuConnect(()newRtuConnect(),LazyThreadSafetyMode.ExecutionAndPublication);publicstaticRtuConnectInstance_instance.Value;privateSerialPort_serialPort;publicstaticIModbusSerialMaster_master;privatereadonlyobject_locknewobject();// 私有构造函数防止外部创建实例privateRtuConnect(){}}单例模式解读LazyRtuConnect将类作为泛型参数采用懒汉式加载线程安全模式为ExecutionAndPublication保证在多线程下只执行一次初始化且所有线程看到的都是同一个值。访问方式外部只能通过RtuConnect.Instance访问唯一实例首次访问时才会真正执行new RtuConnect()。构造函数私有构造函数确保类不能从外部被实例化。二、串口参数配置与 PLC 通信需要配置串口的物理参数privateconstStringPortNameCOM4;privateconstintBauderate9600;privateconstintDataBits8;privateconstStopBitsStopBitsValueStopBits.One;privateconstParityParityValueParity.None;参数值说明端口号COM4串口设备标识波特率9600通信速率需与 PLC 一致数据位8每个字节的数据位数停止位One帧结束标志位校验位None无奇偶校验这些参数需要和 PLC 的 485 接口设置保持一致。三、打开串口与初始化主站Open()方法在程序启动时调用负责创建串口并建立 Modbus RTU 主站。该方法内部使用lock保证线程安全。publicvoidOpen(){lock(_lock){if(_serialPort!null_serialPort.IsOpen)return;try{_serialPortnewSerialPort(PortName,Bauderate,ParityValue,DataBits,StopBitsValue){ReadTimeout1000,// 读取超时 1 秒WriteTimeout1000// 写入超时 1 秒};_serialPort.Open();_masterModbusSerialMaster.CreateRtu(_serialPort);Console.WriteLine(Modbus RTU 通信建立成功);}catch(Exceptionex){// 打开失败时释放资源并抛出异常_serialPort?.Dispose();_serialPortnull;_masternull;thrownewInvalidOperationException($无法打开串口{PortName}:{ex.Message},ex);}}}要点说明要点说明ReadTimeout / WriteTimeout防止因读写超时导致线程无限期阻塞ModbusSerialMaster.CreateRtu()创建 C# 层面的主站对象封装了通过串口与 PLC 通信的细节异常处理使用_serialPort?.Dispose()仅在对象不为空时释放资源四、关闭串口与资源清理Close()方法负责安全地关闭串口并释放资源。它同样需要加锁并且在关闭前会先停止监控线程。publicvoidclose(){lock(_lock){StopMonitoring();if(_serialPort!null_serialPort.IsOpen){try{_serialPort.Close();}catch(Exceptionex){System.Diagnostics.Debug.WriteLine($通讯连接关闭失败:{ex.Message});}finally{_serialPort.Dispose();_serialPortnull;_masternull;}}}}执行流程加锁→ 防止并发操作串口停止监控→ 先调用StopMonitoring()终止心跳线程关闭串口→ 尝试Close()失败时记录调试日志释放资源→Dispose()并置空所有引用五、连接状态监控心跳检测为了解决 USB 被意外拔出后程序无法感知的问题我们加入了监控线程定期尝试读取 PLC 的一个保持寄存器如果读取失败则认为连接丢失自动进行重连。5.1 监控相关字段privateThread_monitorThread;privatevolatilebool_isMonitring;privatereadonlyint_monitorIntervalMs2000;// 检测间隔 2 秒privateconstbyte_testSlaveId1;// 测试用的从站地址privateconstushort_testRegisterAddress0;// 测试用的寄存器地址5.2 启动与停止监控publicvoidStartMonitoring(){if(_isMonitring)return;_isMonitringtrue;_monitorThreadnewThread(MonitorConnection){IsBackgroundtrue,NameSerialMonitor};_monitorThread.Start();}publicvoidStopMonitoring(){_isMonitringfalse;if(_monitorThread!null_monitorThread.IsAlive){_monitorThread.Join(3000);}_monitorThreadnull;}关键要点要点说明volatile 关键字_isMonitring用volatile修饰保证多线程下的可见性后台线程IsBackground true当主程序退出时会被自动终止Thread.Join()先设置标志为false然后调用Join(3000)等待线程在 3 秒内自己退出5.3 监控线程的执行逻辑privatevoidMonitorConnection(){while(_isMonitring){Thread.Sleep(_monitorIntervalMs);if(!_isMonitring)break;boolconnectionLostfalse;lock(_lock){if(_serialPortnull||!_serialPort.IsOpen){connectionLosttrue;}else{try{_master.ReadHoldingRegisters(_testSlaveId,_testRegisterAddress,1);}catch{connectionLosttrue;}}if(connectionLost){// 清理旧连接try{_serialPort?.Close();}catch{}finally{_serialPort?.Dispose();_serialPortnull;_masternull;}// 尝试重连try{_serialPortnewSerialPort(PortName,Bauderate,ParityValue,DataBits,StopBitsValue){ReadTimeout1000,WriteTimeout1000};_serialPort.Open();_masterModbusSerialMaster.CreateRtu(_serialPort);Console.WriteLine(监控线程检测到断开已自动重连成功);}catch(Exceptione){Console.WriteLine($重连失败:{e.Message});_serialPort?.Dispose();_serialPortnull;_masternull;}}}}}循环逻辑说明重置状态connectionLost被重置为false。检查串口检查串口是否为空或未打开是则标记连接丢失。读取测试若串口已打开则尝试读取一个寄存器失败也标记丢失。自动重连若连接丢失先清理旧资源再重新创建串口和主站。线程安全整个过程在lock(_lock)内完成确保与后续的业务读写操作不冲突。六、程序入口集成在Program.cs的Main方法中启动时打开通信并开启监控退出时自动清理。staticvoidMain(){try{RtuConnect.Instance.Open();RtuConnect.Instance.StartMonitoring();// 启动监控ApplicationConfiguration.Initialize();MainManager.homenewHome();Application.Run(MainManager.home);// 程序消息循环}catch(Exceptione){MessageBox.Show($程序启动失败:{e.Message}\n\n{e.StackTrace},错误,MessageBoxButtons.OK,MessageBoxIcon.Error);}finally{RtuConnect.Instance.Close();// 关闭通信并停止监控}}执行顺序说明阻塞等待Application.Run会阻塞当前线程直到窗体关闭。资源回收窗机关闭后finally块执行调用Close()完成资源回收。安全退出即使Close()内部停止监控线程时超时后台线程也会随主线程结束而被系统终止无需担心进程残留。七、关于锁与线程安全的讨论7.1 当前Open/Close只有单线程调用锁多余吗目前确实不会发生竞争但保留锁是防御性设计若未来在多线程环境下调用重连比如手动重连按钮锁能保证串口对象状态的一致性。7.2 监控线程与业务读写的互斥监控线程和未来任何读取保持寄存器的方法都使用同一把锁_lock保证不会出现一个线程正在读寄存器的同时另一个线程在销毁或重建串口确保了线程安全。7.3Thread.Join的正确理解Join(3000)不是“等待 3 秒后杀死线程”而是等待线程结束最多等 3 秒。如果 3 秒内线程执行完方法自然退出Join返回true超时后不再等待线程仍继续运行。最终由后台线程的性质保证进程正常退出。八、总结通过这次实践我掌握了设计模式C# 中线程安全的单例模式实现LazyT串口通信使用System.IO.Ports.SerialPort进行串口通信配置Modbus 协议NModbus 库的 RTU 主站建立多线程编程用后台线程实现连接状态监控与自动重连线程安全锁与线程同步在通信场景中的正确使用资源管理程序生命周期中资源清理的可靠方式这套设计使我的 WinForms 程序能够稳定地与 PLC 保持通信并具备掉线自动恢复的能力。