1. 串口通信与线程安全基础
在工业控制和物联网应用中,串口通信是最常见的设备连接方式之一。与网络通信不同,串口通信具有以下特点:
- 独占性:一个串口在同一时间只能被一个进程或线程访问
- 无内置协议:需要开发者自行实现通信协议和错误处理
- 阻塞操作:读写操作通常是同步和阻塞的
当我们需要在多线程环境中操作串口设备时,会遇到几个关键问题:
- 资源竞争:多个线程同时尝试读写串口会导致数据混乱
- 状态不一致:一个线程正在读取时,另一个线程修改了串口配置
- 死锁风险:不当的同步机制可能导致线程永久阻塞
C#提供了多种线程同步机制,在串口通信场景中最常用的是:
- lock语句:最简单的互斥锁,适合大多数串口操作场景
- Monitor类:lock语句的内部实现,提供更多控制选项
- SemaphoreSlim:适合限制同时访问资源的线程数量
- Mutex:跨进程的同步原语,在串口通信中较少使用
重要提示:在串口通信中,绝对不要使用ReaderWriterLockSlim这类读写锁,因为串口操作本质上是"全互斥"的 - 即使是读操作期间也不允许其他线程执行写操作。
2. 线程安全的单例模式实现
2.1 基础单例结构
我们首先实现一个线程安全的串口控制器单例类。使用Lazy
- 实例创建是线程安全的
- 延迟初始化(只在第一次使用时创建)
- 代码简洁明了
csharp复制public sealed class SerialInstrumentController : IDisposable
{
private static readonly Lazy<SerialInstrumentController> _instance =
new Lazy<SerialInstrumentController>(() => new SerialInstrumentController());
private readonly object _serialLock = new object();
private SerialPort _serialPort;
private bool _disposed = false;
public static SerialInstrumentController Instance => _instance.Value;
private SerialInstrumentController()
{
// 默认初始化
InitializeSerialPort("COM1", 9600);
}
}
2.2 串口初始化与配置
串口初始化需要考虑以下参数:
- 端口名称(COM1, COM3等)
- 波特率(常见值:9600, 19200, 38400, 115200)
- 数据位(通常为8)
- 校验位(None, Odd, Even)
- 停止位(1, 1.5, 2)
- 流控制(None, XOnXOff, Hardware)
csharp复制public void InitializeSerialPort(string portName, int baudRate)
{
lock (_serialLock)
{
ClosePort();
_serialPort = new SerialPort
{
PortName = portName,
BaudRate = baudRate,
DataBits = 8,
Parity = Parity.None,
StopBits = StopBits.One,
Handshake = Handshake.None,
ReadTimeout = 1000,
WriteTimeout = 1000
};
try
{
_serialPort.Open();
Console.WriteLine($"串口 {portName} 已打开");
}
catch (Exception ex)
{
Console.WriteLine($"打开串口失败: {ex.Message}");
_serialPort = null;
throw;
}
}
}
2.3 线程安全的命令发送与接收
串口通信的基本操作需要保证原子性:
- 发送命令
- 等待响应
- 处理响应
csharp复制public bool SendCommand(string command)
{
lock (_serialLock)
{
if (_serialPort == null || !_serialPort.IsOpen)
{
Console.WriteLine("串口未打开");
return false;
}
try
{
_serialPort.Write(command);
Console.WriteLine($"发送命令: {command}");
return true;
}
catch (Exception ex)
{
Console.WriteLine($"发送命令失败: {ex.Message}");
return false;
}
}
}
public string ReadResponse(int timeoutMs = 1000)
{
lock (_serialLock)
{
if (_serialPort == null || !_serialPort.IsOpen)
{
return "ERROR: 串口未打开";
}
try
{
_serialPort.ReadTimeout = timeoutMs;
StringBuilder response = new StringBuilder();
DateTime startTime = DateTime.Now;
while ((DateTime.Now - startTime).TotalMilliseconds < timeoutMs)
{
if (_serialPort.BytesToRead > 0)
{
string data = _serialPort.ReadExisting();
response.Append(data);
if (data.Contains("\n") || data.Contains("\r"))
{
break;
}
}
Thread.Sleep(10);
}
return response.ToString().Trim();
}
catch (TimeoutException)
{
return "TIMEOUT: 读取超时";
}
catch (Exception ex)
{
return $"ERROR: {ex.Message}";
}
}
}
2.4 原子操作:发送并接收
将发送和接收组合成一个原子操作可以简化调用逻辑:
csharp复制public string SendAndReceive(string command, int timeoutMs = 1000)
{
lock (_serialLock)
{
if (!SendCommand(command))
{
return "ERROR: 发送命令失败";
}
return ReadResponse(timeoutMs);
}
}
3. 多线程测试与验证
3.1 基础测试用例
验证单线程下的基本功能:
csharp复制static void Main(string[] args)
{
Console.WriteLine("工控仪器控制演示");
var controller = SerialInstrumentController.Instance;
try
{
controller.InitializeSerialPort("COM3", 9600);
Console.WriteLine("单线程测试:");
string response = controller.SendAndReceive("*IDN?");
Console.WriteLine($"响应: {response}");
}
catch (Exception ex)
{
Console.WriteLine($"错误: {ex.Message}");
}
finally
{
controller.Dispose();
}
}
3.2 并发压力测试
模拟多线程并发访问场景:
csharp复制static void RunMultiThreadTest(SerialInstrumentController controller)
{
int threadCount = 5;
var tasks = new Task[threadCount];
for (int i = 0; i < threadCount; i++)
{
int threadId = i;
tasks[i] = Task.Run(() =>
{
string cmd = $"READ{threadId}";
Console.WriteLine($"线程 {threadId}: 发送命令 {cmd}");
try
{
string response = controller.SendAndReceive(cmd);
Console.WriteLine($"线程 {threadId}: 收到响应 {response}");
}
catch (Exception ex)
{
Console.WriteLine($"线程 {threadId}: 错误 {ex.Message}");
}
});
}
Task.WaitAll(tasks);
}
3.3 异步任务测试
验证async/await模式下的行为:
csharp复制static async Task RunAsyncTest(SerialInstrumentController controller)
{
var tasks = new Task<string>[3];
for (int i = 0; i < 3; i++)
{
tasks[i] = Task.Run(() =>
{
return controller.SendAndReceive("MEASURE?");
});
}
var responses = await Task.WhenAll(tasks);
for (int i = 0; i < responses.Length; i++)
{
Console.WriteLine($"任务 {i} 响应: {responses[i]}");
}
}
4. 高级模式:命令队列实现
对于高并发场景,单例模式可能成为性能瓶颈。我们可以实现一个基于生产者-消费者模式的命令队列:
4.1 队列核心结构
csharp复制public class InstrumentCommandQueue : IDisposable
{
private class CommandRequest
{
public string Command { get; set; }
public TaskCompletionSource<string> CompletionSource { get; set; }
public int TimeoutMs { get; set; }
}
private readonly BlockingCollection<CommandRequest> _commandQueue =
new BlockingCollection<CommandRequest>();
private readonly SerialPort _serialPort;
private readonly Thread _workerThread;
private readonly CancellationTokenSource _cancellationTokenSource;
private bool _disposed = false;
}
4.2 工作线程实现
csharp复制private void ProcessCommands()
{
try
{
foreach (var request in _commandQueue.GetConsumingEnumerable(_cancellationTokenSource.Token))
{
try
{
_serialPort.Write(request.Command);
Console.WriteLine($"发送: {request.Command}");
string response = ReadWithTimeout(request.TimeoutMs);
request.CompletionSource.SetResult(response);
}
catch (Exception ex)
{
request.CompletionSource.SetException(ex);
}
}
}
catch (OperationCanceledException)
{
// 正常取消
}
}
4.3 使用示例
csharp复制var queue = new InstrumentCommandQueue("COM3", 9600);
// 提交命令
var task1 = queue.SubmitCommandAsync("MEASURE:VOLT?");
var task2 = queue.SubmitCommandAsync("MEASURE:CURR?");
// 等待结果
var results = await Task.WhenAll(task1, task2);
5. 工厂模式支持多种仪器
不同厂商的仪器可能有不同的命令集和响应格式,我们可以使用工厂模式来统一接口:
5.1 基础接口定义
csharp复制public interface IInstrumentController
{
string SendCommand(string command);
Task<string> SendCommandAsync(string command);
}
5.2 工厂实现
csharp复制public class InstrumentControllerFactory
{
private static readonly Lazy<InstrumentControllerFactory> _instance =
new Lazy<InstrumentControllerFactory>(() => new InstrumentControllerFactory());
private readonly ConcurrentDictionary<string, IInstrumentController> _controllers =
new ConcurrentDictionary<string, IInstrumentController>();
public static InstrumentControllerFactory Instance => _instance.Value;
public IInstrumentController GetController(string instrumentType, string portName, int baudRate)
{
string key = $"{instrumentType}_{portName}_{baudRate}";
return _controllers.GetOrAdd(key, k =>
{
switch (instrumentType.ToUpper())
{
case "AGILENT_34401A":
return new Agilent34401AController(portName, baudRate);
case "KEITHLEY_2400":
return new Keithley2400Controller(portName, baudRate);
default:
return new GenericInstrumentController(portName, baudRate);
}
});
}
}
5.3 具体仪器实现示例
csharp复制public class Agilent34401AController : IInstrumentController, IDisposable
{
private readonly SerialInstrumentController _serialController;
private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);
public Agilent34401AController(string portName, int baudRate)
{
_serialController = SerialInstrumentController.Instance;
_serialController.InitializeSerialPort(portName, baudRate);
}
public string SendCommand(string command)
{
// 特定的仪器命令处理逻辑
if (command.StartsWith("MEAS:"))
{
command = command.Replace("MEAS:", "MEASURE:") + ";*OPC?";
}
return _serialController.SendAndReceive(command);
}
}
6. 最佳实践与性能优化
6.1 配置管理
使用专门的配置类管理串口参数:
csharp复制public class SerialPortConfig
{
public string PortName { get; set; } = "COM1";
public int BaudRate { get; set; } = 9600;
public int DataBits { get; set; } = 8;
public Parity Parity { get; set; } = Parity.None;
public StopBits StopBits { get; set; } = StopBits.One;
public int ReadTimeout { get; set; } = 1000;
public int WriteTimeout { get; set; } = 1000;
}
6.2 依赖注入集成
在现代应用中,建议使用依赖注入:
csharp复制public static IServiceCollection AddInstrumentServices(this IServiceCollection services, SerialPortConfig config)
{
services.AddSingleton(config);
services.AddSingleton<IInstrumentController>(provider =>
{
var cfg = provider.GetRequiredService<SerialPortConfig>();
return new GenericInstrumentController(cfg.PortName, cfg.BaudRate);
});
return services;
}
6.3 重试机制
实现健壮的重试逻辑:
csharp复制public static T ExecuteWithRetry<T>(Func<T> action, int maxRetries = 3, int delayMs = 100)
{
int retryCount = 0;
while (retryCount < maxRetries)
{
try
{
return action();
}
catch (Exception ex) when (retryCount < maxRetries - 1)
{
retryCount++;
Console.WriteLine($"操作失败,第 {retryCount} 次重试: {ex.Message}");
if (ex is UnauthorizedAccessException || ex is IOException)
{
Thread.Sleep(delayMs * retryCount);
}
else
{
throw;
}
}
}
throw new InvalidOperationException($"操作在 {maxRetries} 次重试后仍失败");
}
6.4 性能优化技巧
- 缓冲区大小:根据数据量调整SerialPort的ReadBufferSize和WriteBufferSize
- 批量操作:合并多个小命令为一个批量命令
- 缓存响应:对频繁查询的只读数据实现本地缓存
- 连接池:对多串口场景实现连接池管理
7. 常见问题排查
7.1 串口无法打开
可能原因:
- 端口被其他程序占用
- 权限不足
- 端口名称错误
解决方案:
csharp复制try
{
_serialPort.Open();
}
catch (UnauthorizedAccessException)
{
// 检查是否有其他程序占用了串口
}
catch (IOException)
{
// 检查端口名称是否正确
}
7.2 数据接收不完整
可能原因:
- 读取超时设置过短
- 未正确处理结束符
- 波特率不匹配
解决方案:
csharp复制// 增加超时时间
_serialPort.ReadTimeout = 2000;
// 明确指定结束符
while (!response.ToString().EndsWith("\r\n"))
{
// 继续读取
}
7.3 多线程下响应错乱
可能原因:
- 未正确实现线程同步
- 共享状态被意外修改
解决方案:
- 确保所有共享资源的访问都有锁保护
- 尽量减少共享状态,使用局部变量
7.4 内存泄漏
可能原因:
- 未正确实现IDisposable
- 事件未注销
解决方案:
csharp复制public void Dispose()
{
if (!_disposed)
{
_disposed = true;
ClosePort();
GC.SuppressFinalize(this);
}
}
~SerialInstrumentController()
{
Dispose(false);
}
8. 方案选型指南
根据应用场景选择合适的线程安全方案:
| 场景特点 | 推荐方案 | 优点 | 缺点 |
|---|---|---|---|
| 低并发,简单应用 | 单例模式+lock | 实现简单,资源消耗少 | 高并发下性能差 |
| 中等并发,需要异步支持 | 单例模式+async/await | 支持现代异步编程模型 | 需要更多代码 |
| 高并发,多仪器 | 命令队列+工作线程 | 吞吐量高,响应时间稳定 | 实现复杂 |
| 多仪器类型,统一接口 | 工厂模式+具体实现 | 扩展性好,维护方便 | 类数量增加 |
在实际项目中,我通常会根据以下因素做选择:
- 并发量:预计会有多少线程同时访问串口
- 响应时间要求:是否需要保证低延迟
- 仪器复杂性:是否需要支持多种仪器类型
- 团队技能:团队成员对线程同步的掌握程度
对于大多数工业控制应用,我建议从单例模式开始,当遇到性能瓶颈时再逐步升级到更复杂的方案。过早优化往往会导致不必要的复杂性。