HslCommunication库的二次协议扩展,适配第三方通讯协议开发,基础框架支持长短连接模式

时间:2021-07-21 03:01:01

本文将使用一个gitHub开源的项目来扩展实现二次协议的开发,该项目已经搭建好了基础层架构,并实现了三菱,西门子,欧姆龙,MODBUS-TCP的通讯示例,也可以参照这些示例开发其他的通讯协议,并Pull request到这个项目中来实现这个项目的最终目标

github地址:https://github.com/dathlin/HslCommunication 如果喜欢可以star或是fork,还可以打赏支持。

联系作者及加群方式(激活码在群里发放):http://www.hslcommunication.cn/Cooperation

在Visual Studio 中的NuGet管理器中可以下载安装,也可以直接在NuGet控制台输入下面的指令安装

Install-Package HslCommunication

如果需要教程:Nuget安装教程:http://www.cnblogs.com/dathlin/p/7705014.html

组件的完整信息和其他API介绍参照:http://www.cnblogs.com/dathlin/p/7703805.html   组件的授权协议,更新日志,都在该页面里面。

本文将展示如果进行二次扩展通讯协议,来进行远程交互,可以是PLC协议,自定义协议等等。以一个示例为切入点,根据这个示例来深入讲解

此处使用到了2个命名空间:

using HslCommunication;
using HslCommunication.Core.Net;

关于两种模式


本组件所提供的所有客户端类,包括三菱,西门子,欧姆龙,modbus-tcp,以及SimplifyNet都是继承自双模式基类,双模式包含了短连接和长连接,下面就具体介绍下两个模式的区别

短连接:每次读写都是一个单独的请求,请求完毕也就关闭了,如果服务器的端口仅仅支持单连接,那么关闭后这个端口可以被其他连接复用,但是在频繁的网络请求下,容易发生异常,会有其他的请求不成功,尤其是多线程的情况下。

长连接:创建一个公用的连接通道,所有的读写请求都利用这个通道来完成,这样的话,读写性能更快速,即时多线程调用也不会影响,内部有同步机制。如果服务器的端口仅仅支持单连接,那么这个端口就被占用了,比如三菱的端口机制,西门子的Modbus tcp端口机制也是这样的。以下代码默认使用长连接,性能更高,还支持多线程同步。

在短连接的模式下,每次请求都是单独的访问,所以没有重连的困扰,在长连接的模式下,如果本次请求失败了,在下次请求的时候,会自动重新连接服务器,直到请求成功为止。另外,尽量所有的读写都对结果的成功进行判断。

关于日志记录


不管是三菱的数据访问类,还是西门子的,还是Modbus tcp访问类,都有一个LogNet属性用来记录日志,该属性是一个接口类,ILogNet,凡事继承该接口的都可以用来记录日志,该日志会在访问失败时,尤其是因为网络的原因导致访问失败时会进行日志记录(如果你为这个 LogNet 属性配置了真实的日志记录器的话):如果你想使用该记录日志的功能,请参照如下的博客进行实例化:

http://www.cnblogs.com/dathlin/p/7691693.html

关于基类:public class NetworkDoubleBase<TNetMessage, TTransform> : NetworkBase where TNetMessage : INetMessage, new() where TTransform : IByteTransform, new()


该基类定义了连接方法,单次的数据请求方法,但是需要指定消息类型,TNetMessage指示了该消息类型必须继承自接口INetMessage,至于TTransform指示了一些数据类型的变换规则,这两个类型指定完成后,后面的事情就是定义地址解析器,定义读写指令创建,定义基础的读写方法,然后扩展不同类型的数据读写。

开始二次开发:


先定义消息:消息的接口指示了如果去接收一条完整的消息,通常都是byte[]数据,我们看一下这个接口的定义

    /// <summary>
