C#上位机开发实战:从串口通信到多线程架构的完整指南

0 阅读7分钟

C#上位机开发实战:从串口通信到多线程架构的完整指南

摘要:  在工业自动化领域,上位机(Host Computer)承担着数据采集、设备监控和人机交互的核心职责。本文以一个真实的电池测试系统上位机项目为蓝本,从需求分析、技术选型、架构设计到关键代码实现,完整呈现一个工业级C#上位机软件的开发全流程。文章涵盖串口/以太网通信、多线程处理、数据解析、实时曲线展示及异常处理等核心模块,并分享项目实践中踩过的坑与解决方案。

一、项目背景:为什么要写这个上位机

2024年初,我接手了一个锂电池分容柜的配套软件项目。硬件端是48通道的充放电测试设备,通过RS485总线连接,每台设备每秒上报约200条实时数据(电压、电流、温度、容量、状态码等)。客户的硬性要求是:

  • 同时监控不少于10台设备(即480个通道);
  • 数据显示延迟不超过500ms;
  • 连续运行7×24小时不崩溃;
  • 具备历史数据存储与导出功能。

在评估了组态王、LabVIEW和C#方案后,我最终选择了C# + .NET 6.0 + WinForms作为技术栈。原因很简单:开发周期紧(4周),团队对C#最熟悉,且.NET生态下有成熟的串口库、图表控件和轻量级数据库方案。本文正是这个项目的复盘与提炼。

二、系统架构设计:分层是稳定性的基石

一个好的上位机架构,决定了后期维护和扩展的难易程度。我采用了经典的三层架构,并加入了通信层作为基础设施:

text

复制

下载

┌─────────────────────────────────────────────────────┐
│                    UI层 (WinForms)                   │
│   - 主仪表盘   - 通道详情   - 参数设置   - 报表    │
├─────────────────────────────────────────────────────┤
│                   业务逻辑层 (BLL)                   │
│   - 数据校验   - 阈值告警   - 充放电策略管理        │
│   - 数据聚合   - 统计计算                          │
├─────────────────────────────────────────────────────┤
│                   数据访问层 (DAL)                   │
│   - SQLite操作   - 配置文件读写   - 日志记录        │
├─────────────────────────────────────────────────────┤
│                   通信层 (Communication)             │
│   - SerialPortManager   - TcpClientManager          │
│   - 协议解析器 (ProtocolParser)                     │
│   - 数据缓存队列 (ConcurrentQueue)                  │
└─────────────────────────────────────────────────────┘

设计原则:

  1. 通信与UI隔离:串口/网口读写操作绝对不能阻塞UI线程,这是铁律;
  2. 数据生产者-消费者模式:硬件发来的数据先入队列,由独立线程消费处理;
  3. 配置外部化:通信参数(波特率、数据位、校验方式)、设备地址映射表都存为JSON配置文件,避免硬编码。

三、通信层的核心实现:稳定收发的关键

3.1 串口管理器封装

直接使用.NET自带的System.IO.Ports.SerialPort类存在两个痛点:它不原生支持超时重试,且事件触发机制在高速数据流下容易丢数据。我的封装方案如下:

csharp

复制

下载

public class SerialPortManager : IDisposable
{
    private SerialPort _serialPort;
    private readonly ConcurrentQueue<byte[]> _receivedDataQueue = new();
    private readonly CancellationTokenSource _cts = new();
    private Task _processTask;
    private readonly int _timeoutMs = 5000;

    public event EventHandler<byte[]> DataReceived;

    public bool Open(string portName, int baudRate, Parity parity, int dataBits, StopBits stopBits)
    {
        try
        {
            _serialPort = new SerialPort(portName, baudRate, parity, dataBits, stopBits);
            _serialPort.ReadTimeout = _timeoutMs;
            _serialPort.WriteTimeout = _timeoutMs;
            _serialPort.DataReceived += OnDataReceived;
            _serialPort.Open();
            
            // 启动后台处理任务
            _processTask = Task.Run(ProcessDataQueue, _cts.Token);
            return true;
        }
        catch (Exception ex)
        {
            Logger.Log($"串口打开失败: {ex.Message}");
            return false;
        }
    }

