C#开发核心知识

C#开发核心

一、C# 线程

什么是线程?

线程可以理解为:

程序中实际执行代码的一条执行路径。

一个 C# 程序启动以后,至少会有一个主线程。

例如 WinForms/WPF 上位机:

Text
1
2
3
4
5
6
7
8
程序
│
└── 主线程
│
├── 创建窗口
├── 显示控件
├── 响应鼠标
└── 响应按钮

如果所有工作都放到主线程中执行,那么一个耗时操作就可能导致界面卡顿。

例如:

Text
1
2
3
4
5
6
while (true)
{
ReadModbus();

Thread.Sleep(1000);
}

如果这个代码运行在 UI 线程,那么:

Text
1
2
3
4
5
6
7
8
9
读取设备
↓
等待设备响应
↓
读取设备
↓
等待
↓
UI无法响应

最终表现就是:

上位机界面卡死。


上位机为什么需要线程?

工业上位机通常需要一直运行。

例如需要每隔 1 秒读取:

Text
1
2
3
4
5
6
冷却塔
├── 温度
├── 压力
├── 流量
├── 风机频率
└── 运行状态

同时用户还要能够:

Text
1
2
3
4
5
6
查看数据
修改参数
启动设备
停止设备
查看报警
切换页面

所以不能让设备通信阻塞 UI。

一般设计为:

Text
1
2
3
4
5
6
7
8
9
10
11
12
C#上位机
│
┌─────────┴─────────┐
↓ ↓
UI线程 后台线程
│ │
页面显示 Modbus
用户操作 串口通信
按钮事件 数据采集
│
↓
数据解析

Thread 的使用

传统 C# 可以使用 Thread:

Text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Thread thread = new Thread(ReadDevice);

thread.IsBackground = true;

thread.Start();

void ReadDevice()
{
while (true)
{
Console.WriteLine("读取设备数据");

Thread.Sleep(1000);
}
}

其中:

Text
1
thread.Start();

表示启动线程。

Text
1
Thread.Sleep(1000);

表示当前线程暂停 1 秒。


Task

现在 .NET 项目中更常见的是 Task:

Text
1
2
3
4
5
6
7
8
9
Task.Run(() =>
{
while (true)
{
ReadDevice();

Thread.Sleep(1000);
}
});

异步场景可以使用:

Text
1
2
3
4
5
6
7
8
9
Task.Run(async () =>
{
while (true)
{
await ReadDeviceAsync();

await Task.Delay(1000);
}
});

现代 C# 中可以简单理解为:

Text
1
2
3
4
5
6
7
8
9
Thread
↓
直接管理线程

Task
↓
更高级的任务模型
↓
通常更推荐

上位机中的线程使用场景

例如:

Text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
主线程
↓
显示冷却塔监控界面

后台Task
↓
读取PLC
↓
读取冷却塔
↓
读取变频器
↓
解析数据
↓
判断报警

这样设备通信不会阻塞 UI。


二、C# 委托 Delegate

什么是委托?

委托可以简单理解为:

可以保存方法引用的类型。

例如:

Text
1
2
3
4
void PrintMessage(string message)
{
Console.WriteLine(message);
}

定义一个委托:

Text
1
delegate void MyDelegate(string message);

然后:

Text
1
2
3
MyDelegate d = PrintMessage;

d("Hello");

本质上相当于:

Text
1
2
3
d
↓
PrintMessage()

所以调用:

Text
1
d("Hello");

就相当于:

Text
1
PrintMessage("Hello");

为什么上位机需要委托?

这里是上位机开发非常重要的一个场景:

后台线程不能直接操作 UI 控件。

例如:

Text
1
2
3
4
Task.Run(() =>
{
labelTemperature.Text = "30℃";
});

在 WinForms/WPF 中可能产生跨线程访问问题。

正确思路是:

Text
1
2
3
4
5
6
7
8
9
10
11
后台线程
↓
读取设备
↓
温度 = 30℃
↓
委托
↓
UI线程
↓
更新Label

Invoke

WinForms 中经常看到:

Text
1
2
3
4
this.Invoke(new Action(() =>
{
labelTemperature.Text = "30℃";
}));

这里的:

Text
1
Action

就是一个委托。

意思是:

把这段 UI 操作交给 UI 线程执行。


Action

Action 表示:

没有返回值的委托。

例如:

Text
1
2
3
4
5
6
Action action = () =>
{
Console.WriteLine("Hello");
};

action();

也可以带参数:

Text
1
2
3
4
5
6
Action<string> action = message =>
{
Console.WriteLine(message);
};

