本文是我在学习 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.GetBytes 和 ToSingle 配合完成字节与浮点数的转换。
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 通信的每一步,欢迎参考与交流。