C# 与 PLC Modbus RTU 通信实践:从单例到线程安全的连接监控

本文记录了我学习用 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;
            }
        }
    }
}

执行流程

  1. 加锁 → 防止并发操作串口
  2. 停止监控 → 先调用 StopMonitoring() 终止心跳线程
  3. 关闭串口 → 尝试 Close(),失败时记录调试日志
  4. 释放资源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 关键字 _isMonitringvolatile 修饰,保证多线程下的可见性
后台线程 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;
                }
            }
        }
    }
}
循环逻辑说明
  1. 重置状态connectionLost 被重置为 false
  2. 检查串口:检查串口是否为空或未打开,是则标记连接丢失。
  3. 读取测试:若串口已打开,则尝试读取一个寄存器,失败也标记丢失。
  4. 自动重连:若连接丢失,先清理旧资源,再重新创建串口和主站。
  5. 线程安全 :整个过程在 lock(_lock) 内完成,确保与后续的业务读写操作不冲突。

六、程序入口集成

Program.csMain 方法中,启动时打开通信并开启监控,退出时自动清理。

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();             // 关闭通信并停止监控
    }
}

执行顺序说明

  1. 阻塞等待Application.Run 会阻塞当前线程,直到窗体关闭。
  2. 资源回收 :窗机关闭后,finally 块执行,调用 Close() 完成资源回收。
  3. 安全退出 :即使 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 保持通信,并具备掉线自动恢复的能力。

相关推荐
不负岁月无痕9 小时前
STL-- C++ vector类 模拟实现
开发语言·c++
晚烛9 小时前
CANN 分布式通信与 HCCL:多 NPU 协作的底层机制
开发语言·人工智能·分布式·python·深度学习
装不满的克莱因瓶9 小时前
新版AI开发框架SpringAIAlibaba vs AgentScope 选型指南
java·开发语言·人工智能·ai·agent·alibaba·springai
雾酩9 小时前
深拷贝与浅拷贝:一篇彻底讲明白的入门博客
开发语言·前端·javascript
丘山望岳9 小时前
C++模板特化:类型与常量的灵活掌控
c语言·开发语言·c++
阿里嘎多学长9 小时前
2026-05-24 GitHub 热点项目精选
开发语言·程序员·github·代码托管
凯瑟琳.奥古斯特9 小时前
原码与补码乘法符号位处理差异
java·开发语言·职场和发展
iiiiyu9 小时前
面向对象案例
java·大数据·开发语言·数据结构·python·编程语言
Chris _data9 小时前
C# WinForms 后台轮询 Modbus 数据与 UI 更新实践
开发语言·ui·c#