赞
踩
本文将尝试从源码角度,使用Tcp/Ip的方式直接与西门子PLC进行交互通讯。
往期博客参考
C#与西门子PLC通讯——新手快速入门 C#与西门子PLC通讯——熟手快速入门 建议先看一下这两篇,了解预设背景。
知其然,知其所以然。
这篇文章,我们就尝试重复造一个轮子。通过对通讯协议的简要分析,我们能够更好地了解与西门子PLC是如何交互的。最后,我们就运用底层方法,使用Socket通讯将一个数组读取出来,再将数组反转之后写回PLC中。
首先,参照 ISO-OSI 参考模型,S7 协议位置如下:
参考西门子官网介绍:S7 协议有哪些属性,优势及特征?
当C#应用程序中与西门子PLC进行通信时,需要经历一系列协议阶段,以确保有效的数据传输和通信。 这些阶段包括TCP/IP协议、TPKT协议、COTP协议和S7连接协议。
{ 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的数据海洋里自由荡漾。
/// <summary> /// 创建一个具备连接所需参数的 PLC 对象。 /// 对于 S7-1200 和 S7-1500,默认值为 rack = 0 和 slot = 0。 /// 如果要连接到外部以太网卡 (CP),则需要 slot > 0。 /// 对于 S7-300 和 S7-400,默认值为 rack = 0 和 slot = 2。 /// </summary> /// <param name="cpu">PLC 的 CpuType(从枚举中选择)</param> /// <param name="ip">PLC 的 IP 地址</param> /// <param name="rack">PLC 的机架号,通常为 0,但请在 Step7 或 TIA Portal 的硬件配置中进行检查</param> /// <param name="slot">PLC 的 CPU 插槽号,对于 S7-1200 和 S7-1500 通常为 0,对于 S7-300 和 S7-400 通常为 2。 /// 如果使用外部以太网卡,必须相应地设置。</param> public Plc(CpuType cpu, string ip, Int16 rack, Int16 slot) : this(cpu, ip, DefaultPort, rack, slot) { } /// <summary> /// 创建一个具备连接所需参数的 PLC 对象。 /// 对于 S7-1200 和 S7-1500,默认值为 rack = 0 和 slot = 0。 /// 如果要连接到外部以太网卡 (CP),则需要 slot > 0。 /// 对于 S7-300 和 S7-400,默认值为 rack = 0 和 slot = 2。 /// </summary> /// <param name="cpu">PLC 的 CpuType(从枚举中选择)</param> /// <param name="ip">PLC 的 IP 地址</param> /// <param name="port">用于连接的端口号,默认为 102。</param> /// <param name="rack">PLC 的机架号,通常为 0,但请在 Step7 或 TIA Portal 的硬件配置中进行检查</param> /// <param name="slot">PLC 的 CPU 插槽号,对于 S7-1200 和 S7-1500 通常为 0,对于 S7-300 和 S7-400 通常为 2。 /// 如果使用外部以太网卡,必须相应地设置。</param> 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; }
同步方法:
/// <summary> /// 连接到 PLC 并执行 COTP 连接请求和 S7 通信设置。 /// </summary> public void Open() { try { OpenAsync().GetAwaiter().GetResult(); } catch (Exception exc) { throw new PlcException(ErrorCode.ConnectionError, $"无法建立与 {IP} 的连接。\n消息:{exc.Message}", exc); } }
其中,同步方法会调用异步方法。
/// <summary> /// 连接到 PLC 并执行 COTP 连接请求和 S7 通信设置。 /// </summary> /// <param name="cancellationToken">用于监视取消请求的令牌。默认值为 None。 /// 请注意,取消不会以任何方式影响打开套接字,只会在成功建立套接字连接后影响用于配置连接的数据传输。 /// 请注意,取消是建议性/协作性的,不会在所有情况下立即导致取消。</param> /// <returns>表示异步打开操作的任务。</returns> 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<NetworkStream> 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);
。
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);
}
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; }
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 大小 }; }
/// <summary> /// 读取并解码指定数量的 "VarType" 数据。 /// 可用于读取相同类型的多个连续变量(Word、DWord、Int 等)。 /// 如果读取不成功,请检查 LastErrorCode 或 LastErrorString。 /// </summary> /// <param name="dataType">内存区域的数据类型,可以是 DB、Timer、Counter、Merker(Memory)、Input、Output。</param> /// <param name="db">内存区域的地址(如果要读取 DB1,则设置为 1)。对于其他内存区域类型(计数器、定时器等),也必须设置此值。</param> /// <param name="startByteAdr">起始字节地址。如果要读取 DB1.DBW200,则将此值设置为 200。</param> /// <param name="varType">要读取的变量的类型</param> /// <param name="bitAdr">位地址。如果要读取 DB1.DBX200.6,则将此参数设置为 6。</param> /// <param name="varCount">要读取的变量数量</param> /// <returns>读取到的数据,如果读取失败则返回 null。</returns> 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
用于计算需要读取的字节数
/// <summary> /// 根据 S7 的 <see cref="VarType"/>(Bool、Word、DWord 等),返回需要读取的字节数。 /// </summary> /// <param name="varType">变量类型</param> /// <param name="varCount">变量数量</param> /// <returns>变量的字节长度</returns> 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 个字节。这个方法用于帮助计算读取变量时需要的字节数。
/// <summary> /// 从指定索引处的 DB 读取指定数量的字节。处理多于 200 字节的情况,使用多次请求。 /// 如果读取不成功,请检查 LastErrorCode 或 LastErrorString。 /// </summary> /// <param name="dataType">内存区域的数据类型,可以是 DB、Timer、Counter、Merker(Memory)、Input、Output。</param> /// <param name="db">内存区域的地址(如果要读取 DB1,则设置为 1)。对于其他内存区域类型(计数器、定时器等),也必须设置此值。</param> /// <param name="startByteAdr">起始字节地址。如果要读取 DB1.DBW200,则将此值设置为 200。</param> /// <param name="count">字节数量,如果要读取 120 字节,将此设置为 120。</param> /// <returns>以数组形式返回字节数据</returns> public byte[] ReadBytes(DataType dataType, int db, int startByteAdr, int count) { var result = new byte[count]; ReadBytes(result, dataType, db, startByteAdr); return result; } /// <summary> /// 从指定索引处的 DB 读取指定数量的字节。处理多于 200 字节的情况,使用多次请求。 /// 如果读取不成功,请检查 LastErrorCode 或 LastErrorString。 /// </summary> /// <param name="buffer">用于接收读取字节的缓冲区。<see cref="Span{T}.Length"/> 确定要读取的字节数。</param> /// <param name="dataType">内存区域的数据类型,可以是 DB、Timer、Counter、Merker(Memory)、Input、Output。</param> /// <param name="db">内存区域的地址(如果要读取 DB1,则设置为 1)。对于其他内存区域类型(计数器、定时器等),也必须设置此值。</param> /// <param name="startByteAdr">起始字节地址。如果要读取 DB1.DBW200,则将此值设置为 200。</param> /// </summary> public void ReadBytes(Span<byte> 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<byte> 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 中进行单次数据读取请求。它执行以下步骤:
另外,关于var package = new MemoryStream(dataToSend);
解读一下: MemoryStream
是引用类型。 当创建一个 MemoryStream
对象时,实际上创建了一个引用,这个引用指向内存中的某个位置,而不是直接存储数据的位置。 因此, package
和 dataToSend
引用相同的内存位置,在 package
中所做的任何更改都会反映在 dataToSend
中。
/// <summary> /// 创建从 PLC 读取字节的标头。 /// </summary> /// <param name="stream">要写入的流。</param> /// <param name="amount">要读取的项目数量。</param> 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
方法用于创建读取请求的头部数据,它包括了读取的数量和其他相关信息。
/// <summary> /// 创建用于请求从 PLC 读取数据的字节包。您需要指定内存类型(dataType)、要读取的内存地址、字节的起始地址和字节数量。 /// </summary> /// <param name="stream">要写入读取数据请求的流。</param> /// <param name="dataType">内存类型(DB、Timer、Counter 等)</param> /// <param name="db">要读取的内存地址</param> /// <param name="startByteAdr">字节的起始地址</param> /// <param name="count">要读取的字节数</param> 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
排队等待发送。
/// <summary> /// 接受一个对象作为输入,尝试将其解析为值数组。这可以用于写入许多相同类型的数据。 /// 您必须指定内存区域类型、内存区域地址、字节起始地址和字节数。 /// 如果写入不成功,请检查 LastErrorCode 或 LastErrorString。 /// </summary> /// <param name="dataType">内存区域的数据类型,可以是 DB、定时器、计数器、Merker(内存)、输入、输出等。</param> /// <param name="db">内存区域的地址(如果要写入 DB1,应将其设置为 1)。对于其他内存区域类型(例如计数器、定时器等),也必须设置此参数。</param> /// <param name="startByteAdr">起始字节地址。如果要写入 DB1.DBW200,则为 200。</param> /// <param name="value">要写入的字节。此参数的长度不能大于 200。如果需要更多,请使用递归。</param> /// <param name="bitAdr">位的地址(0-7)。</param> /// </summary> 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 写入数据。它根据数据类型、内存区域地址、字节起始地址、值以及位地址(如果有的话),采取不同的写入方式。如果要写入位数据,会检查值是否为布尔值或整数,并根据情况进行写入。如果不是位数据,会将值序列化后写入指定的内存区域。
/// <summary>
/// 从指定索引开始向 DB 中写入一定数量的字节。对于超过 200 字节的数据,将使用多个请求进行处理。
/// 如果写入不成功,请检查 LastErrorCode 或 LastErrorString。
/// </summary>
/// <param name="dataType">内存区域的数据类型,可以是 DB、定时器、计数器、Merker(内存)、输入、输出等。</param>
/// <param name="db">内存区域的地址(如果要写入 DB1,应将其设置为 1)。对于其他内存区域类型(例如计数器、定时器等),也必须设置此参数。</param>
/// <param name="startByteAdr">起始字节地址。如果要写入 DB1.DBW200,则为 200。</param>
/// <param name="value">要写入的字节。如果超过 200 字节,将进行多个请求。</param>
/// </summary>
public void WriteBytes(DataType dataType, int db, int startByteAdr, byte[] value)
{
WriteBytes(dataType, db, startByteAdr, value.AsSpan());
}
/// <summary> /// 从指定索引开始向 DB 中写入一定数量的字节。对于超过 200 字节的数据,将使用多个请求进行处理。 /// 如果写入不成功,请检查 LastErrorCode 或 LastErrorString。 /// </summary> /// <param name="dataType">内存区域的数据类型,可以是 DB、定时器、计数器、Merker(内存)、输入、输出等。</param> /// <param name="db">内存区域的地址(如果要写入 DB1,应将其设置为 1)。对于其他内存区域类型(例如计数器、定时器等),也必须设置此参数。</param> /// <param name="startByteAdr">起始字节地址。如果要写入 DB1.DBW200,则为 200。</param> /// <param name="value">要写入的字节。如果超过 200 字节,将进行多个请求。</param> /// </summary> public void WriteBytes(DataType dataType, int db, int startByteAdr, ReadOnlySpan<byte> 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; } }
/// <summary> /// 使用单个请求写入数据到 PLC,处理指定内存区域地址中的字节数据。 /// 如果写入不成功,请检查 LastErrorCode 或 LastErrorString。 /// </summary> /// <param name="dataType">内存区域的数据类型,可以是 DB、定时器、计数器、Merker(内存)、输入、输出等。</param> /// <param name="db">内存区域的地址(如果要写入 DB1,应将其设置为 1)。对于其他内存区域类型(例如计数器、定时器等),也必须设置此参数。</param> /// <param name="startByteAdr">起始字节地址。如果要写入 DB1.DBW200,则为 200。</param> /// <param name="value">要写入的字节数据</param> /// </summary> private void WriteBytesWithASingleRequest(DataType dataType, int db, int startByteAdr, ReadOnlySpan<byte> 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); } }
/// <summary> /// 创建用于写入字节数据到 PLC 的字节数据包。必须指定数据类型(dataType)、内存区域地址(db)、起始字节地址(startByteAdr)和要写入的字节数据(value)。 /// </summary> /// <param name="dataType">内存区域的数据类型,可以是 DB、定时器、计数器、Merker(内存)、输入、输出等。</param> /// <param name="db">内存区域的地址(如果要写入 DB1,应将其设置为 1)。对于其他内存区域类型(例如计数器、定时器等),也必须设置此参数。</param> /// <param name="startByteAdr">起始字节地址。如果要写入 DB1.DBW200,则为 200。</param> /// <param name="value">要写入的字节数据</param> /// </summary> /// <returns>用于写入字节数据的字节数组</returns> private byte[] BuildWriteBytesPackage(DataType dataType, int db, int startByteAdr, ReadOnlySpan<byte> 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的值。
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循环。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(); } } }
本篇文章算是《C#与西门子PLC通讯》的番外篇,扒开了S7 Net Plus的神秘外衣,一探底层逻辑,了解了PLC的交互行为和通讯原理。 未来如果想要在一门新出的开发语言中加入相应的通讯库,那么这段篇博文就可以作为参考蓝本。当然,如果技术能力更高,还可以手写一个高并发的轻量级的通讯库。
欢迎交流。
文章来源: https://lukailin.blog.csdn.net/article/details/134088262
版权声明: 本文为博主原创文章,遵循CC 4.0 BY-SA 知识共享协议,转载请附上原文出处链接和本声明。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。