action("设备连接成功");

上位机中例如:

Text
1
2
3
4
Action<double> updateTemperature = temperature =>
{
labelTemperature.Text = $"{temperature}℃";
};

Func

Func 表示:

有返回值的委托。

例如:

Text
1
2
3
4
5
6
Func<int, int> func = x =>
{
return x * 2;
};

int result = func(10);

结果:

Text
1
20

简单记:

Text
1
2
3
4
5
6
7
Action
↓
无返回值

Func
↓
有返回值

Event 事件

C# 的事件也是建立在委托基础上的。

例如定义设备温度变化事件:

Text
1
public event Action<double> TemperatureChanged;

读取到新的温度:

Text
1
TemperatureChanged?.Invoke(30.5);

UI 层订阅:

Text
1
2
3
4
device.TemperatureChanged += temperature =>
{
labelTemperature.Text = $"{temperature}℃";
};

于是:

Text
1
2
3
4
5
6
7
8
9
设备通信
↓
获取温度30.5℃
↓
触发TemperatureChanged
↓
UI收到事件
↓
更新界面

这种设计非常适合上位机。


三、C# 数值转换

数值转换在普通 C# 开发中很常见。

但是对于工业上位机来说,它的重要性更高。

因为设备通信返回的数据通常是:

Text
1
byte[]

而业务需要的是:

Text
1
2
3
4
5
6
7
温度
压力
流量
频率
电流
电压
功率

所以需要:

Text
1
2
3
4
5
6
7
8
9
byte[]
↓
字节解析
↓
整数
↓
浮点数
↓
实际工程值

Parse

字符串转换成整数:

Text
1
int value = int.Parse("123");

字符串转换成浮点:

Text
1
double value = double.Parse("12.5");

但是:

Text
1
int.Parse("abc");

会抛出异常。


TryParse

实际项目更推荐:

Text
1
2
3
4
if (int.TryParse("123", out int value))
{
Console.WriteLine(value);
}

如果转换失败:

Text
1
bool result = int.TryParse("abc", out int value);

结果:

Text
1
result = false

不会直接抛出异常。

所以:

对用户输入、配置文件、设备数据等不确定数据,优先考虑 TryParse。


Convert

也可以使用:

Text
1
2
3
int value = Convert.ToInt32("123");

double temperature = Convert.ToDouble("30.5");

强制类型转换

例如:

Text
1
2
3
double temperature = 30.8;

int value = (int)temperature;

结果:

Text
1
30

小数部分被截掉。


十六进制转换

工业通信中经常看到:

Text
1
01 03 00 00 00 02

这些实际上是十六进制数据。

例如:

Text
1
2
3
byte value = 0x1A;

string hex = value.ToString("X2");

得到:

Text
1
1A

数组:

Text
1
2
3
4
5
6
7
8
9
byte[] data =
{
0x01,
0x03,
0x00,
0x10
};

string hex = BitConverter.ToString(data);

结果:

Text
1
01-03-00-10

调试 Modbus 时非常常见。


byte[] 转数值

例如:

Text
1
2
3
4
5
byte[] data =
{
0x2C,
0x01
};

可以使用:

Text
1
short value = BitConverter.ToInt16(data, 0);

但是工业通信必须特别注意:

字节序(Byte Order)。

因为设备协议规定的数据顺序不一定与 C# 默认转换方式一致。

例如 Modbus 中经常需要处理:

Text
1
2
高字节
低字节

甚至:

Text
1
2
3
4
ABCD
CDAB
BADC
DCBA

等不同字节/寄存器排列方式。


Modbus 中的数值转换

例如设备返回:

Text
1
01 03 02 01 2C

可以理解为:

Text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
01
↓
设备地址

03
↓
功能码

02
↓
数据长度

01 2C
↓
寄存器数据

:

Text
1
0x012C

转换成十进制:

Text
1
300

如果设备协议规定:

Text
1
实际温度 = 寄存器值 / 10

那么:

Text
1
double temperature = 300 / 10.0;

得到:

Text
1
30℃

这就是工业协议中非常典型的:

原始值 → 工程值


四、C# 串口操作

什么是串口?

串口是一种设备通信方式。

工业现场常见:

Text
1
2
RS232
RS485

其中工业设备中非常常见的是:

RS485 + Modbus RTU

例如:

Text
1
2
3
4
5
6
7
8
9
10
11
C#上位机
│
COM3
│
RS485
│
├── PLC
├── 变频器
├── 电表
├── 温度仪表
└── 冷却塔控制器

SerialPort

C# 提供:

