C#串口通讯实战:双线程协作与AutoResetEvent同步机制详解

📅 2026/6/25 20:36:17
C#串口通讯实战:双线程协作与AutoResetEvent同步机制详解
写C#串口通讯代码,一般是打开串口读数据写数据完成。但实际上如果发了一条命令不等响应就发下一条数据就乱了。所以得发一条等一条。串口等待的时候主线程不能卡死又得保证数据不乱。这就引出了线程同步的问题。本文以 RTU 采集服务器项目中的SerialComm类为例深入分析双线程是怎么协作的AutoResetEvent是怎么用的怎么样处理好串口通信问题。一、代码中的双线程架构1.1 整体设计SerialComm类用了两个BackgroundWorker一个负责发一个负责收线程名称职责轮询间隔发送线程m_AutoExecuteTask从任务队列取命令写入串口200ms接收线程m_AutoReceiveTask轮询串口读取数据500ms为什么要分两个线程因为串口读写是阻塞的。如果用一个线程发完命令等响应响应没来之前就不能干别的或者收数据的时候就不能发命令。分成两个线程收发互不干扰。双线程架构图1.2 启动流程publicvoidStartComm(){this.statustrue;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 接收线程主循环privatevoidAutoReceiveTask_DoWork(objectsender,DoWorkEventArgse){BackgroundWorkerbackgroundWorkersenderasBackgroundWorker;while(!backgroundWorker.CancellationPending){if(this.status){try{intnumthis.mSerialPort.Read(this.mReceiveBuffer,0,1024);this.ResetSerialPort();// 定时重置计数if(num0){byte[]arraynewbyte[num];Array.Copy(this.mReceiveBuffer,array,num);ThreadPool.QueueUserWorkItem(newWaitCallback(this.AnalysisData),array);}}catch{// 异常被吞掉了}}Thread.Sleep(500);}e.Canceltrue;}接收线程每 500ms 轮询一次串口读到数据后通过ThreadPool提交给AnalysisData处理。这里用了线程池避免在接收线程里做耗时的解析工作。1.4 发送线程主循环privatevoidAutoExecuteTask(objectsender,DoWorkEventArgse){BackgroundWorkerbackgroundWorkersenderasBackgroundWorker;while(!backgroundWorker.CancellationPending){Thread.Sleep(200);if(this.task.Count!0this.status){this.ExecuteTask();// 发送命令New_newthis.DequeueTask(null,null);// 检查超时任务if(_new!null){if(!_new.WaitRequest_new.ErrorMessage.Length0){ThreadPool.QueueUserWorkItem(newWaitCallback(this.NewSucceedHandler),_new);}else{if(_new.ErrorMessage.Length0){_new.ErrorMessage等待响应超时;}if(_new.AllowRetry_new.RemainTimes0){_new.RemainTimes-1;this.task.Enqueue(_new);// 重新入队}else{ThreadPool.QueueUserWorkItem(newWaitCallback(this.NewErrorHandler),_new);}}}}}e.Canceltrue;}发送线程每 200ms 检查一次任务队列有任务就发送。发送后检查是否有超时未响应的任务超时则重试或报错。二、同步机制分析2.1 核心问题串口通讯的典型流程是发送命令等待设备响应收到响应后处理问题在于发送和接收是两个线程。发送线程发完命令后怎么知道接收线程收到响应了2.2 AutoResetEvent 的用法SerialComm用了AutoResetEvent来解决这个问题privateAutoResetEventmResetEvent;// 构造函数中初始化this.mResetEventnewAutoResetEvent(false);发送线程发完命令后调用WaitOne阻塞等待privatevoidExecuteTask(){New_newthis.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唤醒发送线程privatevoidAnalysisData(objectrecvBytes){// ... 解析数据 ...if(receivedDataEventArgs.Verify){New_newthis.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 串口定时重置代码里有一个看起来很奇怪的方法privatevoidResetSerialPort(){this.resetcount;if(this.statusthis.resetcount7200){this.resetcount0;try{this.CloseSerialPort();Thread.Sleep(500);this.OpenSerialPort();}catch{}}}每累计 7200 次读取接收线程每 500ms 读一次7200 次约 1 小时就关闭再重新打开串口。为什么要这么做因为串口硬件长时间运行后可能会出现假死——看起来正常但读写不工作。定时重置是一种防御性措施防止串口卡死导致整个系统瘫痪。3.2 缓冲区溢出保护privatevoidAnalysisData(objectrecvBytes){lock(this.mReceivedData){// 检查缓冲区总长度if(((byte[])recvBytes).Length*2this.mReceivedData.Length65536){this.mReceivedData.Length0;// 清空缓冲区}this.mReceivedData.Append(ConvertEx.ByteArrayToHex((byte[])recvBytes));// ...}}如果缓冲区长度超过 65536直接清空。这是一种粗暴但有效的防内存泄漏手段。正常情况下解析完一帧数据后会从缓冲区移除不会累积。但如果解析出错数据会一直堆积清空可以兜底。3.3 重试机制if(_new.AllowRetry_new.RemainTimes0){_new.RemainTimes-1;this.task.Enqueue(_new);// 重新入队}任务超时或出错时如果允许重试且还有重试次数就重新放回队列。这是一种软失败策略给设备一次重试的机会而不是直接报错。四、存在的问题和解决办法4.1 关于 BackgroundWorkerBackgroundWorker是 .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采集本文基于实际项目经验编写代码已脱敏处理。如需完整源码或技术咨询请关注和联系我们。