找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4040

积分

0

好友

529

主题
发表于 5 天前 | 查看: 25| 回复: 0

调试一个工控项目时,串口数据丢包问题相当严重。排查许久后,根源竟在于接收模式的选择错误。这让我回想起刚接触串口编程时,对通信机制的理解也是一团乱麻。

你是否在实际项目中遇到过类似困扰?数据传输时快时慢、偶发性丢包、甚至程序死锁。问题的根源往往不在于硬件,而是接收机制的选择不当。本文将深入探讨 C#/.NET 编程中三种串口接收模式——轮询(Polling)、事件驱动(Event-Driven)和异步读取(Async Read)。每种模式都有其独特的适用场景,选择错误可能导致性能或稳定性问题。我将结合实战中踩过的坑,分享如何根据场景做出正确选择。

先看效果

以下是基于三种接收模式实现的串口监控工具界面。

AppSerialMonitor 工具界面,展示事件驱动接收模式配置

AppSerialMonitor 工具运行中,展示轮询接收模式及数据接收效果

串口接收的三重境界

在设备通信领域,串口接收数据的方式主要分为三种:

  • 轮询模式(Polling) - 主动出击型
  • 事件驱动(Event-Driven) - 被动响应型
  • 异步读取(Async Read) - 现代化异步型

每种模式背后的设计哲学截然不同。选择不当,系统的性能和稳定性都会大打折扣。

轮询模式:老司机的选择

轮询模式如同一个勤勉的门卫,定期(例如每50ms)主动检查串口缓冲区中是否有新数据到达。虽然听起来有些“笨拙”,但在特定场景下反而是最稳妥的方案。

private async Task PollingLoop(CancellationToken cancellationToken)
{
    while (!cancellationToken.IsCancellationRequested)
    {
        try
        {
            ReadFromBuffer(); // 主动读取缓冲区数据
            await Task.Delay(50, cancellationToken); // 50ms轮询间隔
        }
        catch (OperationCanceledException)
        {
            break; // 正常取消
        }
        catch (Exception ex)
        {
            RaiseError($"轮询接收异常:{ex.Message}");
            await Task.Delay(100, cancellationToken); // 出错后稍作等待
        }
    }
}

适用场景

  • 工控环境,对实时性要求不特别苛刻。
  • 数据量小,传输频率低。
  • 需要最大兼容性(某些老旧设备的串口驱动可能存在缺陷)。

优势:稳定性极高,逻辑简单,出问题易排查。
劣势:CPU占用略高,实时性一般。

我曾在一个水处理项目中使用此模式。PLC每秒仅发送一次状态数据,50ms的轮询间隔完全足够,系统稳定运行超过两年。

事件驱动:经典之选

这是 .NET SerialPort 类默认推荐的方式。当有数据到达时,系统会自动触发 DataReceived 事件,就像按响门铃一样。

private void SerialPortOnDataReceived(object sender, SerialDataReceivedEventArgs e)
{
    try
    {
        ReadFromBuffer(); // 在事件处理程序中读取数据
    }
    catch (Exception ex)
    {
        RaiseError($"事件接收异常:{ex.Message}");
    }
}

private void ReadFromBuffer()
{
    if (_serialPort is null || !_serialPort.IsOpen) return;

    lock (_readLock) // 加锁防止并发读取
    {
        var available = _serialPort.BytesToRead;
        if (available <= 0) return;

        var buffer = new byte[available];
        var readCount = _serialPort.Read(buffer, 0, available);

        if (readCount > 0)
        {
            // 处理实际读取到的数据
            var actualData = readCount != buffer.Length
                ? buffer.Take(readCount).ToArray()
                : buffer;
            RaiseDataReceived(actualData);
        }
    }
}

注意:这里的锁至关重要!我见过不少项目在高频数据传输时,因未加锁而导致数据错乱。

适用场景

  • 通用场景,需要在性能和复杂度间取得平衡。
  • 中等数据量,传输频率适中。
  • 对实时性有一定要求。

踩坑预警

  1. DataReceived 事件可能在辅助线程触发,直接更新 UI 会引发跨线程异常。
  2. 一次事件触发可能对应多个数据包,也可能只是一个数据包的一部分。
  3. 某些 USB 转串口芯片在高负载下可能导致事件丢失。

异步读取:现代化方案

这是最“时髦”的方法,充分利用了 .NET 的异步特性,不阻塞调用线程,性能最佳。

