1. 项目概述与核心需求解析
在工业自动化控制系统中,上位机与PLC的实时数据交互和可视化监控是核心功能模块。本项目基于C#开发了一套完整的监控系统,主要实现以下功能:
- 通过ModbusTcp协议与西门子S7-1200 PLC建立稳定通信
- 集成阿尔泰采集卡进行压装电压信号采集
- 实时曲线绘制与自定义判据分析
- 按日期自动生成SQLite数据库文件
- 历史数据查询与导出功能
提示:工业现场的数据采集系统需要特别注意通信稳定性和数据完整性,建议采用双缓冲机制处理采集数据。
1.1 技术选型考量
选择C#作为开发语言主要基于以下因素:
- Windows平台兼容性:工业现场上位机多采用Windows系统
- 丰富的类库支持:包括Socket通信、数据库操作、图形绘制等
- 开发效率高:相比C++等语言更快速实现业务逻辑
- 维护成本低:面向对象特性便于后期功能扩展
ModbusTcp协议选择考虑:
- 工业领域事实标准协议
- S7-1200 PLC原生支持
- 跨厂商设备兼容性好
- 协议简单易于实现
2. 通信模块实现详解
2.1 ModbusTcp通信基础配置
使用NModbus4库实现通信需要先进行基础配置:
csharp复制// 通信参数配置类
public class ModbusConfig
{
public string IP { get; set; } = "192.168.0.100";
public int Port { get; set; } = 502;
public byte SlaveId { get; set; } = 1;
public int Timeout { get; set; } = 1000; // ms
}
// 通信管理类
public class ModbusManager : IDisposable
{
private IModbusMaster _master;
private readonly ModbusConfig _config;
public ModbusManager(ModbusConfig config)
{
_config = config;
var factory = new ModbusFactory();
_master = factory.CreateMaster(new TcpClientAdapter(
new TcpClient(_config.IP, _config.Port)));
}
public ushort[] ReadHoldingRegisters(ushort startAddress, ushort numberOfPoints)
{
return _master.ReadHoldingRegisters(_config.SlaveId, startAddress, numberOfPoints);
}
public void Dispose()
{
_master?.Dispose();
}
}
注意:实际项目中建议将通信模块封装为单独的服务类,避免直接在主线程中操作。
2.2 通信异常处理机制
工业现场通信易受干扰,需要完善的异常处理:
csharp复制public class SafeModbusOperator
{
private readonly ModbusManager _manager;
private readonly int _maxRetries = 3;
public SafeModbusOperator(ModbusManager manager)
{
_manager = manager;
}
public OperationResult<ushort[]> SafeReadRegisters(ushort address, ushort count)
{
int retryCount = 0;
while(retryCount < _maxRetries)
{
try
{
var data = _manager.ReadHoldingRegisters(address, count);
return OperationResult<ushort[]>.Success(data);
}
catch(Exception ex)
{
retryCount++;
Thread.Sleep(100 * retryCount); // 指数退避
if(retryCount == _maxRetries)
{
return OperationResult<ushort[]>.Fail(ex.Message);
}
}
}
return OperationResult<ushort[]>.Fail("Unknown error");
}
}
3. 数据采集与处理模块
3.1 阿尔泰采集卡集成
阿尔泰采集卡通常提供DLL动态库,需要通过P/Invoke方式调用:
csharp复制public class AltaiCardReader
{
[DllImport("AltaiCard.dll")]
private static extern int ATCard_Init(int cardNo);
[DllImport("AltaiCard.dll")]
private static extern double ATCard_ReadVoltage(int channel);
private bool _initialized = false;
public void Initialize()
{
if(ATCard_Init(0) == 0)
{
_initialized = true;
}
}
public double ReadVoltage(int channel)
{
if(!_initialized) throw new InvalidOperationException("Card not initialized");
return ATCard_ReadVoltage(channel);
}
}
3.2 数据采样策略
为保证数据采集的实时性和准确性,采用多线程采集方案:
csharp复制public class DataSampler
{
private readonly AltaiCardReader _reader;
private readonly ConcurrentQueue<double> _dataBuffer;
private Thread _samplingThread;
private bool _isRunning;
private int _samplingInterval = 50; // ms
public DataSampler(AltaiCardReader reader)
{
_reader = reader;
_dataBuffer = new ConcurrentQueue<double>();
}
public void Start()
{
_isRunning = true;
_samplingThread = new Thread(SamplingLoop);
_samplingThread.Start();
}
public void Stop()
{
_isRunning = false;
_samplingThread?.Join();
}
private void SamplingLoop()
{
while(_isRunning)
{
var voltage = _reader.ReadVoltage(0);
_dataBuffer.Enqueue(voltage);
Thread.Sleep(_samplingInterval);
}
}
public bool TryGetData(out double data)
{
return _dataBuffer.TryDequeue(out data);
}
}
4. 实时曲线绘制实现
4.1 图表控件选择与配置
推荐使用ScottPlot或LiveCharts等专业图表库:
csharp复制// ScottPlot配置示例
public class RealTimePlotter
{
private readonly FormsPlot _plot;
private readonly double[] _data;
private int _dataIndex = 0;
public RealTimePlotter(FormsPlot plot, int capacity = 1000)
{
_plot = plot;
_data = new double[capacity];
_plot.Plot.Style(figureBackground: Color.FromArgb(40, 40, 40));
_plot.Plot.XAxis.Label("Time");
_plot.Plot.YAxis.Label("Voltage (V)");
}
public void AddDataPoint(double value)
{
_data[_dataIndex] = value;
_dataIndex = (_dataIndex + 1) % _data.Length;
_plot.Plot.Clear();
var sig = _plot.Plot.AddSignal(_data);
sig.Color = Color.LimeGreen;
_plot.Refresh();
}
}
4.2 自定义曲线判据实现
支持多种判据规则的配置化设计:
csharp复制public interface ICurveCriterion
{
bool CheckAbnormal(double[] data);
}
public class ThresholdCriterion : ICurveCriterion
{
public double Threshold { get; set; }
public bool GreaterThan { get; set; } = true;
public bool CheckAbnormal(double[] data)
{
return GreaterThan
? data.Any(x => x > Threshold)
: data.Any(x => x < Threshold);
}
}
public class SlopeCriterion : ICurveCriterion
{
public double MaxSlope { get; set; }
public bool CheckAbnormal(double[] data)
{
for(int i = 1; i < data.Length; i++)
{
double slope = Math.Abs(data[i] - data[i-1]);
if(slope > MaxSlope) return true;
}
return false;
}
}
5. 数据存储与管理
5.1 按日期分库设计
csharp复制public class DailyDatabaseManager
{
private readonly string _basePath;
public DailyDatabaseManager(string basePath)
{
_basePath = basePath;
Directory.CreateDirectory(_basePath);
}
public SQLiteConnection GetTodayConnection()
{
string date = DateTime.Now.ToString("yyyyMMdd");
string dbPath = Path.Combine(_basePath, $"data_{date}.db");
var conn = new SQLiteConnection($"Data Source={dbPath}");
conn.Open();
// 确保表结构存在
using(var cmd = new SQLiteCommand(conn))
{
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS VoltageData (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Voltage REAL NOT NULL,
Timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
IsAbnormal INTEGER DEFAULT 0
)";
cmd.ExecuteNonQuery();
}
return conn;
}
}
5.2 数据批量插入优化
csharp复制public class DataRecorder
{
private readonly DailyDatabaseManager _dbManager;
private readonly List<VoltageData> _buffer = new List<VoltageData>();
private readonly int _batchSize = 100;
private readonly Timer _flushTimer;
public DataRecorder(DailyDatabaseManager dbManager)
{
_dbManager = dbManager;
_flushTimer = new Timer(FlushBuffer, null, 5000, 5000);
}
public void AddData(VoltageData data)
{
lock(_buffer)
{
_buffer.Add(data);
if(_buffer.Count >= _batchSize)
{
FlushBuffer(null);
}
}
}
private void FlushBuffer(object state)
{
if(_buffer.Count == 0) return;
using(var conn = _dbManager.GetTodayConnection())
using(var transaction = conn.BeginTransaction())
{
try
{
var cmd = new SQLiteCommand(conn);
cmd.CommandText = "INSERT INTO VoltageData (Voltage, IsAbnormal) VALUES (@voltage, @abnormal)";
lock(_buffer)
{
foreach(var data in _buffer)
{
cmd.Parameters.Clear();
cmd.Parameters.AddWithValue("@voltage", data.Voltage);
cmd.Parameters.AddWithValue("@abnormal", data.IsAbnormal ? 1 : 0);
cmd.ExecuteNonQuery();
}
_buffer.Clear();
}
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
}
}
6. 历史数据查询与导出
6.1 多条件查询实现
csharp复制public class DataQueryService
{
private readonly DailyDatabaseManager _dbManager;
public DataQueryService(DailyDatabaseManager dbManager)
{
_dbManager = dbManager;
}
public List<VoltageData> Query(DateTime start, DateTime end,
double? minVoltage = null, double? maxVoltage = null,
bool? abnormalOnly = null)
{
var results = new List<VoltageData>();
// 计算涉及的所有日期
var dates = GetDateRange(start.Date, end.Date);
foreach(var date in dates)
{
string dbPath = Path.Combine(_dbManager.BasePath, $"data_{date:yyyyMMdd}.db");
if(!File.Exists(dbPath)) continue;
using(var conn = new SQLiteConnection($"Data Source={dbPath}"))
{
conn.Open();
var sql = new StringBuilder("SELECT * FROM VoltageData WHERE ");
var conditions = new List<string>();
var parameters = new List<SQLiteParameter>();
// 时间条件
if(date == start.Date && date == end.Date)
{
conditions.Add("Timestamp BETWEEN @start AND @end");
parameters.Add(new SQLiteParameter("@start", start));
parameters.Add(new SQLiteParameter("@end", end));
}
else if(date == start.Date)
{
conditions.Add("Timestamp >= @start");
parameters.Add(new SQLiteParameter("@start", start));
}
else if(date == end.Date)
{
conditions.Add("Timestamp <= @end");
parameters.Add(new SQLiteParameter("@end", end));
}
// 电压范围条件
if(minVoltage.HasValue)
{
conditions.Add("Voltage >= @minVoltage");
parameters.Add(new SQLiteParameter("@minVoltage", minVoltage.Value));
}
if(maxVoltage.HasValue)
{
conditions.Add("Voltage <= @maxVoltage");
parameters.Add(new SQLiteParameter("@maxVoltage", maxVoltage.Value));
}
// 异常数据条件
if(abnormalOnly.HasValue && abnormalOnly.Value)
{
conditions.Add("IsAbnormal = 1");
}
sql.Append(string.Join(" AND ", conditions));
using(var cmd = new SQLiteCommand(sql.ToString(), conn))
{
cmd.Parameters.AddRange(parameters.ToArray());
using(var reader = cmd.ExecuteReader())
{
while(reader.Read())
{
results.Add(new VoltageData
{
Id = reader.GetInt32(0),
Voltage = reader.GetDouble(1),
Timestamp = reader.GetDateTime(2),
IsAbnormal = reader.GetInt32(3) == 1
});
}
}
}
}
}
return results;
}
private IEnumerable<DateTime> GetDateRange(DateTime start, DateTime end)
{
for(var date = start; date <= end; date = date.AddDays(1))
{
yield return date;
}
}
}
6.2 数据导出功能
支持多种格式导出:
csharp复制public class DataExporter
{
public void ExportToCsv(IEnumerable<VoltageData> data, string filePath)
{
using(var writer = new StreamWriter(filePath))
{
writer.WriteLine("Timestamp,Voltage,IsAbnormal");
foreach(var item in data)
{
writer.WriteLine($"{item.Timestamp:yyyy-MM-dd HH:mm:ss.fff},{item.Voltage},{(item.IsAbnormal ? "Yes" : "No")}");
}
}
}
public void ExportToExcel(IEnumerable<VoltageData> data, string filePath)
{
using(var package = new ExcelPackage())
{
var worksheet = package.Workbook.Worksheets.Add("VoltageData");
// 设置表头
worksheet.Cells[1, 1].Value = "Timestamp";
worksheet.Cells[1, 2].Value = "Voltage";
worksheet.Cells[1, 3].Value = "IsAbnormal";
// 填充数据
int row = 2;
foreach(var item in data)
{
worksheet.Cells[row, 1].Value = item.Timestamp;
worksheet.Cells[row, 2].Value = item.Voltage;
worksheet.Cells[row, 3].Value = item.IsAbnormal ? "Yes" : "No";
row++;
}
// 自动调整列宽
worksheet.Cells[worksheet.Dimension.Address].AutoFitColumns();
package.SaveAs(new FileInfo(filePath));
}
}
}
7. 系统集成与界面设计
7.1 主界面布局设计
推荐采用WPF实现现代化界面:
xml复制<Window x:Class="MonitorSystem.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="工业监控系统" Height="800" Width="1200">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 状态栏 -->
<StatusBar Grid.Row="0">
<StatusBarItem>
<TextBlock x:Name="ConnectionStatus" Text="未连接"/>
</StatusBarItem>
<StatusBarItem>
<TextBlock x:Name="DataStatus" Text="无数据"/>
</StatusBarItem>
</StatusBar>
<!-- 主内容区 -->
<TabControl Grid.Row="1">
<TabItem Header="实时监控">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="250"/>
</Grid.ColumnDefinitions>
<!-- 实时曲线 -->
<wpf:ScottPlot x:Name="RealTimePlot" Grid.Column="0"/>
<!-- 参数面板 -->
<StackPanel Grid.Column="1">
<GroupBox Header="通信参数">
<!-- 参数输入控件 -->
</GroupBox>
<GroupBox Header="采集参数">
<!-- 参数输入控件 -->
</GroupBox>
</StackPanel>
</Grid>
</TabItem>
<TabItem Header="历史数据">
<!-- 历史数据查询界面 -->
</TabItem>
</TabControl>
<!-- 操作栏 -->
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right">
<Button x:Name="StartBtn" Content="开始监控" Click="StartBtn_Click"/>
<Button x:Name="StopBtn" Content="停止监控" Click="StopBtn_Click"/>
<Button x:Name="ExportBtn" Content="导出数据" Click="ExportBtn_Click"/>
</StackPanel>
</Grid>
</Window>
7.2 后台逻辑实现
csharp复制public partial class MainWindow : Window
{
private readonly ModbusManager _modbusManager;
private readonly DataSampler _sampler;
private readonly RealTimePlotter _plotter;
private readonly DataRecorder _recorder;
public MainWindow()
{
InitializeComponent();
// 初始化各模块
var config = new ModbusConfig { IP = "192.168.0.100" };
_modbusManager = new ModbusManager(config);
var cardReader = new AltaiCardReader();
cardReader.Initialize();
_sampler = new DataSampler(cardReader);
_plotter = new RealTimePlotter(RealTimePlot);
var dbManager = new DailyDatabaseManager("Data");
_recorder = new DataRecorder(dbManager);
// 启动数据更新定时器
var timer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(100)
};
timer.Tick += UpdateDisplay;
timer.Start();
}
private void UpdateDisplay(object sender, EventArgs e)
{
if(_sampler.TryGetData(out var voltage))
{
_plotter.AddDataPoint(voltage);
var data = new VoltageData
{
Voltage = voltage,
IsAbnormal = CheckAbnormal(voltage)
};
_recorder.AddData(data);
DataStatus.Text = $"最新电压: {voltage:F2}V";
}
}
private bool CheckAbnormal(double voltage)
{
// 实现异常检测逻辑
return voltage > 10.0;
}
private void StartBtn_Click(object sender, RoutedEventArgs e)
{
_sampler.Start();
ConnectionStatus.Text = "已连接";
}
private void StopBtn_Click(object sender, RoutedEventArgs e)
{
_sampler.Stop();
ConnectionStatus.Text = "已断开";
}
}
8. 项目部署与维护
8.1 安装包制作
使用Inno Setup制作安装程序:
ini复制[Setup]
AppName=工业监控系统
AppVersion=1.0
DefaultDirName={pf}\IndustrialMonitor
DefaultGroupName=工业监控系统
OutputDir=output
OutputBaseFilename=IndustrialMonitorSetup
Compression=lzma
SolidCompression=yes
[Files]
Source: "bin\Release\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs
[Icons]
Name: "{group}\工业监控系统"; Filename: "{app}\MonitorSystem.exe"
Name: "{commondesktop}\工业监控系统"; Filename: "{app}\MonitorSystem.exe"
[Run]
Filename: "{app}\MonitorSystem.exe"; Description: "启动监控系统"; Flags: postinstall nowait skipifsilent
8.2 日志系统设计
csharp复制public static class Logger
{
private static readonly string LogPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
private static readonly object LockObj = new object();
static Logger()
{
Directory.CreateDirectory(LogPath);
}
public static void Log(LogLevel level, string message, Exception ex = null)
{
lock(LockObj)
{
string date = DateTime.Now.ToString("yyyyMMdd");
string logFile = Path.Combine(LogPath, $"log_{date}.txt");
var logContent = new StringBuilder();
logContent.Append($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] ");
logContent.Append($"{level.ToString().ToUpper()}: ");
logContent.Append(message);
if(ex != null)
{
logContent.AppendLine();
logContent.Append($"Exception: {ex.Message}");
logContent.AppendLine();
logContent.Append($"StackTrace: {ex.StackTrace}");
}
File.AppendAllText(logFile, logContent.ToString() + Environment.NewLine);
}
}
}
public enum LogLevel
{
Debug,
Info,
Warning,
Error
}
9. 性能优化技巧
9.1 通信性能优化
- 批量读取优化:
csharp复制// 批量读取多个寄存器
public ushort[] ReadMultipleRegisters(ushort startAddress, ushort[] addresses)
{
// 将相邻地址合并为连续读取
var ranges = MergeContinuousRanges(addresses);
var results = new Dictionary<ushort, ushort>();
foreach(var range in ranges)
{
var data = _master.ReadHoldingRegisters(_config.SlaveId, range.Start, range.Count);
for(int i = 0; i < data.Length; i++)
{
results.Add((ushort)(range.Start + i), data[i]);
}
}
// 按原始请求顺序返回结果
return addresses.Select(addr => results[addr]).ToArray();
}
- 异步通信模式:
csharp复制public async Task<ushort[]> ReadHoldingRegistersAsync(ushort startAddress, ushort numberOfPoints)
{
return await Task.Run(() =>
{
return _master.ReadHoldingRegisters(_config.SlaveId, startAddress, numberOfPoints);
});
}
9.2 界面渲染优化
- 曲线渲染优化:
csharp复制// 控制刷新频率
private DateTime _lastRenderTime = DateTime.MinValue;
private const double MinRenderInterval = 50; // ms
public void AddDataPointWithThrottle(double value)
{
var now = DateTime.Now;
if((now - _lastRenderTime).TotalMilliseconds < MinRenderInterval)
{
return;
}
AddDataPoint(value);
_lastRenderTime = now;
}
- 数据采样降噪:
csharp复制public class DataFilter
{
private readonly int _windowSize;
private readonly Queue<double> _window;
public DataFilter(int windowSize = 5)
{
_windowSize = windowSize;
_window = new Queue<double>(windowSize);
}
public double Filter(double rawValue)
{
_window.Enqueue(rawValue);
if(_window.Count > _windowSize)
{
_window.Dequeue();
}
return _window.Average();
}
}
10. 实际应用中的经验分享
10.1 工业现场常见问题
- 通信中断处理:
- 实现心跳检测机制,定期检查连接状态
- 自动重连逻辑应包含指数退避策略
- 关键数据点应缓存并在恢复后补传
- 信号干扰应对:
- 在采集卡输入端增加硬件滤波器
- 软件端实现数字滤波算法
- 异常值检测与剔除机制
- 大容量数据存储优化:
- 采用分表存储策略,按小时或按设备分表
- 定期归档旧数据
- 建立适当的索引提高查询效率
10.2 调试技巧
- Modbus通信调试:
- 使用Modbus Poll等工具验证PLC通信
- 记录原始通信报文便于排查协议问题
- 逐步增加通信频率测试系统稳定性
- 性能瓶颈定位:
- 使用Stopwatch精确测量各环节耗时
- 重点关注数据库操作和界面渲染
- 采用生产者-消费者模式解耦数据采集与处理
- 内存泄漏排查:
- 定期检查各模块的内存占用
- 特别注意事件订阅和静态引用
- 使用内存分析工具定位问题
11. 项目扩展方向
11.1 功能扩展建议
- 多PLC支持:
- 设计可配置的PLC列表
- 实现通信负载均衡
- 统一数据模型处理不同PLC数据
- 报警管理:
- 多级报警配置(预警、一般报警、严重报警)
- 声光报警、短信通知等多样化报警方式
- 报警确认与处理流程记录
- 远程监控:
- 基于WebSocket实现实时数据推送
- 安全认证与权限控制
- 移动端适配
11.2 架构优化方向
- 微服务化改造:
- 将通信、采集、存储等功能拆分为独立服务
- 通过消息队列实现服务间通信
- 容器化部署提高可维护性
- 配置中心:
- 集中管理系统所有配置参数
- 支持热更新无需重启应用
- 版本管理与回滚功能
- 数据中台集成:
- 对接企业数据中台
- 提供标准化数据接口
- 支持大数据分析场景