    private void OnDataReceived(object sender, SerialDataReceivedEventArgs e)
    {
        try
        {
            int bytesToRead = _serialPort.BytesToRead;
            byte[] buffer = new byte[bytesToRead];
            int read = _serialPort.Read(buffer, 0, bytesToRead);
            
            if (read > 0)
            {
                // 截取有效数据(假设协议以0xAA 0x55开头)
                byte[] validData = ExtractValidFrame(buffer);
                if (validData != null)
                {
                    _receivedDataQueue.Enqueue(validData);
                }
            }
        }
        catch (TimeoutException)
        {
            // 超时不做特殊处理,避免死循环
        }
        catch (Exception ex)
        {
            Logger.Log($"数据接收异常: {ex.Message}");
        }
    }

    private void ProcessDataQueue()
    {
        while (!_cts.Token.IsCancellationRequested)
        {
            if (_receivedDataQueue.TryDequeue(out byte[] data))
            {
                // 触发上层事件,将数据交给业务逻辑层
                DataReceived?.Invoke(this, data);
            }
            else
            {
                // 队列空时短暂休眠,避免CPU空转
                Thread.Sleep(10);
            }
        }
    }

    public void Dispose()
    {
        _cts.Cancel();
        _processTask?.Wait(1000);
        _serialPort?.Close();
        _serialPort?.Dispose();
    }
}

关键细节:

  • 使用ConcurrentQueue保证线程安全,不需要额外加锁;
  • 接收到数据后不直接处理,而是入队后由独立Task消费,将数据处理与串口读取解耦;
  • 超时设置能防止硬件不响应时线程永久阻塞。

3.2 自定义协议解析器

工业设备的通信协议往往不是标准的Modbus,而是厂家自定义的二进制协议。本例中数据帧格式为:

帧头(2B)地址(1B)命令(1B)数据长度(2B)数据体(NB)CRC16(2B)

csharp

复制

下载

public class ProtocolParser
{
    public static bool TryParse(byte[] rawData, out DeviceData deviceData)
    {
        deviceData = null;
        
        // 最小帧长度校验
        if (rawData.Length < 8) return false;
        
        // 校验帧头
        if (rawData[0] != 0xAA || rawData[1] != 0x55) return false;
        
        // 读取数据长度
        int dataLen = (rawData[3] << 8) | rawData[4];
        if (rawData.Length != 7 + dataLen + 2) return false;
        
        // CRC校验(省略具体算法)
        ushort calcCrc = Crc16.Compute(rawData, 0, rawData.Length - 2);
        ushort recvCrc = (ushort)((rawData[rawData.Length - 2] << 8) | rawData[rawData.Length - 1]);
        if (calcCrc != recvCrc) return false;
        
        // 解析数据体
        deviceData = new DeviceData
        {
            DeviceAddress = rawData[2],
            Command = rawData[3],
            Voltage = BitConverter.ToSingle(rawData, 6),
            Current = BitConverter.ToSingle(rawData, 10),
            Temperature = BitConverter.ToSingle(rawData, 14),
            Capacity = BitConverter.ToSingle(rawData, 18),
            Timestamp = DateTime.Now
        };
        return true;
    }
}

四、UI与数据绑定:让数据"活"起来

4.1 实时曲线控件

在WinForms中,我选择了开源的ScottPlot控件(比原生Chart性能更好,支持十万级数据点渲染)。每个通道的电压曲线在一个独立Panel中展示:

csharp

复制

下载

public partial class ChannelControl : UserControl
{
    private readonly ScottPlot.Plot _plot;
    private readonly List<double> _voltageHistory = new();
    private readonly int _maxPoints = 300; // 只保留最近300个点

    public ChannelControl(int channelId)
    {
        InitializeComponent();
        _plot = formsPlot1.Plot;
        _plot.Title($"通道 {channelId}");
        _plot.YLabel("电压 (V)");
        _plot.XLabel("采样序号");
        _plot.AddSignal(_voltageHistory.ToArray(), color: Color.Green);
    }