Text
1
using System.IO.Ports;

创建串口:

Text
1
SerialPort serialPort = new SerialPort();

配置:

Text
1
2
3
4
5
serialPort.PortName = "COM3";
serialPort.BaudRate = 9600;
serialPort.DataBits = 8;
serialPort.StopBits = StopBits.One;
serialPort.Parity = Parity.None;

典型配置:

Text
1
2
3
4
波特率:9600
数据位:8
校验位:None
停止位:1

也就是常说:

Text
1
9600 8N1

打开串口

Text
1
2
3
4
if (!serialPort.IsOpen)
{
serialPort.Open();
}

关闭:

Text
1
serialPort.Close();

发送数据

例如发送 Modbus 请求:

Text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
byte[] request =
{
0x01,
0x03,
0x00,
0x00,
0x00,
0x02
};

serialPort.Write(
request,
0,
request.Length
);

实际 Modbus RTU 报文还需要 CRC:

Text
1
01 03 00 00 00 02 CRC CRC

接收数据

可以读取:

Text
1
2
3
4
5
6
7
byte[] buffer = new byte[256];

int length = serialPort.Read(
buffer,
0,
buffer.Length
);

然后:

Text
1
2
byte[] response =
buffer.Take(length).ToArray();

这里已经出现了:

Text
1
2
3
4
5
SerialPort
↓
byte[]
↓
LINQ

DataReceived

SerialPort 也可以监听数据:

Text
1
serialPort.DataReceived += SerialPort_DataReceived;

处理:

Text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void SerialPort_DataReceived(
object sender,
SerialDataReceivedEventArgs e)
{
int count = serialPort.BytesToRead;

byte[] buffer = new byte[count];

serialPort.Read(
buffer,
0,
count
);
}

流程:

Text
1
2
3
4
5
6
7
8
9
设备返回
↓
串口收到数据
↓
DataReceived
↓
读取byte[]
↓
解析Modbus

DataReceived 的重要问题

不能简单认为:

Text
1
2
3
DataReceived
=
一条完整报文

例如设备应该返回:

Text
1
01 03 02 01 2C CRC CRC

第一次可能收到:

Text
1
01 03 02

第二次:

Text
1
01 2C CRC CRC

所以实际工业通信通常需要:

Text
1
2
3
4
5
6
7
8
9
10
11
串口接收
↓
接收缓存
↓
不断追加数据
↓
判断完整报文
↓
CRC校验
↓
解析

这是实际做 Modbus 时非常重要的知识。


五、LINQ

什么是 LINQ?

LINQ:

Language Integrated Query

可以理解为:

C# 对集合数据进行查询、过滤、排序、统计的一套语法和 API。

例如:

Text
1
2
3
4
List<int> numbers = new List<int>
{
10, 20, 30, 40, 50
};

查询大于 20:

Text
1
2
3
var result = numbers
.Where(x => x > 20)
.ToList();

得到:

Text
1
2
3
30
40
50

Where

过滤数据:

Text
1
2
3
var alarms = devices
.Where(x => x.IsAlarm)
.ToList();

例如:

Text
1
2
3
4
var highTemperatureDevices =
devices
.Where(x => x.Temperature > 80)
.ToList();

表示:

找出温度超过 80℃ 的设备。


Select

Select 主要用于数据转换。

例如:

Text
1
2
3
var names = devices
.Select(x => x.Name)
.ToList();

原来:

Text
1
2
3
Device
Device
Device

转换成:

Text
1
2
3
设备名称
设备名称
设备名称

OrderBy

升序:

Text
1
2
3
var result = devices
.OrderBy(x => x.Temperature)
.ToList();

降序:

Text
1
2
3
var result = devices
.OrderByDescending(x => x.Temperature)
.ToList();

例如:

Text
1
2
3
冷却塔1  30℃
冷却塔2 35℃
冷却塔3 28℃

按照温度降序:

Text
1
2
3
冷却塔2
冷却塔1
冷却塔3

FirstOrDefault

查询第一个符合条件的对象:

Text
1
2
var device = devices
.FirstOrDefault(x => x.Id == 1001);

如果没有找到:

Text
1
null

因此实际开发中通常比直接使用:

Text
1
First()

更安全。


Any

判断是否存在:

Text
1
2
bool hasAlarm = devices
.Any(x => x.IsAlarm);

例如:

Text
1
2
bool highTemperature =
devices.Any(x => x.Temperature > 80);

表示:

有没有设备温度超过 80℃?

在报警系统中非常实用。


Count

统计数量:

Text
1
int count = devices.Count();

