本文将尝试从源码角度,使用Tcp/Ip的方式直接与西门子PLC进行交互通讯。
C#与西门子PLC通讯 系列文章目录
往期博客参考
C#与西门子PLC通讯——新手快速入门
C#与西门子PLC通讯——熟手快速入门
建议先看一下这两篇,了解预设背景。
前言
知其然,知其所以然。
这篇文章,我们就尝试重复造一个轮子。通过对通讯协议的简要分析,我们能够更好地了解与西门子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 声明对象
/// <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;
}
1.3.2 建立连接TcpIp连接
同步方法:
/// <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);
。
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 读方法入口
/// <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 个字节。这个方法用于帮助计算读取变量时需要的字节数。
1.3.4.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 中进行单次数据读取请求。它执行以下步骤:
- 创建一个用于发送请求的数据包,包括所需的标头信息。
- 调用 WriteReadHeader 方法来添加请求的头部信息,以指定读取的数量和其他相关信息。
- 调用 BuildReadDataRequestPackage 方法来添加包含读取请求的详细信息,如内存类型、内存地址、起始字节地址以及要读取的字节数。
- 发送请求数据包到 PLC 并接收响应数据。
- 验证响应以确保读取成功,并将响应数据中的有效字节复制到提供的 buffer 中。
- 如果出现异常,捕获异常并将其抛出,带有适当的错误代码,以便在发生错误时能够进行适当的处理。
另外,关于
var package = new MemoryStream(dataToSend);
解读一下:MemoryStream
是引用类型。
当创建一个MemoryStream
对象时,实际上创建了一个引用,这个引用指向内存中的某个位置,而不是直接存储数据的位置。
因此,package
和dataToSend
引用相同的内存位置,在package
中所做的任何更改都会反映在dataToSend
中。
1.3.4.3 读命令头的组装
/// <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
方法用于创建读取请求的头部数据,它包括了读取的数量和其他相关信息。
1.3.4.4 读命令字节包的组装
/// <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
排队等待发送。
1.3.5 同步写命令
1.3.5.1 写方法入口
/// <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 写入数据。它根据数据类型、内存区域地址、字节起始地址、值以及位地址(如果有的话),采取不同的写入方式。如果要写入位数据,会检查值是否为布尔值或整数,并根据情况进行写入。如果不是位数据,会将值序列化后写入指定的内存区域。
1.3.5.2 组装写命令前的准备
/// <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);
}
}
1.3.5.3 写命令头和字节包的组装
/// <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的值。
三、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.toymoban.com/news/detail-763934.html
欢迎交流。文章来源地址https://www.toymoban.com/news/detail-763934.html
到了这里,关于C#与西门子PLC通讯——手搓S7通讯协议的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!