private async Task AsyncReadLoop(CancellationToken cancellationToken)
{
    if (_serialPort is null) return;

    var buffer = new byte[1024]; // 读取缓冲区

    while (!cancellationToken.IsCancellationRequested)
    {
        try
        {
            var readCount = await _serialPort.BaseStream.ReadAsync(
                buffer.AsMemory(0, buffer.Length),
                cancellationToken);

            if (readCount > 0)
            {
                var data = new byte[readCount];
                Buffer.BlockCopy(buffer, 0, data, 0, readCount);
                RaiseDataReceived(data);
            }
        }
        catch (OperationCanceledException)
        {
            break; // 正常取消
        }
        catch (Exception ex)
        {
            RaiseError($"异步读取异常:{ex.Message}");
            await Task.Delay(100, cancellationToken); // 避免异常死循环
        }
    }
}

适用场景

  • 高性能要求,大数据量传输。
  • 现代化应用,希望充分利用异步编程优势。
  • 需要同时处理多个串口(多路并发)。

优势:性能最佳,线程利用率高,扩展性好。
劣势:实现复杂度稍高,对 .NET 版本有一定要求。

在最近的一个数据采集项目中,需要并行处理8个串口的数据,异步读取模式的优势得以凸显——CPU占用率比事件驱动模式降低了约30%。

模式切换的艺术

实际项目中,不同阶段可能需要不同的接收模式。例如,调试时使用轮询(便于观察),生产环境使用事件驱动(追求稳定),高负载时切换到异步模式。

public void SwitchMode(ReceiveMode mode)
{
    if (!IsOpen)
    {
        CurrentMode = mode; // 串口未打开,仅记录模式
        return;
    }

    StopReceive();    // 停止当前接收
    CurrentMode = mode; // 更新模式
    StartReceive(mode); // 启动新模式
}

private void StartReceive(ReceiveMode mode)
{
    if (!IsOpen || _serialPort is null) return;

    _receiveCts = new CancellationTokenSource();

    switch (mode)
    {
        case ReceiveMode.Polling:
            _receiveTask = Task.Run(() => PollingLoop(_receiveCts.Token));
            break;
        case ReceiveMode.EventDriven:
            _serialPort.DataReceived += SerialPortOnDataReceived;
            break;
        case ReceiveMode.AsyncRead:
            _receiveTask = Task.Run(() => AsyncReadLoop(_receiveCts.Token));
            break;
    }
}

这种设计允许你在运行时动态调整接收策略。例如,当检测到数据量突增时,可自动切换到异步模式以应对。

实战经验总结

关于缓冲区管理

缓冲区处理蕴含不少细节,不容忽视:

private void ReadFromBuffer()
{
    // ... 前面的状态检查代码

    var available = _serialPort.BytesToRead;
    if (available <= 0) return;

    var buffer = new byte[available];
    var readCount = _serialPort.Read(buffer, 0, available);

    // 关键:处理实际读取的数据量可能小于缓冲区大小的情况
    if (readCount != buffer.Length)
    {
        var actual = new byte[readCount];
        Buffer.BlockCopy(buffer, 0, actual, 0, readCount);
        buffer = actual;
    }

    RaiseDataReceived(buffer);
}

为何需要这样处理?因为 BytesToRead 属性返回的可用字节数,与实际调用 Read 方法读取到的数据量可能不一致。我曾遇到过 BytesToRead 返回100,但实际只成功读取到95字节的情况。

异常处理的门道

串口编程中的异常处理必须严谨:

private void StopReceive()
{
    // 先取消事件订阅
    if (_serialPort is not null)
        _serialPort.DataReceived -= SerialPortOnDataReceived;

    if (_receiveCts is null) return;

    try
    {
        _receiveCts.Cancel();
        _receiveTask?.Wait(600); // 给予600ms的等待时间
    }
    catch (AggregateException ex) when (ex.InnerExceptions.All(
        e => e is TaskCanceledException or OperationCanceledException))
    {
        // 任务取消引发的异常是预期的,可忽略
    }
    catch (Exception ex)
    {
        RaiseError($"停止接收任务异常:{ex.Message}");
    }
    finally
    {
        _receiveCts.Dispose();
        _receiveCts = null;
        _receiveTask = null;
    }
}

注意其中的 when 条件子句——这是 C# 6.0 引入的异常过滤器,用于精确捕获和处理特定类型的异常。

性能调优秘籍

内存分配优化

高频数据传输时,频繁分配 byte 数组会给垃圾回收器(GC)带来压力:

// ❌ 错误做法:每次读取都分配新数组
private void BadReadFromBuffer()
{
    var buffer = new byte[_serialPort.BytesToRead]; // 每次分配新数组
    // ...
}

// ✅ 正确做法:在异步模式中复用缓冲区
private async Task AsyncReadLoop(CancellationToken cancellationToken)
{
    var buffer = new byte[1024]; // 在循环外仅分配一次

    while (!cancellationToken.IsCancellationRequested)
    {
        var readCount = await _serialPort.BaseStream.ReadAsync(
            buffer.AsMemory(0, buffer.Length), cancellationToken);

        if (readCount > 0)
        {
            // 仅在需要传递数据副本时才分配新数组
            var data = new byte[readCount];
            Buffer.BlockCopy(buffer, 0, data, 0, readCount);
            RaiseDataReceived(data);
        }
    }
}

