一、前言:为什么选WinForms+串口通信?
工业场景中,传感器、PLC、智能仪表等设备大多通过 RS232/RS485串口 输出数据(如温度、压力、转速等),而WinForms是C#最成熟的桌面UI框架,具有:
上手快:拖拽式UI设计,无需复杂前端知识;兼容性强:支持Windows XP/7/10/11,适配工业现场老旧电脑;串口支持完善:.NET内置库,无需额外依赖;轻量高效:程序体积小,运行占用资源少,适合工业嵌入式PC。
System.IO.Ports
本教程从环境搭建→UI设计→串口通信→数据解析→实时显示→数据存储→异常处理,全程保姆级步骤,新手也能跟着实现一套可落地的工业数据采集系统。
二、最终效果预览
完成后将获得一个功能完整的工业数据采集上位机,支持:
串口自动枚举、参数配置(波特率、数据位、校验位等);实时采集串口数据(ASCII/十六进制);数据解析后实时曲线显示(温度、压力等);采集数据本地存储(SQLite),支持历史数据查询/导出;串口自动重连、异常提示、日志记录;采集启停控制、数据清空、参数保存等功能。
三、环境准备(新手必看)
3.1 软件环境
开发工具:Visual Studio 2022(社区版免费,官网直接下载);.NET框架:.NET 6.0(VS2022默认自带,无需额外安装);依赖库(NuGet安装):
:轻量级本地数据库,存储采集数据;
System.Data.SQLite.Core+
Serilog+
Serilog.Sinks.Console:工业级日志记录;
Serilog.Sinks.File:实时曲线绘制(比WinForms自带Chart更流畅)。
OxyPlot.WindowsForms
3.2 硬件环境(可选,无硬件也能测试)
串口设备:工业传感器(如温度传感器DS18B20、压力传感器PT100)、PLC(如西门子S7-200)、USB-RS485适配器(如CH340、PL2303);测试替代:无硬件时,用「虚拟串口工具」(如VSPD)创建一对虚拟串口(COM3/COM4),用串口助手(如SecureCRT)模拟设备发送数据。
四、 step1:创建WinForms项目
打开Visual Studio 2022,点击「创建新项目」;搜索「Windows 窗体应用(.NET)」,选择后点击「下一步」;项目名称:,保存路径自定义,点击「创建」;框架选择:.NET 6.0(长期支持版),点击「创建」;等待项目加载完成,默认生成
IndustrialDataCollection(主窗口)。
Form1.cs
3.3 安装依赖库
右键项目名称→「管理NuGet程序包」;切换到「浏览」标签,分别搜索以下包并安装(版本选最新稳定版):
System.Data.SQLite.Core
Serilog
Serilog.Sinks.Console
Serilog.Sinks.File
OxyPlot.WindowsForms
安装完成后,可在「已安装」标签中查看,确保无遗漏。
五、 step2:UI设计(拖拽式操作,全程可视化)
打开,按以下步骤拖拽控件,最终UI布局如下(可根据喜好调整大小和位置):
Form1.cs [设计]
5.1 UI控件布局与属性设置
| 控件类型 | 控件名称(Name) | 用途 | 关键属性设置 |
|---|---|---|---|
| GroupBox | gbSerialConfig | 串口配置区域 | Text=“串口配置” |
| ComboBox | cboSerialPort | 选择串口(如COM3) | DropDownStyle=DropDownList |
| ComboBox | cboBaudRate | 选择波特率 | Items添加:9600、19200、38400、115200;默认9600 |
| ComboBox | cboDataBits | 选择数据位 | Items添加:8;默认8 |
| ComboBox | cboParity | 选择校验位 | Items添加:None、Odd、Even;默认None |
| ComboBox | cboStopBits | 选择停止位 | Items添加:One、Two;默认One |
| Button | btnConnect | 连接/断开串口 | Text=“连接串口” |
| GroupBox | gbDataDisplay | 数据显示区域 | Text=“数据显示” |
| RadioButton | rbAscii | ASCII显示模式 | Text=“ASCII”;Checked=true |
| RadioButton | rbHex | 十六进制显示模式 | Text=“十六进制” |
| TextBox | txtRawData | 显示原始串口数据 | Multiline=true;ScrollBars=Vertical;ReadOnly=true |
| GroupBox | gbRealTimeChart | 实时曲线区域 | Text=“实时数据曲线” |
| OxyPlot.WindowsForms.PlotView | plotView | 绘制温度/压力曲线 | Dock=Fill |
| GroupBox | gbControl | 控制区域 | Text=“控制操作” |
| Button | btnStartCollect | 开始采集 | Text=“开始采集” |
| Button | btnStopCollect | 停止采集 | Text=“停止采集”;Enabled=false |
| Button | btnClearData | 清空显示数据 | Text=“清空数据” |
| Button | btnExportData | 导出历史数据 | Text=“导出数据” |
| GroupBox | gbLog | 日志显示区域 | Text=“系统日志” |
| TextBox | txtLog | 显示系统日志(连接/异常等) | Multiline=true;ScrollBars=Vertical;ReadOnly=true |
5.2 UI设计最终效果
![图片[1] - C#上位机从0到1:WinForms+串口通信,搭建工业数据采集系统(保姆级) - 宋马](https://img-blog.csdnimg.cn/20240520153128231.png)
(注:实际设计时,可通过「属性窗口」调整控件大小、字体、颜色,让界面更美观)
六、 step3:核心代码实现(分模块,注释详细)
6.1 全局变量与初始化(Form1.cs)
先定义全局变量(串口对象、数据库对象、采集状态等),并在中初始化日志、串口列表、曲线等。
Form_Load
using System;
using System.Collections.Generic;
using System.IO.Ports;
using System.Windows.Forms;
using Serilog;
using System.Data.SQLite;
using OxyPlot;
using OxyPlot.Series;
using OxyPlot.Axes;
using System.IO;
using System.Text;
namespace IndustrialDataCollection
{
public partial class Form1 : Form
{
#region 全局变量
// 串口对象
private SerialPort _serialPort = new SerialPort();
// 采集状态(是否正在采集)
private bool _isCollecting = false;
// 数据库连接字符串(数据存储在程序目录下的DataCollection.db)
private readonly string _dbConnectionString = $"Data Source={Application.StartupPath}DataCollection.db;Version=3;";
// 实时曲线相关
private PlotModel _plotModel;
private LineSeries _tempSeries; // 温度曲线
private LineSeries _pressSeries; // 压力曲线
private int _dataPointIndex = 0; // 数据点索引(x轴)
// 缓存采集数据(批量写入数据库,提高性能)
private List<CollectedData> _dataCache = new List<CollectedData>();
#endregion
#region 数据模型(采集数据实体)
/// <summary>
/// 采集数据实体(对应数据库表)
/// </summary>
private class CollectedData
{
public DateTime CollectTime { get; set; } // 采集时间
public string RawData { get; set; } // 原始数据
public float Temperature { get; set; } // 解析后的温度
public float Pressure { get; set; } // 解析后的压力
}
#endregion
public Form1()
{
InitializeComponent();
// 初始化日志(程序启动时执行)
InitLog();
// 初始化数据库(创建数据表)
InitDatabase();
}
/// <summary>
/// 窗体加载时初始化
/// </summary>
private void Form1_Load(object sender, EventArgs e)
{
// 1. 枚举可用串口并添加到下拉框
EnumSerialPorts();
// 2. 初始化实时曲线
InitRealTimeChart();
// 3. 绑定串口事件(数据接收事件)
_serialPort.DataReceived += SerialPort_DataReceived;
// 4. 加载上次保存的串口参数(可选,提升用户体验)
LoadSerialConfig();
Log.Information("系统启动成功,等待串口连接...");
AppendLog("系统启动成功,等待串口连接...");
}
#region 初始化模块(日志、数据库、曲线)
/// <summary>
/// 初始化Serilog日志(记录系统操作、异常)
/// </summary>
private void InitLog()
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.WriteTo.Console() // 控制台输出(开发时用)
.WriteTo.File(
path: $"{Application.StartupPath}Logssystem_log_.log",
rollingInterval: RollingInterval.Day, // 按天滚动日志
encoding: Encoding.UTF8,
retainedFileCountLimit: 30 // 保留30天日志
)
.CreateLogger();
}
/// <summary>
/// 初始化SQLite数据库(创建采集数据表)
/// </summary>
private void InitDatabase()
{
try
{
// 检查数据库文件是否存在,不存在则自动创建
if (!File.Exists(Application.StartupPath + "DataCollection.db"))
{
SQLiteConnection.CreateFile(Application.StartupPath + "DataCollection.db");
Log.Information("数据库文件创建成功");
}
// 连接数据库并创建表
using (var conn = new SQLiteConnection(_dbConnectionString))
{
conn.Open();
string createTableSql = @"
CREATE TABLE IF NOT EXISTS CollectedData (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
CollectTime DATETIME NOT NULL,
RawData TEXT NOT NULL,
Temperature REAL,
Pressure REAL
);";
using (var cmd = new SQLiteCommand(createTableSql, conn))
{
cmd.ExecuteNonQuery();
}
Log.Information("数据库表初始化成功");
AppendLog("数据库初始化成功");
}
}
catch (Exception ex)
{
Log.Error(ex, "数据库初始化失败");
AppendLog($"数据库初始化失败:{ex.Message}");
MessageBox.Show($"数据库初始化失败:{ex.Message}", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
/// <summary>
/// 初始化实时曲线(OxyPlot)
/// </summary>
private void InitRealTimeChart()
{
// 1. 创建图表模型
_plotModel = new PlotModel { Title = "温度(℃)& 压力(MPa)实时曲线" };
// 2. 添加X轴(时间轴,仅显示索引)
var xAxis = new LinearAxis
{
Title = "采集次数",
Position = AxisPosition.Bottom,
MajorGridlineStyle = LineStyle.Solid,
MinorGridlineStyle = LineStyle.Dot
};
_plotModel.Axes.Add(xAxis);
// 3. 添加Y轴(数值轴,温度和压力共用)
var yAxis = new LinearAxis
{
Title = "数值",
Position = AxisPosition.Left,
MajorGridlineStyle = LineStyle.Solid,
MinorGridlineStyle = LineStyle.Dot,
Minimum = 0, // 最小值(可根据实际场景调整)
Maximum = 100 // 最大值(可动态调整)
};
_plotModel.Axes.Add(yAxis);
// 4. 创建温度曲线(红色)
_tempSeries = new LineSeries
{
Title = "温度(℃)",
Color = OxyColors.Red,
MarkerType = MarkerType.Circle,
MarkerSize = 3,
MarkerFill = OxyColors.Red
};
// 5. 创建压力曲线(蓝色)
_pressSeries = new LineSeries
{
Title = "压力(MPa)",
Color = OxyColors.Blue,
MarkerType = MarkerType.Square,
MarkerSize = 3,
MarkerFill = OxyColors.Blue
};
// 6. 将曲线添加到图表
_plotModel.Series.Add(_tempSeries);
_plotModel.Series.Add(_pressSeries);
// 7. 绑定到PlotView控件
plotView.Model = _plotModel;
}
/// <summary>
/// 枚举可用串口(COM口)
/// </summary>
private void EnumSerialPorts()
{
try
{
// 清空下拉框
cboSerialPort.Items.Clear();
// 获取所有可用串口
string[] serialPorts = SerialPort.GetPortNames();
if (serialPorts.Length == 0)
{
cboSerialPort.Items.Add("无可用串口");
AppendLog("无可用串口,请检查硬件连接");
Log.Warn("无可用串口");
return;
}
// 添加到下拉框
foreach (var port in serialPorts)
{
cboSerialPort.Items.Add(port);
}
// 默认选择第一个串口
cboSerialPort.SelectedIndex = 0;
AppendLog($"检测到可用串口:{string.Join(",", serialPorts)}");
}
catch (Exception ex)
{
Log.Error(ex, "枚举串口失败");
AppendLog($"枚举串口失败:{ex.Message}");
}
}
#endregion
#region 串口操作模块(连接、断开、数据接收)
/// <summary>
/// 连接/断开串口按钮点击事件
/// </summary>
private void btnConnect_Click(object sender, EventArgs e)
{
if (!_serialPort.IsOpen)
{
// 连接串口
if (ConnectSerialPort())
{
btnConnect.Text = "断开串口";
gbSerialConfig.Enabled = false; // 连接后禁用配置修改
btnStartCollect.Enabled = true;
AppendLog("串口连接成功");
}
}
else
{
// 断开串口
DisconnectSerialPort();
btnConnect.Text = "连接串口";
gbSerialConfig.Enabled = true;
btnStartCollect.Enabled = false;
btnStopCollect.Enabled = false;
_isCollecting = false;
AppendLog("串口断开连接");
}
}
/// <summary>
/// 连接串口
/// </summary>
private bool ConnectSerialPort()
{
try
{
// 检查串口是否已被占用
if (_serialPort.IsOpen)
{
_serialPort.Close();
}
// 配置串口参数
_serialPort.PortName = cboSerialPort.SelectedItem.ToString();
_serialPort.BaudRate = int.Parse(cboBaudRate.SelectedItem.ToString());
_serialPort.DataBits = int.Parse(cboDataBits.SelectedItem.ToString());
// 校验位转换(ComboBox文本→Parity枚举)
_serialPort.Parity = cboParity.SelectedItem.ToString() switch
{
"None" => Parity.None,
"Odd" => Parity.Odd,
"Even" => Parity.Even,
_ => Parity.None
};
// 停止位转换(ComboBox文本→StopBits枚举)
_serialPort.StopBits = cboStopBits.SelectedItem.ToString() switch
{
"One" => StopBits.One,
"Two" => StopBits.Two,
_ => StopBits.One
};
_serialPort.ReadTimeout = 500; // 读取超时时间(毫秒)
_serialPort.WriteTimeout = 500; // 写入超时时间(毫秒)
_serialPort.NewLine = "
"; // 换行符(根据设备调整)
// 打开串口
_serialPort.Open();
// 保存串口配置(下次启动自动加载)
SaveSerialConfig();
Log.Information($"串口连接成功:{_serialPort.PortName} {_serialPort.BaudRate},{_serialPort.DataBits},{_serialPort.Parity},{_serialPort.StopBits}");
return true;
}
catch (Exception ex)
{
Log.Error(ex, "串口连接失败");
AppendLog($"串口连接失败:{ex.Message}");
MessageBox.Show($"串口连接失败:{ex.Message}", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
return false;
}
}
/// <summary>
/// 断开串口
/// </summary>
private void DisconnectSerialPort()
{
try
{
if (_serialPort.IsOpen)
{
_serialPort.Close();
Log.Information($"串口断开:{_serialPort.PortName}");
}
}
catch (Exception ex)
{
Log.Error(ex, "串口断开失败");
AppendLog($"串口断开失败:{ex.Message}");
}
}
/// <summary>
/// 串口数据接收事件(核心:获取设备发送的数据)
/// </summary>
private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
{
try
{
if (!_isCollecting || !_serialPort.IsOpen)
return;
// 读取串口数据(两种方式:按字节读/按行读,根据设备选择)
// 方式1:按字节读(适合固定长度数据)
// int byteCount = _serialPort.BytesToRead;
// byte[] buffer = new byte[byteCount];
// _serialPort.Read(buffer, 0, byteCount);
// 方式2:按行读(适合带换行符的ASCII数据,本教程用这种)
string rawData = _serialPort.ReadLine().Trim(); // 读取一行数据并去除首尾空格
// 跨线程访问UI(串口接收事件是子线程,不能直接操作UI控件)
this.Invoke(new Action(() =>
{
// 显示原始数据(ASCII/十六进制)
if (rbAscii.Checked)
{
txtRawData.AppendText($"[{DateTime.Now:HH:mm:ss}] {rawData}
");
}
else
{
// 转换为十六进制显示
string hexData = ByteArrayToHexString(Encoding.ASCII.GetBytes(rawData));
txtRawData.AppendText($"[{DateTime.Now:HH:mm:ss}] {hexData}
");
}
// 自动滚动到最新数据
txtRawData.ScrollToCaret();
// 解析数据(关键:根据设备协议解析温度、压力,这里模拟解析)
if (ParseDeviceData(rawData, out float temperature, out float pressure))
{
// 缓存数据(批量写入数据库,减少IO操作)
_dataCache.Add(new CollectedData
{
CollectTime = DateTime.Now,
RawData = rawData,
Temperature = temperature,
Pressure = pressure
});
// 每10条数据批量写入数据库(可调整)
if (_dataCache.Count >= 10)
{
BatchInsertDataToDb();
}
// 更新实时曲线
UpdateRealTimeChart(temperature, pressure);
}
}));
}
catch (Exception ex)
{
Log.Error(ex, "串口数据接收失败");
this.Invoke(new Action(() =>
{
AppendLog($"数据接收失败:{ex.Message}");
}));
}
}
/// <summary>
/// 保存串口配置到配置文件(下次启动自动加载)
/// </summary>
private void SaveSerialConfig()
{
Properties.Settings.Default.SerialPortName = cboSerialPort.SelectedItem.ToString();
Properties.Settings.Default.BaudRate = cboBaudRate.SelectedItem.ToString();
Properties.Settings.Default.DataBits = cboDataBits.SelectedItem.ToString();
Properties.Settings.Default.Parity = cboParity.SelectedItem.ToString();
Properties.Settings.Default.StopBits = cboStopBits.SelectedItem.ToString();
Properties.Settings.Default.Save(); // 保存配置
}
/// <summary>
/// 加载上次保存的串口配置
/// </summary>
private void LoadSerialConfig()
{
try
{
// 检查配置是否存在
if (!string.IsNullOrEmpty(Properties.Settings.Default.SerialPortName))
{
cboSerialPort.SelectedItem = Properties.Settings.Default.SerialPortName;
cboBaudRate.SelectedItem = Properties.Settings.Default.BaudRate;
cboDataBits.SelectedItem = Properties.Settings.Default.DataBits;
cboParity.SelectedItem = Properties.Settings.Default.Parity;
cboStopBits.SelectedItem = Properties.Settings.Default.StopBits;
AppendLog("加载上次串口配置成功");
}
}
catch (Exception ex)
{
Log.Warn(ex, "加载串口配置失败,使用默认配置");
AppendLog("加载串口配置失败,使用默认配置");
}
}
#endregion
#region 数据处理模块(解析、存储、曲线更新)
/// <summary>
/// 解析设备数据(核心:根据实际设备协议修改!!!)
/// 本教程模拟协议:设备发送格式为 "T:25.5,P:0.8"(T=温度,P=压力)
/// </summary>
private bool ParseDeviceData(string rawData, out float temperature, out float pressure)
{
temperature = 0;
pressure = 0;
try
{
// 模拟解析:按逗号分割数据
string[] dataParts = rawData.Split(',');
if (dataParts.Length != 2)
{
AppendLog($"数据格式错误:{rawData}(预期格式:T:xx.x,P:xx.x)");
return false;
}
// 解析温度(T:25.5 → 25.5)
string tempStr = dataParts[0].Split(':')[1];
if (!float.TryParse(tempStr, out temperature))
{
AppendLog($"温度解析失败:{dataParts[0]}");
return false;
}
// 解析压力(P:0.8 → 0.8)
string pressStr = dataParts[1].Split(':')[1];
if (!float.TryParse(pressStr, out pressure))
{
AppendLog($"压力解析失败:{dataParts[1]}");
return false;
}
// 解析成功,日志输出
AppendLog($"解析成功:温度={temperature:F1}℃,压力={pressure:F1}MPa");
return true;
}
catch (Exception ex)
{
AppendLog($"数据解析失败:{ex.Message}(原始数据:{rawData})");
Log.Error(ex, $"数据解析失败:{rawData}");
return false;
}
}
/// <summary>
/// 批量插入数据到数据库(提高性能)
/// </summary>
private void BatchInsertDataToDb()
{
try
{
using (var conn = new SQLiteConnection(_dbConnectionString))
{
conn.Open();
// 开启事务(批量插入更快)
using (var transaction = conn.BeginTransaction())
{
string insertSql = @"
INSERT INTO CollectedData (CollectTime, RawData, Temperature, Pressure)
VALUES (@CollectTime, @RawData, @Temperature, @Pressure);";
using (var cmd = new SQLiteCommand(insertSql, conn, transaction))
{
// 添加参数(避免SQL注入)
cmd.Parameters.AddWithValue("@CollectTime", DateTime.Now);
cmd.Parameters.AddWithValue("@RawData", "");
cmd.Parameters.AddWithValue("@Temperature", 0);
cmd.Parameters.AddWithValue("@Pressure", 0);
foreach (var data in _dataCache)
{
cmd.Parameters["@CollectTime"].Value = data.CollectTime;
cmd.Parameters["@RawData"].Value = data.RawData;
cmd.Parameters["@Temperature"].Value = data.Temperature;
cmd.Parameters["@Pressure"].Value = data.Pressure;
cmd.ExecuteNonQuery();
}
}
// 提交事务
transaction.Commit();
Log.Information($"批量插入{_dataCache.Count}条数据到数据库");
// 清空缓存
_dataCache.Clear();
}
}
}
catch (Exception ex)
{
Log.Error(ex, "批量插入数据库失败");
AppendLog($"数据存储失败:{ex.Message}");
}
}
/// <summary>
/// 更新实时曲线
/// </summary>
private void UpdateRealTimeChart(float temperature, float pressure)
{
// 添加数据点
_tempSeries.Points.Add(new DataPoint(_dataPointIndex, temperature));
_pressSeries.Points.Add(new DataPoint(_dataPointIndex, pressure));
_dataPointIndex++;
// 限制曲线显示点数(仅显示最近100个数据点,避免卡顿)
if (_tempSeries.Points.Count > 100)
{
_tempSeries.Points.RemoveAt(0);
_pressSeries.Points.RemoveAt(0);
// 调整X轴范围(自动滚动)
((LinearAxis)_plotModel.Axes[0]).Minimum = _dataPointIndex - 100;
((LinearAxis)_plotModel.Axes[0]).Maximum = _dataPointIndex;
}
// 刷新图表
_plotModel.InvalidatePlot(true);
}
/// <summary>
/// 字节数组转换为十六进制字符串(用于十六进制显示)
/// </summary>
private string ByteArrayToHexString(byte[] buffer)
{
StringBuilder sb = new StringBuilder();
foreach (byte b in buffer)
{
sb.AppendFormat("{0:X2} ", b);
}
return sb.ToString().Trim();
}
#endregion
#region 控制操作模块(采集启停、清空、导出)
/// <summary>
/// 开始采集按钮点击事件
/// </summary>
private void btnStartCollect_Click(object sender, EventArgs e)
{
_isCollecting = true;
btnStartCollect.Enabled = false;
btnStopCollect.Enabled = true;
AppendLog("开始采集数据...");
Log.Information("采集启动");
}
/// <summary>
/// 停止采集按钮点击事件
/// </summary>
private void btnStopCollect_Click(object sender, EventArgs e)
{
_isCollecting = false;
btnStartCollect.Enabled = true;
btnStopCollect.Enabled = false;
// 剩余缓存数据写入数据库
if (_dataCache.Count > 0)
{
BatchInsertDataToDb();
}
AppendLog("停止采集数据");
Log.Information("采集停止");
}
/// <summary>
/// 清空数据按钮点击事件
/// </summary>
private void btnClearData_Click(object sender, EventArgs e)
{
if (MessageBox.Show("确定要清空显示数据吗?(数据库数据不会删除)", "确认", MessageBoxButtons.YesNo, MessageBoxIcon.Question) == DialogResult.Yes)
{
txtRawData.Clear();
// 清空曲线
_tempSeries.Points.Clear();
_pressSeries.Points.Clear();
_dataPointIndex = 0;
_plotModel.InvalidatePlot(true);
AppendLog("显示数据已清空");
}
}
/// <summary>
/// 导出历史数据按钮点击事件(导出为CSV文件,可用Excel打开)
/// </summary>
private void btnExportData_Click(object sender, EventArgs e)
{
try
{
// 选择保存路径
SaveFileDialog saveFileDialog = new SaveFileDialog
{
Filter = "CSV文件 (*.csv)|*.csv|所有文件 (*.*)|*.*",
Title = "导出历史数据",
FileName = $"采集数据_{DateTime.Now:yyyyMMddHHmmss}.csv",
InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.Desktop)
};
if (saveFileDialog.ShowDialog() == DialogResult.OK)
{
// 读取数据库数据
List<CollectedData> allData = new List<CollectedData>();
using (var conn = new SQLiteConnection(_dbConnectionString))
{
conn.Open();
string querySql = "SELECT CollectTime, RawData, Temperature, Pressure FROM CollectedData ORDER BY CollectTime DESC;";
using (var cmd = new SQLiteCommand(querySql, conn))
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
allData.Add(new CollectedData
{
CollectTime = Convert.ToDateTime(reader["CollectTime"]),
RawData = reader["RawData"].ToString(),
Temperature = Convert.ToSingle(reader["Temperature"]),
Pressure = Convert.ToSingle(reader["Pressure"])
});
}
}
}
// 写入CSV文件
using (var sw = new StreamWriter(saveFileDialog.FileName, false, Encoding.UTF8))
{
// 写入表头
sw.WriteLine("采集时间,原始数据,温度(℃),压力(MPa)");
// 写入数据
foreach (var data in allData)
{
sw.WriteLine($"{data.CollectTime:yyyy-MM-dd HH:mm:ss},{data.RawData},{data.Temperature:F1},{data.Pressure:F1}");
}
}
MessageBox.Show($"导出成功!共导出{allData.Count}条数据", "成功", MessageBoxButtons.OK, MessageBoxIcon.Information);
AppendLog($"导出历史数据成功:{saveFileDialog.FileName}");
Log.Information($"导出{allData.Count}条数据到{saveFileDialog.FileName}");
}
}
catch (Exception ex)
{
Log.Error(ex, "导出数据失败");
AppendLog($"导出数据失败:{ex.Message}");
MessageBox.Show($"导出数据失败:{ex.Message}", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
#endregion
#region 辅助方法(日志追加、窗体关闭事件)
/// <summary>
/// 追加日志到txtLog控件
/// </summary>
private void AppendLog(string logMsg)
{
if (txtLog.InvokeRequired)
{
// 跨线程访问UI
txtLog.Invoke(new Action(() => AppendLog(logMsg)));
return;
}
txtLog.AppendText($"[{DateTime.Now:HH:mm:ss}] {logMsg}
");
txtLog.ScrollToCaret(); // 自动滚动到最新日志
}
/// <summary>
/// 窗体关闭事件(释放资源)
/// </summary>
private void Form1_FormClosing(object sender, FormClosingEventArgs e)
{
// 断开串口
if (_serialPort.IsOpen)
{
DisconnectSerialPort();
}
// 剩余缓存数据写入数据库
if (_dataCache.Count > 0)
{
BatchInsertDataToDb();
}
// 关闭日志
Log.CloseAndFlush();
}
#endregion
}
}
6.2 关键代码说明(新手重点看)
(1)串口数据接收与跨线程UI访问
串口的事件是子线程触发的,不能直接操作WinForms的UI控件(如
DataReceived、
txtRawData),否则会报错。因此必须用
plotView将UI操作切换到主线程。
this.Invoke(new Action(() => { ... }))
(2)数据解析逻辑(核心!必须根据实际设备修改)
本教程模拟的设备数据格式是 (温度25.5℃,压力0.8MPa),实际工业设备的协议各不相同(如Modbus RTU、自定义十六进制协议),需按设备手册修改
T:25.5,P:0.8方法:
ParseDeviceData
例1:如果设备发送十六进制数据 (Modbus RTU协议),需用Modbus解析库(如NModbus)解析;例2:如果设备发送固定长度ASCII数据
01 03 04 00 FA 00 64 78 39(时间+温度10+压力100),需按位置截取并转换。
20240520153000255080
(3)数据批量存储(提高性能)
频繁单条写入数据库会导致IO压力大,因此用列表缓存数据,每10条批量写入数据库,可根据实际采集频率调整缓存大小。
_dataCache
(4)实时曲线优化(避免卡顿)
仅显示最近100个数据点,超过后删除最旧的数据,同时调整X轴范围,实现曲线自动滚动,避免数据过多导致界面卡顿。
七、 step4:测试系统(无硬件也能测)
7.1 无硬件测试(虚拟串口)
下载安装「虚拟串口工具VSPD」(自行百度搜索,免费版可用);打开VSPD,创建一对虚拟串口(如COM3和COM4),点击「Add Pair」;打开「串口助手」(如SecureCRT、SSCOM),配置串口COM4:波特率9600、数据位8、校验位None、停止位1,打开串口;运行我们的上位机,选择串口COM3,点击「连接串口」→「开始采集」;在串口助手中发送模拟数据 (按回车发送),上位机将实时显示数据并绘制曲线。
T:26.8,P:0.9
7.2 硬件测试(实际传感器/PLC)
将USB-RS485适配器插入电脑,安装驱动(CH340驱动官网可下载);传感器/PLC的RS485接口(A/B端子)与适配器的A/B端子对应连接(注意正负极,接反会无法通信);打开上位机,选择对应的串口(如COM3),配置与设备一致的波特率、校验位等参数;点击「连接串口」→「开始采集」,即可看到实时数据。
八、 step5:工业场景优化(可选,提升稳定性)
8.1 串口自动重连(应对意外断开)
在或定时任务中检测串口连接状态,断开时自动重试连接:
SerialPort_DataReceived
/// <summary>
/// 定时检测串口连接状态(添加一个Timer控件,Interval=3000)
/// </summary>
private void timerCheckSerial_Tick(object sender, EventArgs e)
{
if (_serialPort.IsOpen)
return;
// 正在采集时,自动重连
if (_isCollecting)
{
AppendLog("串口意外断开,尝试自动重连...");
if (ConnectSerialPort())
{
AppendLog("串口自动重连成功");
}
else
{
AppendLog("串口自动重连失败,请检查硬件");
}
}
}
8.2 数据校验(避免脏数据)
工业现场存在电磁干扰,数据可能传输错误,需添加校验逻辑:
奇偶校验:串口配置中启用或
Parity.Odd,硬件自动校验;自定义校验位:设备发送数据时附带校验位(如CRC16、和校验),上位机接收后验证。
Parity.Even
8.3 异常处理增强(如串口占用、数据库写失败)
在方法中添加串口占用检测:
ConnectSerialPort
// 检查串口是否被其他程序占用
try
{
_serialPort.Open();
}
catch (UnauthorizedAccessException)
{
AppendLog("串口被其他程序占用,请关闭后重试");
return false;
}
九、 避坑指南(新手常见问题)
9.1 串口连接失败(报错“访问被拒绝”)
原因:串口被其他程序占用(如串口助手、其他上位机);解决:关闭所有占用串口的程序,重新连接。
9.2 看不到可用串口(cboSerialPort显示“无可用串口”)
原因1:USB-RS485适配器未安装驱动;解决1:安装对应驱动(CH340驱动、PL2303驱动);原因2:适配器硬件故障或USB接口损坏;解决2:更换USB接口或适配器。
9.3 能连接串口,但收不到数据
原因1:串口参数(波特率、校验位、停止位)与设备不一致;解决1:核对设备手册,修改上位机的串口配置;原因2:RS485接线错误(A/B接反);解决2:调换适配器的A/B端子接线;原因3:设备未发送数据(如未启动设备、设备无数据输出);解决3:用串口助手确认设备是否正常发送数据。
9.4 数据解析失败(显示“数据格式错误”)
原因:方法的解析逻辑与设备实际发送格式不匹配;解决:用串口助手捕获设备发送的原始数据,按格式修改解析逻辑。
ParseDeviceData
9.5 曲线不显示或卡顿
原因1:未调用刷新图表;解决1:确保添加数据点后调用刷新方法;原因2:数据点过多(超过1000个);解决2:限制曲线显示的最大数据点数量(如本教程的100个)。
_plotModel.InvalidatePlot(true)
十、 总结
本教程从0到1搭建了一个功能完整的工业数据采集系统,涵盖WinForms UI设计、串口通信、数据解析、实时曲线、数据存储、日志记录等核心功能,新手可直接复用代码,只需修改方法适配实际设备。
ParseDeviceData
后续可扩展的功能:
支持Modbus RTU/TCP协议(集成NModbus库);历史数据查询(按时间范围筛选);数据超限告警(弹窗+声音提示);网络远程传输(将数据上传到服务器);多设备同时采集(多串口/多线程)。
按照步骤操作,即使是新手也能在1-2小时内完成搭建,快速落地工业现场的数据采集需求。


















暂无评论内容