C#与西门子PLC通讯——手搓S7通讯协议
本文将尝试从源码角度,使用Tcp/Ip的方式直接与西门子PLC进行交互通讯。
C#与西门子PLC通讯 系列文章目录
往期博客参考
C#与西门子PLC通讯——新手快速入门
C#与西门子PLC通讯——熟手快速入门
建议先看一下这两篇,了解预设背景。
文章目录
- C#与西门子PLC通讯 系列文章目录
- 前言
- 一、通讯协议
-
- 1.1 S7协议位置
- 1.2 S7通讯协议
- 1.3 S7 Net Plus源码赏析
-
- 1.3.1 声明对象
- 1.3.2 建立连接TcpIp连接
- 1.3.3 通讯协议交互
-
- 1.3.3.1 TPKT协议和COTP协议
- 1.3.3.2 S7连接协议
- 1.3.4 同步读命令
-
- 1.3.4.1 读方法入口
- 1.3.4.2 组装读命令前的准备
- 1.3.4.3 读命令头的组装
- 1.3.4.4 读命令字节包的组装
- 1.3.5 同步写命令
-
- 1.3.5.1 写方法入口
- 1.3.5.2 组装写命令前的准备
- 1.3.5.3 写命令头和字节包的组装
- 二、数据准备
- 三、C#使用Socket 读取PLC数据
-
- 3.1 分析
- 3.2 源码
- 3.2 运行效果
- 总结
前言
知其然,知其所以然。
这篇文章,我们就尝试重复造一个轮子。通过对通讯协议的简要分析,我们能够更好地了解与西门子PLC是如何交互的。最后,我们就运用底层方法,使用Socket通讯将一个数组读取出来,再将数组反转之后写回PLC中。
一、通讯协议
1.1 S7协议位置
首先,参照 ISO-OSI 参考模型,S7 协议位置如下:

