写C#串口通讯代码,一般是打开串口,读数据,写数据,完成。
但实际上,如果发了一条命令,不等响应就发下一条,数据就乱了。所以得"发一条等一条"。
串口等待的时候,主线程不能卡死,又得保证数据不乱。这就引出了线程同步的问题。
本文以 RTU 采集服务器项目中的SerialComm类为例,深入分析双线程是怎么协作的,AutoResetEvent是怎么用的,怎么样处理好串口通信问题。
一、代码中的双线程架构
1.1 整体设计
SerialComm类用了两个BackgroundWorker,一个负责发,一个负责收:
| 线程 | 名称 | 职责 | 轮询间隔 |
|---|---|---|---|
| 发送线程 | m_AutoExecuteTask | 从任务队列取命令,写入串口 | 200ms |
| 接收线程 | m_AutoReceiveTask | 轮询串口,读取数据 | 500ms |
为什么要分两个线程?因为串口读写是阻塞的。如果用一个线程,发完命令等响应,响应没来之前就不能干别的;或者收数据的时候,就不能发命令。分成两个线程,收发互不干扰。
双线程架构图:
1.2 启动流程
publicvoidStartComm(){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 接收线程主循环
privatevoidAutoReceiveTask_DoWork(objectsender,DoWorkEventArgse){BackgroundWorkerbackgroundWorker=senderasBackgroundWorker;while(!backgroundWorker.CancellationPending){if(this.status){try{intnum=this.mSerialPort.Read(this.mReceiveBuffer,0,1024);this.ResetSerialPort();// 定时重置计数if(num>0){byte[]array=newbyte[num];Array.Copy(this.mReceiveBuffer,array,num);ThreadPool.QueueUserWorkItem(newWaitCallback(this.AnalysisData),array);}}catch{// 异常被吞掉了}}Thread.Sleep(500);}e.Cancel=true;}接收线程每 500ms 轮询一次串口,读到数据后通过ThreadPool提交给AnalysisData处理。这里用了线程池,避免在接收线程里做耗时的解析工作。
1.4 发送线程主循环
privatevoidAutoExecuteTask(objectsender,DoWorkEventArgse){BackgroundWorkerbackgroundWorker=senderasBackgroundWorker;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(newWaitCallback(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(newWaitCallback(this.NewErrorHandler),_new);}}}}}e.Cancel=true;}发送线程每 200ms 检查一次任务队列,有任务就发送。发送后检查是否有超时未响应的任务,超时则重试或报错。
二、同步机制分析
2.1 核心问题
串口通讯的典型流程是:
- 发送命令
- 等待设备响应
- 收到响应后处理
问题在于:发送和接收是两个线程。发送线程发完命令后,怎么知道接收线程收到响应了?
2.2 AutoResetEvent 的用法
SerialComm用了AutoResetEvent来解决这个问题:
privateAutoResetEventmResetEvent;// 构造函数中初始化this.mResetEvent=newAutoResetEvent(false);发送线程发完命令后,调用WaitOne阻塞等待:
privatevoidExecuteTask(){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唤醒发送线程:
privatevoidAnalysisData(objectrecvBytes){// ... 解析数据 ...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 串口定时重置
代码里有一个看起来很奇怪的方法:
privatevoidResetSerialPort(){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 缓冲区溢出保护
privatevoidAnalysisData(objectrecvBytes){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采集
本文基于实际项目经验编写,代码已脱敏处理。如需完整源码或技术咨询,请关注和联系我们。