C#开发核心
一、C# 线程
什么是线程?
线程可以理解为:
程序中实际执行代码的一条执行路径。
一个 C# 程序启动以后,至少会有一个主线程。
例如 WinForms/WPF 上位机:
1 | 程序 |
如果所有工作都放到主线程中执行,那么一个耗时操作就可能导致界面卡顿。
例如:
1 | while (true) |
如果这个代码运行在 UI 线程,那么:
1 | 读取设备 |
最终表现就是:
上位机界面卡死。
上位机为什么需要线程?
工业上位机通常需要一直运行。
例如需要每隔 1 秒读取:
1 | 冷却塔 |
同时用户还要能够:
1 | 查看数据 |
所以不能让设备通信阻塞 UI。
一般设计为:
1 | C#上位机 |
Thread 的使用
传统 C# 可以使用 Thread:
1 | Thread thread = new Thread(ReadDevice); |
其中:
1 | thread.Start(); |
表示启动线程。
1 | Thread.Sleep(1000); |
表示当前线程暂停 1 秒。
Task
现在 .NET 项目中更常见的是 Task:
1 | Task.Run(() => |
异步场景可以使用:
1 | Task.Run(async () => |
现代 C# 中可以简单理解为:
1 | Thread |
上位机中的线程使用场景
例如:
1 | 主线程 |
这样设备通信不会阻塞 UI。
二、C# 委托 Delegate
什么是委托?
委托可以简单理解为:
可以保存方法引用的类型。
例如:
1 | void PrintMessage(string message) |
定义一个委托:
1 | delegate void MyDelegate(string message); |
然后:
1 | MyDelegate d = PrintMessage; |
本质上相当于:
1 | d |
所以调用:
1 | d("Hello"); |
就相当于:
1 | PrintMessage("Hello"); |
为什么上位机需要委托?
这里是上位机开发非常重要的一个场景:
后台线程不能直接操作 UI 控件。
例如:
1 | Task.Run(() => |
在 WinForms/WPF 中可能产生跨线程访问问题。
正确思路是:
1 | 后台线程 |
Invoke
WinForms 中经常看到:
1 | this.Invoke(new Action(() => |
这里的:
1 | Action |
就是一个委托。
意思是:
把这段 UI 操作交给 UI 线程执行。
Action
Action 表示:
没有返回值的委托。
例如:
1 | Action action = () => |
也可以带参数:
1 | Action<string> action = message => |
上位机中例如:
1 | Action<double> updateTemperature = temperature => |
Func
Func 表示:
有返回值的委托。
例如:
1 | Func<int, int> func = x => |
结果:
1 | 20 |
简单记:
1 | Action |
Event 事件
C# 的事件也是建立在委托基础上的。
例如定义设备温度变化事件:
1 | public event Action<double> TemperatureChanged; |
读取到新的温度:
1 | TemperatureChanged?.Invoke(30.5); |
UI 层订阅:
1 | device.TemperatureChanged += temperature => |
于是:
1 | 设备通信 |
这种设计非常适合上位机。
三、C# 数值转换
数值转换在普通 C# 开发中很常见。
但是对于工业上位机来说,它的重要性更高。
因为设备通信返回的数据通常是:
1 | byte[] |
而业务需要的是:
1 | 温度 |
所以需要:
1 | byte[] |
Parse
字符串转换成整数:
1 | int value = int.Parse("123"); |
字符串转换成浮点:
1 | double value = double.Parse("12.5"); |
但是:
1 | int.Parse("abc"); |
会抛出异常。
TryParse
实际项目更推荐:
1 | if (int.TryParse("123", out int value)) |
如果转换失败:
1 | bool result = int.TryParse("abc", out int value); |
结果:
1 | result = false |
不会直接抛出异常。
所以:
对用户输入、配置文件、设备数据等不确定数据,优先考虑
TryParse。
Convert
也可以使用:
1 | int value = Convert.ToInt32("123"); |
强制类型转换
例如:
1 | double temperature = 30.8; |
结果:
1 | 30 |
小数部分被截掉。
十六进制转换
工业通信中经常看到:
1 | 01 03 00 00 00 02 |
这些实际上是十六进制数据。
例如:
1 | byte value = 0x1A; |
得到:
1 | 1A |
数组:
1 | byte[] data = |
结果:
1 | 01-03-00-10 |
调试 Modbus 时非常常见。
byte[] 转数值
例如:
1 | byte[] data = |
可以使用:
1 | short value = BitConverter.ToInt16(data, 0); |
但是工业通信必须特别注意:
字节序(Byte Order)。
因为设备协议规定的数据顺序不一定与 C# 默认转换方式一致。
例如 Modbus 中经常需要处理:
1 | 高字节 |
甚至:
1 | ABCD |
等不同字节/寄存器排列方式。
Modbus 中的数值转换
例如设备返回:
1 | 01 03 02 01 2C |
可以理解为:
1 | 01 |
:
1 | 0x012C |
转换成十进制:
1 | 300 |
如果设备协议规定:
1 | 实际温度 = 寄存器值 / 10 |
那么:
1 | double temperature = 300 / 10.0; |
得到:
1 | 30℃ |
这就是工业协议中非常典型的:
原始值 → 工程值
四、C# 串口操作
什么是串口?
串口是一种设备通信方式。
工业现场常见:
1 | RS232 |
其中工业设备中非常常见的是:
RS485 + Modbus RTU
例如:
1 | C#上位机 |
SerialPort
C# 提供:
1 | using System.IO.Ports; |
创建串口:
1 | SerialPort serialPort = new SerialPort(); |
配置:
1 | serialPort.PortName = "COM3"; |
典型配置:
1 | 波特率:9600 |
也就是常说:
1 | 9600 8N1 |
打开串口
1 | if (!serialPort.IsOpen) |
关闭:
1 | serialPort.Close(); |
发送数据
例如发送 Modbus 请求:
1 | byte[] request = |
实际 Modbus RTU 报文还需要 CRC:
1 | 01 03 00 00 00 02 CRC CRC |
接收数据
可以读取:
1 | byte[] buffer = new byte[256]; |
然后:
1 | byte[] response = |
这里已经出现了:
1 | SerialPort |
DataReceived
SerialPort 也可以监听数据:
1 | serialPort.DataReceived += SerialPort_DataReceived; |
处理:
1 | private void SerialPort_DataReceived( |
流程:
1 | 设备返回 |
DataReceived 的重要问题
不能简单认为:
1 | DataReceived |
例如设备应该返回:
1 | 01 03 02 01 2C CRC CRC |
第一次可能收到:
1 | 01 03 02 |
第二次:
1 | 01 2C CRC CRC |
所以实际工业通信通常需要:
1 | 串口接收 |
这是实际做 Modbus 时非常重要的知识。
五、LINQ
什么是 LINQ?
LINQ:
Language Integrated Query
可以理解为:
C# 对集合数据进行查询、过滤、排序、统计的一套语法和 API。
例如:
1 | List<int> numbers = new List<int> |
查询大于 20:
1 | var result = numbers |
得到:
1 | 30 |
Where
过滤数据:
1 | var alarms = devices |
例如:
1 | var highTemperatureDevices = |
表示:
找出温度超过 80℃ 的设备。
Select
Select 主要用于数据转换。
例如:
1 | var names = devices |
原来:
1 | Device |
转换成:
1 | 设备名称 |
OrderBy
升序:
1 | var result = devices |
降序:
1 | var result = devices |
例如:
1 | 冷却塔1 30℃ |
按照温度降序:
1 | 冷却塔2 |
FirstOrDefault
查询第一个符合条件的对象:
1 | var device = devices |
如果没有找到:
1 | null |
因此实际开发中通常比直接使用:
1 | First() |
更安全。
Any
判断是否存在:
1 | bool hasAlarm = devices |
例如:
1 | bool highTemperature = |
表示:
有没有设备温度超过 80℃?
在报警系统中非常实用。
Count
统计数量:
1 | int count = devices.Count(); |
统计报警设备:
1 | int alarmCount = |
Max、Min、Average
最高温度:
1 | double max = |
最低温度:
1 | double min = |
平均温度:
1 | double avg = |
在节能系统中就非常有意义。
例如:
1 | 冷却塔1:30℃ |
计算平均温度:
1 | double avg = |
得到:
1 | 31℃ |
GroupBy
可以根据某个属性进行分组:
1 | var result = devices |
例如设备状态:
1 | 运行 |
分组之后:
1 | 运行:3台 |
非常适合做:
设备监控统计。
六、五项技术如何组合到上位机项目中
假设现在开发一个:
冷却塔科技节能监控系统
系统需要实时读取:
1 | 冷却塔 |
整个技术流程可以理解为:
1 | C#上位机 |
七、一个简化的实际代码结构
可以把它抽象成:
1 | Task.Run(() => |
简化版本
1 | Thread / Task |
八、上位机开发中各技术的定位
九、最重要概念:
① 线程
为什么需要?
因为设备通信不能阻塞 UI。
1 | UI线程 |
② 委托
为什么需要?
因为后台线程采集完数据以后,需要通知 UI 线程更新。
1 | 后台线程 → 委托 → UI线程 |
③ 数值转换
为什么需要?
因为工业设备传过来的通常是二进制:
1 | byte[] |
而业务需要:
1 | 30℃ |
④ 串口
干什么?
负责真正把数据发给/接收自现场设备。
1 | C# |
⑤ LINQ
干什么?
设备数据拿回来以后,对数据进行处理。
1 | 设备数据 |
十、上位机开发思维
可以优先按照这个思路去理解:
1 | 第一步:数据从哪里来? |