HslCommunication库的二次协议扩展,适配第三方通讯协议开发,基础框架支持长短连接模式
Posted dathlin
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了HslCommunication库的二次协议扩展,适配第三方通讯协议开发,基础框架支持长短连接模式相关的知识,希望对你有一定的参考价值。
本文将使用一个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库的二次协议扩展,适配第三方通讯协议开发,基础框架支持长短连接模式的主要内容,如果未能解决你的问题,请参考以下文章
还在用ABAP进行SAP产品的二次开发?来了解下这种全新的二次开发理念吧