C#串口通讯实战:双线程协作与AutoResetEvent同步机制详解
写C#串口通讯代码,一般是打开串口,读数据,写数据,完成。
但实际上,如果发了一条命令,不等响应就发下一条,数据就乱了。所以得"发一条等一条"。
串口等待的时候,主线程不能卡死,又得保证数据不乱。这就引出了线程同步的问题。
本文以 RTU 采集服务器项目中的 SerialComm 类为例,深入分析双线程是怎么协作的,AutoResetEvent 是怎么用的,怎么样处理好串口通信问题。
一、代码中的双线程架构
1.1 整体设计
SerialComm 类用了两个 BackgroundWorker,一个负责发,一个负责收:
| 线程 | 名称 | 职责 | 轮询间隔 |
|---|---|---|---|
| 发送线程 | m_AutoExecuteTask |
从任务队列取命令,写入串口 | 200ms |
| 接收线程 | m_AutoReceiveTask |
轮询串口,读取数据 | 500ms |
为什么要分两个线程?因为串口读写是阻塞的。如果用一个线程,发完命令等响应,响应没来之前就不能干别的;或者收数据的时候,就不能发命令。分成两个线程,收发互不干扰。
双线程架构图:

1.2 启动流程
public void StartComm()
{
this.status = true;
this.OpenSerialPort();
this.mSerialPort.DiscardInBuffer(); // 清空输入缓冲区
if (!this.m_AutoReceiveTask.IsBusy)
{
this.m_AutoReceiveTask.RunWorkerAsync(); // 启动接收线程
}
if (!this.m_AutoExecuteTask.IsBusy)
{
this.m_AutoExecuteTask.RunWorkerAsync(); // 启动发送线程
}
}
启动时先打开串口,清空缓冲区里的脏数据,然后启动两个后台线程。
1.3 接收线程主循环
private void AutoReceiveTask_DoWork(object sender, DoWorkEventArgs e)
{
BackgroundWorker backgroundWorker = sender as BackgroundWorker;
while (!backgroundWorker.CancellationPending)
{
if (this.status)
{
try
{
int num = this.mSerialPort.Read(this.mReceiveBuffer, 0, 1024);
this.ResetSerialPort(); // 定时重置计数
if (num > 0)
{
byte[] array = new byte[num];
Array.Copy(this.mReceiveBuffer, array, num);
ThreadPool.QueueUserWorkItem(new WaitCallback(this.AnalysisData), array);
}
}
catch
{
// 异常被吞掉了
}
}
Thread.Sleep(500);
}
e.Cancel = true;
}
接收线程每 500ms 轮询一次串口,读到数据后通过 ThreadPool 提交给 AnalysisData 处理。这里用了线程池,避免在接收线程里做耗时的解析工作。
1.4 发送线程主循环
private void AutoExecuteTask(object sender, DoWorkEventArgs e)
{
BackgroundWorker backgroundWorker = sender as BackgroundWorker;
while (!backgroundWorker.CancellationPending)
{
Thread.Sleep(200);
if (this.task.Count != 0 && this.status)
{
this.ExecuteTask(); // 发送命令
New _new = this.DequeueTask(null, null); // 检查超时任务
if (_new != null)
{
if (!_new.WaitRequest && _new.ErrorMessage.Length == 0)
{
ThreadPool.QueueUserWorkItem(new WaitCallback(this.NewSucceedHandler), _new);
}
else
{
if (_new.ErrorMessage.Length == 0)
{
_new.ErrorMessage = "等待响应超时!";
}
if (_new.AllowRetry && _new.RemainTimes > 0)
{
_new.RemainTimes -= 1;
this.task.Enqueue(_new); // 重新入队
}
else
{
ThreadPool.QueueUserWorkItem(new WaitCallback(this.NewErrorHandler), _new);
}
}
}
}
}
e.Cancel = true;
}
发送线程每 200ms 检查一次任务队列,有任务就发送。发送后检查是否有超时未响应的任务,超时则重试或报错。
二、同步机制分析
2.1 核心问题
串口通讯的典型流程是:
- 发送命令
- 等待设备响应
- 收到响应后处理
问题在于:发送和接收是两个线程。发送线程发完命令后,怎么知道接收线程收到响应了?
2.2 AutoResetEvent 的用法
SerialComm 用了 AutoResetEvent 来解决这个问题:
private AutoResetEvent mResetEvent;
// 构造函数中初始化
this.mResetEvent = new AutoResetEvent(false);
发送线程发完命令后,调用 WaitOne 阻塞等待:
private void ExecuteTask()
{
New _new = this.task.Obtain();
if (_new != null)
{
// ...
this.mSerialPort.Write(array, 0, array.Length);
this.mCurrentTask = _new;
if (_new.WaitRequest)
{
this.mResetEvent.WaitOne(_new.Timeout * 1000, false); // 阻塞等待
}
}
}
接收线程解析到响应后,调用 Set 唤醒发送线程:
private void AnalysisData(object recvBytes)
{
// ... 解析数据 ...
if (receivedDataEventArgs.Verify)
{
New _new = this.DequeueTask(receivedDataEventArgs.DeviceId, receivedDataEventArgs.MonitorId);
if (_new != null)
{
this.mResetEvent.Set(); // 唤醒发送线程
// ... 处理响应 ...
}
}
}
2.3 同步流程图
AutoResetEvent 同步时序图:

