C#上位机从0到1:WinForms+串口通信,搭建工业数据采集系统(保姆级)

一、前言:为什么选WinForms+串口通信?

工业场景中,传感器、PLC、智能仪表等设备大多通过 RS232/RS485串口 输出数据(如温度、压力、转速等),而WinForms是C#最成熟的桌面UI框架,具有:

上手快:拖拽式UI设计,无需复杂前端知识;兼容性强:支持Windows XP/7/10/11,适配工业现场老旧电脑;串口支持完善:.NET内置
System.IO.Ports
库,无需额外依赖;轻量高效:程序体积小,运行占用资源少,适合工业嵌入式PC。

本教程从环境搭建→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
:工业级日志记录;
OxyPlot.WindowsForms
:实时曲线绘制(比WinForms自带Chart更流畅)。

3.2 硬件环境(可选,无硬件也能测试)

串口设备:工业传感器(如温度传感器DS18B20、压力传感器PT100)、PLC(如西门子S7-200)、USB-RS485适配器(如CH340、PL2303);测试替代:无硬件时,用「虚拟串口工具」(如VSPD)创建一对虚拟串口(COM3/COM4),用串口助手(如SecureCRT)模拟设备发送数据。

四、 step1:创建WinForms项目

打开Visual Studio 2022,点击「创建新项目」;搜索「Windows 窗体应用(.NET)」,选择后点击「下一步」;项目名称:
IndustrialDataCollection
,保存路径自定义,点击「创建」;框架选择:.NET 6.0(长期支持版),点击「创建」;等待项目加载完成,默认生成
Form1.cs
(主窗口)。

3.3 安装依赖库

右键项目名称→「管理NuGet程序包」;切换到「浏览」标签,分别搜索以下包并安装(版本选最新稳定版):

System.Data.SQLite.Core

Serilog

Serilog.Sinks.Console

Serilog.Sinks.File

OxyPlot.WindowsForms

安装完成后,可在「已安装」标签中查看,确保无遗漏。

五、 step2:UI设计(拖拽式操作,全程可视化)

打开
Form1.cs [设计]
,按以下步骤拖拽控件,最终UI布局如下(可根据喜好调整大小和位置):

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+串口通信,搭建工业数据采集系统(保姆级) - 宋马
(注:实际设计时,可通过「属性窗口」调整控件大小、字体、颜色,让界面更美观)

六、 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访问

串口的
DataReceived
事件是子线程触发的,不能直接操作WinForms的UI控件(如
txtRawData

plotView
),否则会报错。因此必须用
this.Invoke(new Action(() => { ... }))
将UI操作切换到主线程。

(2)数据解析逻辑(核心!必须根据实际设备修改)

本教程模拟的设备数据格式是
T:25.5,P:0.8
(温度25.5℃,压力0.8MPa),实际工业设备的协议各不相同(如Modbus RTU、自定义十六进制协议),需按设备手册修改
ParseDeviceData
方法:

例1:如果设备发送十六进制数据
01 03 04 00 FA 00 64 78 39
(Modbus RTU协议),需用Modbus解析库(如NModbus)解析;例2:如果设备发送固定长度ASCII数据
20240520153000255080
(时间+温度10+压力100),需按位置截取并转换。

(3)数据批量存储(提高性能)

频繁单条写入数据库会导致IO压力大,因此用
_dataCache
列表缓存数据,每10条批量写入数据库,可根据实际采集频率调整缓存大小。

(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

Parity.Even
,硬件自动校验;自定义校验位:设备发送数据时附带校验位(如CRC16、和校验),上位机接收后验证。

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:未调用
_plotModel.InvalidatePlot(true)
刷新图表;解决1:确保添加数据点后调用刷新方法;原因2:数据点过多(超过1000个);解决2:限制曲线显示的最大数据点数量(如本教程的100个)。

十、 总结

本教程从0到1搭建了一个功能完整的工业数据采集系统,涵盖WinForms UI设计、串口通信、数据解析、实时曲线、数据存储、日志记录等核心功能,新手可直接复用代码,只需修改
ParseDeviceData
方法适配实际设备。

后续可扩展的功能:

支持Modbus RTU/TCP协议(集成NModbus库);历史数据查询(按时间范围筛选);数据超限告警(弹窗+声音提示);网络远程传输(将数据上传到服务器);多设备同时采集(多串口/多线程)。

按照步骤操作,即使是新手也能在1-2小时内完成搭建,快速落地工业现场的数据采集需求。

© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容