统计信息的价值

不要忽视通信统计功能,它在生产环境排查问题时极具价值:

public long ReceivedBytes { get; private set; }
public long SentBytes { get; private set; }

private void RaiseDataReceived(byte[] data)
{
    ReceivedBytes += data.Length; // 累加接收字节数
    DataReceived?.Invoke(this, data);
}

public void SendHex(string hexText)
{
    var buffer = ParseHexString(hexText);
    _serialPort.Write(buffer, 0, buffer.Length);
    SentBytes += buffer.Length; // 累加发送字节数
}

有了这些统计数据,你可以轻松发现通信异常,例如出现“只发不收”或数据流量突然骤减等情况。

完整的 Service 实现

using System.IO.Ports;
using System.Text;

namespace AppSerial01;

public enum ReceiveMode
{
    Polling,
    EventDriven,
    AsyncRead
}

public sealed class SerialService : IDisposable
{
    private readonly object _readLock = new();
    private SerialPort? _serialPort;
    private CancellationTokenSource? _receiveCts;
    private Task? _receiveTask;

    public event EventHandler<byte[]>? DataReceived;
    public event EventHandler<string>? ErrorOccurred;

    public ReceiveMode CurrentMode { get; private set; } = ReceiveMode.EventDriven;

    public bool IsOpen => _serialPort?.IsOpen == true;

    public long ReceivedBytes { get; private set; }
    public long SentBytes { get; private set; }

    public void Open(string portName, int baudRate, int dataBits, StopBits stopBits, Parity parity, ReceiveMode mode)
    {
        Close();

        try
        {
            _serialPort = new SerialPort(portName, baudRate, parity, dataBits, stopBits)
            {
                ReadTimeout = 500,
                WriteTimeout = 500,
                Encoding = Encoding.ASCII
            };

            _serialPort.Open();
            CurrentMode = mode;
            StartReceive(mode);
        }
        catch (Exception ex)
        {
            RaiseError($"打开串口失败:{ex.Message}");
            Close();
            throw;
        }
    }

    public void SwitchMode(ReceiveMode mode)
    {
        if (!IsOpen)
        {
            CurrentMode = mode;
            return;
        }

        StopReceive();
        CurrentMode = mode;
        StartReceive(mode);
    }

    public void SendText(string text)
    {
        if (!IsOpen || _serialPort is null)
        {
            throw new InvalidOperationException("串口未打开。");
        }

        _serialPort.Write(text);
        SentBytes += _serialPort.Encoding.GetByteCount(text);
    }

    public void SendHex(string hexText)
    {
        if (!IsOpen || _serialPort is null)
        {
            throw new InvalidOperationException("串口未打开。");
        }

        var buffer = ParseHexString(hexText);
        _serialPort.Write(buffer, 0, buffer.Length);
        SentBytes += buffer.Length;
    }

    public void ResetStatistics()
    {
        ReceivedBytes = 0;
        SentBytes = 0;
    }

    public void Close()
    {
        StopReceive();

        if (_serialPort is null)
        {
            return;
        }

        try
        {
            if (_serialPort.IsOpen)
            {
                _serialPort.Close();
            }
        }
        catch (Exception ex)
        {
            RaiseError($"关闭串口异常:{ex.Message}");
        }
        finally
        {
            _serialPort.Dispose();
            _serialPort = null;
        }
    }

    private void StartReceive(ReceiveMode mode)
    {
        if (!IsOpen || _serialPort is null)
        {
            return;
        }

        _receiveCts = new CancellationTokenSource();

        switch (mode)
        {
            case ReceiveMode.Polling:
                _receiveTask = Task.Run(() => PollingLoop(_receiveCts.Token));
                break;
            case ReceiveMode.EventDriven:
                _serialPort.DataReceived += SerialPortOnDataReceived;
                break;
            case ReceiveMode.AsyncRead:
                _receiveTask = Task.Run(() => AsyncReadLoop(_receiveCts.Token));
                break;
        }
    }

    private void StopReceive()
    {
        if (_serialPort is not null)
        {
            _serialPort.DataReceived -= SerialPortOnDataReceived;
        }

        if (_receiveCts is null)
        {
            return;
        }

        try
        {
            _receiveCts.Cancel();
            _receiveTask?.Wait(600);
        }
        catch (AggregateException ex) when (ex.InnerExceptions.All(e => e is TaskCanceledException or OperationCanceledException))
        {
        }
        catch (Exception ex)
        {
            RaiseError($"停止接收任务异常:{ex.Message}");
        }
        finally
        {
            _receiveCts.Dispose();
            _receiveCts = null;
            _receiveTask = null;
        }
    }