AutoResetEvent 的特点是:Set 一次,只能唤醒一个 WaitOne。唤醒后自动重置为未信号状态。这正好适合"发一条等一条"的场景。
2.4 超时处理
如果设备没响应,WaitOne 会超时返回:
this.mResetEvent.WaitOne(_new.Timeout * 1000, false);
超时后,发送线程继续执行,在 AutoExecuteTask 中检查到 ErrorMessage 为空但任务已完成,就会标记为"等待响应超时"。
三、防御性编程实践
3.1 串口定时重置
代码里有一个看起来很奇怪的方法:
private void ResetSerialPort()
{
this.resetcount++;
if (this.status && this.resetcount > 7200)
{
this.resetcount = 0;
try
{
this.CloseSerialPort();
Thread.Sleep(500);
this.OpenSerialPort();
}
catch
{
}
}
}
每累计 7200 次读取(接收线程每 500ms 读一次,7200 次约 1 小时),就关闭再重新打开串口。
为什么要这么做?因为串口硬件长时间运行后,可能会出现"假死"——看起来正常,但读写不工作。定时重置是一种防御性措施,防止串口卡死导致整个系统瘫痪。
3.2 缓冲区溢出保护
private void AnalysisData(object recvBytes)
{
lock (this.mReceivedData)
{
// 检查缓冲区总长度
if (((byte[])recvBytes).Length * 2 + this.mReceivedData.Length > 65536)
{
this.mReceivedData.Length = 0; // 清空缓冲区
}
this.mReceivedData.Append(ConvertEx.ByteArrayToHex((byte[])recvBytes));
// ...
}
}
如果缓冲区长度超过 65536,直接清空。这是一种粗暴但有效的防内存泄漏手段。正常情况下,解析完一帧数据后会从缓冲区移除,不会累积。但如果解析出错,数据会一直堆积,清空可以兜底。
3.3 重试机制
if (_new.AllowRetry && _new.RemainTimes > 0)
{
_new.RemainTimes -= 1;
this.task.Enqueue(_new); // 重新入队
}
任务超时或出错时,如果允许重试且还有重试次数,就重新放回队列。这是一种"软失败"策略,给设备一次重试的机会,而不是直接报错。
四、存在的问题和解决办法
4.1 关于 BackgroundWorker
BackgroundWorker 是 .NET 2.0 引入的,现在已经不推荐使用了。微软官方建议用 Task 和 async/await 替代。
但在这个项目里,BackgroundWorker 用得挺顺手。它自带 CancellationPending 属性,方便控制线程退出;RunWorkerAsync 一行代码启动,比 Thread 简单。
技术选型没有绝对的对错,适合场景的就是好的。
4.2 关于异常处理
代码里有不少空的 catch 块:
catch
{
}
在串口通讯场景下,串口读写经常会有各种异常(设备断开、缓冲区溢出等),如果每个异常都处理,代码会很复杂,所以直接吞掉不处理。
但更好的做法是至少记录日志,方便排查问题。
4.3 关于线程安全
AnalysisData 方法用了 lock (this.mReceivedData),保证缓冲区操作的线程安全。但其他地方,比如 task 队列的操作,没有加锁。
这是因为 task 队列的操作都在发送线程里,不会有并发问题。而 mReceivedData 在接收线程和 AnalysisData(通过线程池调用)中都会访问,所以需要加锁。
线程安全不是"到处加锁",而是"该加的地方加"。
五、总结
-
串口通讯是半双工的,收发要分开。双线程架构是常见做法,一个负责发,一个负责收。
-
AutoResetEvent适合"发一条等一条"的同步场景。发送线程WaitOne,接收线程Set,配合任务队列实现有序通讯。 -
防御性编程很重要。串口定时重置、缓冲区溢出保护、重试机制,这些都是应对硬件不确定性的手段。
-
技术选型要看场景。
BackgroundWorker虽然老了,但在这个项目里用得挺合适。不一定要追新。 -
异常处理不能偷懒。空的
catch块虽然省事,但出了问题很难排查。至少记个日志。 -
线程安全要精准。不是到处加锁,而是分析清楚哪些资源会被并发访问,只在那里加锁。
关键词:C#串口通讯,双线程协作、AutoResetEvent、同步机制、生产者-消费者模式、RTU采集
本文基于实际项目经验编写,代码已脱敏处理。如需完整源码或技术咨询,请关注和联系我们。
更多推荐

所有评论(0)