/// 本系统的消息类,包含了各种解析规则,数据信息提取规则
/// </summary>
public interface INetMessage
{
/// <summary>
/// 消息头的指令长度
/// </summary>
int ProtocolHeadBytesLength { get; } /// <summary>
/// 从当前的头子节文件中提取出接下来需要接收的数据长度
/// </summary>
/// <returns>返回接下来的数据内容长度</returns>
int GetContentLengthByHeadBytes(); /// <summary>
/// 检查头子节的合法性
/// </summary>
/// <param name="token">特殊的令牌,有些特殊消息的验证</param>
/// <returns></returns>
bool CheckHeadBytesLegal(byte[] token); /// <summary>
/// 获取头子节里的消息标识
/// </summary>
/// <returns></returns>
int GetHeadBytesIdentity(); /// <summary>
/// 消息头字节
/// </summary>
byte[] HeadBytes { get; set; } /// <summary>
/// 消息内容字节
/// </summary>
byte[] ContentBytes { get; set; } /// <summary>
/// 发送的字节信息
/// </summary>
byte[] SendBytes { get; set; }
} }

举例来说明:例子一:Modbus-Tcp消息,通常如下:

byte[0] byte[1]  消息头 byte[0]*256+byte[1]

byte[2] byte[3] 必须都是0,否则不是Modbus协议

byte[4] byte[5] 后面跟着的消息长度,长度为byte[4]*256 + byte[5]

byte[6] 站号

byte[7] 功能码

byte[8] byte[9] 地址

...

...

等等,不管后面是什么了

OK,现在已经可以写TNetMessage了,主要思路是先接收6个长度的头子节,接收完后 HeadBytes 就是6个长度的字节,如果需要验证,就判断byte[2],byte[3]是不是都为0,然后写一个方法,从这个头子节数据里分析出接下来的数据长度, 然后就可以按照下面写。

下面的验证消息接收的合法性,还需要根据发送消息的消息号,接收的消息号要一致。

    /// <summary>
/// Modbus-Tcp协议支持的消息解析类
/// </summary>
public class ModbusTcpMessage : INetMessage
{
/// <summary>
/// 消息头的指令长度
/// </summary>
public int ProtocolHeadBytesLength
{
get { return 6; }
} /// <summary>
/// 从当前的头子节文件中提取出接下来需要接收的数据长度
/// </summary>
/// <returns>返回接下来的数据内容长度</returns>
public int GetContentLengthByHeadBytes( )
{
return = HeadBytes[4] * 256 + HeadBytes[5];
} /// <summary>
/// 检查头子节的合法性
/// </summary>
/// <param name="token">特殊的令牌,有些特殊消息的验证</param>
/// <returns></returns>
public bool CheckHeadBytesLegal( byte[] token )
{
if (SendBytes[0] != HeadBytes[0] || SendBytes[1] != HeadBytes[1]) return false;
return HeadBytes[2] == 0x00 && HeadBytes[3] == 0x00;
} /// <summary>
/// 获取头子节里的消息标识
/// </summary>
/// <returns></returns>
public int GetHeadBytesIdentity( )
{
return HeadBytes[0] * 256 + HeadBytes[1];// 有些协议没有标识就返回0
} /// <summary>
/// 消息头字节
/// </summary>
public byte[] HeadBytes { get; set; } /// <summary>
/// 消息内容字节
/// </summary>
public byte[] ContentBytes { get; set; } /// <summary>
/// 发送的字节信息
/// </summary>
public byte[] SendBytes { get; set; } }

消息类写好 ,接下来就选取IByteTransform接口的类,这个接口定义了什么呢?定义了常用的数据类型和byte[]数组之间的转换方法。为什么要实现这个接口呢,因为不同设备的数据定义规则是不一样的,比如C#的类库,地位在前,高位在后,三菱PLC中也是类似的,西门子确实地位在后,高位在前,但是modbus-tcp和fins协议却以双字节为单位。

所以本系统系统三个常用的数据转换类,如果有其他的机制,后面可以扩展,这三个类如下:

  • RegularByteTransform 常规的数据转换,低位在前,高位在后
  • ReverseBytesTransform 高地位反转的数据转换类,高位在前,地位在后
  • ReverseWordTransform 以字节为单位进行反转的数据类

那么我们就选择好了类型,然后通讯类已经基本成型了

public class ModbusTcpNet : NetworkDoubleBase<ModbusTcpMessage, ReverseWordTransform>
{ }

然后创建基础的读取指令方法,和写入指令方法,此处简便处理,只针对寄存器进行操作

        /// <summary>