    public void UpdateData(float voltage, float current, float temp)
    {
        // UI线程安全更新
        if (InvokeRequired)
        {
            Invoke(new Action(() => UpdateData(voltage, current, temp)));
            return;
        }

        _voltageHistory.Add(voltage);
        if (_voltageHistory.Count > _maxPoints)
            _voltageHistory.RemoveAt(0);

        // 更新曲线数据
        _plot.Clear();
        _plot.AddSignal(_voltageHistory.ToArray(), color: Color.Green);
        
        // 刷新数值显示
        lblVoltage.Text = $"{voltage:F3} V";
        lblCurrent.Text = $"{current:F3} A";
        lblTemp.Text = $"{temp:F1} °C";
        
        formsPlot1.Refresh();
    }
}

4.2 数据聚合展示(主仪表盘)

对于48个通道的总览,我使用DataGridView绑定一个DataTable,每收到一条新数据就更新对应行。注意要启用DoubleBuffered以减少闪烁:

csharp

复制

下载

public class DashboardForm : Form
{
    private DataTable _dataTable;
    private readonly object _lockObj = new();

    public DashboardForm()
    {
        InitializeDataTable();
        dataGridView1.DataSource = _dataTable;
        // 开启双缓冲减少刷新闪烁
        typeof(DataGridView).GetProperty("DoubleBuffered", 
            BindingFlags.Instance | BindingFlags.NonPublic)
            ?.SetValue(dataGridView1, true);
    }

    private void InitializeDataTable()
    {
        _dataTable = new DataTable();
        _dataTable.Columns.Add("通道号", typeof(int));
        _dataTable.Columns.Add("电压(V)", typeof(float));
        _dataTable.Columns.Add("电流(A)", typeof(float));
        _dataTable.Columns.Add("温度(°C)", typeof(float));
        _dataTable.Columns.Add("容量(Ah)", typeof(float));
        _dataTable.Columns.Add("状态", typeof(string));
        _dataTable.Columns.Add("更新时间", typeof(DateTime));
        
        // 预填充48行
        for (int i = 1; i <= 48; i++)
        {
            _dataTable.Rows.Add(i, 0f, 0f, 0f, 0f, "待机", DateTime.Now);
        }
    }

    public void UpdateChannelData(DeviceData data)
    {
        lock (_lockObj)
        {
            var row = _dataTable.Select($"通道号 = {data.DeviceAddress}").FirstOrDefault();
            if (row != null)
            {
                row["电压(V)"] = data.Voltage;
                row["电流(A)"] = data.Current;
                row["温度(°C)"] = data.Temperature;
                row["容量(Ah)"] = data.Capacity;
                row["状态"] = GetStatusText(data.StatusCode);
                row["更新时间"] = DateTime.Now;
            }
        }
    }
}

五、数据持久化:轻量级SQLite方案

工业上位机不需要重型数据库,SQLite足以胜任。我封装了一个简单的数据访问层:

csharp

复制

下载

public class DataRepository : IDisposable
{
    private readonly SQLiteConnection _connection;
    private readonly string _dbPath;

    public DataRepository(string dbPath = "history.db")
    {
        _dbPath = dbPath;
        _connection = new SQLiteConnection($"Data Source={_dbPath};Version=3;");
        _connection.Open();
        CreateTables();
    }

    private void CreateTables()
    {
        string sql = @"
            CREATE TABLE IF NOT EXISTS ChannelHistory (
                Id INTEGER PRIMARY KEY AUTOINCREMENT,
                ChannelId INTEGER NOT NULL,
                Voltage REAL,
                Current REAL,
                Temperature REAL,
                Capacity REAL,
                StatusCode INTEGER,
                RecordTime TEXT NOT NULL
            );
            CREATE INDEX IF NOT EXISTS idx_channel_time 
                ON ChannelHistory(ChannelId, RecordTime DESC);
        ";
        using var cmd = new SQLiteCommand(sql, _connection);
        cmd.ExecuteNonQuery();
    }

