本文將使用一個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方法。
更復雜的實際開發例子,可以參見項目的源代碼,歡迎大家完善開發其他的通訊協議。
創作不易,感謝打賞