# temperature-control-system **Repository Path**: zailushang12/xiong ## Basic Information - **Project Name**: temperature-control-system - **Description**: 个人学习项目(工控、C#、winform、S7) - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 3 - **Created**: 2025-09-11 - **Last Updated**: 2025-09-11 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README temperature_control_system 介绍 <<<<<<< HEAD 个人学习项目(工控、C#、winform、S7、modbus) 使用说明 主要包含:主界面、三个模态框。 主界面包含: 1、列表:设备监控列表负责动态更新传感器采集信息,设备实时报警列表负责监控异常设备,设备开关列表负责统计设备状态,设备运行情况列表负责统计运行和停止数量,链接状态文本框负责更新plc链接状态。 2、工控按钮:三个模态框圆角按钮,设备开关全控圆角按钮,启动监控圆角按钮 三个模态框包括: 1、通信设置:选择 CPU 类型、IP 地址、端口号、机架、插槽 用于 连接主站 2、设备信息:选择 设备编号、设备名称、相对位置、状态地址 用于 连接从站 3、存储配置:选择信息类别 起始位置 数据类型 用于PLC的存储区域中查找和管理采集到的外部设备数据 ## PLC通信优化 - 日志、容错、设计模式上优化 ### 优化方向 1:统一地址格式(避免硬编码拆分) 解决方案 说明:DataType.Input:指定要读取的存储区域类型 1.定义地址格式规范:强制要求StateAddress必须为Qx.y或Ix.y格式(如Q1.0)。 2.封装地址解析方法:将地址解析逻辑独立成工具类,避免重复代码。 ``` // 定义 DataType 枚举类型 public enum DataType { Input, Output, Memory } // 工具类:地址解析器 public static class AddressParser { // 解析地址字符串(如"Q1.0"),返回字节和位 // 元组是一种轻量级的数据结构,能够将多个值组合成一个单一的对象。 // 在C#里,从C# 7.0开始就支持创建和使用元组 public static (DataType dataType, int byteAddr, byte bitAddr) Parse(string address) { if (string.IsNullOrEmpty(address) throw new ArgumentException("地址不能为空"); // 提取类型前缀(I/Q/M等) DataType type = address[0] switch //switch表达式 { 'I' => DataType.Input, 'Q' => DataType.Output, 'M' => DataType.Memory, _ => throw new ArgumentException($"无效地址类型: {address}") }; // 拆分字节和位(如"1.0") // 这行代码主要执行了两个操作,分别是截取字符串和分割字符串 string[] parts = address.Substring(1).Split('.'); if (parts.Length!= 2 || !int.TryParse(parts[0], out int byteAddr) || !byte.TryParse(parts[1], out byte bitAddr)) throw new ArgumentException($"地址格式错误: {address}"); return (type, byteAddr, bitAddr); } } // 使用示例(修改CreateStateDataItems方法) private DataItem[] CreateStateDataItems(bool state) { var dataItems = new List(); foreach (var device in CommonHelper.deviceList) { // 解析地址(此处假设device.StateAddress已经是完整地址,如"Q1.0") var (dataType, byteAddr, bitAddr) = AddressParser.Parse(device.StateAddress); dataItems.Add(new DataItem { DataType = dataType, VarType = VarType.Bit, DB = 0, StartByteAdr = byteAddr, BitAdr = bitAddr, Value = state }); } return dataItems.ToArray(); } ``` ### 优化方向 2:增加配置校验(提前发现错误) 问题:如果设备配置中的StateAddress格式错误,代码运行时会崩溃。 解决方案:在程序启动时或加载设备列表时,提前校验所有地址的合法性。 ``` // 在加载deviceList后,执行校验 public void LoadDeviceConfig() { var devList = CommonHelper.deviceList; foreach (var device in devList) { try { // 尝试解析地址,格式错误会抛出异常 AddressParser.Parse(device.StateAddress); } catch (Exception ex) { throw new InvalidConfigException($"设备 {device.DeviceNo} 地址配置错误: {device.StateAddress}", ex); } } } ``` ### 优化方向 3:增强Write操作的容错性 通用逻辑 - while循环写入 + try、catch处理方法异常 + 异常处理(判断最大次数 + 延时等待恢复) 问题:直接调用plc.Write可能因PLC通信失败导致程序崩溃。 解决方案:增加重试机制和异常处理,并反馈操作结果。 ``` // 封装安全的Write方法 // 该方法用于安全地向PLC(可编程逻辑控制器)写入数据,当写入失败时会进行重试操作 // 参数 address: 要写入的PLC地址,类型为字符串 // 参数 state: 要写入的状态值,类型为布尔值 // 参数 maxRetries: 最大重试次数,默认为3次 public bool SafeWrite(string address, bool state, int maxRetries = 3) { // 初始化重试计数器,记录当前重试的次数 int retryCount = 0; // 进入循环,只要重试次数小于最大重试次数,就会继续尝试写入操作 while (retryCount < maxRetries) { try { // 调用PLC的Write方法,尝试将指定状态写入到指定地址 plc.Write(address, state); // 如果写入操作没有抛出异常,说明写入成功,返回true return true; } catch (PlcCommunicationException ex) { // 如果写入过程中抛出了PLC通信异常,说明写入失败,重试次数加1 retryCount++; // 检查重试次数是否已经达到或超过最大重试次数 if (retryCount >= maxRetries) { // 如果达到最大重试次数,使用日志记录工具记录错误信息,包含失败的地址和异常信息 Log.Error($"写入PLC地址 {address} 失败: {ex.Message}"); // 写入失败,返回false return false; } // 如果还未达到最大重试次数,线程暂停1秒(1000毫秒),给PLC和网络一些时间恢复,然后进行下一次重试 Thread.Sleep(1000); } } // 如果循环结束后仍然没有成功写入,说明写入失败,返回false return false; } // 在ChangeDevState方法中使用 // 该方法用于改变设备的状态,会调用SafeWrite方法向PLC写入状态,并根据写入结果更新UI状态 // 参数 ucSwitch: 自定义的设备开关控件,用于显示设备状态 // 参数 state: 要设置的设备状态,类型为布尔值 private void ChangeDevState(UCDevSwitch ucSwitch, bool state) { // 构建要写入的PLC地址,假设地址以"Q"开头,后面拼接stateAddr变量的值 string address = "Q" + stateAddr; // 调用SafeWrite方法,尝试将指定状态写入到指定地址,并获取写入结果 bool success = SafeWrite(address, state); // 检查写入操作是否成功 if (success) { // 如果写入成功,更新UI状态 // 根据写入的状态值更新设备开关控件的可操作状态 // 如果当前状态为false,则设备可以打开;如果当前状态为true,则设备可以关闭 ucSwitch.CanOpen = !state; ucSwitch.CanClose = state; } else { // 如果写入失败,弹出消息框提示用户控制设备失败,并建议检查PLC连接 MessageBox.Show("控制设备失败,请检查PLC连接!"); } } ``` ### 优化方向 4:引入日志记录(关键操作留痕) 问题:当设备控制出现问题时,缺乏日志难以排查原因。 解决方案:在关键操作(如Write)前后记录日志。 ``` // 在SafeWrite方法中增加日志 public bool SafeWrite(string address, bool state, int maxRetries = 3) { Log.Info($"尝试写入地址 {address} 值 {state}..."); // ...原有逻辑... if (success) Log.Info($"写入地址 {address} 成功"); else Log.Error($"写入地址 {address} 失败"); return success; } ``` 总结:写入前(尝试写入)、写入成功、超过最大重试次数写入失败等节点记录 ### 优化方向5:综合 #### 增加数据校验(防止解析错误) - 问题:若PLC返回的字节数与预期不符,直接创建BitArray会出错。 - 优化思路:校验数据长度是否匹配。 #### 封装通用读取方法(解决重复代码+容错) - 将PLC读取逻辑封装到一个安全的方法中,支持重试和日志。 #### 使用策略模式(消除if-else分支) 通用逻辑 - 抽象策略角色、具体策略角色、环境角色 - 接口(读取转换方法)+ 角色(具体算法实现)+工厂调用(获取具体角色(字典)) - 通俗解释:策略工厂(使用字典封装读取数据方法 + 选择如何读取数据) 效果 - 为每种ParaType定义处理策略,包括如何读取和转换数据。 - 改造主循环代码(简洁 + 灵活) - 最终效果:代码量减少50%,新增参数类型只需添加一个策略类,且通信稳定性大幅提升! ``` // 定义策略接口 // 该接口定义了一个通用的方法 ReadAndConvert,用于读取数据并进行转换操作 // 不同的具体策略类将实现这个方法,以处理不同类型的数据 public interface IDataHandler { // ReadAndConvert 方法接收两个参数: // para:表示 PLC 参数对象,包含了数据读取所需的相关信息,如 DB 编号、起始位置、变量类型等 // count:表示要读取的数据数量 // 方法返回一个 object 类型的结果,因为不同的数据处理可能返回不同类型的对象 object ReadAndConvert(PlcParameter para, int count); } // 具体策略类:功率处理器 // 该类实现了 IDataHandler 接口,用于处理功率数据的读取和转换 public class PowerHandler : IDataHandler { // 实现接口的 ReadAndConvert 方法 public object ReadAndConvert(PlcParameter para, int count) { // 调用 SafeRead 方法从 PLC 中安全地读取数据 // 这里指定读取的数据类型为 ushort[],即无符号短整型数组 // para.DB 表示要读取的 DB 编号,para.FirstPosition 表示起始位置,para.VarType 表示变量类型,count 表示要读取的数据数量 ushort[] raw = SafeRead(para.DB, para.FirstPosition, para.VarType, count); // 由于功率数据可能不需要额外的转换,直接返回读取到的原始数据 return raw; } } // 具体策略类:温度处理器 // 该类实现了 IDataHandler 接口,用于处理温度数据的读取和转换 public class TemperatureHandler : IDataHandler { // 实现接口的 ReadAndConvert 方法 public object ReadAndConvert(PlcParameter para, int count) { // 调用 SafeRead 方法从 PLC 中安全地读取数据 // 这里指定读取的数据类型为 uint[],即无符号整型数组 // para.DB 表示要读取的 DB 编号,para.FirstPosition 表示起始位置,para.VarType 表示变量类型,count 表示要读取的数据数量 uint[] raw = SafeRead(para.DB, para.FirstPosition, para.VarType, count); // 调用专用的转换方法 ConvertToDecimal 对读取到的原始数据进行转换 // 转换后的结果作为最终结果返回 return ConvertToDecimal(raw); } } // 策略工厂类 // 该类用于根据参数类型返回对应的处理器实例,实现了策略模式中的工厂角色 public static class DataHandlerFactory { // 定义一个静态的字典,用于存储不同参数类型对应的处理器实例 // 字典的键为参数类型的字符串,值为实现了 IDataHandler 接口的处理器实例 private static Dictionary _handlers = new Dictionary { // 键为 "Power",对应的值为 PowerHandler 类的实例,用于处理功率数据 { "Power", new PowerHandler() }, // 键为 "Temperature",对应的值为 TemperatureHandler 类的实例,用于处理温度数据 { "Temperature", new TemperatureHandler() }, // 键为 "Speed",对应的值为 SpeedHandler 类的实例,用于处理速度数据 { "Speed", new SpeedHandler() }, // 键为 "Pressure",对应的值为 PressureHandler 类的实例,用于处理压力数据 { "Pressure", new PressureHandler() } }; // 静态方法,用于根据参数类型获取对应的处理器实例 public static IDataHandler GetHandler(string paraType) { // 尝试从字典中根据参数类型查找对应的处理器实例 if (_handlers.TryGetValue(paraType, out var handler)) // 如果找到,返回对应的处理器实例 return handler; // 如果未找到,抛出一个不支持的异常,提示不支持该参数类型 throw new NotSupportedException($"不支持的参数类型: {paraType}"); } } // 主逻辑部分,遍历 CommonHelper.storeSets 中的每个参数 foreach (var para in CommonHelper.storeSets) { try { // 调用 DataHandlerFactory 的 GetHandler 方法,根据参数的 ParaType 属性获取对应的处理器实例 var handler = DataHandlerFactory.GetHandler(para.ParaType); // 调用处理器实例的 ReadAndConvert 方法,动态读取并转换数据 // 使用 dynamic 关键字,允许在运行时处理不同类型的返回值 dynamic result = handler.ReadAndConvert(para, count); // 根据参数的 ParaType 属性进行类型判断,将结果赋值给对应的全局变量 switch (para.ParaType) { // 如果参数类型为 "Power",将结果赋值给 powers 全局变量 case "Power": powers = result; break; // 如果参数类型为 "Temperature",将结果赋值给 temperatures 全局变量 case "Temperature": temperatures = result; break; // 如果参数类型为 "Speed",将结果赋值给 speeds 全局变量 case "Speed": speeds = result; break; // 如果参数类型为 "Pressure",将结果赋值给 pressures 全局变量 case "Pressure": pressures = result; break; } } catch (Exception ex) { // 如果在处理过程中发生异常,使用日志记录工具将错误信息记录下来 // 日志记录的错误信息包括处理的参数类型和异常的具体信息 Log.Error($"处理参数{para.ParaType}失败: {ex.Message}"); } } ``` 以上代码中字典使用时的语法:在 C# 中,确实可以使用集合初始化器的类似方式(对象初始化器)在创建类的实例时直接初始化属性 策略模式简图 - 接口:IDataHandler 是策略接口,定义了一个抽象方法 ReadAndConvert,用于读取和转换数据。 - 具体策略类:PowerHandler 和 TemperatureHandler 是具体的策略类,它们实现了 IDataHandler 接口,代表了不同的数据处理算法。 - 策略工厂类:DataHandlerFactory 是策略工厂类,通过 GetHandler 方法根据参数类型创建并返回相应的具体策略类实例。 ``` classDiagram class IDataHandler { + ReadAndConvert(PlcParameter para, int count): object } class PowerHandler { + ReadAndConvert(PlcParameter para, int count): object } class TemperatureHandler { + ReadAndConvert(PlcParameter para, int count): object } class DataHandlerFactory { + GetHandler(string paraType): IDataHandler } IDataHandler <|.. PowerHandler : implements IDataHandler <|.. TemperatureHandler : implements DataHandlerFactory --> IDataHandler : creates ``` #### 异步安全读取 通用逻辑 - 包装成设备读取状态服务类 - 初始化方法,在程序启动时调用,用于计算地址范围和构建设备位映射 - Ling遍历所有设备校验设备地址 - 异步读取所有设备状态的方法 - 安全读取字节数据的异步方法,包含重试机制 增强读取容错性+校验安全性+避免阻塞UI线程(补充 + 记录每次读取耗时 + stopWatch慢查询) 为什么说以上代码能够“提升用户体验,防止界面卡顿? - 原理:当调用 ReadAllDeviceStatesAsync 方法时,await SafeReadBytesAsync() 会暂停该方法的执行,将控制权返回给调用者。这意味着在等待 SafeReadBytesAsync 方法完成读取字节数据的过程中,不会阻塞当前线程 ``` // 设备状态读取服务类 // 该类用于从 PLC(可编程逻辑控制器)读取设备的状态信息 // 包含初始化、读取所有设备状态等功能 public class PlcStateReader { // 存储读取字节范围的最小字节地址 private int minByte; // 存储读取字节范围的最大字节地址 private int maxByte; // 设备编号与对应状态位在 BitArray 中索引的映射字典 // 键为设备编号,值为该设备状态位在 BitArray 中的索引 private Dictionary deviceBitMap = new Dictionary(); // 初始化方法,在程序启动时调用,用于计算地址范围和构建设备位映射 public void Initialize(IList devices) { // 计算地址范围 // 使用 LINQ 的 Select 方法遍历设备列表,解析每个设备的状态地址,提取字节地址 // 并将这些字节地址存储到 allBytes 列表中 var allBytes = devices.Select(d => AddressParser.Parse(d.StateAddress).byteAddr).ToList(); // 找出所有字节地址中的最小值,作为读取的起始字节地址 minByte = allBytes.Min(); // 找出所有字节地址中的最大值,用于后续计算读取的字节数量 maxByte = allBytes.Max(); // 构建设备位映射 // 遍历设备列表中的每个设备 foreach (var device in devices) { // 解析当前设备的状态地址,提取字节地址和位地址 var (_, byteAddr, bitAddr) = AddressParser.Parse(device.StateAddress); // 计算该设备状态位在 BitArray 中的索引 // 先计算相对于起始字节的偏移量,再乘以 8(每个字节 8 位)加上位地址 int bitIndex = (byteAddr - minByte) * 8 + bitAddr; // 将设备编号和对应的位索引添加到映射字典中 deviceBitMap.Add(device.DeviceNo, bitIndex); } } // 异步读取所有设备状态的方法 // Task> 是一个用于表示异步操作返回值类型的声明 public async Task> ReadAllDeviceStatesAsync() { try { // 调用 SafeReadBytesAsync 方法异步读取字节数据 var bytes = await SafeReadBytesAsync(); // 将读取的字节数据转换为 BitArray 对象,方便处理位信息 var states = new BitArray(bytes); // 将设备编号和对应的状态信息存储到字典中 // 遍历设备位映射字典,根据索引从 BitArray 中获取设备状态 return deviceBitMap.ToDictionary( kvp => kvp.Key, // 键为设备编号 kvp => states[kvp.Value] // 值为设备状态(布尔值) ); } catch (Exception ex) { // 若读取过程中出现异常,记录错误日志 Log.Error($"状态读取失败: {ex.Message}"); // 返回一个空的字典,表示读取失败 return new Dictionary(); } } // 安全读取字节数据的异步方法,包含重试机制 private async Task SafeReadBytesAsync() { // 计算需要读取的字节数量 int byteCount = maxByte - minByte + 1; // 异步执行读取操作 return await Task.Run(() => { // 重试次数,初始化为 0 int retry = 0; // 最多重试 3 次 while (retry < 3) { try { // 调用 plc 的 Read 方法读取字节数据 var data = plc.Read(DataType.Input, minByte, 0, VarType.Byte, byteCount); // 检查读取的数据是否为字节数组,且长度是否符合预期 if (data is byte[] bytes && bytes.Length == byteCount) { // 若符合要求,返回读取的字节数组 return bytes; } // 若数据不符合要求,抛出异常 throw new InvalidDataException("PLC返回数据长度不符"); } catch (Exception ex) { // 重试次数加 1 retry++; // 记录重试日志,包含重试次数和异常信息 Log.Warning($"读取重试 {retry}/3: {ex.Message}"); // 等待 500 毫秒后再次尝试读取 Thread.Sleep(500); } } // 若重试 3 次后仍未成功,抛出超时异常 throw new TimeoutException("PLC读取超时"); }); } } ``` 优化不是一蹴而就,而是一个逐步迭代的过程。建议从最核心的地址类型修正和配置校验入手,再逐步增加容错和日志功能。每完成一个优化点,立即测试验证,确保不影响现有功能。最终你会发现,代码不仅更健壮,后期维护和扩展也会轻松很多! ### 缓存优化 1.缓存机制:对频繁读取且变化缓慢的状态(如设备型号),添加缓存减少PLC访问次数。 2.性能监控:记录每次读取耗时,优化慢查询(如合并高频读取请求)。 3.性能压测:模拟大量设备(如1000+)测试读取性能,优化字节范围计算算法。 #### 1. 记录每次读取耗时 目标:监控每个PLC读取操作的耗时,找出慢查询。 实现:用 Stopwatch 计时,并记录到日志。 ``` // 带耗时记录的读取方法 /// /// 从 PLC 读取指定地址的数据,并记录读取操作的耗时。 /// /// 要读取的 PLC 地址。 /// 读取到的位数据数组。 public BitArray ReadWithTiming(string address) { // 创建并启动一个 Stopwatch 实例,用于记录操作耗时 var stopwatch = Stopwatch.StartNew(); try { // 调用 PLC 的同步读取方法,执行实际的读取操作 BitArray data = plc.Read(address); return data; } finally { // 停止 Stopwatch stopwatch.Stop(); // 使用日志记录工具记录读取操作的耗时信息 Log.Info($"读取地址 {address} 耗时 {stopwatch.ElapsedMilliseconds}ms"); } } ``` 使用 try-finally 结构可以保证即使 plc.Read(address) 方法抛出异常,stopwatch 也会被正确停止,并且会记录下读取操作所花费的时间 异步版本(记录耗时) ``` // 异步版本(记录耗时) /// /// 异步从 PLC 读取指定地址的数据,并记录读取操作的耗时。 /// /// 要读取的 PLC 地址。 /// 一个表示异步操作的任务,任务完成时返回读取到的位数据数组。 public async Task ReadWithTimingAsync(string address) { // 创建并启动一个 Stopwatch 实例,用于记录操作耗时 var stopwatch = Stopwatch.StartNew(); try { // 调用 PLC 的异步读取方法,执行实际的读取操作 return await plc.ReadAsync(address); } finally { // 停止 Stopwatch stopwatch.Stop(); // 使用日志记录工具记录异步读取操作的耗时信息 Log.Info($"异步读取地址 {address} 耗时 {stopwatch.ElapsedMilliseconds}ms"); } } ``` #### 2. 缓存不常变化的数据(如设备型号) 通用逻辑 - 需要在数据更新方法后面添加缓存存储(设备编号修改、添加、删除) 目标:减少对PLC的重复读取,提升响应速度。 实现:使用 MemoryCache 或 ConcurrentDictionary 缓存数据。 ``` public class DeviceCache { // 静态的内存缓存实例,使用系统默认的内存缓存 private static readonly MemoryCache _cache = MemoryCache.Default; // 用于线程同步的锁对象 private static readonly object _lock = new object(); private PLC plc = new PLC(); // 获取设备型号(带缓存) /// /// 从缓存中获取指定设备的型号,如果缓存中不存在,则从 PLC 读取并缓存结果。 /// /// 设备的 ID。 /// 设备的型号。 public string GetDeviceModel(int deviceId) { // 生成缓存键,用于唯一标识设备型号的缓存项 string cacheKey = $"DeviceModel_{deviceId}"; // 尝试从缓存中获取设备型号 // 第一次检查:查看缓存中是否存在设备型号 if (_cache.Get(cacheKey) is string model) { // 如果缓存命中,直接返回缓存中的设备型号 return model; } // 加锁,确保只有一个线程能进入临界区 // 使用锁来确保线程安全,避免多个线程同时进行重复查询 lock (_lock) { // 双检锁机制,再次检查缓存中是否存在设备型号 // 第二次检查:再次查看缓存中是否存在设备型号 if (_cache.Get(cacheKey) is string cachedModel) return cachedModel; // 如果缓存中不存在,从 PLC 读取真实的设备型号(模拟耗时操作) string modelFromPlc = plc.ReadDeviceModel(deviceId); // 将从 PLC 读取的设备型号添加到缓存中,设置缓存过期时间为 10 分钟 _cache.Add(cacheKey, modelFromPlc, DateTimeOffset.Now.AddMinutes(10)); // 返回从 PLC 读取的设备型号 return modelFromPlc; } } } ``` 问题:如果保存在list中,还有必要使用缓存? 1. 数据结构特性 - List:本质是线性容器,适合顺序访问和批量操作,但随机查找效率为O(n) - MemoryCache:基于哈希表+链表(类似Dictionary),查找效率为O(1),内置内存回收策略 - ▶️ 当设备数量>1000时,List.Contains()效率会明显劣于MemoryCache.Get() 2. 缓存过期与更新机制 - List 无自动过期机制:当你把设备编号存于 List 中时,只要程序不重启或者不手动清除,这些数据会一直保留在内存里。如果设备编号信息会随着时间变化(例如数据库中设备编号有新增、删除或修改),那么 List 中的数据就可能过时,你需要手动编写复杂的逻辑来更新 List。 - 系统缓存有过期机制:使用 MemoryCache 可以为缓存项设置过期时间,如代码中 _cache.Add(cacheKey, modelFromPlc, DateTimeOffset.Now.AddMinutes(10)); 就设置了缓存 10 分钟后过期。这样,即使你忘记手动更新缓存,系统也会在一定时间后自动清理过期数据,保证数据的时效性。 3. 线程安全与并发控制 - List 需手动实现并发控制:在多线程环境下,对 List 进行读写操作需要手动实现复杂的线程安全机制,否则可能会出现数据不一致的问题。 - 系统缓存自带并发控制:MemoryCache 是线程安全的,它内部已经实现了并发控制机制。多个线程可以同时对缓存进行读写操作,而不会出现数据冲突的问题,代码中使用双检锁只是为了避免重复查询,而不是为了保证缓存操作的线程安全。 4. 场景化决策树 ✅ 建议保留List的场景: 1.设备总数<100且永不变化 2.只用于启动时预加载的静态数据 3.访问频率<1次/分钟的低频场景 ⚠️ 必须迁移到MemoryCache的场景: 1.设备信息动态变化(需缓存失效机制) 2.存在批量设备状态同步需求(可通过CacheRegions管理) 3.需要实现LRU等淘汰策略 4.系统需要横向扩展(缓存一致性要求) 5. 混合架构建议 对于您描述的具体场景,推荐采用分层缓存策略。 最终建议:除非是极小规模的静态数据,否则推荐采用系统缓存。List更适合作为只读数据的预加载容器,而非动态缓存解决方案。缓存策略的选择本质上是对时间局部性与空间局部性的权衡,而系统缓存提供了更专业的时空平衡机制。 ## 数据处理优化 结合`Modbus TCP`协议扩展第三方设备接入,采用`多线程`与`异步队列`优化数据吞吐性能 - 是否需要接入Modbus? a.当企业需要对车间控制系统进行扩展,增加更多的通信设备时,由于 S7 连接数的限制,可能无法简单地通过直接连接新设备来实现。这就需要对系统进行重新规划和改造,例如更换支持更多连接数的 PLC 型号,或者采用其他通信架构来解决连接数不足的问题,从而增加了系统扩展的成本和复杂度。 b.如果车间需接入非西门子设备(如仪表、传感器、第三方PLC),且这些设备仅支持Modbus协议(尤其是老旧设备),则必须集成Modbus TCP。 c.若系统需频繁读写大量数据点(如每秒数千个标签),或存在多个设备并行通信,多线程可充分利用多核CPU资源,避免单线程阻塞 d.PS:(西门子PLC的S7连接数有限,通常为1-4个) - 线程安全双缓冲队列 - 双缓冲的概念 - 双缓冲是一种用于优化数据处理和显示的技术,它通常包含两个缓冲区。一个缓冲区用于数据的生产(写入),另一个缓冲区用于数据的消费(读取)。当一个缓冲区正在被消费时,另一个缓冲区可以同时进行数据的生产,这样可以避免数据生产和消费过程中的冲突,提高系统的并发性能和响应速度。 - 实现步骤: a.安装NuGet包 System.Collections.Concurrent b.替换原始List为BlockingCollection - 调试技巧: - 在Visual Studio中使用并行堆栈窗口观察线程交互 - 通过_dataQueue.Count监控队列积压情况 - 优化价值: - 解决采集线程与UI线程竞争问题(你的项目已有Invoke,此优化更彻底) - 体现生产者-消费者模式理解 ### 简单模型 ``` using System; using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; // 引入 Modbus 设备相关的命名空间,用于实现 Modbus 通信 using Modbus.Device; using System.Net; using System.Net.Sockets; //Task.Run 方法会异步执行指定的方法,因此 CollectData 和 ProcessData 方法会在后台线程中运行,不会阻塞 Main 方法的执行 namespace ModbusExample { class Program { // 异步队列用于存储采集到的数据 // ConcurrentQueue 是线程安全的队列,适合在多线程环境下使用 //dataQueue 是一个线程安全的队列,用于存储从 Modbus 设备采集到的数据 private static ConcurrentQueue dataQueue = new ConcurrentQueue(); // 用于取消异步操作的 CancellationTokenSource // 可以通过调用其 Cancel 方法来取消所有使用该令牌的任务 private static CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); static void Main(string[] args) { // 模拟多个第三方设备的 IP 地址 // 这里存储了两个设备的 IP 地址,可根据实际情况添加更多 string[] deviceIPs = { "192.168.1.100", "192.168.1.101" }; // 启动数据采集线程 // 遍历每个设备的 IP 地址,为每个设备启动一个数据采集任务 foreach (string ip in deviceIPs) { // 使用 Task.Run 方法异步执行 CollectData 方法 // 并传入取消令牌,以便在需要时取消该任务 Task.Run(() => CollectData(ip), cancellationTokenSource.Token); } // 启动数据处理线程 // 启动一个数据处理任务,同样传入取消令牌 // 启动一个异步任务,并传递取消令牌 Task.Run(() => ProcessData(), cancellationTokenSource.Token); // 提示用户按任意键退出程序 Console.WriteLine("Press any key to exit..."); // 等待用户按下任意键 Console.ReadKey(); // 取消所有任务 // 调用 CancellationTokenSource 的 Cancel 方法,向所有使用该令牌的任务发送取消信号 cancellationTokenSource.Cancel(); } //存储 static void CollectData(string deviceIP) { try { // 使用 TcpClient 建立与 Modbus 设备的 TCP 连接 using (TcpClient client = new TcpClient()) { // 连接到指定 IP 地址和端口(Modbus TCP 默认端口为 502) client.Connect(IPAddress.Parse(deviceIP), 502); // 创建 Modbus IP 主站对象,用于与 Modbus 设备进行通信 IModbusMaster master = ModbusIpMaster.CreateIp(client); // 持续采集数据,直到接收到取消信号 while (!cancellationTokenSource.Token.IsCancellationRequested) { // 读取保持寄存器数据 // 从设备地址为 1 的设备的地址 0 开始读取 10 个寄存器 ushort[] data = master.ReadHoldingRegisters(1, 0, 10); // 将采集到的数据加入队列 // 使用 ConcurrentQueue 的 Enqueue 方法将数据添加到队列中 dataQueue.Enqueue(data); // 模拟采集间隔 // 线程休眠 1000 毫秒(即 1 秒),避免过于频繁地采集数据 Thread.Sleep(1000); } } } catch (Exception ex) { // 捕获并处理采集数据过程中可能出现的异常 // 输出错误信息,包括设备 IP 地址和异常消息 Console.WriteLine($"Error collecting data from {deviceIP}: {ex.Message}"); } } //处理 static void ProcessData() { // 持续处理数据,直到接收到取消信号 while (!cancellationTokenSource.Token.IsCancellationRequested) { // 尝试从队列中取出数据 // 如果队列中有数据,则将其出队并存储在 data 变量中,同时返回 true // 如果队列为空,则返回 false if (dataQueue.TryDequeue(out ushort[] data)) { // 处理采集到的数据 // 将采集到的数据以逗号分隔的字符串形式输出到控制台 Console.WriteLine($"Processing data: {string.Join(", ", data)}"); } else { // 队列中没有数据,稍作等待 // 线程休眠 100 毫秒,避免 CPU 空转 Thread.Sleep(100); } } } } } ``` ### 标准模型 通用逻辑 首先理解主站和从站,可以理解为主站代表上位机,而我们编写了上位机程序负责让上位机对传感器进行输入输出操作,从站代表传感器那一端的设备。 设备配置类+任务定义类 为什么这样设计:(重点理解) - 多主站系统支持:在一些复杂的工业控制系统中,可能存在多个主站设备。不同的主站可能负责不同的任务或者管理不同区域的从站设备。通过为每个 ModbusDevice 实例分配独立的 Master,可以方便地构建多主站系统。例如,一个大型工厂的自动化系统中,可能有一个主站负责监控生产线上的设备,另一个主站负责管理仓库中的设备 - 这种分离设计使得同一个 ModbusDevice 对象可以被多个不同的 ModbusTask 使用,提高了代码的复用性。比如,一个 Modbus 设备可能需要进行多次不同的寄存器读取操作,只需要创建不同的 ModbusTask 对象并关联同一个 ModbusDevice 对象即可。 - 问题:一个 Modbus 设备可能需要进行多次不同的寄存器读取操 怎么理解 我的理解是读取寄存器不同位置 - 你的理解是正确的。Modbus 有多种类型的寄存器,例如离散输入(用于读取开关量输入信号)、线圈(用于控制开关量输出)、输入寄存器(用于读取模拟量输入数据,如传感器的测量值)和保持寄存器(可读写,常用于存储设备的配置参数等)。一个设备可能同时具有多种类型的寄存器,为了获取设备的全面信息,就需要对不同类型、不同位置的寄存器进行多次读取操作 以下是一个基于Modbus TCP协议、结合多线程与异步队列优化数据吞吐的C#示例代码,使用NModbus库实现协议通信: ``` using System; using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; using Modbus.Device; using Modbus.Utility; // Modbus设备管理器类,实现IDisposable接口以便于资源释放 public class ModbusDeviceManager : IDisposable { // 异步任务队列,采用生产者 - 消费者模式 // BlockingCollection是一个线程安全的集合,用于存储Modbus任务 private readonly BlockingCollection _taskQueue = new BlockingCollection(); // 取消令牌源,用于取消异步操作 private readonly CancellationTokenSource _cts = new CancellationTokenSource(); // 并发字典,用于存储Modbus设备,键为设备ID,值为ModbusDevice对象 private readonly ConcurrentDictionary _devices = new ConcurrentDictionary(); // 设备配置类,用于存储Modbus设备的相关信息 public class ModbusDevice { // 设备的唯一标识符 public string DeviceId { get; set; } // 设备的IP地址 public string IpAddress { get; set; } // 设备的端口号 public int Port { get; set; } // 设备的从站ID public byte SlaveId { get; set; } // Modbus主站对象,用于与设备进行通信 public IModbusMaster Master { get; set; } } // Modbus任务定义类,用于描述要执行的Modbus操作 public class ModbusTask { // 要操作的Modbus设备 public ModbusDevice Device { get; set; } // 起始寄存器地址 public ushort StartAddress { get; set; } // 要读取的寄存器数量 public ushort NumRegisters { get; set; } } // 初始化设备连接池,将设备添加到设备字典中并创建Modbus主站对象 public void AddDevice(ModbusDevice device) { // 创建Modbus工厂对象 var factory = new ModbusFactory(); // 创建TCP客户端适配器,用于与设备建立连接 var adapter = new TcpClientAdapter(device.IpAddress, device.Port); // 使用工厂创建Modbus主站对象 device.Master = factory.CreateMaster(adapter); // 将设备添加到并发字典中 _devices.TryAdd(device.DeviceId, device); } // 启动生产者 - 消费者线程 public void Start() { // 启动消费者线程,根据CPU核心数动态调整线程数量 int workerCount = Environment.ProcessorCount; for (int i = 0; i < workerCount; i++) { // 启动一个异步任务来处理任务队列 Task.Run(() => ProcessQueue(_cts.Token)); } // 生产者线程:定时生成数据采集任务 Task.Run(() => { // 只要取消令牌未被请求取消,就持续执行 while (!_cts.IsCancellationRequested) { // 遍历所有设备 foreach (var device in _devices.Values) { // 向任务队列中添加一个新的Modbus任务 _taskQueue.Add(new ModbusTask { Device = device, StartAddress = 0, // 起始寄存器地址 NumRegisters = 10 // 读取寄存器数量 }, _cts.Token); } // 数据采集间隔,暂停100毫秒 Thread.Sleep(100); } }, _cts.Token); } // 消费者线程处理队列 private async Task ProcessQueue(CancellationToken token) { // 只要取消令牌未被请求取消,就持续执行 while (!token.IsCancellationRequested) { // 尝试从任务队列中取出一个任务,最多等待100毫秒 if (_taskQueue.TryTake(out ModbusTask task, 100, token)) { try { // 异步读取保持寄存器 // task.Device.SlaveId:Modbus 从站的 ID,用于指定要与之通信的 Modbus 设备。 // task.StartAddress:要读取的保持寄存器的起始地址 // task.NumRegisters:要读取的保持寄存器的数量 var result = await Task.Run(() => task.Device.Master.ReadHoldingRegisters( task.Device.SlaveId, task.StartAddress, task.NumRegisters )); // 处理数据(示例:转换为浮点数) float[] values = ModbusUtility.ConvertRegistersToFloat( result, ModbusUtility.Endianness.BigEndian ); // 触发数据到达事件(可绑定UI更新) OnDataReceived?.Invoke(this, new DataEventArgs { DeviceId = task.Device.DeviceId, Values = values }); } catch (Exception ex) { // 异常处理(记录日志、重连等) HandleError(task.Device, ex); } } } } // 数据到达事件,当有新的数据读取完成时触发 public event EventHandler OnDataReceived; // 数据到达事件的参数类 public class DataEventArgs : EventArgs { // 设备的唯一标识符 public string DeviceId { get; set; } // 读取到的数据值数组 public float[] Values { get; set; } } // 错误处理(示例:自动重连) private void HandleError(ModbusDevice device, Exception ex) { // 如果设备的主站传输层未连接 if (device.Master.Transport?.IsConnected == false) { // 释放当前传输层资源 device.Master.Transport?.Dispose(); // 创建新的TCP客户端适配器 var adapter = new TcpClientAdapter(device.IpAddress, device.Port); // 更新设备主站的传输层 device.Master.Transport = adapter; } } // 释放资源 public void Dispose() { // 取消所有异步操作 _cts.Cancel(); // 遍历所有设备,释放设备主站资源 foreach (var device in _devices.Values) { device.Master?.Dispose(); } } } // 使用示例 class Program { static void Main() { // 创建Modbus设备管理器实例 var manager = new ModbusDeviceManager(); // 添加设备配置 manager.AddDevice(new ModbusDeviceManager.ModbusDevice { DeviceId = "Motor1", IpAddress = "192.168.1.100", Port = 502, SlaveId = 1 }); // 订阅数据到达事件 manager.OnDataReceived += (sender, e) => { // 输出设备ID和读取到的数据 Console.WriteLine($"Device {e.DeviceId} Data: {string.Join(",", e.Values)}"); }; // 启动采集服务 manager.Start(); // 程序退出时释放资源 AppDomain.CurrentDomain.ProcessExit += (s, e) => manager.Dispose(); // 保持程序运行 Console.ReadLine(); } } ``` ## 数据库优化 优化原因:在实际应用中,传感器会不断地产生大量的记录。如果每产生一条记录就向数据库插入一次,会造成频繁的数据库操作,影响效率。因此,代码中的 InsertBatch 方法将多条传感器记录集中起来,批量插入到数据库中,这样可以减少数据库的操作次数,提高数据插入的效率。 以下是针对车间控制系统的SQLite性能优化方案及C#代码实例: ``` using System; using System.Collections.Generic; using System.Data.SQLite; using System.Diagnostics; // 定义 DataLogger 类,该类实现了 IDisposable 接口,用于资源的释放 public class DataLogger : IDisposable { // 定义一个只读的 SQLiteConnection 对象,用于与 SQLite 数据库建立连接 private readonly SQLiteConnection _conn; // 定义一个 SQLiteTransaction 对象,用于管理数据库事务 private SQLiteTransaction _transaction; // 定义一个只读的整数变量,用于指定批量插入数据时的批次大小,可根据测试结果进行调整 private readonly int _batchSize = 500; // 定义一个整数计数器,用于记录当前批次中已插入的数据数量 private int _counter; // 构造函数,接收一个字符串类型的数据库文件路径作为参数 public DataLogger(string dbPath) { // 创建一个 SQLiteConnectionStringBuilder 对象,用于构建数据库连接字符串 var csb = new SQLiteConnectionStringBuilder { // 设置数据库文件的路径 DataSource = dbPath, // 设置同步模式为 Off,等价于 PRAGMA synchronous=OFF,可提高写入性能,但可能会牺牲数据安全性 SyncMode = SynchronizationModes.Off, // 设置日志模式为 Memory,将日志文件存储在内存中,可提高写入性能 JournalMode = SQLiteJournalModeEnum.Memory, // 设置默认的超时时间为 30 秒 DefaultTimeout = 30, // 启用连接池,可提高数据库连接的重用性和性能 Pooling = true }; // 使用构建好的连接字符串创建一个 SQLiteConnection 对象 _conn = new SQLiteConnection(csb.ToString()); // 打开数据库连接 _conn.Open(); // 使用 using 语句创建一个 SQLiteCommand 对象,确保在使用完后自动释放资源 using var cmd = new SQLiteCommand(_conn); // 设置 SQL 命令文本,用于创建一个名为 sensor_data 的表,如果该表不存在的话 cmd.CommandText = @"CREATE TABLE IF NOT EXISTS sensor_data( id INTEGER PRIMARY KEY AUTOINCREMENT, device_id INTEGER NOT NULL, value REAL NOT NULL, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP)"; // 执行 SQL 命令,创建表 cmd.ExecuteNonQuery(); } // 该方法用于批量插入传感器记录 public void InsertBatch(IEnumerable records) { try { // 开始一个数据库事务 BeginTransaction(); // 创建一个 SQLiteCommand 对象,用于执行插入数据的 SQL 命令 var cmd = new SQLiteCommand(_conn); // 设置 SQL 命令文本,用于向 sensor_data 表中插入数据 cmd.CommandText = @"INSERT INTO sensor_data (device_id, value) VALUES (@devId, @val)"; // 遍历传入的传感器记录集合 foreach (var record in records) { // 清除命令对象中的所有参数 cmd.Parameters.Clear(); // 添加设备 ID 参数 cmd.Parameters.AddWithValue("@devId", record.DeviceId); // 添加传感器值参数 cmd.Parameters.AddWithValue("@val", record.Value); // 执行 SQL 命令,插入一条记录 cmd.ExecuteNonQuery(); // 计数器加 1 if (++_counter >= _batchSize) { // 如果计数器达到批次大小,则提交当前事务 CommitTransaction(); // 开始一个新的事务 BeginTransaction(); // 重置计数器 _counter = 0; } } } finally { // 无论是否发生异常,都提交当前事务 CommitTransaction(); } } // 该方法用于开始一个数据库事务 private void BeginTransaction() { // 如果当前没有活动的事务,则开始一个新的事务 if (_transaction == null) { _transaction = _conn.BeginTransaction(); } } // 该方法用于提交当前的数据库事务 private void CommitTransaction() { // 如果当前有活动的事务 if (_transaction != null) { // 提交事务 _transaction.Commit(); // 释放事务对象的资源 _transaction.Dispose(); // 将事务对象置为 null _transaction = null; // 重置计数器 _counter = 0; } } // 实现 IDisposable 接口的 Dispose 方法,用于释放资源 public void Dispose() { // 提交当前事务 CommitTransaction(); // 关闭数据库连接 _conn?.Close(); // 释放数据库连接对象的资源 _conn?.Dispose(); } } // 定义一个只读记录类型 SensorRecord,用于表示传感器记录 public record SensorRecord(int DeviceId, double Value); class Program { static void Main() { // 定义数据库文件的路径 string dbPath = "sensor_data.db"; // 创建 DataLogger 对象,传入数据库文件路径 using (var logger = new DataLogger(dbPath)) { // 生成一些示例的传感器记录 var records = new List { new SensorRecord(1, 23.5), new SensorRecord(2, 45.6), new SensorRecord(3, 67.8) }; // 调用 InsertBatch 方法,将传感器记录批量插入到数据库中 logger.InsertBatch(records); Console.WriteLine("数据插入成功!"); } Console.WriteLine("资源已释放,程序结束。"); } } ``` 4.性能测试建议: ``` // 测试代码示例 var logger = new DataLogger("test.db"); var testData = GenerateTestData(3000); // 生成测试数据 var sw = Stopwatch.StartNew(); logger.InsertBatch(testData); sw.Stop(); Console.WriteLine($"插入 {testData.Count} 条数据耗时:{sw.ElapsedMilliseconds}ms"); ``` ## 扩展第三方设备接入 结合`Modbus TCP`协议扩展第三方设备接入,采用`多线程`与`异步队列`优化数据吞吐性能 - 是否需要接入Modbus? a.当企业需要对车间控制系统进行扩展,增加更多的通信设备时,由于 S7 连接数的限制,可能无法简单地通过直接连接新设备来实现。这就需要对系统进行重新规划和改造,例如更换支持更多连接数的 PLC 型号,或者采用其他通信架构来解决连接数不足的问题,从而增加了系统扩展的成本和复杂度。 b.如果车间需接入非西门子设备(如仪表、传感器、第三方PLC),且这些设备仅支持Modbus协议(尤其是老旧设备),则必须集成Modbus TCP。 c.若系统需频繁读写大量数据点(如每秒数千个标签),或存在多个设备并行通信,多线程可充分利用多核CPU资源,避免单线程阻塞 d.PS:(西门子PLC的S7连接数有限,通常为1-4个) - 线程安全双缓冲队列 - 双缓冲的概念 - 双缓冲是一种用于优化数据处理和显示的技术,它通常包含两个缓冲区。一个缓冲区用于数据的生产(写入),另一个缓冲区用于数据的消费(读取)。当一个缓冲区正在被消费时,另一个缓冲区可以同时进行数据的生产,这样可以避免数据生产和消费过程中的冲突,提高系统的并发性能和响应速度。 - 实现步骤: a.安装NuGet包 System.Collections.Concurrent b.替换原始List为BlockingCollection - 调试技巧: - 在Visual Studio中使用并行堆栈窗口观察线程交互 - 通过_dataQueue.Count监控队列积压情况 - 优化价值: - 解决采集线程与UI线程竞争问题(你的项目已有Invoke,此优化更彻底) - 体现生产者-消费者模式理解 ### 简单模型 ``` using System; using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; // 引入 Modbus 设备相关的命名空间,用于实现 Modbus 通信 using Modbus.Device; using System.Net; using System.Net.Sockets; //Task.Run 方法会异步执行指定的方法,因此 CollectData 和 ProcessData 方法会在后台线程中运行,不会阻塞 Main 方法的执行 namespace ModbusExample { class Program { // 异步队列用于存储采集到的数据 // ConcurrentQueue 是线程安全的队列,适合在多线程环境下使用 //dataQueue 是一个线程安全的队列,用于存储从 Modbus 设备采集到的数据 private static ConcurrentQueue dataQueue = new ConcurrentQueue(); // 用于取消异步操作的 CancellationTokenSource // 可以通过调用其 Cancel 方法来取消所有使用该令牌的任务 private static CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); static void Main(string[] args) { // 模拟多个第三方设备的 IP 地址 // 这里存储了两个设备的 IP 地址,可根据实际情况添加更多 string[] deviceIPs = { "192.168.1.100", "192.168.1.101" }; // 启动数据采集线程 // 遍历每个设备的 IP 地址,为每个设备启动一个数据采集任务 foreach (string ip in deviceIPs) { // 使用 Task.Run 方法异步执行 CollectData 方法 // 并传入取消令牌,以便在需要时取消该任务 Task.Run(() => CollectData(ip), cancellationTokenSource.Token); } // 启动数据处理线程 // 启动一个数据处理任务,同样传入取消令牌 // 启动一个异步任务,并传递取消令牌 Task.Run(() => ProcessData(), cancellationTokenSource.Token); // 提示用户按任意键退出程序 Console.WriteLine("Press any key to exit..."); // 等待用户按下任意键 Console.ReadKey(); // 取消所有任务 // 调用 CancellationTokenSource 的 Cancel 方法,向所有使用该令牌的任务发送取消信号 cancellationTokenSource.Cancel(); } //存储 static void CollectData(string deviceIP) { try { // 使用 TcpClient 建立与 Modbus 设备的 TCP 连接 using (TcpClient client = new TcpClient()) { // 连接到指定 IP 地址和端口(Modbus TCP 默认端口为 502) client.Connect(IPAddress.Parse(deviceIP), 502); // 创建 Modbus IP 主站对象,用于与 Modbus 设备进行通信 IModbusMaster master = ModbusIpMaster.CreateIp(client); // 持续采集数据,直到接收到取消信号 while (!cancellationTokenSource.Token.IsCancellationRequested) { // 读取保持寄存器数据 // 从设备地址为 1 的设备的地址 0 开始读取 10 个寄存器 ushort[] data = master.ReadHoldingRegisters(1, 0, 10); // 将采集到的数据加入队列 // 使用 ConcurrentQueue 的 Enqueue 方法将数据添加到队列中 dataQueue.Enqueue(data); // 模拟采集间隔 // 线程休眠 1000 毫秒(即 1 秒),避免过于频繁地采集数据 Thread.Sleep(1000); } } } catch (Exception ex) { // 捕获并处理采集数据过程中可能出现的异常 // 输出错误信息,包括设备 IP 地址和异常消息 Console.WriteLine($"Error collecting data from {deviceIP}: {ex.Message}"); } } //处理 static void ProcessData() { // 持续处理数据,直到接收到取消信号 while (!cancellationTokenSource.Token.IsCancellationRequested) { // 尝试从队列中取出数据 // 如果队列中有数据,则将其出队并存储在 data 变量中,同时返回 true // 如果队列为空,则返回 false if (dataQueue.TryDequeue(out ushort[] data)) { // 处理采集到的数据 // 将采集到的数据以逗号分隔的字符串形式输出到控制台 Console.WriteLine($"Processing data: {string.Join(", ", data)}"); } else { // 队列中没有数据,稍作等待 // 线程休眠 100 毫秒,避免 CPU 空转 Thread.Sleep(100); } } } } } ``` ### 标准模型 通用逻辑 首先理解主站和从站,可以理解为主站代表上位机,而我们编写了上位机程序负责让上位机对传感器进行输入输出操作,从站代表传感器那一端的设备。 设备配置类+任务定义类 为什么这样设计:(重点理解) - 多主站系统支持:在一些复杂的工业控制系统中,可能存在多个主站设备。不同的主站可能负责不同的任务或者管理不同区域的从站设备。通过为每个 ModbusDevice 实例分配独立的 Master,可以方便地构建多主站系统。例如,一个大型工厂的自动化系统中,可能有一个主站负责监控生产线上的设备,另一个主站负责管理仓库中的设备 - 这种分离设计使得同一个 ModbusDevice 对象可以被多个不同的 ModbusTask 使用,提高了代码的复用性。比如,一个 Modbus 设备可能需要进行多次不同的寄存器读取操作,只需要创建不同的 ModbusTask 对象并关联同一个 ModbusDevice 对象即可。 - 问题:一个 Modbus 设备可能需要进行多次不同的寄存器读取操 怎么理解 我的理解是读取寄存器不同位置 - 你的理解是正确的。Modbus 有多种类型的寄存器,例如离散输入(用于读取开关量输入信号)、线圈(用于控制开关量输出)、输入寄存器(用于读取模拟量输入数据,如传感器的测量值)和保持寄存器(可读写,常用于存储设备的配置参数等)。一个设备可能同时具有多种类型的寄存器,为了获取设备的全面信息,就需要对不同类型、不同位置的寄存器进行多次读取操作 以下是一个基于Modbus TCP协议、结合多线程与异步队列优化数据吞吐的C#示例代码,使用NModbus库实现协议通信: ``` using System; using System.Collections.Concurrent; using System.Threading; using System.Threading.Tasks; using Modbus.Device; using Modbus.Utility; // Modbus设备管理器类,实现IDisposable接口以便于资源释放 public class ModbusDeviceManager : IDisposable { // 异步任务队列,采用生产者 - 消费者模式 // BlockingCollection是一个线程安全的集合,用于存储Modbus任务 private readonly BlockingCollection _taskQueue = new BlockingCollection(); // 取消令牌源,用于取消异步操作 private readonly CancellationTokenSource _cts = new CancellationTokenSource(); // 并发字典,用于存储Modbus设备,键为设备ID,值为ModbusDevice对象 private readonly ConcurrentDictionary _devices = new ConcurrentDictionary(); // 设备配置类,用于存储Modbus设备的相关信息 public class ModbusDevice { // 设备的唯一标识符 public string DeviceId { get; set; } // 设备的IP地址 public string IpAddress { get; set; } // 设备的端口号 public int Port { get; set; } // 设备的从站ID public byte SlaveId { get; set; } // Modbus主站对象,用于与设备进行通信 public IModbusMaster Master { get; set; } } // Modbus任务定义类,用于描述要执行的Modbus操作 public class ModbusTask { // 要操作的Modbus设备 public ModbusDevice Device { get; set; } // 起始寄存器地址 public ushort StartAddress { get; set; } // 要读取的寄存器数量 public ushort NumRegisters { get; set; } } // 初始化设备连接池,将设备添加到设备字典中并创建Modbus主站对象 public void AddDevice(ModbusDevice device) { // 创建Modbus工厂对象 var factory = new ModbusFactory(); // 创建TCP客户端适配器,用于与设备建立连接 var adapter = new TcpClientAdapter(device.IpAddress, device.Port); // 使用工厂创建Modbus主站对象 device.Master = factory.CreateMaster(adapter); // 将设备添加到并发字典中 _devices.TryAdd(device.DeviceId, device); } // 启动生产者 - 消费者线程 public void Start() { // 启动消费者线程,根据CPU核心数动态调整线程数量 int workerCount = Environment.ProcessorCount; for (int i = 0; i < workerCount; i++) { // 启动一个异步任务来处理任务队列 Task.Run(() => ProcessQueue(_cts.Token)); } // 生产者线程:定时生成数据采集任务 Task.Run(() => { // 只要取消令牌未被请求取消,就持续执行 while (!_cts.IsCancellationRequested) { // 遍历所有设备 foreach (var device in _devices.Values) { // 向任务队列中添加一个新的Modbus任务 _taskQueue.Add(new ModbusTask { Device = device, StartAddress = 0, // 起始寄存器地址 NumRegisters = 10 // 读取寄存器数量 }, _cts.Token); } // 数据采集间隔,暂停100毫秒 Thread.Sleep(100); } }, _cts.Token); } // 消费者线程处理队列 private async Task ProcessQueue(CancellationToken token) { // 只要取消令牌未被请求取消,就持续执行 while (!token.IsCancellationRequested) { // 尝试从任务队列中取出一个任务,最多等待100毫秒 if (_taskQueue.TryTake(out ModbusTask task, 100, token)) { try { // 异步读取保持寄存器 // task.Device.SlaveId:Modbus 从站的 ID,用于指定要与之通信的 Modbus 设备。 // task.StartAddress:要读取的保持寄存器的起始地址 // task.NumRegisters:要读取的保持寄存器的数量 var result = await Task.Run(() => task.Device.Master.ReadHoldingRegisters( task.Device.SlaveId, task.StartAddress, task.NumRegisters )); // 处理数据(示例:转换为浮点数) float[] values = ModbusUtility.ConvertRegistersToFloat( result, ModbusUtility.Endianness.BigEndian ); // 触发数据到达事件(可绑定UI更新) OnDataReceived?.Invoke(this, new DataEventArgs { DeviceId = task.Device.DeviceId, Values = values }); } catch (Exception ex) { // 异常处理(记录日志、重连等) HandleError(task.Device, ex); } } } } // 数据到达事件,当有新的数据读取完成时触发 public event EventHandler OnDataReceived; // 数据到达事件的参数类 public class DataEventArgs : EventArgs { // 设备的唯一标识符 public string DeviceId { get; set; } // 读取到的数据值数组 public float[] Values { get; set; } } // 错误处理(示例:自动重连) private void HandleError(ModbusDevice device, Exception ex) { // 如果设备的主站传输层未连接 if (device.Master.Transport?.IsConnected == false) { // 释放当前传输层资源 device.Master.Transport?.Dispose(); // 创建新的TCP客户端适配器 var adapter = new TcpClientAdapter(device.IpAddress, device.Port); // 更新设备主站的传输层 device.Master.Transport = adapter; } } // 释放资源 public void Dispose() { // 取消所有异步操作 _cts.Cancel(); // 遍历所有设备,释放设备主站资源 foreach (var device in _devices.Values) { device.Master?.Dispose(); } } } // 使用示例 class Program { static void Main() { // 创建Modbus设备管理器实例 var manager = new ModbusDeviceManager(); // 添加设备配置 manager.AddDevice(new ModbusDeviceManager.ModbusDevice { DeviceId = "Motor1", IpAddress = "192.168.1.100", Port = 502, SlaveId = 1 }); // 订阅数据到达事件 manager.OnDataReceived += (sender, e) => { // 输出设备ID和读取到的数据 Console.WriteLine($"Device {e.DeviceId} Data: {string.Join(",", e.Values)}"); }; // 启动采集服务 manager.Start(); // 程序退出时释放资源 AppDomain.CurrentDomain.ProcessExit += (s, e) => manager.Dispose(); // 保持程序运行 Console.ReadLine(); } } ``` ======= 个人学习项目(工控、C#、winform) <<<<<<< HEAD ======= >>>>>>> master >>>>>>> feature/项目功能全面优化