C# WinForms 后台轮询 Modbus 数据与 UI 更新实践

本文是我在学习 C# 与 PLC Modbus RTU 通信过程中的第二篇记录。

主要内容:在 WinForms 主窗体中创建后台轮询线程,循环读取 PLC 保持寄存器并更新 UI,以及如何安全退出线程、避免进程残留。


目录


一、轮询线程的整体设计

在主窗体的 Home_Load 事件中启动一个后台线程,专门负责与 PLC 通信、读取数据并刷新界面。

这样做的好处是:串口通信的阻塞操作不会卡死 UI,用户操作依然流畅。

1.1 相关字段声明

csharp 复制代码
// 后台轮询线程
private Thread _pollThread;

// 用于优雅取消线程的令牌源
private CancellationTokenSource _cts;

// 串口访问锁,保证同一时间只有一个线程访问串口
private static readonly object _modbusLock = new object();

二、启动轮询线程:Home_Load

csharp 复制代码
private void Home_Load(object sender, EventArgs e)
{
    // 检查 Modbus 主站是否已初始化
    if (RtuConnect._master == null)
    {
        MessageBox.Show("通信未打开");
        return;
    }

    // 创建取消令牌源
    _cts = new CancellationTokenSource();

    // 创建线程,指定方法为 PollModbusData
    _pollThread = new Thread(PollModbusData);
    _pollThread.IsBackground = true;   // 设为后台线程,窗口关闭后自动终止

    // 启动线程,将取消令牌作为参数传入
    _pollThread.Start(_cts.Token);
}

关键点解释:

  • IsBackground = true 是防止进程残留的关键。即使轮询线程仍在运行,只要主窗口关闭,前台线程结束,后台线程就会被系统强制回收。
  • CancellationToken 传给线程方法,以便在线程内部检测取消信号,实现协作式退出。

三、轮询方法核心逻辑:PollModbusData

csharp 复制代码
private void PollModbusData(object token)
{
    // 从参数中获取取消令牌
    CancellationToken ct = (CancellationToken)token;
    byte slaveId = ReadRealData.DEFAULT_SLAVE_ID;

    // 只要未请求取消,就持续循环
    while (!ct.IsCancellationRequested)
    {
        try
        {
            float[] Arr_Pos = new float[3];
            float[] Arr_Vel = new float[3];

            // 加锁,防止与其他线程同时访问串口(例如监控线程)
            lock (_modbusLock)
            {
                // 读取位置(地址 54,连续 6 个寄存器,3 个 float)
                ushort[] posReg = RtuConnect._master.ReadHoldingRegisters(slaveId, 54, 6);
                Arr_Pos[0] = ConvertRegistersToFloat(posReg, 0);
                Arr_Pos[1] = ConvertRegistersToFloat(posReg, 2);
                Arr_Pos[2] = ConvertRegistersToFloat(posReg, 4);

                // 读取速度(地址 80,连续 6 个寄存器)
                ushort[] velReg = RtuConnect._master.ReadHoldingRegisters(slaveId, 80, 6);
                Arr_Vel[0] = ConvertRegistersToFloat(velReg, 0);
                Arr_Vel[1] = ConvertRegistersToFloat(velReg, 2);
                Arr_Vel[2] = ConvertRegistersToFloat(velReg, 4);
            }

            // 回到 UI 线程更新控件
            this.Invoke(new Action(() =>
            {
                In_Curr_Pos1.Text = Arr_Pos[0].ToString("0.##") + "°";
                In_Curr_Pos2.Text = Arr_Pos[1].ToString("0.##") + "°";
                In_Curr_Pos3.Text = Arr_Pos[2].ToString("0.##") + "°";
                In_Curr_Vel1.Text = Arr_Vel[0].ToString("0.##") + "°";
                In_Curr_Vel2.Text = Arr_Vel[1].ToString("0.##") + "°";
                In_Curr_Vel3.Text = Arr_Vel[2].ToString("0.##") + "°";
            }));

            // 轮询间隔 100ms
            Thread.Sleep(100);
        }
        catch (Exception ex)
        {
            // 如果通信出错,在 UI 上显示错误信息,并稍作等待避免疯狂重试
            this.Invoke(new Action(() =>
            {
                labelStatus.Text = ex.Message;
            }));
            Thread.Sleep(500);
        }
    }
}

3.1 各部分说明

取消令牌协作退出

循环条件 !ct.IsCancellationRequested 确保一旦外部调用 _cts.Cancel(),循环就会结束,线程会自动退出。

浮点数转换方法 ConvertRegistersToFloat

csharp 复制代码
private float ConvertRegistersToFloat(ushort[] regs, int startIndex)
{
    // 低字在前:regs[startIndex] 是低16位,regs[startIndex+1] 是高16位
    uint combined = ((uint)regs[startIndex + 1] << 16) | regs[startIndex];
    byte[] bytes = BitConverter.GetBytes(combined);
    return BitConverter.ToSingle(bytes, 0);
}