    public void InsertBatch(List<DeviceData> dataList)
    {
        using var transaction = _connection.BeginTransaction();
        try
        {
            string sql = @"
                INSERT INTO ChannelHistory 
                (ChannelId, Voltage, Current, Temperature, Capacity, StatusCode, RecordTime)
                VALUES (@ChannelId, @Voltage, @Current, @Temperature, @Capacity, @StatusCode, @RecordTime)
            ";
            using var cmd = new SQLiteCommand(sql, _connection);
            
            foreach (var data in dataList)
            {
                cmd.Parameters.Clear();
                cmd.Parameters.AddWithValue("@ChannelId", data.DeviceAddress);
                cmd.Parameters.AddWithValue("@Voltage", data.Voltage);
                cmd.Parameters.AddWithValue("@Current", data.Current);
                cmd.Parameters.AddWithValue("@Temperature", data.Temperature);
                cmd.Parameters.AddWithValue("@Capacity", data.Capacity);
                cmd.Parameters.AddWithValue("@StatusCode", data.StatusCode);
                cmd.Parameters.AddWithValue("@RecordTime", data.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff"));
                cmd.ExecuteNonQuery();
            }
            
            transaction.Commit();
        }
        catch
        {
            transaction.Rollback();
            throw;
        }
    }

    // 批量插入使用事务,性能提升10倍以上
}

六、异常处理与稳定性保障

7×24小时运行的系统,最怕的就是未捕获的异常导致程序崩溃。我采用了三层防护:

1. 全局异常捕获

csharp

复制

下载

Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException);
AppDomain.CurrentDomain.UnhandledException += (s, e) =>
{
    var ex = e.ExceptionObject as Exception;
    Logger.Log($"致命错误: {ex?.Message}\n{ex?.StackTrace}");
    // 保存现场数据,尝试优雅重启
    RestartApplication();
};
Application.ThreadException += (s, e) =>
{
    Logger.Log($"UI线程异常: {e.Exception.Message}");
    // 不退出,仅记录并继续
};

2. 看门狗(Watchdog)线程

csharp

复制

下载

private void StartWatchdog()
{
    Task.Run(async () =>
    {
        while (!_cts.Token.IsCancellationRequested)
        {
            await Task.Delay(30000); // 每30秒检查一次
            if (!IsCommunicationHealthy())
            {
                Logger.Log("通信异常,尝试重新连接...");
                ReconnectDevice();
            }
        }
    });
}

3. 日志系统
使用NLog或自定义滚动文件日志,按天分割,保留最近30天。日志级别分为Info、Warning、Error、Fatal,便于事后排查。

七、部署与交付的注意事项

  1. 单文件发布:使用.NET 6.0的PublishSingleFile功能,将所有依赖打包成一个exe,方便现场部署;
  2. 配置文件外部化:将设备参数、通信参数放在appsettings.json中,现场工程师用记事本就能修改;
  3. 安装包制作:使用Inno Setup制作安装包,自动检测.NET运行时,如缺失则从微软官方下载;
  4. 用户手册:准备一份图文并茂的《现场快速操作指南》,涵盖启动、配置、查看数据和常见问题处理。

八、总结与反思

项目最终在第25天上线测试,连续运行两周无重启,数据完整率99.97%(丢失的数据主要来自硬件端偶发的掉线)。回顾整个过程,有几点经验值得铭记:

  • 先跑通再优化:前期不要过度设计,先把最小可行版本跑起来,让客户能看到数据流动,后续迭代才有方向;
  • 日志是最重要的朋友:现场出现问题时,没有日志就等于盲人摸象。把能打的日志都打上,尤其是通信收发原始数据的十六进制打印;
  • 与硬件工程师密切沟通:上位机开发最大的坑往往不是代码问题,而是"协议文档写错了"或"硬件固件版本不一致"。建立每日联调机制,有问题当天解决;
  • 预留手动模式:当自动采集出现异常时,给现场工程师留一个"手动发送指令"的调试窗口,能极大提升问题排查效率。

C#上位机开发看似简单,实则是系统工程——你不仅是个写代码的,更是设备、协议、数据和用户之间的桥梁。做好这座桥,需要技术深度,更需要现场的耐心与细心。