/// 读取数据的基础指令,需要指定指令码,地址,长度
/// </summary>
/// <param name="code"></param>
/// <param name="address"></param>
/// <param name="count"></param>
/// <returns></returns>
private OperateResult<byte[]> BuildReadCommandBase( byte code, string address, ushort count )
{
ushort add = ushort.Parse( address );
ushort messageId = (ushort)softIncrementCount.GetCurrentValue( );
byte[] buffer = new byte[12];
buffer[0] = (byte)(messageId / 256);
buffer[1] = (byte)(messageId % 256);
buffer[2] = 0x00;
buffer[3] = 0x00;
buffer[4] = 0x00;
buffer[5] = 0x06;
buffer[6] = station;
buffer[7] = code;
buffer[8] = (byte)(add / 256);
buffer[9] = (byte)(add % 256);
buffer[10] = (byte)(count / 256);
buffer[11] = (byte)(count % 256); return OperateResult.CreateSuccessResult( buffer );
}

然后读取寄存器的基础方法是这样设计,基类里有个方法:

        /// <summary>
/// 使用底层的数据报文来通讯,传入需要发送的消息,返回一条完整的数据指令
/// </summary>
/// <param name="send">发送的完整的报文信息</param>
/// <returns>接收的完整的报文信息</returns>
public OperateResult<byte[]> ReadFromCoreServer( byte[] send );

这个方法是一次数据交互的成功与否,所以我们要封装一个二次方法,不仅仅是进行数据交互,进行消息的二次验证,如果验证失败,就返回错误还有相关的消息

        private OperateResult<byte[]> CheckModbusTcpResponse( byte[] send )
{
OperateResult<byte[]> result = ReadFromCoreServer( send );
if (result.IsSuccess)
{
if ((send[7] + 0x80) == result.Content[7])
{
// 发生了错误
result.IsSuccess = false;
result.Message = GetDescriptionByErrorCode( result.Content[8] );
result.ErrorCode = result.Content[8];
}
}
return result;
}

然后在封装一层基础的通信方法,在读取到数据并且验证成功之后,把读取到的数据内容单独提取出来,好让后续进行更加方便的处理。

        /// <summary>
/// 读取服务器的数据,需要指定不同的功能码
/// </summary>
/// <param name="code">指令</param>
/// <param name="address">地址</param>
/// <param name="length">长度</param>
/// <returns></returns>
private OperateResult<byte[]> ReadModBusBase( byte code, string address, ushort length )
{
OperateResult<byte[]> command = BuildReadCommandBase( code, address, length );
if (!command.IsSuccess) return OperateResult.CreateFailedResult<byte[]>( command ); OperateResult<byte[]> resultBytes = CheckModbusTcpResponse( command.Content );
if (resultBytes.IsSuccess)
{
// 二次数据处理
if (resultBytes.Content?.Length >= 9)
{
byte[] buffer = new byte[resultBytes.Content.Length - 9];
Array.Copy( resultBytes.Content, 9, buffer, 0, buffer.Length );
resultBytes.Content = buffer;
}
}
return resultBytes;
}

有了上面两层的基础,最终提供了一个读取寄存器的基础方法,也就是第三层的方法

        /// <summary>
/// 从Modbus服务器批量读取寄存器的信息,需要指定起始地址,读取长度
/// </summary>
/// <param name="address">起始地址,格式为"1234"</param>
/// <param name="length">读取的数量</param>
/// <returns>带有成功标志的字节信息</returns>
public OperateResult<byte[]> Read( string address, ushort length )
{
OperateResult<byte[]> read = ReadModBusBase( ModbusInfo.ReadRegister, address, length );
if (!read.IsSuccess) return OperateResult.CreateFailedResult<byte[]>( read );
return read;
}

有了上面的读取寄存器的方法,那么我们可以方便的扩展其他基础类型的数据读取了。

        /// <summary>