    private async Task PollingLoop(CancellationToken cancellationToken)
    {
        while (!cancellationToken.IsCancellationRequested)
        {
            try
            {
                ReadFromBuffer();
                await Task.Delay(50, cancellationToken);
            }
            catch (OperationCanceledException)
            {
                break;
            }
            catch (Exception ex)
            {
                RaiseError($"轮询接收异常:{ex.Message}");
                await Task.Delay(100, cancellationToken);
            }
        }
    }

    private async Task AsyncReadLoop(CancellationToken cancellationToken)
    {
        if (_serialPort is null)
        {
            return;
        }

        var buffer = new byte[1024];

        while (!cancellationToken.IsCancellationRequested)
        {
            try
            {
                var readCount = await _serialPort.BaseStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken);
                if (readCount > 0)
                {
                    var data = new byte[readCount];
                    Buffer.BlockCopy(buffer, 0, data, 0, readCount);
                    RaiseDataReceived(data);
                }
            }
            catch (OperationCanceledException)
            {
                break;
            }
            catch (Exception ex)
            {
                RaiseError($"异步读取异常:{ex.Message}");
                await Task.Delay(100, cancellationToken);
            }
        }
    }

    private void SerialPortOnDataReceived(object sender, SerialDataReceivedEventArgs e)
    {
        try
        {
            ReadFromBuffer();
        }
        catch (Exception ex)
        {
            RaiseError($"事件接收异常:{ex.Message}");
        }
    }

    private void ReadFromBuffer()
    {
        if (_serialPort is null || !_serialPort.IsOpen)
        {
            return;
        }

        lock (_readLock)
        {
            var available = _serialPort.BytesToRead;
            if (available <= 0)
            {
                return;
            }

            var buffer = new byte[available];
            var readCount = _serialPort.Read(buffer, 0, available);

            if (readCount <= 0)
            {
                return;
            }

            if (readCount != buffer.Length)
            {
                var actual = new byte[readCount];
                Buffer.BlockCopy(buffer, 0, actual, 0, readCount);
                buffer = actual;
            }

            RaiseDataReceived(buffer);
        }
    }

    private static byte[] ParseHexString(string hexText)
    {
        var cleaned = hexText.Replace(" ", string.Empty).Replace("\r", string.Empty).Replace("\n", string.Empty);

        if (cleaned.Length == 0)
        {
            return Array.Empty<byte>();
        }

        if (cleaned.Length % 2 != 0)
        {
            throw new FormatException("十六进制字符串长度必须为偶数。");
        }

        var bytes = new byte[cleaned.Length / 2];
        for (var i = 0; i < bytes.Length; i++)
        {
            bytes[i] = Convert.ToByte(cleaned.Substring(i * 2, 2), 16);
        }

        return bytes;
    }

    private void RaiseDataReceived(byte[] data)
    {
        ReceivedBytes += data.Length;
        DataReceived?.Invoke(this, data);
    }

    private void RaiseError(string message)
    {
        ErrorOccurred?.Invoke(this, message);
    }

    public void Dispose()
    {
        Close();
        GC.SuppressFinalize(this);
    }
}

选择指南:什么场景用什么模式?

场景类型 推荐模式 理由
工控设备,低频数据 轮询 稳定性压倒一切
通用串口应用 事件驱动 在性能和复杂度间取得最佳平衡
高性能数据采集 异步读取 充分利用系统资源,吞吐量高
多串口并发处理 异步读取 线程效率最高,资源占用少
调试阶段 轮询 逻辑清晰,便于单步调试和观察
嵌入式设备通信 事件驱动 兼容性较好,资源消耗可控

以上是基于经验的总结。在实际项目中,最稳妥的做法是对三种模式都进行测试,根据具体的性能表现和稳定性数据来做出最终决策。

写在最后

串口通信看似基础,实则细节繁多。选择合适的接收模式,是保障应用稳定高效运行的关键。选对了,系统稳如磐石;选错了,则可能陷入无尽的调试泥潭。

简单比喻这三种模式:

  • 轮询像勤恳的老黄牛,踏实可靠。
  • 事件驱动像精明的管家,响应及时且恰到好处。
  • 异步读取像高效的跑车,追求极致的性能。

在云栈社区,我们持续分享此类实战经验与深度解析,帮助开发者规避陷阱,提升技能。希望本文能助你在未来的串口项目中做出更明智的技术选型。




上一篇:都说职场站队重要,这位专注设备维护的技术员为何每次裁员都稳如泰山?
下一篇:5款小众浏览器扩展神器,重塑你的网页阅读与高效管理工作流
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-7 21:15 , Processed in 1.155036 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表