编辑
2026-03-31
C#
00

目录

👨‍💻先看效果
🎯 串口接收的三重境界
🔍 轮询模式:老司机的选择
⚡ 事件驱动:经典的选择
🚀 异步读取:现代化的方案
🛠️ 模式切换的艺术
💡 实战经验总结
关于缓冲区管理
异常处理的门道
🔧 性能调优秘籍
内存分配优化
统计信息的价值
📜完整的Service
🎯 选择指南:什么场景用什么模式?
💭 写在最后

调试一个工控项目时,串口数据丢包严重得要命。追查半天才发现——接收模式选错了!这让我想起刚入行那会儿,对串口通信的理解简直是一团糟。

想想看,咱们在实际项目中用串口通信时,是不是经常碰到这样的窘境?数据时快时慢,偶尔丢包,有时候还死锁。根本原因往往不是硬件问题,而是接收机制选择不当。

今天就来聊聊C#串口编程中的三种接收模式——每种都有各自的适用场景,用错了就是灾难。我会把这些年踩过的坑都抖出来,希望能帮你们少走些弯路。

👨‍💻先看效果

image.png

image.png

🎯 串口接收的三重境界

做过设备通信的同学都知道,串口接收数据的方式大致分为三种:

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

每种模式背后的设计思路截然不同。选错了,性能和稳定性都会大打折扣。

🔍 轮询模式:老司机的选择

轮询模式就像是个勤快的门卫——每隔一段时间就去看看有没有数据到达。虽然听起来很"笨",但在某些场景下反而是最稳妥的方案。

csharp
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); // 出错后稍微等等 } } }

适用场景

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

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

我在一个水处理项目中就用过这种模式。PLC每秒只发送一次状态数据,50ms的轮询间隔完全够用,而且运行两年多没出过问题。

⚡ 事件驱动:经典的选择

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

csharp
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. 事件可能在工作线程触发,直接更新UI会报错
  2. 一次事件可能对应多个数据包,也可能只是一个数据包的一部分
  3. 某些USB转串口芯片在高负载时事件可能丢失

🚀 异步读取:现代化的方案

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

csharp
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%。

🛠️ 模式切换的艺术

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

csharp
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; } }

这种设计让你可以在运行时动态调整策略。比如检测到数据量突然增大时,自动切换到异步模式。

💡 实战经验总结

关于缓冲区管理

别小看缓冲区的处理,这里面学问大着呢:

csharp
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字节的情况。

异常处理的门道

串口编程中,异常处理绝对不能马虎:

csharp
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造成压力,特别是在高频数据传输时:

csharp
// ❌ 错误做法:每次都new 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); } } }

统计信息的价值

别忽视统计功能,这在生产环境排查问题时特别有用:

csharp
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

c#
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); } }

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

场景类型推荐模式理由
工控设备,低频数据轮询稳定性最重要
通用串口应用事件驱动平衡性能和复杂度
高性能数据采集异步读取充分利用系统资源
多串口并发异步读取线程效率最高
调试阶段轮询便于单步调试
嵌入式通信事件驱动兼容性好

当然,这只是经验总结。实际项目中,最好是三种模式都测试一下,根据具体表现来决定。

💭 写在最后

串口通信看似简单,实则暗藏玄机。选对接收模式,能让你的应用稳如老狗;选错了,就是无尽的调试地狱。

这三种模式各有千秋:

  • 轮询像老黄牛,踏实可靠
  • 事件驱动像管家,恰到好处
  • 异步读取像跑车,性能飞跃

最后想问大家:你们在串口项目中都踩过哪些坑?有没有遇到过模式选择导致的性能问题?欢迎在评论区分享你们的实战经验。

技术标签:#C#开发 #串口通信 #异步编程 #性能优化 #设备通信


相关信息

通过网盘分享的文件:AppSerial01.zip 链接: https://pan.baidu.com/s/1K-agyEfZ8cO0CnllG2k6VQ?pwd=siic 提取码: siic --来自百度网盘超级会员v9的分享

本文作者:技术老小子

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!