/// 读取指定地址的short数据
/// </summary>
/// <param name="address">起始地址,格式为"1234"</param>
/// <returns>带有成功标志的short数据</returns>
public OperateResult<short> ReadInt16( string address )
{
return GetInt16ResultFromBytes( Read( address, 1 ) );
} /// <summary>
/// 读取指定地址的ushort数据
/// </summary>
/// <param name="address">起始地址,格式为"1234"</param>
/// <returns>带有成功标志的ushort数据</returns>
public OperateResult<ushort> ReadUInt16( string address )
{
return GetUInt16ResultFromBytes( Read( address, 1 ) );
} /// <summary>
/// 读取指定地址的int数据
/// </summary>
/// <param name="address">起始地址,格式为"1234"</param>
/// <returns>带有成功标志的int数据</returns>
public OperateResult<int> ReadInt32( string address )
{
return GetInt32ResultFromBytes( Read( address, 2 ) );
} /// <summary>
/// 读取指定地址的uint数据
/// </summary>
/// <param name="address">起始地址,格式为"1234"</param>
/// <returns>带有成功标志的uint数据</returns>
public OperateResult<uint> ReadUInt32( string address )
{
return GetUInt32ResultFromBytes( Read( address, 2 ) );
} /// <summary>
/// 读取指定地址的float数据
/// </summary>
/// <param name="address">起始地址,格式为"1234"</param>
/// <returns>带有成功标志的float数据</returns>
public OperateResult<float> ReadFloat( string address )
{
return GetSingleResultFromBytes( Read( address, 2 ) );
} /// <summary>
/// 读取指定地址的long数据
/// </summary>
/// <param name="address">起始地址,格式为"1234"</param>
/// <returns>带有成功标志的long数据</returns>
public OperateResult<long> ReadInt64( string address )
{
return GetInt64ResultFromBytes( Read( address, 4 ) );
} /// <summary>
/// 读取指定地址的ulong数据
/// </summary>
/// <param name="address">起始地址,格式为"1234"</param>
/// <returns>带有成功标志的ulong数据</returns>
public OperateResult<ulong> ReadUInt64( string address )
{
return GetUInt64ResultFromBytes( Read( address, 4 ) );
} /// <summary>
/// 读取指定地址的double数据
/// </summary>
/// <param name="address">起始地址,格式为"1234"</param>
/// <returns>带有成功标志的double数据</returns>
public OperateResult<double> ReadDouble( string address )
{
return GetDoubleResultFromBytes( Read( address, 4 ) );
} /// <summary>
/// 读取地址地址的String数据,字符串编码为ASCII
/// </summary>
/// <param name="address">起始地址,格式为"1234"</param>
/// <param name="length">字符串长度</param>
/// <returns>带有成功标志的string数据</returns>
public OperateResult<string> ReadString( string address, ushort length )
{
return GetStringResultFromBytes( Read( address, length ) );
}

到这里为止,就写完了寄存器的读取方法,实际上会更加复杂点,会把地址解析专门拿出来做成地址解析器,因为有些PLC的地址是比较复杂,例如西门子的"M100.2",就需要写个专门的解析器来解析,针对单次读取上限,也可以支持更具地址来多次访问等等操作。

写入数据的例子:

写入的操作通常不会返回数据,只要验证完指令的逻辑性即可,我们把地址解析器拿出来看看,先写地址解析器

        /// <summary>
/// 解析数据地址,解析出地址类型,起始地址
/// </summary>
/// <param name="address">数据地址</param>
/// <returns>解析出地址类型,起始地址,DB块的地址</returns>
private OperateResult<int> AnalysisAddress( string address )
{
try
{
return OperateResult.CreateSuccessResult( Convert.ToInt32( address ) );
}
catch (Exception ex)
{
return new OperateResult<int>( )
{
Message = ex.Message
};
}
}

解析完地址后,就创建写入的基础指令,需要指定字节数组,如下的创建方式是针对了多个寄存器写入的代码

        private OperateResult<byte[]> BuildWriteRegisterCommand( string address, byte[] data )
{
OperateResult<int> analysis = AnalysisAddress( address );
if (!analysis.IsSuccess) return OperateResult.CreateFailedResult<byte[]>( analysis ); ushort messageId = (ushort)softIncrementCount.GetCurrentValue( );
byte[] buffer = new byte[13 + data.Length];
buffer[0] = (byte)(messageId / 256);
buffer[1] = (byte)(messageId % 256);
buffer[2] = 0x00;
buffer[3] = 0x00;
buffer[4] = (byte)((buffer.Length - 6) / 256);
buffer[5] = (byte)((buffer.Length - 6) % 256);
buffer[6] = station;
buffer[7] = ModbusInfo.WriteRegister;
buffer[8] = (byte)(analysis.Content / 256);
buffer[9] = (byte)(analysis.Content % 256);
buffer[10] = (byte)(data.Length / 2 / 256);
buffer[11] = (byte)(data.Length / 2 % 256); buffer[12] = (byte)(data.Length);
data.CopyTo( buffer, 13 );
return OperateResult.CreateSuccessResult( buffer );
}

