1. 工业控制领域的C#与西门子PLC通讯实战
在工业自动化领域,西门子PLC(可编程逻辑控制器)作为控制系统的核心设备,与上位机软件的通讯一直是工程师们关注的重点。传统的组态软件虽然功能强大,但在需要高度定制化或批量处理的场景下,往往显得力不从心。而使用C#直接与PLC建立通讯,则能够提供更灵活的控制方式和更高的执行效率。
我从事工业自动化软件开发已有8年时间,从最早的OPC通讯到现在的原生Socket直连,经历了各种通讯方式的迭代。今天要分享的是基于C#原生Socket与西门子S7-200 SMART PLC的以太网通讯方案,这种方案最大的优势在于:
- 完全自主控制通讯过程
- 可同时连接多台PLC设备
- 内存占用低,响应速度快
- 摆脱对第三方库的依赖
2. 通讯基础与环境准备
2.1 西门子S7协议概述
西门子S7系列PLC使用的是一种基于OSI模型的通讯协议,它工作在传输层(第4层),通常使用TCP端口102进行通讯。协议的核心特点包括:
- 采用面向连接的通讯方式
- 数据以大端字节序传输
- 每个请求都有特定的报文头结构
- 支持多种功能码(读、写、诊断等)
理解这些基础特性对于后续的报文构造至关重要。我曾经遇到过字节序处理不当导致通讯失败的情况,后来通过抓包分析才发现是数据解析方向搞反了。
2.2 开发环境配置
要实现C#与西门子PLC的通讯,需要准备以下环境:
- Visual Studio 2019或更高版本(社区版即可)
- .NET Framework 4.5+或.NET Core 3.1+
- 西门子S7-200 SMART PLC(固件版本V2.0以上)
- 本地网络环境(建议使用交换机直连)
注意:在开始开发前,请确保PLC的IP地址已正确配置,并且开发电脑能够ping通PLC。我曾经在一个项目中花了半天时间排查通讯问题,最后发现是Windows防火墙阻止了连接。
3. 基础通讯实现
3.1 建立TCP连接
与PLC建立连接的第一步是创建Socket并连接到PLC的IP地址和端口。以下是基础连接代码:
csharp复制using System.Net;
using System.Net.Sockets;
public class PlcConnector
{
private Socket _socket;
private string _plcIp;
private int _port;
public PlcConnector(string plcIp, int port = 102)
{
_plcIp = plcIp;
_port = port;
}
public bool Connect()
{
try
{
_socket = new Socket(AddressFamily.InterNetwork,
SocketType.Stream,
ProtocolType.Tcp);
// 设置发送和接收超时为2秒
_socket.SendTimeout = 2000;
_socket.ReceiveTimeout = 2000;
_socket.Connect(new IPEndPoint(IPAddress.Parse(_plcIp), _port));
// 使用Poll方法检测真实连接状态
return _socket.Poll(1000, SelectMode.SelectWrite);
}
catch(Exception ex)
{
Console.WriteLine($"连接失败: {ex.Message}");
return false;
}
}
}
这段代码中有几个关键点需要注意:
- 设置了SendTimeout和ReceiveTimeout,避免网络异常时程序长时间阻塞
- 使用Poll方法而非Connected属性检测连接状态,因为Connected只能反映最近一次的操作状态
- 将连接逻辑封装在try-catch中,增强程序的健壮性
3.2 连接状态检测优化
在实际项目中,我发现仅靠基础的连接检测是不够的。PLC可能会在通讯过程中突然断电或网络中断,因此需要更可靠的检测机制。以下是我总结的优化方案:
csharp复制public bool IsReallyConnected()
{
if(_socket == null || !_socket.Connected)
return false;
// 检测方法1:发送0字节数据测试连接
try
{
_socket.Send(new byte[0], 0, SocketFlags.None);
return true;
}
catch
{
return false;
}
// 检测方法2:Poll结合SelectMode.SelectError
// return _socket.Poll(1000, SelectMode.SelectError);
}
这两种方法各有优劣:
- 发送0字节数据的方法最可靠,但会产生少量网络流量
- Poll方法更轻量,但在某些特殊网络环境下可能不够准确
建议根据实际场景选择合适的检测方式。在我的一个水处理项目中,采用了双重检测机制,显著提高了通讯可靠性。
4. 异步通讯实现
4.1 基础异步读写模型
与PLC通讯最怕的就是阻塞UI线程,因此异步操作是必须的。以下是基础的异步读写模板:
csharp复制public class PlcAsyncCommunicator
{
private Socket _socket;
private byte[] _receiveBuffer = new byte[4096];
public void StartAsyncReceiving()
{
try
{
_socket.BeginReceive(_receiveBuffer, 0, _receiveBuffer.Length,
SocketFlags.None, ReceiveCallback, null);
}
catch(Exception ex)
{
Console.WriteLine($"开始接收失败: {ex.Message}");
// 这里应该触发重连逻辑
}
}
private void ReceiveCallback(IAsyncResult ar)
{
try
{
int bytesRead = _socket.EndReceive(ar);
if(bytesRead > 0)
{
ProcessReceivedData(_receiveBuffer, bytesRead);
// 继续接收下一条消息
StartAsyncReceiving();
}
else
{
// 连接已关闭
Console.WriteLine("PLC主动断开连接");
}
}
catch(Exception ex)
{
Console.WriteLine($"接收数据出错: {ex.Message}");
// 异常处理逻辑
}
}
public void SendAsync(byte[] data)
{
try
{
_socket.BeginSend(data, 0, data.Length,
SocketFlags.None, SendCallback, null);
}
catch(Exception ex)
{
Console.WriteLine($"发送失败: {ex.Message}");
}
}
private void SendCallback(IAsyncResult ar)
{
try
{
int bytesSent = _socket.EndSend(ar);
Console.WriteLine($"已发送{bytesSent}字节");
}
catch(Exception ex)
{
Console.WriteLine($"发送回调出错: {ex.Message}");
}
}
private void ProcessReceivedData(byte[] data, int length)
{
// 解析PLC返回的报文
// ...
}
}
4.2 异步模型优化
基础的异步模型虽然能用,但在高频率通讯场景下会出现性能问题。经过多次实践,我总结出以下优化方案:
- 缓冲区管理:使用环形缓冲区避免频繁内存分配
- 双缓冲技术:准备两个缓冲区交替使用
- IO完成端口:对于高性能需求,可以使用SocketAsyncEventArgs
以下是使用SocketAsyncEventArgs的优化实现:
csharp复制public class HighPerformancePlcCommunicator
{
private Socket _socket;
private SocketAsyncEventArgs _receiveArgs;
private SocketAsyncEventArgs _sendArgs;
private byte[] _receiveBuffer;
public HighPerformancePlcCommunicator()
{
_receiveBuffer = new byte[8192];
_receiveArgs = new SocketAsyncEventArgs();
_receiveArgs.SetBuffer(_receiveBuffer, 0, _receiveBuffer.Length);
_receiveArgs.Completed += OnReceiveCompleted;
_sendArgs = new SocketAsyncEventArgs();
_sendArgs.Completed += OnSendCompleted;
}
public void StartReceiving()
{
if(!_socket.ReceiveAsync(_receiveArgs))
{
// 同步完成时直接处理
OnReceiveCompleted(null, _receiveArgs);
}
}
private void OnReceiveCompleted(object sender, SocketAsyncEventArgs e)
{
if(e.BytesTransferred > 0 && e.SocketError == SocketError.Success)
{
ProcessData(e.Buffer, e.Offset, e.BytesTransferred);
StartReceiving();
}
else
{
// 连接出错处理
}
}
public void Send(byte[] data)
{
_sendArgs.SetBuffer(data, 0, data.Length);
if(!_socket.SendAsync(_sendArgs))
{
OnSendCompleted(null, _sendArgs);
}
}
private void OnSendCompleted(object sender, SocketAsyncEventArgs e)
{
// 发送完成处理
}
}
这种实现方式的优势在于:
- 减少了GC压力
- 提高了IO吞吐量
- 更精细的控制超时和错误处理
在一个汽车生产线项目中,使用这种优化方案后,通讯延迟从平均50ms降低到了15ms以下。
5. 多PLC并行通讯
5.1 基础并行通讯实现
工业现场往往需要同时与多台PLC通讯,使用Parallel.ForEach可以简化这一过程:
csharp复制public class MultiPlcController
{
private List<string> _plcIps = new List<string>
{
"192.168.1.10",
"192.168.1.11",
"192.168.1.12"
};
public void ReadAllPlcs()
{
var options = new ParallelOptions
{
MaxDegreeOfParallelism = Environment.ProcessorCount * 2
};
Parallel.ForEach(_plcIps, options, plcIp =>
{
using(var connector = new PlcConnector(plcIp))
{
if(connector.Connect())
{
var data = connector.ReadDataBlock(1, 0, 10);
// 处理数据...
}
}
});
}
}
5.2 高级并行通讯优化
基础并行通讯虽然简单,但在处理大量PLC时仍有优化空间。以下是更高级的实现方案:
csharp复制public class AdvancedPlcBatchReader
{
private ConcurrentDictionary<string, PlcData> _results =
new ConcurrentDictionary<string, PlcData>();
public async Task<Dictionary<string, PlcData>> ReadBatchAsync(
IEnumerable<string> plcIps,
int batchSize = 5)
{
var semaphore = new SemaphoreSlim(batchSize);
var tasks = plcIps.Select(ip => ProcessPlcAsync(ip, semaphore));
await Task.WhenAll(tasks);
return _results.ToDictionary(kv => kv.Key, kv => kv.Value);
}
private async Task ProcessPlcAsync(string ip, SemaphoreSlim semaphore)
{
await semaphore.WaitAsync();
try
{
using(var connector = new PlcConnector(ip))
{
var data = await connector.ReadDataBlockAsync(1, 0, 10);
_results[ip] = data;
}
}
finally
{
semaphore.Release();
}
}
}
这种实现方式的优势包括:
- 控制并发数量,避免资源耗尽
- 使用异步操作提高吞吐量
- 线程安全的结果收集
- 更好的异常处理
在一个智能仓储项目中,使用这种方案同时连接了50台PLC,内存占用仅120MB,而传统的线程池方式则需要200MB以上。
6. 数据块读写实战
6.1 读取数据块实现
西门子PLC的数据存储在数据块(DB)中,读取DB块需要构造特定的报文。以下是读取DB块的完整实现:
csharp复制public byte[] ReadDataBlock(int dbNumber, int startByte, int length)
{
// 构造读取请求报文
var request = BuildReadRequest(dbNumber, startByte, length);
// 发送请求
_socket.Send(request);
// 接收响应
byte[] response = new byte[1024];
int bytesRead = _socket.Receive(response);
// 解析响应
return ParseReadResponse(response, bytesRead, length);
}
private byte[] BuildReadRequest(int dbNumber, int startByte, int length)
{
var request = new List<byte>();
// TPKT头
request.Add(0x03); // 版本
request.Add(0x00);
request.AddRange(BitConverter.GetBytes((short)(21 + length)).Reverse());
// COTP头
request.Add(0x02); // PDU类型
request.Add(0xF0);
request.Add(0x80);
// S7头
request.Add(0x32); // 协议ID
request.Add(0x01); // 消息类型
request.AddRange(new byte[] { 0x00, 0x00 }); // 保留
request.AddRange(BitConverter.GetBytes((short)0).Reverse()); // PDU引用
request.AddRange(BitConverter.GetBytes((short)length).Reverse()); // 参数长度
request.AddRange(BitConverter.GetBytes((short)0).Reverse()); // 数据长度
// 参数部分
request.Add(0x04); // 功能码:读
request.Add(0x01); // 项目数
request.Add(0x12); // 变量规格
request.Add(0x0A); // 地址长度
request.Add(0x10); // 语法ID:S7Any
// 数据块地址
request.Add((byte)(dbNumber >> 8)); // 块号高字节
request.Add((byte)(dbNumber & 0xFF)); // 块号低字节
request.Add(0x00); // 保留
request.Add(0x00); // 数据类型:BYTE
request.AddRange(BitConverter.GetBytes(startByte * 8).Reverse()); // 位地址
request.Add(0x00);
request.Add((byte)(length * 8)); // 位长度
return request.ToArray();
}
private byte[] ParseReadResponse(byte[] response, int bytesRead, int expectedLength)
{
// 验证响应头
if(bytesRead < 20 || response[8] != 0x32 || response[9] != 0x03)
throw new InvalidDataException("无效的PLC响应");
// 获取数据部分长度
int dataLength = BitConverter.ToInt16(new byte[] { response[16], response[15] }, 0);
// 提取数据
byte[] data = new byte[expectedLength];
Array.Copy(response, 21, data, 0, Math.Min(expectedLength, dataLength));
return data;
}
6.2 写入数据块实现
写入数据块与读取类似,但需要构造不同的报文:
csharp复制public void WriteDataBlock(int dbNumber, int startByte, byte[] data)
{
// 构造写入请求报文
var request = BuildWriteRequest(dbNumber, startByte, data);
// 发送请求
_socket.Send(request);
// 接收响应
byte[] response = new byte[1024];
int bytesRead = _socket.Receive(response);
// 验证响应
if(bytesRead < 14 || response[8] != 0x32 || response[9] != 0x03)
throw new InvalidDataException("写入操作失败");
}
private byte[] BuildWriteRequest(int dbNumber, int startByte, byte[] data)
{
var request = new List<byte>();
// TPKT头
request.Add(0x03); // 版本
request.Add(0x00);
request.AddRange(BitConverter.GetBytes((short)(21 + data.Length)).Reverse());
// COTP头
request.Add(0x02); // PDU类型
request.Add(0xF0);
request.Add(0x80);
// S7头
request.Add(0x32); // 协议ID
request.Add(0x01); // 消息类型
request.AddRange(new byte[] { 0x00, 0x00 }); // 保留
request.AddRange(BitConverter.GetBytes((short)0).Reverse()); // PDU引用
request.AddRange(BitConverter.GetBytes((short)10).Reverse()); // 参数长度
request.AddRange(BitConverter.GetBytes((short)data.Length).Reverse()); // 数据长度
// 参数部分
request.Add(0x05); // 功能码:写
request.Add(0x01); // 项目数
request.Add(0x12); // 变量规格
request.Add(0x0A); // 地址长度
request.Add(0x10); // 语法ID:S7Any
// 数据块地址
request.Add((byte)(dbNumber >> 8)); // 块号高字节
request.Add((byte)(dbNumber & 0xFF)); // 块号低字节
request.Add(0x00); // 保留
request.Add(0x02); // 数据类型:BYTE
request.AddRange(BitConverter.GetBytes(startByte * 8).Reverse()); // 位地址
request.Add(0x00);
request.Add((byte)(data.Length * 8)); // 位长度
// 数据部分
request.AddRange(data);
return request.ToArray();
}
7. 性能优化与异常处理
7.1 通讯性能优化技巧
在与PLC通讯过程中,我总结了以下性能优化经验:
- 批量读写:尽量减少通讯次数,一次读取或写入多个数据
- 合理设置超时:根据网络状况设置适当的超时时间
- 连接池管理:对于频繁通讯的场景,维护一个连接池
- 数据压缩:对于大量数据传输,可以考虑使用压缩算法
- 心跳机制:定期发送心跳包保持连接活跃
以下是连接池的实现示例:
csharp复制public class PlcConnectionPool : IDisposable
{
private ConcurrentBag<PlcConnector> _connections;
private string _plcIp;
private int _port;
private int _maxPoolSize;
public PlcConnectionPool(string plcIp, int port = 102, int maxPoolSize = 10)
{
_plcIp = plcIp;
_port = port;
_maxPoolSize = maxPoolSize;
_connections = new ConcurrentBag<PlcConnector>();
}
public PlcConnector GetConnection()
{
if(_connections.TryTake(out var connector))
{
if(connector.IsConnected)
return connector;
connector.Dispose();
}
if(_connections.Count < _maxPoolSize)
{
var newConnector = new PlcConnector(_plcIp, _port);
if(newConnector.Connect())
return newConnector;
}
throw new Exception("无法获取PLC连接");
}
public void ReturnConnection(PlcConnector connector)
{
if(connector.IsConnected)
{
_connections.Add(connector);
}
else
{
connector.Dispose();
}
}
public void Dispose()
{
while(_connections.TryTake(out var connector))
{
connector.Dispose();
}
}
}
7.2 异常处理与重连机制
工业环境网络不稳定,完善的异常处理机制至关重要:
csharp复制public class RobustPlcCommunicator
{
private PlcConnector _connector;
private int _retryCount = 3;
private int _retryDelay = 1000;
public async Task<byte[]> ReadWithRetry(int dbNumber, int start, int length)
{
int attempt = 0;
while(attempt < _retryCount)
{
try
{
if(_connector == null || !_connector.IsConnected)
{
_connector?.Dispose();
_connector = new PlcConnector("192.168.1.10");
if(!_connector.Connect())
throw new Exception("连接失败");
}
return await _connector.ReadDataBlockAsync(dbNumber, start, length);
}
catch(Exception ex)
{
attempt++;
if(attempt >= _retryCount)
throw;
await Task.Delay(_retryDelay * attempt);
}
}
throw new Exception("所有重试尝试均失败");
}
}
8. 实际应用案例
8.1 生产线数据采集系统
在某汽车零部件生产线项目中,我们需要实时采集20台S7-200 SMART PLC的生产数据。系统要求:
- 采集频率:500ms/次
- 数据点:每台PLC约50个数据点
- 稳定性:24/7不间断运行
实现方案:
- 使用连接池管理PLC连接
- 采用异步并行通讯
- 实现断线自动重连
- 数据缓存与批量写入数据库
核心代码结构:
csharp复制public class ProductionDataCollector
{
private List<string> _plcIps;
private Timer _collectionTimer;
private PlcConnectionPool _connectionPool;
public void Start()
{
_connectionPool = new PlcConnectionPool(_plcIps, maxPoolSize: 10);
_collectionTimer = new Timer(CollectData, null, 0, 500);
}
private async void CollectData(object state)
{
try
{
var tasks = _plcIps.Select(ip => CollectPlcDataAsync(ip));
var results = await Task.WhenAll(tasks);
// 处理并存储数据
ProcessAndStoreResults(results);
}
catch(Exception ex)
{
LogError(ex);
}
}
private async Task<PlcData> CollectPlcDataAsync(string ip)
{
using(var connector = _connectionPool.GetConnection())
{
var data1 = await connector.ReadDataBlockAsync(1, 0, 10);
var data2 = await connector.ReadDataBlockAsync(1, 10, 10);
// 读取更多数据...
return new PlcData(ip, data1, data2);
}
}
}
该系统已稳定运行3年,平均每月处理超过2000万条生产数据。
8.2 智能仓储控制系统
在某电商仓储项目中,需要控制50台S7-200 SMART PLC实现自动化分拣。系统特点:
- 高并发控制指令
- 毫秒级响应要求
- 99.99%可用性要求
解决方案:
- 使用SocketAsyncEventArgs实现高性能通讯
- 采用消息队列缓冲控制指令
- 实现多级故障转移机制
- 详细的性能监控与报警
核心性能优化代码:
csharp复制public class HighSpeedPlcController
{
private ConcurrentQueue<PlcCommand> _commandQueue = new ConcurrentQueue<PlcCommand>();
private List<PlcHighSpeedConnector> _connectors;
public void StartProcessing()
{
Task.Run(async () =>
{
while(true)
{
if(_commandQueue.TryDequeue(out var command))
{
var connector = _connectors[command.PlcIndex];
await connector.SendCommandAsync(command);
}
else
{
await Task.Delay(1);
}
}
});
}
}
public class PlcHighSpeedConnector
{
private SocketAsyncEventArgs _sendArgs;
private SocketAsyncEventArgs _receiveArgs;
private Socket _socket;
public async Task SendCommandAsync(PlcCommand command)
{
var request = BuildCommandRequest(command);
_sendArgs.SetBuffer(request, 0, request.Length);
var completionSource = new TaskCompletionSource<bool>();
_sendArgs.UserToken = completionSource;
if(!_socket.SendAsync(_sendArgs))
{
completionSource.SetResult(true);
}
await completionSource.Task;
}
private void OnSendCompleted(object sender, SocketAsyncEventArgs e)
{
var completionSource = e.UserToken as TaskCompletionSource<bool>;
completionSource?.SetResult(e.SocketError == SocketError.Success);
}
}
该系统峰值时可处理每秒500+条控制指令,平均响应时间小于10ms。