参考西门子官网介绍:S7 协议有哪些属性,优势及特征?
1.2 S7通讯协议
当C#应用程序中与西门子PLC进行通信时,需要经历一系列协议阶段,以确保有效的数据传输和通信。
这些阶段包括TCP/IP协议、TPKT协议、COTP协议和S7连接协议。
- 通信的第一阶段是建立TCP/IP连接的三次握手。这是通信的基础,它确保了数据能够可靠地在客户端和PLC之间传输。
- 一旦TCP/IP连接建立,下一步是使用TPKT协议。如果直接去尝试发送别的信息,就会被PLC踢出去。
- 接下来是COTP协议。COTP协议用于建立和管理连接,它为应用层提供了一种连接导向的通信方式。在COTP外包一层TPKT,这样就可以发送给PLC,完成协议的确认。
- 最后,我们到达S7连接协议阶段,这是与西门子PLC通信的核心协议。从源码来看,这个是一个固定的内容{ 3, 0, 0, 25, 2, 240, 128, 50, 1, 0, 0, 255, 255, 0, 8, 0, 0, 240, 0, 0, 3, 0, 3, 3, 192};。
网络上有很多写得很好的关于S7通讯协议的介绍和分析,这里就不做复读机啦。
完成上述操作之后,就开启了新世界的大门,在PLC的数据海洋里自由荡漾。
1.3 S7 Net Plus源码赏析
1.3.1 声明对象
////// 创建一个具备连接所需参数的 PLC 对象。 /// 对于 S7-1200 和 S7-1500,默认值为 rack = 0 和 slot = 0。 /// 如果要连接到外部以太网卡 (CP),则需要 slot > 0。 /// 对于 S7-300 和 S7-400,默认值为 rack = 0 和 slot = 2。 /// /// PLC 的 CpuType(从枚举中选择) /// PLC 的 IP 地址 /// PLC 的机架号,通常为 0,但请在 Step7 或 TIA Portal 的硬件配置中进行检查 /// PLC 的 CPU 插槽号,对于 S7-1200 和 S7-1500 通常为 0,对于 S7-300 和 S7-400 通常为 2。 /// 如果使用外部以太网卡,必须相应地设置。 public Plc(CpuType cpu, string ip, Int16 rack, Int16 slot) : this(cpu, ip, DefaultPort, rack, slot) { } ////// 创建一个具备连接所需参数的 PLC 对象。 /// 对于 S7-1200 和 S7-1500,默认值为 rack = 0 和 slot = 0。 /// 如果要连接到外部以太网卡 (CP),则需要 slot > 0。 /// 对于 S7-300 和 S7-400,默认值为 rack = 0 和 slot = 2。 /// /// PLC 的 CpuType(从枚举中选择) /// PLC 的 IP 地址 /// 用于连接的端口号,默认为 102。 /// PLC 的机架号,通常为 0,但请在 Step7 或 TIA Portal 的硬件配置中进行检查 /// PLC 的 CPU 插槽号,对于 S7-1200 和 S7-1500 通常为 0,对于 S7-300 和 S7-400 通常为 2。 /// 如果使用外部以太网卡,必须相应地设置。 public Plc(CpuType cpu, string ip, int port, Int16 rack, Int16 slot) : this(ip, port, TsapPair.GetDefaultTsapPair(cpu, rack, slot)) { if (!Enum.IsDefined(typeof(CpuType), cpu)) throw new ArgumentException( $"参数 '{nameof(cpu)}' 的值 ({cpu}) 对于枚举类型 '{typeof(CpuType).Name}' 无效。", nameof(cpu)); CPU = cpu; Rack = rack; Slot = slot; }
1.3.2 建立连接TcpIp连接
同步方法:
////// 连接到 PLC 并执行 COTP 连接请求和 S7 通信设置。 /// public void Open() { try { OpenAsync().GetAwaiter().GetResult(); } catch (Exception exc) { throw new PlcException(ErrorCode.ConnectionError, $"无法建立与 {IP} 的连接。\n消息:{exc.Message}", exc); } }
其中,同步方法会调用异步方法。
////// 连接到 PLC 并执行 COTP 连接请求和 S7 通信设置。 /// /// 用于监视取消请求的令牌。默认值为 None。 /// 请注意,取消不会以任何方式影响打开套接字,只会在成功建立套接字连接后影响用于配置连接的数据传输。 /// 请注意,取消是建议性/协作性的,不会在所有情况下立即导致取消。 /// 表示异步打开操作的任务。 public async Task OpenAsync(CancellationToken cancellationToken = default) { var stream = await ConnectAsync(cancellationToken).ConfigureAwait(false); try { await queue.Enqueue(async () => { cancellationToken.ThrowIfCancellationRequested(); await EstablishConnection(stream, cancellationToken).ConfigureAwait(false); _stream = stream; return default(object); }).ConfigureAwait(false); } catch (Exception) { stream.Dispose(); throw; } }
ConnectAsync对应TcpIp连接方法:
private async Task ConnectAsync(CancellationToken cancellationToken)
{
tcpClient = new TcpClient();
ConfigureConnection();
#if NET5_0_OR_GREATER
await tcpClient.ConnectAsync(IP, Port, cancellationToken).ConfigureAwait(false);
#else
await tcpClient.ConnectAsync(IP, Port).ConfigureAwait(false);
#endif
return tcpClient.GetStream();
}
.Net5 以上会调用await tcpClient.ConnectAsync(IP, Port, cancellationToken).ConfigureAwait(false);。
.Net5 以下会调用await tcpClient.ConnectAsync(IP, Port).ConfigureAwait(false);。
1.3.3 通讯协议交互
OpenAsync中EstablishConnection就是建立TPKT协议、COTP协议和S7连接协议三个通讯握手的阶段。
其中,RequestConnection对应TPKT协议、COTP协议,SetupConnection对应S7连接协议。
private async Task EstablishConnection(Stream stream, CancellationToken cancellationToken)
{
// 发起TPKT和COTP连接请求
await RequestConnection(stream, cancellationToken).ConfigureAwait(false);
// 设置S7连接协议
await SetupConnection(stream, cancellationToken).ConfigureAwait(false);
}
1.3.3.1 TPKT协议和COTP协议
private async Task RequestConnection(Stream stream, CancellationToken cancellationToken)
{
// 获取COTP连接请求数据
var requestData = ConnectionRequest.GetCOTPConnectionRequest(TsapPair);
// 发送请求并等待响应
var response = await NoLockRequestTpduAsync(stream, requestData, cancellationToken).ConfigureAwait(false);
// 检查响应是否为连接确认类型
if (response.PDUType != COTP.PduType.ConnectionConfirmed)
{
throw new InvalidDataException("连接请求被拒绝", response.TPkt.Data, 1, 0x0d);
}
}
public static byte[] GetCOTPConnectionRequest(TsapPair tsapPair)
{
// 构建COTP连接请求数据
byte[] bSend1 = {
3, 0, 0, 22, // TPKT
17, // COTP 头部长度
224, // 连接请求
0, 0, // 目标参考
0, 46, // 源参考
0, // 标志位
193, // 参数代码 (源 TASP)
2, // 参数长度
tsapPair.Local.FirstByte, tsapPair.Local.SecondByte, // 源 TASP
194, // 参数代码 (目标 TASP)
2, // 参数长度
tsapPair.Remote.FirstByte, tsapPair.Remote.SecondByte, // 目标 TASP
192, // 参数代码 (TPDU 大小)
1, // 参数长度
10 // TPDU 大小 (2^10 = 1024)
};
return bSend1;
}
1.3.3.2 S7连接协议
private async Task SetupConnection(Stream stream, CancellationToken cancellationToken)
{
// 获取S7连接设置数据
var setupData = GetS7ConnectionSetup();
// 发送设置数据并等待响应
var s7data = await NoLockRequestTsduAsync(stream, setupData, 0, setupData.Length, cancellationToken)
.ConfigureAwait(false);
// 检查响应数据是否足够
if (s7data.Length < 2)
throw new WrongNumberOfBytesException("响应中未收到足够的数据以进行通信设置");
// 检查S7 Ack数据
if (s7data[1] != 0x03)
throw new InvalidDataException("读取通信设置响应时出现错误", s7data, 1, 0x03);
if (s7data.Length < 20)
throw new WrongNumberOfBytesException("响应中未收到足够的数据以进行通信设置");
// TODO: 检查这是否应该是 UInt16。
MaxPDUSize = s7data[18] * 256 + s7data[19];
}
// 发送固定的配置信息
private byte[] GetS7ConnectionSetup()
{
// 构建S7连接设置数据
return new byte[] { 3, 0, 0, 25, 2, 240, 128, 50, 1, 0, 0, 255, 255, 0, 8, 0, 0, 240, 0, 0, 3, 0, 3,
3, 192 // 使用 960 PDU 大小
};
}
1.3.4 同步读命令
1.3.4.1 读方法入口
////// 读取并解码指定数量的 "VarType" 数据。 /// 可用于读取相同类型的多个连续变量(Word、DWord、Int 等)。 /// 如果读取不成功,请检查 LastErrorCode 或 LastErrorString。 /// /// 内存区域的数据类型,可以是 DB、Timer、Counter、Merker(Memory)、Input、Output。 /// 内存区域的地址(如果要读取 DB1,则设置为 1)。对于其他内存区域类型(计数器、定时器等),也必须设置此值。 /// 起始字节地址。如果要读取 DB1.DBW200,则将此值设置为 200。 /// 要读取的变量的类型 /// 位地址。如果要读取 DB1.DBX200.6,则将此参数设置为 6。 /// 要读取的变量数量 /// 读取到的数据,如果读取失败则返回 null。 public object? Read(DataType dataType, int db, int startByteAdr, VarType varType, int varCount, byte bitAdr = 0) { int cntBytes = VarTypeToByteLength(varType, varCount); byte[] bytes = ReadBytes(dataType, db, startByteAdr, cntBytes); return ParseBytes(varType, bytes, varCount, bitAdr); }
其中,VarTypeToByteLength用于计算需要读取的字节数
////// 根据 S7 的 (Bool、Word、DWord 等),返回需要读取的字节数。 /// /// 变量类型 /// 变量数量 /// 变量的字节长度 internal static int VarTypeToByteLength(VarType varType, int varCount = 1) { switch (varType) { case VarType.Bit: return (varCount + 7) / 8; case VarType.Byte: return (varCount < 1) ? 1 : varCount; case VarType.String: return varCount; case VarType.S7String: return ((varCount + 2) & 1) == 1 ? (varCount + 3) : (varCount + 2); case VarType.S7WString: return (varCount * 2) + 4; case VarType.Word: case VarType.Timer: case VarType.Int: case VarType.Counter: case VarType.Date: return varCount * 2; case VarType.DWord: case VarType.DInt: case VarType.Real: case VarType.Time: return varCount * 4; case VarType.LReal: case VarType.DateTime: return varCount * 8; case VarType.DateTimeLong: return varCount * 12; default: return 0; } }
根据指定的 S7 变量类型和数量,返回需要读取的字节数。不同的变量类型占用不同的字节数,例如 Bit 类型可能只需要 1 个字节,而 Word 类型需要 2 个字节。这个方法用于帮助计算读取变量时需要的字节数。
1.3.4.2 组装读命令前的准备
////// 从指定索引处的 DB 读取指定数量的字节。处理多于 200 字节的情况,使用多次请求。 /// 如果读取不成功,请检查 LastErrorCode 或 LastErrorString。 /// /// 内存区域的数据类型,可以是 DB、Timer、Counter、Merker(Memory)、Input、Output。 /// 内存区域的地址(如果要读取 DB1,则设置为 1)。对于其他内存区域类型(计数器、定时器等),也必须设置此值。 /// 起始字节地址。如果要读取 DB1.DBW200,则将此值设置为 200。 /// 字节数量,如果要读取 120 字节,将此设置为 120。 /// 以数组形式返回字节数据 public byte[] ReadBytes(DataType dataType, int db, int startByteAdr, int count) { var result = new byte[count]; ReadBytes(result, dataType, db, startByteAdr); return result; } ////// 从指定索引处的 DB 读取指定数量的字节。处理多于 200 字节的情况,使用多次请求。 /// 如果读取不成功,请检查 LastErrorCode 或 LastErrorString。 /// /// 用于接收读取字节的缓冲区。 确定要读取的字节数。 /// 内存区域的数据类型,可以是 DB、Timer、Counter、Merker(Memory)、Input、Output。 /// 内存区域的地址(如果要读取 DB1,则设置为 1)。对于其他内存区域类型(计数器、定时器等),也必须设置此值。 /// 起始字节地址。如果要读取 DB1.DBW200,则将此值设置为 200。 /// public void ReadBytes(Span buffer, DataType dataType, int db, int startByteAdr) { int index = 0; while (buffer.Length > 0) { // 这适用于 SNAP7 上的 MaxPDUSize-1。但不适用于 MaxPDUSize-0。 var maxToRead = Math.Min(buffer.Length, MaxPDUSize - 18); ReadBytesWithSingleRequest(dataType, db, startByteAdr + index, buffer.Slice(0, maxToRead)); buffer = buffer.Slice(maxToRead); index += maxToRead; } } private void ReadBytesWithSingleRequest(DataType dataType, int db, int startByteAdr, Span buffer) { try { // 首先创建标头 const int packageSize = 19 + 12; // 19 头部 + 12 用于 1 个请求 var dataToSend = new byte[packageSize]; var package = new MemoryStream(dataToSend); WriteReadHeader(package); BuildReadDataRequestPackage(package, dataType, db, startByteAdr, buffer.Length); var s7data = RequestTsdu(dataToSend); AssertReadResponse(s7data, buffer.Length); s7data.AsSpan(18, buffer.Length).CopyTo(buffer); } catch (Exception exc) { throw new PlcException(ErrorCode.ReadData, exc); } }
ReadBytesWithSingleRequest 方法,用于从 PLC 中进行单次数据读取请求。它执行以下步骤:
- 创建一个用于发送请求的数据包,包括所需的标头信息。
- 调用 WriteReadHeader 方法来添加请求的头部信息,以指定读取的数量和其他相关信息。
- 调用 BuildReadDataRequestPackage 方法来添加包含读取请求的详细信息,如内存类型、内存地址、起始字节地址以及要读取的字节数。
- 发送请求数据包到 PLC 并接收响应数据。
- 验证响应以确保读取成功,并将响应数据中的有效字节复制到提供的 buffer 中。
- 如果出现异常,捕获异常并将其抛出,带有适当的错误代码,以便在发生错误时能够进行适当的处理。
另外,关于var package = new MemoryStream(dataToSend);解读一下:
MemoryStream是引用类型。
当创建一个 MemoryStream对象时,实际上创建了一个引用,这个引用指向内存中的某个位置,而不是直接存储数据的位置。
因此, package 和 dataToSend 引用相同的内存位置,在 package 中所做的任何更改都会反映在 dataToSend 中。
1.3.4.3 读命令头的组装
////// 创建从 PLC 读取字节的标头。 /// /// 要写入的流。 /// 要读取的项目数量。 private static void WriteReadHeader(System.IO.MemoryStream stream, int amount = 1) { // 头部大小 19,每个项目 12 字节 WriteTpktHeader(stream, 19 + 12 * amount); WriteDataHeader(stream); WriteS7Header(stream, 0x01, 2 + 12 * amount, 0); // 功能代码:读取请求 stream.WriteByte(0x04); // 请求的数量 stream.WriteByte((byte)amount); } private static void WriteTpktHeader(System.IO.MemoryStream stream, int length) { stream.Write(new byte[] { 0x03, 0x00 }); stream.Write(Word.ToByteArray((ushort) length)); } private static void WriteDataHeader(System.IO.MemoryStream stream) { stream.Write(new byte[] { 0x02, 0xf0, 0x80 }); } private static void WriteS7Header(System.IO.MemoryStream stream, byte messageType, int parameterLength, int dataLength) { stream.WriteByte(0x32); // S7 协议 ID stream.WriteByte(messageType); // 消息类型 stream.Write(new byte[] { 0x00, 0x00 }); // 保留字段 stream.Write(new byte[] { 0x00, 0x00 }); // PDU stream.Write(Word.ToByteArray((ushort) parameterLength)); stream.Write(Word.ToByteArray((ushort) dataLength)); }
这段代码是用于创建不同协议层的头部数据,以便与 PLC 进行通信。它包括 TPKT 头、数据头和 S7 头。这些头部数据在与 PLC 通信时起着关键作用,确保数据的正确传输和解析。其中,WriteReadHeader 方法用于创建读取请求的头部数据,它包括了读取的数量和其他相关信息。
1.3.4.4 读命令字节包的组装
////// 创建用于请求从 PLC 读取数据的字节包。您需要指定内存类型(dataType)、要读取的内存地址、字节的起始地址和字节数量。 /// /// 要写入读取数据请求的流。 /// 内存类型(DB、Timer、Counter 等) /// 要读取的内存地址 /// 字节的起始地址 /// 要读取的字节数 private static void BuildReadDataRequestPackage(System.IO.MemoryStream stream, DataType dataType, int db, int startByteAdr, int count = 1) { // 单个数据请求 = 12 字节 stream.Write(new byte[] { 0x12, 0x0a, 0x10 }); switch (dataType) { case DataType.Timer: case DataType.Counter: stream.WriteByte((byte)dataType); break; default: stream.WriteByte(0x02); break; } stream.Write(Word.ToByteArray((ushort)(count))); stream.Write(Word.ToByteArray((ushort)(db))); stream.WriteByte((byte)dataType); var overflow = (int)(startByteAdr * 8 / 0xffffU); // 处理地址大于 8191 的字节 stream.WriteByte((byte)overflow); switch (dataType) { case DataType.Timer: case DataType.Counter: stream.Write(Word.ToByteArray((ushort)(startByteAdr))); break; default: stream.Write(Word.ToByteArray((ushort)((startByteAdr) * 8))); break; } }
这段代码用于创建用于请求从 PLC 读取数据的字节包。它需要指定内存类型(如 DB、Timer、Counter)、内存地址、字节的起始地址和要读取的字节数。创建的字节包将包含有关读取请求的详细信息,以便与 PLC 进行通信。
组装完成后,则进入RequestTsdu排队等待发送。
1.3.5 同步写命令
1.3.5.1 写方法入口
////// 接受一个对象作为输入,尝试将其解析为值数组。这可以用于写入许多相同类型的数据。 /// 您必须指定内存区域类型、内存区域地址、字节起始地址和字节数。 /// 如果写入不成功,请检查 LastErrorCode 或 LastErrorString。 /// /// 内存区域的数据类型,可以是 DB、定时器、计数器、Merker(内存)、输入、输出等。 /// 内存区域的地址(如果要写入 DB1,应将其设置为 1)。对于其他内存区域类型(例如计数器、定时器等),也必须设置此参数。 /// 起始字节地址。如果要写入 DB1.DBW200,则为 200。 /// 要写入的字节。此参数的长度不能大于 200。如果需要更多,请使用递归。 /// 位的地址(0-7)。 /// public void Write(DataType dataType, int db, int startByteAdr, object value, int bitAdr = -1) { if (bitAdr != -1) { ...//位读取方法 } else WriteBytes(dataType, db, startByteAdr, Serialization.SerializeValue(value)); }
这段代码的作用是根据传入的参数,向 PLC 写入数据。它根据数据类型、内存区域地址、字节起始地址、值以及位地址(如果有的话),采取不同的写入方式。如果要写入位数据,会检查值是否为布尔值或整数,并根据情况进行写入。如果不是位数据,会将值序列化后写入指定的内存区域。
1.3.5.2 组装写命令前的准备
////// 从指定索引开始向 DB 中写入一定数量的字节。对于超过 200 字节的数据,将使用多个请求进行处理。 /// 如果写入不成功,请检查 LastErrorCode 或 LastErrorString。 /// /// 内存区域的数据类型,可以是 DB、定时器、计数器、Merker(内存)、输入、输出等。 /// 内存区域的地址(如果要写入 DB1,应将其设置为 1)。对于其他内存区域类型(例如计数器、定时器等),也必须设置此参数。 /// 起始字节地址。如果要写入 DB1.DBW200,则为 200。 /// 要写入的字节。如果超过 200 字节,将进行多个请求。 /// public void WriteBytes(DataType dataType, int db, int startByteAdr, byte[] value) { WriteBytes(dataType, db, startByteAdr, value.AsSpan()); }
////// 从指定索引开始向 DB 中写入一定数量的字节。对于超过 200 字节的数据,将使用多个请求进行处理。 /// 如果写入不成功,请检查 LastErrorCode 或 LastErrorString。 /// /// 内存区域的数据类型,可以是 DB、定时器、计数器、Merker(内存)、输入、输出等。 /// 内存区域的地址(如果要写入 DB1,应将其设置为 1)。对于其他内存区域类型(例如计数器、定时器等),也必须设置此参数。 /// 起始字节地址。如果要写入 DB1.DBW200,则为 200。 /// 要写入的字节。如果超过 200 字节,将进行多个请求。 /// public void WriteBytes(DataType dataType, int db, int startByteAdr, ReadOnlySpan value) { int localIndex = 0; while (value.Length > 0) { //TODO: 弄清楚如何在这里使用 MaxPDUSize //Snap7 似乎对 PDU 大小超过 256 有问题,即使在连接设置中 Snap7 回复了更大的 PDU 大小。 var maxToWrite = Math.Min(value.Length, MaxPDUSize - 28); // TODO 仅在 MaxPDUSize 为 480 时测试过 WriteBytesWithASingleRequest(dataType, db, startByteAdr + localIndex, value.Slice(0, maxToWrite)); value = value.Slice(maxToWrite); localIndex += maxToWrite; } }
////// 使用单个请求写入数据到 PLC,处理指定内存区域地址中的字节数据。 /// 如果写入不成功,请检查 LastErrorCode 或 LastErrorString。 /// /// 内存区域的数据类型,可以是 DB、定时器、计数器、Merker(内存)、输入、输出等。 /// 内存区域的地址(如果要写入 DB1,应将其设置为 1)。对于其他内存区域类型(例如计数器、定时器等),也必须设置此参数。 /// 起始字节地址。如果要写入 DB1.DBW200,则为 200。 /// 要写入的字节数据 /// private void WriteBytesWithASingleRequest(DataType dataType, int db, int startByteAdr, ReadOnlySpan value) { try { var dataToSend = BuildWriteBytesPackage(dataType, db, startByteAdr, value); var s7data = RequestTsdu(dataToSend); ValidateResponseCode((ReadWriteErrorCode)s7data[14]); } catch (Exception exc) { throw new PlcException(ErrorCode.WriteData, exc); } }
1.3.5.3 写命令头和字节包的组装
////// 创建用于写入字节数据到 PLC 的字节数据包。必须指定数据类型(dataType)、内存区域地址(db)、起始字节地址(startByteAdr)和要写入的字节数据(value)。 /// /// 内存区域的数据类型,可以是 DB、定时器、计数器、Merker(内存)、输入、输出等。 /// 内存区域的地址(如果要写入 DB1,应将其设置为 1)。对于其他内存区域类型(例如计数器、定时器等),也必须设置此参数。 /// 起始字节地址。如果要写入 DB1.DBW200,则为 200。 /// 要写入的字节数据 /// /// 用于写入字节数据的字节数组 private byte[] BuildWriteBytesPackage(DataType dataType, int db, int startByteAdr, ReadOnlySpan value) { int varCount = value.Length; // 首先创建标头 int packageSize = 35 + varCount; var packageData = new byte[packageSize]; var package = new MemoryStream(packageData); package.WriteByte(3); package.WriteByte(0); // 完整的包大小 package.Write(Int.ToByteArray((short)packageSize)); // 此重载不分配字节数组,它引用程序集的静态数据段 package.Write(new byte[] { 2, 0xf0, 0x80, 0x32, 1, 0, 0 }); package.Write(Word.ToByteArray((ushort)(varCount - 1))); package.Write(new byte[] { 0, 0x0e }); package.Write(Word.ToByteArray((ushort)(varCount + 4))); package.Write(new byte[] { 0x05, 0x01, 0x12, 0x0a, 0x10, 0x02 }); package.Write(Word.ToByteArray((ushort)varCount)); package.Write(Word.ToByteArray((ushort)(db))); package.WriteByte((byte)dataType); var overflow = (int)(startByteAdr * 8 / 0xffffU); // 处理地址大于 8191 的字 package.WriteByte((byte)overflow); package.Write(Word.ToByteArray((ushort)(startByteAdr * 8))); package.Write(new byte[] { 0, 4 }); package.Write(Word.ToByteArray((ushort)(varCount * 8))); // 现在将标头和数据合并 package.Write(value); return packageData; }
这段代码的作用是创建用于写入字节数据到 PLC 的字节数据包。
它根据指定的数据类型、内存区域地址、起始字节地址和字节数据构建了一个完整的数据包。然后将数据包和字节数据合并为一个字节数组,以进行写入操作。
组装完成后,则进入RequestTsdu排队等待发送。
二、数据准备
启动西门子PLC仿真器。
给PLC写入测试数据,例如在熟手快速入门中,对Word数组修改从10到1的值。

