写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 核心问题

串口通讯的典型流程是:

  1. 发送命令
  2. 等待设备响应
  3. 收到响应后处理

问题在于:发送和接收是两个线程。发送线程发完命令后,怎么知道接收线程收到响应了?

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 引入的,现在已经不推荐使用了。微软官方建议用 Taskasync/await 替代。

但在这个项目里,BackgroundWorker 用得挺顺手。它自带 CancellationPending 属性,方便控制线程退出;RunWorkerAsync 一行代码启动,比 Thread 简单。

技术选型没有绝对的对错,适合场景的就是好的。

4.2 关于异常处理

代码里有不少空的 catch 块:

catch
{
}

在串口通讯场景下,串口读写经常会有各种异常(设备断开、缓冲区溢出等),如果每个异常都处理,代码会很复杂,所以直接吞掉不处理。

但更好的做法是至少记录日志,方便排查问题。

4.3 关于线程安全

AnalysisData 方法用了 lock (this.mReceivedData),保证缓冲区操作的线程安全。但其他地方,比如 task 队列的操作,没有加锁。

这是因为 task 队列的操作都在发送线程里,不会有并发问题。而 mReceivedData 在接收线程和 AnalysisData(通过线程池调用)中都会访问,所以需要加锁。

线程安全不是"到处加锁",而是"该加的地方加"。


五、总结

  1. 串口通讯是半双工的,收发要分开。双线程架构是常见做法,一个负责发,一个负责收。

  2. AutoResetEvent 适合"发一条等一条"的同步场景。发送线程 WaitOne,接收线程 Set,配合任务队列实现有序通讯。

  3. 防御性编程很重要。串口定时重置、缓冲区溢出保护、重试机制,这些都是应对硬件不确定性的手段。

  4. 技术选型要看场景。BackgroundWorker 虽然老了,但在这个项目里用得挺合适。不一定要追新。

  5. 异常处理不能偷懒。空的 catch 块虽然省事,但出了问题很难排查。至少记个日志。

  6. 线程安全要精准。不是到处加锁,而是分析清楚哪些资源会被并发访问,只在那里加锁。

关键词:C#串口通讯,双线程协作、AutoResetEvent、同步机制、生产者-消费者模式、RTU采集

本文基于实际项目经验编写,代码已脱敏处理。如需完整源码或技术咨询,请关注和联系我们。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