统计报警设备:

Text
1
2
int alarmCount =
devices.Count(x => x.IsAlarm);

Max、Min、Average

最高温度:

Text
1
2
double max =
devices.Max(x => x.Temperature);

最低温度:

Text
1
2
double min =
devices.Min(x => x.Temperature);

平均温度:

Text
1
2
double avg =
devices.Average(x => x.Temperature);

在节能系统中就非常有意义。

例如:

Text
1
2
3
冷却塔1:30℃
冷却塔2:32℃
冷却塔3:31℃

计算平均温度:

Text
1
2
double avg =
towers.Average(x => x.Temperature);

得到:

Text
1
31℃

GroupBy

可以根据某个属性进行分组:

Text
1
2
var result = devices
.GroupBy(x => x.Status);

例如设备状态:

Text
1
2
3
4
5
运行
运行
停止
故障
运行

分组之后:

Text
1
2
3
运行:3台
停止:1台
故障:1台

非常适合做:

设备监控统计。


六、五项技术如何组合到上位机项目中

假设现在开发一个:

冷却塔科技节能监控系统

系统需要实时读取:

Text
1
2
3
4
5
6
7
冷却塔
├── 温度
├── 压力
├── 流量
├── 电流
├── 风机频率
└── 运行状态

整个技术流程可以理解为:

Text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
C#上位机
│
┌─────────┴─────────┐
│ │
UI线程 后台Task
│ │
显示设备数据 │
用户操作 │
↓
SerialPort
↓
RS485
↓
Modbus RTU
↓
byte[]
↓
CRC + 报文解析
↓
数值转换
↓
Device对象
↓
LINQ
↓
┌────────────┼───────────┐
↓ ↓ ↓
Where Any Average
↓ ↓ ↓
报警设备 是否报警 平均温度
│ │ │
└────────────┴───────────┘
↓
委托
↓
Invoke
↓
UI线程
↓
更新监控界面

七、一个简化的实际代码结构

可以把它抽象成:

Text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
Task.Run(() =>
{
while (true)
{
// 1. Modbus请求
byte[] request = BuildRequest();

// 2. 串口发送
serialPort.Write(
request,
0,
request.Length
);

// 3. 接收数据
byte[] response = ReadResponse();

// 4. 解析数据
Device device = ParseResponse(response);

// 5. 数据转换
device.Temperature =
device.RawTemperature / 10.0;

// 6. 更新设备集合
devices.Add(device);

// 7. LINQ判断报警
bool hasAlarm =
devices.Any(x => x.IsAlarm);

// 8. 委托切换UI线程
Invoke(new Action(() =>
{
labelTemperature.Text =
$"{device.Temperature}℃";

labelAlarm.Text =
hasAlarm ? "有报警" : "正常";
}));

Thread.Sleep(1000);
}
});

简化版本

Text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Thread / Task
↓
后台采集

SerialPort
↓
设备通信

byte[]
↓
数据接收

数值转换
↓
工程数据

LINQ
↓
数据处理

Delegate / Action / Invoke
↓
UI线程更新

八、上位机开发中各技术的定位

九、最重要概念:

① 线程

为什么需要?

因为设备通信不能阻塞 UI。

Text
1
2
3
UI线程
+
后台通信线程

② 委托

为什么需要?

因为后台线程采集完数据以后,需要通知 UI 线程更新。

Text
1
后台线程 → 委托 → UI线程

③ 数值转换

为什么需要?

因为工业设备传过来的通常是二进制:

Text
1
byte[]

而业务需要:

Text
1
2
3
4
30℃
0.5MPa
25Hz
100A

④ 串口

干什么?

负责真正把数据发给/接收自现场设备。

Text
1
2
3
4
5
6
7
8
9
C#
↓
SerialPort
↓
RS485
↓
Modbus RTU
↓
PLC/设备

⑤ LINQ

干什么?

设备数据拿回来以后,对数据进行处理。

Text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
设备数据
↓
Where
↓
过滤报警设备

设备数据
↓
Average
↓
计算平均值

设备数据
↓
OrderBy
↓
排序

十、上位机开发思维

可以优先按照这个思路去理解:

Text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
第一步:数据从哪里来?
↓
SerialPort
↓
Modbus RTU

第二步:数据是什么?
↓
byte[]
↓
数值转换

第三步:谁负责读取?
↓
Thread / Task

第四步:数据怎么通知界面?
↓
Delegate / Event
↓
Invoke

第五步:数据怎么处理?
↓
LINQ
↓
Where / Select / Any
Count / Max / Average