三、C#使用Socket 读取PLC数据
3.1 分析
-
首先是机架号rack 、槽号slot。
在TASP中,S71500为TsapPair(new Tsap(0x01, 0x00), new Tsap(0x03, (byte) ((rack << 5) | slot)))分别对应Source TASP和Destination TASP。
-
其次,交互地址为:DB1.DBW4。读写长度为2
×
\times
× 10 = 20个字节。
因此,读取的字节数:20;请求点数:10;地址:4(需要改为大端存储)。
-
此外,还需要注意的是,从Send到Receive中间需要有个时间间隔,等待PLC完全发送过来。然而时间间隔难以估计,我们就改用读取到指定长度expectedDataSize就退出while循环。
3.2 源码
using System.Net.Sockets;
namespace TcpIpS71500
{
internal class Program
{
static void Main(string[] args)
{
Console.WriteLine("手工读写PLC");
// 创建套接字并连接到远程设备
Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
socket.Connect("192.168.0.100", 102);
// 发送连接请求参数
byte[] connectionRequestData = {
3, 0, 0, 22, //TPKT
17, //COTP Header Length
224, //Connect Request
0, 0, //Destination Reference
0, 46, //Source Reference
0, //Flags
193, //Parameter Code (src-tasp)
2, //Parameter Length
1, 0, //Source TASP
194, //Parameter Code (dst-tasp)
2, //Parameter Length
3, 1, //Destination
192, //Parameter Code (tpdu-size)
1, //Parameter Length
10 //TPDU Size (2^10 = 1024)
};
socket.Send(connectionRequestData);
// 接收连接响应
byte[] connectionResponseData = new byte[22];
socket.Receive(connectionResponseData);
// 发送连接设置请求参数
byte[] connectionSetupData = {
3, 0, 0, 25, 2, 240, 128, 50, 1, 0, 0, 255, 255, 0, 8, 0, 0, 240, 0, 0, 3, 0, 3,
3, 192 // 使用960字节的PDU大小
};
socket.Send(connectionSetupData);
// 接收连接设置响应
// 如果这里不接收,数据还会留在socket缓冲区,导致下次读取时一并传回
byte[] connectionSetupResponseData = new byte[22];
socket.Receive(connectionSetupResponseData);
// 读取PLC中的数据
// 地址为DB1.DBW4
byte[] readDataRequest = {
// TPKT Header
0x03, 0x00,
0x00, 0x1f,
// COTP Header
0x02, 0xf0, 0x80,
// S7 Header
0x32,
0x01,
0x00, 0x00,
0x00, 0x00,
// 参数部分
0x00, 0x0e,
0x00, 0x00,
// S7参数
0x04, // 读取功能码
0x01, // Item分组
0x12, 0x0a, 0x10,
0x04, // 数据类型
0x00, 0x0a, // 请求点数 - 10个
0x00, 0x01, // DB块编号
0x84, // DB块
// 地址(以3个字节表示)
BitConverter.GetBytes((int)(4 << 3))[2],
BitConverter.GetBytes((int)(4 << 3))[1],
BitConverter.GetBytes((int)(4 << 3))[0],
};
socket.Send(readDataRequest);
// 设置预期的数据大小和缓冲区
int expectedDataSize = 50; // 期望接收的数据大小
byte[] receivedData = new byte[expectedDataSize];
int totalReceived = 0; // 已接收的数据大小
int timeout = 10000; // 超时时间,以毫秒为单位(这里设置为10秒)
socket.Send(readDataRequest);
DateTime startTime = DateTime.Now;
while (totalReceived < expectedDataSize)
{
if ((DateTime.Now - startTime).TotalMilliseconds > timeout)
{
// 处理超时,可以抛出异常或执行其他操作
Console.WriteLine("接收超时");
break;
}
int received = socket.Receive(receivedData, totalReceived, expectedDataSize - totalReceived, SocketFlags.None);
if (received == 0)
{
// 连接已关闭,可以抛出异常或执行其他操作
Console.WriteLine("连接已关闭");
break;
}
totalReceived += received;
}
// 提取20个字节的数据
byte[] extractedBytes = new byte[20];
// 检查是否有足够的元素可供读取
if (receivedData.Length >= 30 + 20)
{
Array.Copy(receivedData, 30, extractedBytes, 0, 20);
// 将每两个字节组合为ushort数组
ushort[] ushortArray = new ushort[extractedBytes.Length / 2];
for (int i = 0; i < ushortArray.Length; i++)
{
ushortArray[i] = BitConverter.ToUInt16(new byte[] { extractedBytes[2 * i + 1], extractedBytes[2 * i] });
}
// 遍历输出到控制台
foreach (ushort value in ushortArray)
{
Console.WriteLine(value);
}
}
else
{
// 处理数组长度不足的情况
Console.WriteLine("数组长度不足,无法提取20个字节。");
}
// 翻转后重新写入PLC
byte[] reversedBytes = new byte[20];
for (int i = 0; i < extractedBytes.Length; i += 2)
{
reversedBytes[i] = extractedBytes[extractedBytes.Length - i - 2];
reversedBytes[i + 1] = extractedBytes[extractedBytes.Length - i - 1];
}
// 写入PLC中的数据
// 地址为DB1.DBW4
byte[] writePrepare = {
0x03, 0x00,
0x00, 0x37, //包长度 = 35 + reversedBytes.Length
2, 0xf0, 0x80, 0x32, 1, 0, 0,
0x00, 0x13, //(ushort)(reversedBytes.Length - 1)
0x00, 0x0e,
0x00, 0x18, //(ushort)(reversedBytes.Length + 4)
0x05, 0x01, 0x12, 0x0a, 0x10, 0x02,
0x00, 0x14, // 请求点数 - 20个Bytes
0x00, 0x01, // DB块编号
0x84, // DB块
// 地址(以3个字节表示)
BitConverter.GetBytes((int)(4 << 3))[2],
BitConverter.GetBytes((int)(4 << 3))[1],
BitConverter.GetBytes((int)(4 << 3))[0],
0, 4,
0x00, 0xA0
// value
};
byte[] writeDataRequest = writePrepare.Concat(reversedBytes).ToArray();
socket.Send(writeDataRequest);
// 接收连接响应
byte[] writeResponseData = new byte[240];
socket.Receive(writeResponseData);
socket.Close();
}
}
}
3.2 运行效果


总结
本篇文章算是《C#与西门子PLC通讯》的番外篇,扒开了S7 Net Plus的神秘外衣,一探底层逻辑,了解了PLC的交互行为和通讯原理。
未来如果想要在一门新出的开发语言中加入相应的通讯库,那么这段篇博文就可以作为参考蓝本。当然,如果技术能力更高,还可以手写一个高并发的轻量级的通讯库。
欢迎交流。
本文来自网络,不代表协通编程立场,如若转载,请注明出处:https://www.net2asp.com/cc37509695.html