那么写入数据基础方法就是

        /// <summary>
/// 将数据写入到Modbus的寄存器上去,需要指定起始地址和数据内容
/// </summary>
/// <param name="address">起始地址,格式为"1234"</param>
/// <param name="value">写入的数据,长度根据data的长度来指示</param>
/// <returns>返回写入结果</returns>
public OperateResult Write( string address, byte[] value )
{
OperateResult<byte[]> command = BuildWriteRegisterCommand( address, value );
if (!command.IsSuccess)
{
return command;
} return CheckModbusTcpResponse( command.Content );
}

然后我们再想支持其他的数据类型,就好办很多了

        #region Write Short

        /// <summary>
/// 向寄存器中写入short数组,返回值说明
/// </summary>
/// <param name="address">要写入的数据地址</param>
/// <param name="values">要写入的实际数据</param>
/// <returns>返回写入结果</returns>
public OperateResult Write( string address, short[] values )
{
return Write( address, ByteTransform.TransByte( values ) );
} /// <summary>
/// 向寄存器中写入short数据,返回值说明
/// </summary>
/// <param name="address">要写入的数据地址</param>
/// <param name="value">要写入的实际数据</param>
/// <returns>返回写入结果</returns>
public OperateResult Write( string address, short value )
{
return Write( address, new short[] { value } );
} #endregion #region Write UShort /// <summary>
/// 向寄存器中写入ushort数组,返回值说明
/// </summary>
/// <param name="address">要写入的数据地址</param>
/// <param name="values">要写入的实际数据</param>
/// <returns>返回写入结果</returns>
public OperateResult Write( string address, ushort[] values )
{
return Write( address, ByteTransform.TransByte( values ) );
} /// <summary>
/// 向寄存器中写入ushort数据,返回值说明
/// </summary>
/// <param name="address">要写入的数据地址</param>
/// <param name="value">要写入的实际数据</param>
/// <returns>返回写入结果</returns>
public OperateResult Write( string address, ushort value )
{
return Write( address, new ushort[] { value } );
} #endregion #region Write Int /// <summary>
/// 向寄存器中写入int数组,返回值说明
/// </summary>
/// <param name="address">要写入的数据地址</param>
/// <param name="values">要写入的实际数据</param>
/// <returns>返回写入结果</returns>
public OperateResult Write( string address, int[] values )
{
return Write( address, ByteTransform.TransByte( values ) );
} /// <summary>
/// 向寄存器中写入int数据,返回值说明
/// </summary>
/// <param name="address">要写入的数据地址</param>
/// <param name="value">要写入的实际数据</param>
/// <returns>返回写入结果</returns>
public OperateResult Write( string address, int value )
{
return Write( address, new int[] { value } );
} #endregion #region Write UInt /// <summary>
/// 向寄存器中写入uint数组,返回值说明
/// </summary>
/// <param name="address">要写入的数据地址</param>
/// <param name="values">要写入的实际数据</param>
/// <returns>返回写入结果</returns>
public OperateResult Write( string address, uint[] values )
{
return Write( address, ByteTransform.TransByte( values ) );
} /// <summary>
/// 向寄存器中写入uint数据,返回值说明
/// </summary>
/// <param name="address">要写入的数据地址</param>
/// <param name="value">要写入的实际数据</param>
/// <returns>返回写入结果</returns>
public OperateResult Write( string address, uint value )
{
return Write( address, new uint[] { value } );
} #endregion #region Write Float /// <summary>
/// 向寄存器中写入float数组,返回值说明
/// </summary>
/// <param name="address">要写入的数据地址</param>
/// <param name="values">要写入的实际数据</param>
/// <returns>返回写入结果</returns>
public OperateResult Write( string address, float[] values )
{
return Write( address, ByteTransform.TransByte( values ) );
} /// <summary>
/// 向寄存器中写入float数据,返回值说明
/// </summary>
/// <param name="address">要写入的数据地址</param>
/// <param name="value">要写入的实际数据</param>
/// <returns>返回写入结果</returns>
public OperateResult Write( string address, float value )
{
return Write( address, new float[] { value } );
} #endregion #region Write Long /// <summary>
/// 向寄存器中写入long数组,返回值说明
/// </summary>
/// <param name="address">要写入的数据地址</param>
/// <param name="values">要写入的实际数据</param>
/// <returns>返回写入结果</returns>
public OperateResult Write( string address, long[] values )
{
return Write( address, ByteTransform.TransByte( values ) );
} /// <summary>
/// 向寄存器中写入long数据,返回值说明
/// </summary>
/// <param name="address">要写入的数据地址</param>
/// <param name="value">要写入的实际数据</param>
/// <returns>返回写入结果</returns>
public OperateResult Write( string address, long value )
{
return Write( address, new long[] { value } );
} #endregion #region Write ULong /// <summary>
/// 向寄存器中写入ulong数组,返回值说明
/// </summary>
/// <param name="address">要写入的数据地址</param>
/// <param name="values">要写入的实际数据</param>
/// <returns>返回写入结果</returns>
public OperateResult Write( string address, ulong[] values )
{
return Write( address, ByteTransform.TransByte( values ) );
} /// <summary>
/// 向寄存器中写入ulong数据,返回值说明
/// </summary>
/// <param name="address">要写入的数据地址</param>
/// <param name="value">要写入的实际数据</param>
/// <returns>返回写入结果</returns>
public OperateResult Write( string address, ulong value )
{
return Write( address, new ulong[] { value } );
} #endregion #region Write Double /// <summary>
/// 向寄存器中写入double数组,返回值说明
/// </summary>
/// <param name="address">要写入的数据地址</param>
/// <param name="values">要写入的实际数据</param>
/// <returns>返回写入结果</returns>
public OperateResult Write( string address, double[] values )
{
return Write( address, ByteTransform.TransByte( values ) );
} /// <summary>
/// 向寄存器中写入double数据,返回值说明
/// </summary>
/// <param name="address">要写入的数据地址</param>
/// <param name="value">要写入的实际数据</param>
/// <returns>返回写入结果</returns>
public OperateResult Write( string address, double value )
{
return Write( address, new double[] { value } );
} #endregion

