调试一个工控项目时,串口数据丢包问题相当严重。排查许久后,根源竟在于接收模式的选择错误。这让我回想起刚接触串口编程时,对通信机制的理解也是一团乱麻。
你是否在实际项目中遇到过类似困扰?数据传输时快时慢、偶发性丢包、甚至程序死锁。问题的根源往往不在于硬件,而是接收机制的选择不当。本文将深入探讨 C#/.NET 编程中三种串口接收模式——轮询(Polling)、事件驱动(Event-Driven)和异步读取(Async Read)。每种模式都有其独特的适用场景,选择错误可能导致性能或稳定性问题。我将结合实战中踩过的坑,分享如何根据场景做出正确选择。
先看效果
以下是基于三种接收模式实现的串口监控工具界面。


串口接收的三重境界
在设备通信领域,串口接收数据的方式主要分为三种:
- 轮询模式(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);
}
}
}
注意:这里的锁至关重要!我见过不少项目在高频数据传输时,因未加锁而导致数据错乱。
适用场景:
- 通用场景,需要在性能和复杂度间取得平衡。
- 中等数据量,传输频率适中。
- 对实时性有一定要求。
踩坑预警:
DataReceived 事件可能在辅助线程触发,直接更新 UI 会引发跨线程异常。
- 一次事件触发可能对应多个数据包,也可能只是一个数据包的一部分。
- 某些 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);
}
}
选择指南:什么场景用什么模式?
| 场景类型 |
推荐模式 |
理由 |
| 工控设备,低频数据 |
轮询 |
稳定性压倒一切 |
| 通用串口应用 |
事件驱动 |
在性能和复杂度间取得最佳平衡 |
| 高性能数据采集 |
异步读取 |
充分利用系统资源,吞吐量高 |
| 多串口并发处理 |
异步读取 |
线程效率最高,资源占用少 |
| 调试阶段 |
轮询 |
逻辑清晰,便于单步调试和观察 |
| 嵌入式设备通信 |
事件驱动 |
兼容性较好,资源消耗可控 |
以上是基于经验的总结。在实际项目中,最稳妥的做法是对三种模式都进行测试,根据具体的性能表现和稳定性数据来做出最终决策。
写在最后
串口通信看似基础,实则细节繁多。选择合适的接收模式,是保障应用稳定高效运行的关键。选对了,系统稳如磐石;选错了,则可能陷入无尽的调试泥潭。
简单比喻这三种模式:
- 轮询像勤恳的老黄牛,踏实可靠。
- 事件驱动像精明的管家,响应及时且恰到好处。
- 异步读取像高效的跑车,追求极致的性能。
在云栈社区,我们持续分享此类实战经验与深度解析,帮助开发者规避陷阱,提升技能。希望本文能助你在未来的串口项目中做出更明智的技术选型。