本文记录了我学习用 C# 通过 Modbus RTU 协议与 PLC 通信的过程。
内容涵盖 RtuConnect 类的设计思路、关键代码解析、线程安全的考虑、连接状态监控以及优雅的程序退出机制。
一、RtuConnect 类的整体结构
RtuConnect 是一个密封类(sealed),负责管理与 PLC 的串口通信,内部采用单例模式,确保整个程序只有一个通信主站实例。
csharp
public sealed class RtuConnect
{
// 单例实现
private static readonly Lazy<RtuConnect> _instance =
new Lazy<RtuConnect>(() => new RtuConnect(), LazyThreadSafetyMode.ExecutionAndPublication);
public static RtuConnect Instance => _instance.Value;
private SerialPort _serialPort;
public static IModbusSerialMaster _master;
private readonly object _lock = new object();
// 私有构造函数,防止外部创建实例
private RtuConnect() { }
}
单例模式解读
Lazy<RtuConnect>:将类作为泛型参数,采用懒汉式加载,线程安全模式为ExecutionAndPublication,保证在多线程下只执行一次初始化,且所有线程看到的都是同一个值。- 访问方式 :外部只能通过
RtuConnect.Instance访问唯一实例,首次访问时才会真正执行new RtuConnect()。 - 构造函数:私有构造函数确保类不能从外部被实例化。
二、串口参数配置
与 PLC 通信需要配置串口的物理参数:
csharp
private const String PortName = "COM4";
private const int Bauderate = 9600;
private const int DataBits = 8;
private const StopBits StopBitsValue = StopBits.One;
private const Parity ParityValue = Parity.None;
| 参数 | 值 | 说明 |
|---|---|---|
| 端口号 | COM4 | 串口设备标识 |
| 波特率 | 9600 | 通信速率,需与 PLC 一致 |
| 数据位 | 8 | 每个字节的数据位数 |
| 停止位 | One | 帧结束标志位 |
| 校验位 | None | 无奇偶校验 |
这些参数需要和 PLC 的 485 接口设置保持一致。
三、打开串口与初始化主站
Open() 方法在程序启动时调用,负责创建串口并建立 Modbus RTU 主站。该方法内部使用 lock 保证线程安全。
csharp
public void Open()
{
lock (_lock)
{
if (_serialPort != null && _serialPort.IsOpen)
return;
try
{
_serialPort = new SerialPort(PortName, Bauderate, ParityValue, DataBits, StopBitsValue)
{
ReadTimeout = 1000, // 读取超时 1 秒
WriteTimeout = 1000 // 写入超时 1 秒
};
_serialPort.Open();
_master = ModbusSerialMaster.CreateRtu(_serialPort);
Console.WriteLine("Modbus RTU 通信建立成功");
}
catch (Exception ex)
{
// 打开失败时释放资源并抛出异常
_serialPort?.Dispose();
_serialPort = null;
_master = null;
throw new InvalidOperationException($"无法打开串口{PortName}:{ex.Message}", ex);
}
}
}
要点说明
| 要点 | 说明 |
|---|---|
| ReadTimeout / WriteTimeout | 防止因读写超时导致线程无限期阻塞 |
| ModbusSerialMaster.CreateRtu() | 创建 C# 层面的主站对象,封装了通过串口与 PLC 通信的细节 |
| 异常处理 | 使用 _serialPort?.Dispose(),仅在对象不为空时释放资源 |
四、关闭串口与资源清理
Close() 方法负责安全地关闭串口并释放资源。它同样需要加锁,并且在关闭前会先停止监控线程。
csharp
public void close()
{
lock (_lock)
{
StopMonitoring();
if (_serialPort != null && _serialPort.IsOpen)
{
try
{
_serialPort.Close();
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"通讯连接关闭失败:{ex.Message}");
}
finally
{
_serialPort.Dispose();
_serialPort = null;
_master = null;
}
}
}
}
执行流程
- 加锁 → 防止并发操作串口
- 停止监控 → 先调用
StopMonitoring()终止心跳线程 - 关闭串口 → 尝试
Close(),失败时记录调试日志 - 释放资源 →
Dispose()并置空所有引用
五、连接状态监控(心跳检测)
为了解决 USB 被意外拔出后程序无法感知的问题,我们加入了监控线程:定期尝试读取 PLC 的一个保持寄存器,如果读取失败则认为连接丢失,自动进行重连。
5.1 监控相关字段
csharp
private Thread _monitorThread;
private volatile bool _isMonitring;
private readonly int _monitorIntervalMs = 2000; // 检测间隔 2 秒
private const byte _testSlaveId = 1; // 测试用的从站地址
private const ushort _testRegisterAddress = 0; // 测试用的寄存器地址
5.2 启动与停止监控
csharp
public void StartMonitoring()
{
if (_isMonitring) return;
_isMonitring = true;
_monitorThread = new Thread(MonitorConnection)
{
IsBackground = true,
Name = "SerialMonitor"
};
_monitorThread.Start();
}
public void StopMonitoring()
{
_isMonitring = false;
if (_monitorThread != null && _monitorThread.IsAlive)
{
_monitorThread.Join(3000);
}
_monitorThread = null;
}
关键要点
| 要点 | 说明 |
|---|---|
| volatile 关键字 | _isMonitring 用 volatile 修饰,保证多线程下的可见性 |
| 后台线程 | IsBackground = true,当主程序退出时会被自动终止 |
| Thread.Join() | 先设置标志为 false,然后调用 Join(3000) 等待线程在 3 秒内自己退出 |
5.3 监控线程的执行逻辑
csharp
private void MonitorConnection()
{
while (_isMonitring)
{
Thread.Sleep(_monitorIntervalMs);
if (!_isMonitring) break;
bool connectionLost = false;
lock (_lock)
{
if (_serialPort == null || !_serialPort.IsOpen)
{
connectionLost = true;
}
else
{
try
{
_master.ReadHoldingRegisters(_testSlaveId, _testRegisterAddress, 1);
}
catch
{
connectionLost = true;
}
}
if (connectionLost)
{
// 清理旧连接
try
{
_serialPort?.Close();
}
catch { }
finally
{
_serialPort?.Dispose();
_serialPort = null;
_master = null;
}
// 尝试重连
try
{
_serialPort = new SerialPort(PortName, Bauderate, ParityValue, DataBits, StopBitsValue)
{
ReadTimeout = 1000,
WriteTimeout = 1000
};
_serialPort.Open();
_master = ModbusSerialMaster.CreateRtu(_serialPort);
Console.WriteLine("监控线程检测到断开,已自动重连成功");
}
catch (Exception e)
{
Console.WriteLine($"重连失败: {e.Message}");
_serialPort?.Dispose();
_serialPort = null;
_master = null;
}
}
}
}
}
循环逻辑说明
- 重置状态 :
connectionLost被重置为false。 - 检查串口:检查串口是否为空或未打开,是则标记连接丢失。
- 读取测试:若串口已打开,则尝试读取一个寄存器,失败也标记丢失。
- 自动重连:若连接丢失,先清理旧资源,再重新创建串口和主站。
- 线程安全 :整个过程在
lock(_lock)内完成,确保与后续的业务读写操作不冲突。
六、程序入口集成
在 Program.cs 的 Main 方法中,启动时打开通信并开启监控,退出时自动清理。
csharp
static void Main()
{
try
{
RtuConnect.Instance.Open();
RtuConnect.Instance.StartMonitoring(); // 启动监控
ApplicationConfiguration.Initialize();
MainManager.home = new Home();
Application.Run(MainManager.home); // 程序消息循环
}
catch (Exception e)
{
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.3 Thread.Join 的正确理解
Join(3000) 不是"等待 3 秒后杀死线程",而是等待线程结束,最多等 3 秒。如果 3 秒内线程执行完方法自然退出,Join 返回 true;超时后不再等待,线程仍继续运行。最终由后台线程的性质保证进程正常退出。
八、总结
通过这次实践,我掌握了:
- 设计模式 :C# 中线程安全的单例模式实现(
Lazy<T>) - 串口通信 :使用
System.IO.Ports.SerialPort进行串口通信配置 - Modbus 协议:NModbus 库的 RTU 主站建立
- 多线程编程:用后台线程实现连接状态监控与自动重连
- 线程安全:锁与线程同步在通信场景中的正确使用
- 资源管理:程序生命周期中资源清理的可靠方式
这套设计使我的 WinForms 程序能够稳定地与 PLC 保持通信,并具备掉线自动恢复的能力。