到这里为止,基础的操作和扩展讲的差不多了。接下来就要针对某些特殊的设备进行适配,比如我在实际的开发中,发现西门子,欧姆龙的通信协议中,没有一个握手信号交互的过程,在西门子里还要进行2次握手,在欧姆龙里要进行一次握手,这些握手信息在网络连接上之后就需要进行交互,不然无法现在读取。在上述的MODBUS协议了就不需要握手信号,如果想支持握手信号,那么就要重写一个方法

        /// <summary>
/// 在连接上欧姆龙PLC后,需要进行一步握手协议
/// </summary>
/// <param name="socket"></param>
/// <returns></returns>
protected override OperateResult InitilizationOnConnect( Socket socket )
{
// handSingle就是握手信号字节
OperateResult<byte[], byte[]> read = ReadFromCoreServerBase( socket, handSingle );
if (!read.IsSuccess) return read; // 检查返回的状态
byte[] buffer = new byte[4];
buffer[0] = read.Content2[7];
buffer[1] = read.Content2[6];
buffer[2] = read.Content2[5];
buffer[3] = read.Content2[4];
int status = BitConverter.ToInt32( buffer, 0 );
if(status != 0)
{
return new OperateResult( )
{
ErrorCode = status,
Message = "初始化失败,具体原因请根据错误码查找"
};
} // 提取PLC的节点地址
if (read.Content2.Length >= 16)
{
DA1 = read.Content2[15];
}
return OperateResult.CreateSuccessResult( ) ;
}

上面的代码所示就是,欧姆龙协议的握手信号的处理方式,处理成功就返回为真的Result对象,处理失败就返回假的结果对象。

注意:握手信号使用的方法必须是ReadFromCoreServerBase方法。

更复杂的实际开发例子,可以参见项目的源代码,欢迎大家完善开发其他的通讯协议。

创作不易,感谢打赏


HslCommunication库的二次协议扩展,适配第三方通讯协议开发,基础框架支持长短连接模式