# 从零到一:用C# WinForms快速构建汇川PLC ModbusTCP通信工具
最近在做一个设备监控项目,需要实时采集产线上几台汇川PLC的数据。刚开始接触工业通信时,总觉得Modbus协议复杂难懂,各种功能码、寄存器地址让人眼花缭乱。但实际做下来才发现,只要掌握几个核心要点,用C# WinForms快速搭建一个可用的通信工具,真的不需要太多时间。今天我就把自己踩过的坑和总结的经验分享出来,希望能帮到那些需要在短时间内实现PLC通信功能的开发者。
对于大多数工业场景来说,我们并不需要从零开始实现Modbus协议栈。市面上已经有成熟的第三方库可以大大简化开发流程。更重要的是,我们需要理解PLC通信的本质——它本质上就是客户端(我们的上位机程序)通过TCP/IP协议向服务器(PLC)发送特定的数据包,然后解析返回的数据包。理解了这一点,整个开发过程就会清晰很多。
## 1. 环境准备与项目搭建
### 1.1 选择合适的开发环境
我推荐使用Visual Studio 2022或更高版本,社区版完全免费且功能足够强大。对于.NET版本的选择,考虑到工业现场的稳定性要求,我建议使用**.NET Framework 4.7.2**或更高版本。这个版本在Windows系统上兼容性最好,而且很多工业现场的工控机可能还在使用较老的Windows系统。
> 注意:虽然.NET Core/ .NET 5+是微软的新方向,但在工业控制领域,.NET Framework的成熟度和稳定性仍然是首选。如果你的项目需要跨平台,再考虑使用.NET Core。
创建一个新的WinForms项目时,我习惯按照以下结构组织:
```
InovancePLCCommunicator/
├── Properties/
├── References/
├── Forms/
│ └── MainForm.cs
├── Models/
│ ├── PLCDevice.cs
│ └── CommunicationConfig.cs
├── Services/
│ ├── ModbusService.cs
│ └── DataLogger.cs
├── Utilities/
│ └── ByteConverter.cs
└── Program.cs
```
### 1.2 添加必要的NuGet包
Modbus通信最常用的库是**NModbus**,它支持RTU、ASCII和TCP三种协议。在NuGet包管理器中搜索并安装:
```bash
Install-Package NModbus
Install-Package NModbus.Serial
```
如果你需要更高级的功能,比如异步操作支持更好的库,可以考虑**EasyModbusTCP**:
```bash
Install-Package EasyModbusTCP
```
这里我以NModbus为例,因为它更接近协议底层,有助于理解Modbus的工作原理。
安装完成后,你的项目引用中应该能看到:
- NModbus
- NModbus.Serial(虽然我们主要用TCP,但这个包包含了一些基础工具类)
### 1.3 基础界面设计
WinForms的界面设计要简洁实用。对于PLC通信工具,我通常包含以下几个核心区域:
1. **连接配置区**:IP地址、端口号(默认502)、站号(Slave ID)
2. **数据读写区**:地址输入、数据类型选择、读写按钮
3. **状态显示区**:连接状态、通信日志、错误信息
4. **数据展示区**:表格或文本框显示读取到的数据
一个简单的设计布局可以参考下面的表格:
| 区域 | 控件类型 | 主要功能 |
|------|----------|----------|
| 连接配置 | TextBox + Button | 输入PLC IP,建立/断开连接 |
| 寄存器操作 | ComboBox + TextBox + Button | 选择功能码,输入地址,执行读写 |
| 状态监控 | Label + RichTextBox | 显示连接状态和通信日志 |
| 数据展示 | DataGridView | 以表格形式展示读取的数据 |
在实际编码时,我会先设计好界面,然后逐步添加功能。记住,工业软件的首要原则是**稳定可靠**,其次才是美观。
## 2. ModbusTCP核心通信实现
### 2.1 理解ModbusTCP协议栈
在开始编码之前,我们需要对ModbusTCP的协议格式有个基本了解。与ModbusRTU不同,ModbusTCP在应用层数据单元(ADU)前添加了MBAP头(Modbus Application Protocol Header)。
一个完整的ModbusTCP请求报文结构如下:
| 字段 | 长度(字节) | 说明 |
|------|-------------|------|
| 事务标识符 | 2 | 用于请求-响应匹配,通常递增 |
| 协议标识符 | 2 | ModbusTCP固定为0x0000 |
| 长度字段 | 2 | 后续字节数(单元标识符+功能码+数据) |
| 单元标识符 | 1 | 从站地址(Slave ID) |
| 功能码 | 1 | 操作类型(如0x03读保持寄存器) |
| 数据 | N | 具体参数和数据 |
对于读取保持寄存器(功能码0x03)的请求,数据部分包含:
- 起始地址高字节(2字节)
- 起始地址低字节(2字节)
- 寄存器数量高字节(2字节)
- 寄存器数量低字节(2字节)
响应报文的数据部分则是实际的寄存器值。
### 2.2 封装通信服务类
我不喜欢把所有的通信逻辑都写在Form的代码后面文件里,那样会让代码难以维护。更好的做法是封装一个专门的通信服务类:
```csharp
using System;
using System.Net.Sockets;
using Modbus.Device;
namespace InovancePLCCommunicator.Services
{
public class ModbusTcpService : IDisposable
{
private TcpClient _tcpClient;
private IModbusMaster _modbusMaster;
private bool _isConnected = false;
public bool IsConnected => _isConnected;
public string LastError { get; private set; }
public event EventHandler<string> LogMessage;
public ModbusTcpService()
{
_tcpClient = new TcpClient();
_tcpClient.SendTimeout = 3000;
_tcpClient.ReceiveTimeout = 3000;
}
public bool Connect(string ipAddress, int port = 502, byte slaveId = 1)
{
try
{
LogMessage?.Invoke(this, $"正在连接 {ipAddress}:{port}...");
// 设置连接超时
var connectTask = _tcpClient.ConnectAsync(ipAddress, port);
if (!connectTask.Wait(TimeSpan.FromSeconds(5)))
{
LastError = "连接超时";
return false;
}
if (!_tcpClient.Connected)
{
LastError = "连接失败";
return false;
}
// 创建Modbus主站实例
_modbusMaster = ModbusIpMaster.CreateIp(_tcpClient);
_modbusMaster.Transport.Retries = 3;
_modbusMaster.Transport.ReadTimeout = 2000;
_modbusMaster.Transport.WriteTimeout = 2000;
_isConnected = true;
LogMessage?.Invoke(this, $"连接成功,Slave ID: {slaveId}");
return true;
}
catch (Exception ex)
{
LastError = $"连接异常: {ex.Message}";
LogMessage?.Invoke(this, $"连接失败: {ex.Message}");
return false;
}
}
public ushort[] ReadHoldingRegisters(byte slaveId, ushort startAddress, ushort numberOfRegisters)
{
if (!_isConnected || _modbusMaster == null)
throw new InvalidOperationException("未连接到PLC");
try
{
LogMessage?.Invoke(this, $"读取保持寄存器: 地址={startAddress}, 数量={numberOfRegisters}");
return _modbusMaster.ReadHoldingRegisters(slaveId, startAddress, numberOfRegisters);
}
catch (Exception ex)
{
LastError = $"读取失败: {ex.Message}";
throw;
}
}
public bool[] ReadCoils(byte slaveId, ushort startAddress, ushort numberOfCoils)
{
// 类似的实现...
}
public void Dispose()
{
_modbusMaster?.Dispose();
_tcpClient?.Close();
_isConnected = false;
}
}
}
```
这个服务类封装了最基础的连接和读取功能。在实际项目中,你可能还需要添加写入寄存器、读取输入寄存器等功能。
### 2.3 处理字节序问题
工业设备通信中最容易出问题的地方就是**字节序**。不同的PLC厂商、不同的数据类型可能有不同的字节序排列方式。
汇川PLC的M区寄存器(MW地址)通常使用**大端序(Big-Endian)**,而x86架构的PC使用**小端序(Little-Endian)**。这意味着我们需要在读取和写入时进行字节序转换。
我封装了一个字节序转换工具类:
```csharp
using System;
namespace InovancePLCCommunicator.Utilities
{
public static class ByteConverter
{
// 将两个寄存器(4字节)转换为32位整数
public static int RegistersToInt32(ushort highRegister, ushort lowRegister, bool isBigEndian = true)
{
byte[] bytes = new byte[4];
if (isBigEndian)
{
// 大端序:高位在前
bytes[0] = (byte)(highRegister >> 8);
bytes[1] = (byte)(highRegister & 0xFF);
bytes[2] = (byte)(lowRegister >> 8);
bytes[3] = (byte)(lowRegister & 0xFF);
}
else
{
// 小端序:低位在前
bytes[0] = (byte)(lowRegister & 0xFF);
bytes[1] = (byte)(lowRegister >> 8);
bytes[2] = (byte)(highRegister & 0xFF);
bytes[3] = (byte)(highRegister >> 8);
}
return BitConverter.ToInt32(bytes, 0);
}
// 将32位整数转换为两个寄存器
public static void Int32ToRegisters(int value, out ushort register1, out ushort register2, bool isBigEndian = true)
{
byte[] bytes = BitConverter.GetBytes(value);
if (isBigEndian)
{
register1 = (ushort)((bytes[0] << 8) | bytes[1]);
register2 = (ushort)((bytes[2] << 8) | bytes[3]);
}
else
{
register1 = (ushort)((bytes[2] << 8) | bytes[3]);
register2 = (ushort)((bytes[0] << 8) | bytes[1]);
}
}
// 单精度浮点数转换
public static float RegistersToFloat(ushort register1, ushort register2, bool isBigEndian = true)
{
byte[] bytes = new byte[4];
if (isBigEndian)
{
bytes[0] = (byte)(register1 >> 8);
bytes[1] = (byte)(register1 & 0xFF);
bytes[2] = (byte)(register2 >> 8);
bytes[3] = (byte)(register2 & 0xFF);
}
else
{
bytes[0] = (byte)(register2 & 0xFF);
bytes[1] = (byte)(register2 >> 8);
bytes[2] = (byte)(register1 & 0xFF);
bytes[3] = (byte)(register1 >> 8);
}
return BitConverter.ToSingle(bytes, 0);
}
}
}
```
在实际使用中,我发现汇川PLC对于不同的数据类型可能有不同的字节序要求。最稳妥的方法是先进行测试:写入一个已知的值,然后读取回来,观察字节的排列顺序。
## 3. 汇川PLC M区寄存器操作实战
### 3.1 理解M区寄存器的地址映射
汇川PLC的M区寄存器在Modbus协议中映射为**保持寄存器**(Holding Registers),使用功能码0x03进行读取,0x06或0x10进行写入。
这里有一个关键点需要理解:PLC中的MW地址与Modbus地址的对应关系。在汇川PLC中:
- **MW0** 对应 Modbus地址 **0**
- **MW1** 对应 Modbus地址 **1**
- **MW100** 对应 Modbus地址 **100**
但要注意,有些PLC厂商的地址映射可能需要偏移。比如西门子PLC的MW0可能对应Modbus地址400001。汇川相对简单,通常是直接映射。
对于不同数据类型的存储,需要了解:
| 数据类型 | 占用寄存器数 | 说明 |
|----------|-------------|------|
| BOOL | 1位(不单独占用) | 通常用线圈操作,或寄存器的某一位 |
| BYTE | 0.5个寄存器 | 实际存储时占用一个寄存器的高8位或低8位 |
| WORD | 1个寄存器 | 16位无符号整数 |
| INT | 1个寄存器 | 16位有符号整数 |
| DWORD | 2个寄存器 | 32位无符号整数 |
| DINT | 2个寄存器 | 32位有符号整数 |
| REAL | 2个寄存器 | 单精度浮点数 |
### 3.2 实现多功能读写操作
基于前面的通信服务类,我们可以实现一个更完整的读写管理器:
```csharp
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace InovancePLCCommunicator.Services
{
public class PLCDataManager
{
private readonly ModbusTcpService _modbusService;
private readonly Dictionary<string, DataItem> _dataItems;
public PLCDataManager(ModbusTcpService modbusService)
{
_modbusService = modbusService;
_dataItems = new Dictionary<string, DataItem>();
}
// 定义支持的数据类型
public enum DataType
{
Bool,
Byte,
Int16,
UInt16,
Int32,
UInt32,
Float,
Double,
String
}
// 数据项定义
public class DataItem
{
public string Name { get; set; }
public DataType Type { get; set; }
public ushort StartAddress { get; set; }
public ushort Length { get; set; }
public object Value { get; set; }
public DateTime LastUpdate { get; set; }
public bool IsValid { get; set; }
}
// 批量读取数据项
public async Task<bool> ReadDataItemsAsync(byte slaveId, List<DataItem> items)
{
try
{
// 按地址排序,优化读取效率
items.Sort((a, b) => a.StartAddress.CompareTo(b.StartAddress));
foreach (var item in items)
{
switch (item.Type)
{
case DataType.Bool:
var coils = _modbusService.ReadCoils(slaveId, item.StartAddress, item.Length);
item.Value = coils.Length > 0 ? coils[0] : false;
break;
case DataType.Int16:
var registers = _modbusService.ReadHoldingRegisters(slaveId, item.StartAddress, 1);
item.Value = (short)registers[0];
break;
case DataType.UInt16:
registers = _modbusService.ReadHoldingRegisters(slaveId, item.StartAddress, 1);
item.Value = registers[0];
break;
case DataType.Int32:
case DataType.UInt32:
case DataType.Float:
registers = _modbusService.ReadHoldingRegisters(slaveId, item.StartAddress, 2);
if (item.Type == DataType.Int32)
item.Value = ByteConverter.RegistersToInt32(registers[0], registers[1]);
else if (item.Type == DataType.UInt32)
item.Value = (uint)ByteConverter.RegistersToInt32(registers[0], registers[1]);
else
item.Value = ByteConverter.RegistersToFloat(registers[0], registers[1]);
break;
case DataType.String:
// 字符串需要特殊处理,通常每个字符占用两个字节
ushort stringLength = (ushort)(item.Length * 2);
registers = _modbusService.ReadHoldingRegisters(slaveId, item.StartAddress, stringLength);
item.Value = RegistersToString(registers);
break;
}
item.LastUpdate = DateTime.Now;
item.IsValid = true;
}
return true;
}
catch (Exception ex)
{
// 记录错误,但继续处理其他项
Console.WriteLine($"读取数据项失败: {ex.Message}");
return false;
}
}
private string RegistersToString(ushort[] registers)
{
// 将寄存器数组转换为字符串
// 这里假设每个寄存器存储一个字符(ASCII或Unicode)
char[] chars = new char[registers.Length];
for (int i = 0; i < registers.Length; i++)
{
chars[i] = (char)registers[i];
}
return new string(chars).TrimEnd('\0');
}
// 定时轮询方法
public async Task StartPollingAsync(byte slaveId, int intervalMs = 1000)
{
while (_modbusService.IsConnected)
{
try
{
var itemsToRead = new List<DataItem>(_dataItems.Values);
await ReadDataItemsAsync(slaveId, itemsToRead);
// 更新数据项字典
foreach (var item in itemsToRead)
{
if (_dataItems.ContainsKey(item.Name))
_dataItems[item.Name] = item;
}
// 触发数据更新事件
OnDataUpdated?.Invoke(this, _dataItems);
await Task.Delay(intervalMs);
}
catch (Exception ex)
{
Console.WriteLine($"轮询异常: {ex.Message}");
await Task.Delay(5000); // 异常后等待更长时间
}
}
}
public event EventHandler<Dictionary<string, DataItem>> OnDataUpdated;
}
}
```
这个管理器类提供了数据项的批量读取和定时轮询功能,在实际项目中非常实用。
### 3.3 错误处理与重试机制
工业现场的网络环境可能不稳定,因此健壮的错误处理机制至关重要。我通常实现一个带重试的通信包装器:
```csharp
public class RobustModbusClient
{
private readonly ModbusTcpService _innerClient;
private readonly int _maxRetries;
private readonly int _retryDelayMs;
public RobustModbusClient(ModbusTcpService innerClient, int maxRetries = 3, int retryDelayMs = 100)
{
_innerClient = innerClient;
_maxRetries = maxRetries;
_retryDelayMs = retryDelayMs;
}
public async Task<T> ExecuteWithRetryAsync<T>(Func<Task<T>> operation, string operationName)
{
int retryCount = 0;
while (retryCount <= _maxRetries)
{
try
{
return await operation();
}
catch (SocketException ex) when (retryCount < _maxRetries)
{
retryCount++;
Console.WriteLine($"{operationName} 失败 (尝试 {retryCount}/{_maxRetries}): {ex.Message}");
if (retryCount < _maxRetries)
{
await Task.Delay(_retryDelayMs * retryCount); // 指数退避
// 尝试重新连接
if (!_innerClient.IsConnected)
{
Console.WriteLine("尝试重新连接...");
// 这里需要根据实际情况实现重连逻辑
}
}
}
catch (Exception ex)
{
// 非网络错误,直接抛出
Console.WriteLine($"{operationName} 发生非重试错误: {ex.Message}");
throw;
}
}
throw new InvalidOperationException($"{operationName} 在 {_maxRetries} 次重试后仍失败");
}
public async Task<ushort[]> ReadRegistersWithRetry(byte slaveId, ushort address, ushort count)
{
return await ExecuteWithRetryAsync(
() => Task.FromResult(_innerClient.ReadHoldingRegisters(slaveId, address, count)),
$"读取寄存器 [{address}, {count}]"
);
}
}
```
这种重试机制可以显著提高通信的稳定性,特别是在网络波动较大的环境中。
## 4. 高级功能与性能优化
### 4.1 异步编程模式
在WinForms中使用异步编程需要特别注意线程安全问题。所有UI更新必须在UI线程上执行:
```csharp
public partial class MainForm : Form
{
private readonly ModbusTcpService _modbusService;
private readonly PLCDataManager _dataManager;
private CancellationTokenSource _pollingCts;
public MainForm()
{
InitializeComponent();
_modbusService = new ModbusTcpService();
_dataManager = new PLCDataManager(_modbusService);
// 订阅日志事件
_modbusService.LogMessage += OnLogMessage;
_dataManager.OnDataUpdated += OnDataUpdated;
}
private void OnLogMessage(object sender, string message)
{
// 确保在UI线程上更新
if (rtbLog.InvokeRequired)
{
rtbLog.Invoke(new Action(() => OnLogMessage(sender, message)));
return;
}
rtbLog.AppendText($"{DateTime.Now:HH:mm:ss} - {message}\n");
rtbLog.ScrollToCaret();
}
private void OnDataUpdated(object sender, Dictionary<string, PLCDataManager.DataItem> data)
{
if (dataGridView.InvokeRequired)
{
dataGridView.Invoke(new Action(() => OnDataUpdated(sender, data)));
return;
}
// 更新DataGridView
UpdateDataGrid(data);
}
private async void btnStartPolling_Click(object sender, EventArgs e)
{
if (_pollingCts != null)
{
// 停止轮询
_pollingCts.Cancel();
btnStartPolling.Text = "开始轮询";
_pollingCts = null;
return;
}
try
{
_pollingCts = new CancellationTokenSource();
btnStartPolling.Text = "停止轮询";
// 在后台线程执行轮询
await Task.Run(async () =>
{
await _dataManager.StartPollingAsync(
slaveId: (byte)nudSlaveId.Value,
intervalMs: (int)nudInterval.Value
);
}, _pollingCts.Token);
}
catch (OperationCanceledException)
{
// 轮询被取消,正常退出
OnLogMessage(this, "轮询已停止");
}
catch (Exception ex)
{
MessageBox.Show($"轮询异常: {ex.Message}", "错误",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
finally
{
if (_pollingCts != null)
{
_pollingCts.Dispose();
_pollingCts = null;
}
btnStartPolling.Text = "开始轮询";
}
}
private void UpdateDataGrid(Dictionary<string, PLCDataManager.DataItem> data)
{
// 清空现有行
dataGridView.Rows.Clear();
foreach (var item in data.Values)
{
dataGridView.Rows.Add(
item.Name,
item.Type.ToString(),
$"MW{item.StartAddress}",
item.Value?.ToString() ?? "N/A",
item.LastUpdate.ToString("HH:mm:ss"),
item.IsValid ? "✓" : "✗"
);
}
}
}
```
### 4.2 数据缓存与历史记录
对于监控系统,通常需要保存历史数据。我实现了一个简单的数据记录器:
```csharp
public class DataLogger
{
private readonly string _logDirectory;
private readonly int _maxFileSizeMB;
private readonly Queue<DataRecord> _memoryCache;
private readonly int _cacheCapacity;
public DataLogger(string logDirectory = "Logs", int maxFileSizeMB = 10, int cacheCapacity = 1000)
{
_logDirectory = logDirectory;
_maxFileSizeMB = maxFileSizeMB;
_cacheCapacity = cacheCapacity;
_memoryCache = new Queue<DataRecord>(cacheCapacity);
// 确保日志目录存在
Directory.CreateDirectory(_logDirectory);
}
public class DataRecord
{
public DateTime Timestamp { get; set; }
public string TagName { get; set; }
public object Value { get; set; }
public string Quality { get; set; } // Good, Bad, Uncertain
}
public void LogData(string tagName, object value, string quality = "Good")
{
var record = new DataRecord
{
Timestamp = DateTime.Now,
TagName = tagName,
Value = value,
Quality = quality
};
// 添加到内存缓存
lock (_memoryCache)
{
if (_memoryCache.Count >= _cacheCapacity)
_memoryCache.Dequeue();
_memoryCache.Enqueue(record);
}
// 异步写入文件
Task.Run(() => WriteToFile(record));
}
private void WriteToFile(DataRecord record)
{
try
{
string dateStr = DateTime.Now.ToString("yyyyMMdd");
string filePath = Path.Combine(_logDirectory, $"DataLog_{dateStr}.csv");
// 检查文件大小
if (File.Exists(filePath))
{
var fileInfo = new FileInfo(filePath);
if (fileInfo.Length > _maxFileSizeMB * 1024 * 1024)
{
// 文件过大,创建新文件
string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
string newPath = Path.Combine(_logDirectory, $"DataLog_{dateStr}_{timestamp}.csv");
File.Move(filePath, newPath);
}
}
// 写入CSV
bool isNewFile = !File.Exists(filePath);
using (var writer = new StreamWriter(filePath, true, Encoding.UTF8))
{
if (isNewFile)
{
// 写入CSV头
writer.WriteLine("Timestamp,TagName,Value,Quality");
}
writer.WriteLine($"{record.Timestamp:yyyy-MM-dd HH:mm:ss.fff},{record.TagName},{record.Value},{record.Quality}");
}
}
catch (Exception ex)
{
Console.WriteLine($"写入日志失败: {ex.Message}");
}
}
public List<DataRecord> GetRecentData(string tagName = null, int maxRecords = 100)
{
lock (_memoryCache)
{
var query = _memoryCache.AsEnumerable();
if (!string.IsNullOrEmpty(tagName))
query = query.Where(r => r.TagName == tagName);
return query
.OrderByDescending(r => r.Timestamp)
.Take(maxRecords)
.ToList();
}
}
}
```
### 4.3 性能监控与诊断
为了确保通信的稳定性,我通常会添加性能监控功能:
```csharp
public class CommunicationMonitor
{
private readonly List<CommunicationMetric> _metrics;
private readonly object _lock = new object();
private DateTime _lastResetTime;
public CommunicationMonitor()
{
_metrics = new List<CommunicationMetric>();
_lastResetTime = DateTime.Now;
}
public class CommunicationMetric
{
public DateTime Timestamp { get; set; }
public string Operation { get; set; }
public bool Success { get; set; }
public long DurationMs { get; set; }
public int DataSize { get; set; }
public string ErrorMessage { get; set; }
}
public void RecordOperation(string operation, bool success, long durationMs,
int dataSize = 0, string errorMessage = null)
{
lock (_lock)
{
var metric = new CommunicationMetric
{
Timestamp = DateTime.Now,
Operation = operation,
Success = success,
DurationMs = durationMs,
DataSize = dataSize,
ErrorMessage = errorMessage
};
_metrics.Add(metric);
// 保持最近1000条记录
if (_metrics.Count > 1000)
_metrics.RemoveAt(0);
}
}
public PerformanceSummary GetSummary(TimeSpan period)
{
lock (_lock)
{
var cutoffTime = DateTime.Now - period;
var recentMetrics = _metrics.Where(m => m.Timestamp >= cutoffTime).ToList();
if (recentMetrics.Count == 0)
return new PerformanceSummary();
var successfulOps = recentMetrics.Where(m => m.Success).ToList();
var failedOps = recentMetrics.Where(m => !m.Success).ToList();
return new PerformanceSummary
{
TotalOperations = recentMetrics.Count,
SuccessfulOperations = successfulOps.Count,
FailedOperations = failedOps.Count,
SuccessRate = (double)successfulOps.Count / recentMetrics.Count * 100,
AverageDurationMs = successfulOps.Any() ?
successfulOps.Average(m => m.DurationMs) : 0,
MaxDurationMs = successfulOps.Any() ?
successfulOps.Max(m => m.DurationMs) : 0,
MinDurationMs = successfulOps.Any() ?
successfulOps.Min(m => m.DurationMs) : 0,
TotalDataSize = recentMetrics.Sum(m => m.DataSize),
LastError = failedOps.LastOrDefault()?.ErrorMessage
};
}
}
public class PerformanceSummary
{
public int TotalOperations { get; set; }
public int SuccessfulOperations { get; set; }
public int FailedOperations { get; set; }
public double SuccessRate { get; set; }
public double AverageDurationMs { get; set; }
public double MaxDurationMs { get; set; }
public double MinDurationMs { get; set; }
public long TotalDataSize { get; set; }
public string LastError { get; set; }
public override string ToString()
{
return $"成功率: {SuccessRate:F1}% | " +
$"平均耗时: {AverageDurationMs:F1}ms | " +
$"总操作: {TotalOperations}";
}
}
public void Reset()
{
lock (_lock)
{
_metrics.Clear();
_lastResetTime = DateTime.Now;
}
}
}
```
这个监控器可以帮助我们发现性能瓶颈和通信问题。在实际项目中,我通常会定期(比如每分钟)将性能摘要显示在状态栏上。
### 4.4 配置管理与持久化
工业软件通常需要保存配置信息。我使用JSON格式保存配置:
```csharp
public class AppConfig
{
public string LastIpAddress { get; set; } = "192.168.1.100";
public int LastPort { get; set; } = 502;
public byte LastSlaveId { get; set; } = 1;
public int PollingInterval { get; set; } = 1000;
public List<DataTagConfig> DataTags { get; set; } = new List<DataTagConfig>();
public bool AutoConnect { get; set; } = false;
public bool AutoStartPolling { get; set; } = false;
public string LogLevel { get; set; } = "Info";
public class DataTagConfig
{
public string Name { get; set; }
public string DataType { get; set; }
public ushort Address { get; set; }
public ushort Length { get; set; }
public string Description { get; set; }
public double ScalingFactor { get; set; } = 1.0;
public double Offset { get; set; } = 0.0;
public string Unit { get; set; }
public double AlarmHigh { get; set; }
public double AlarmLow { get; set; }
}
}
public class ConfigManager
{
private readonly string _configPath;
private AppConfig _config;
public ConfigManager(string configFileName = "config.json")
{
string appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
string appFolder = Path.Combine(appDataPath, "InovancePLCCommunicator");
Directory.CreateDirectory(appFolder);
_configPath = Path.Combine(appFolder, configFileName);
}
public AppConfig LoadConfig()
{
try
{
if (File.Exists(_configPath))
{
string json = File.ReadAllText(_configPath, Encoding.UTF8);
_config = JsonConvert.DeserializeObject<AppConfig>(json);
}
else
{
_config = new AppConfig();
SaveConfig(); // 创建默认配置
}
return _config;
}
catch (Exception ex)
{
Console.WriteLine($"加载配置失败: {ex.Message}");
return new AppConfig(); // 返回默认配置
}
}
public void SaveConfig()
{
try
{
string json = JsonConvert.SerializeObject(_config, Formatting.Indented);
File.WriteAllText(_configPath, json, Encoding.UTF8);
}
catch (Exception ex)
{
Console.WriteLine($"保存配置失败: {ex.Message}");
}
}
public void UpdateConfig(Action<AppConfig> updateAction)
{
updateAction?.Invoke(_config);
SaveConfig();
}
}
```
使用Newtonsoft.Json库来处理JSON序列化:
```bash
Install-Package Newtonsoft.Json
```
这样,用户的所有配置都会自动保存,下次启动时自动加载。
在实际项目中,我发现很多开发者忽略了配置管理的重要性。一个好的配置系统可以让软件更加用户友好,也便于现场调试。