很多 PLC 采用"低字在前"的排列方式(例如地址 54 放低 16 位,55 放高 16 位),而系统是小端模式,因此需要将高地址的寄存器左移 16 位后与低地址寄存器合并,再转为 float。

BitConverter.GetBytesToSingle 配合完成字节与浮点数的转换。

lock (_modbusLock) 锁的作用
RtuConnect 类中的监控线程也会访问串口(读取测试寄存器),与本轮询线程共享同一个串口对象。

使用静态锁 _modbusLock 可以保证同一时刻只有一个线程在进行串口读写,避免冲突。

UI 跨线程更新:this.Invoke

后台线程不能直接操作 UI 控件,必须通过 Invoke 将操作封送到主 UI 线程。
new Action(() => { ... }) 是匿名方法,简洁明了。

格式化字符串 "0.##" 的精确含义

  • 至少显示一位整数(即使是 0)。
  • 小数部分最多显示两位,末尾的零自动省略。
    例:12.30 → "12.3",12.00 → "12",0.456 → "0.46"。

异常处理

catch 中把异常消息显示到状态标签 labelStatus,同样通过 Invoke 执行。

出错后 Sleep(500) 避免通信故障时连续高速重试。

四、线程的安全退出:FormClosing

csharp 复制代码
// 构造函数中注册事件
public Home()
{
    InitializeComponent();
    this.FormClosing += HomeFormClosing;
}

// 窗体关闭时的处理
private void HomeFormClosing(Object sender, FormClosingEventArgs e)
{
    _cts?.Cancel();            // 通知轮询线程取消
    _pollThread?.Join(1000);   // 等待轮询线程退出,最多等 1 秒
    // 不需要调用 Application.Exit()
}

设计意图和注意事项:

  • _cts?.Cancel() 发出取消信号,轮询线程会在下一次循环条件检查时退出。
  • _pollThread?.Join(1000) 阻塞 UI 线程最多 1 秒,等待轮询线程自己结束。这是为了给线程一个"收尾"的机会,避免它在访问已被销毁的控件时产生异常。
  • 不再需要 Application.Exit()。主窗体关闭后,Application.Run 会自然返回,主线程执行 finally 块清理通信资源后退出,进程结束。若轮询线程未能在 1 秒内退出,由于它是后台线程(IsBackground = true),也会被系统强制终止,不会有进程残留。

关于 Application.Exit() 的错误认知:

曾以为不加 Application.Exit() 程序会一直运行,实际上主窗体关闭后 Application.Run 就返回了,主线程自然结束。过去遇到"任务管理器残留进程"是因为轮询线程未设为后台线程(默认是前台线程),即使窗口关了,前台线程仍会阻止进程退出。现在通过 IsBackground = true 解决了此问题。

五、总结

通过本篇实践,我掌握了:

  • 如何在 WinForms 中使用后台线程执行周期性的通信任务
  • 利用 CancellationToken 实现优雅的线程退出
  • 理解前台线程与后台线程的区别,并通过设置 IsBackground = true 防止进程残留
  • 使用 lock 保护共享串口资源,避免多线程冲突
  • 跨线程安全更新 UI 的 Invoke 模式
  • Modbus 保持寄存器中多字节浮点数的解析(低字在前 + 小端转换)

至此,我的 C# 与 PLC 通信程序已具备:

  • 稳定的串口连接管理(含自动重连监控)
  • 后台实时数据轮询
  • 安全、无残留的程序退出机制

本系列持续记录我学习 C# 与 Modbus RTU 通信的每一步,欢迎参考与交流。

相关推荐
iiiiyu14 小时前
面向对象案例
java·大数据·开发语言·数据结构·python·编程语言
晚风叙码14 小时前
从0吃透C++入门|第一个程序、命名空间与缺省函数基础
开发语言·c++
j_xxx404_14 小时前
Linux线程:核心机制与优雅的 C++ 封装实践|附源码
linux·运维·服务器·开发语言·c++·人工智能·ai
qingyulee14 小时前
机器学习概述、KNN算法
开发语言·python·机器学习
mohaoyuan14 小时前
软考架构师知识点汇总
开发语言·架构
Zhang~Ling14 小时前
C++ 模板进阶:非类型参数、特化与分离编译深度解析
开发语言·c++
Oj92q85H514 小时前
如何在Dev-C++中使用TDM-GCC编译项目
linux·开发语言·c++
Chase_______14 小时前
【Java】String 常量池、== 与 equals 详解:从引用比较到 intern() 一次讲清
java·开发语言
QCzblack14 小时前
期中考复现
开发语言·python