【.NET6+Modbus】Modbus TCP協議解析、模擬環境以及基於.NET實現基礎通訊

WeskyNet發表於2022-04-09

 前言:隨著工業化的發展,目前越來越多的開發,從網際網路走向傳統行業。其中,工業領域也是其中之一,包括各大廠也都在陸陸續續加入工業4.0的程式當中。

工業領域,最核心的基礎設施,應該是與下位硬體裝置或程式進行通訊有關的了,而下位機市場基本上是PLC的天下。而PLC產品就像程式語言一樣,型別繁多,協議也多種多樣。例如,西門子PLC最常用的S7協議、施耐德PLC最常用的Modbus協議、以及標準工業通訊協議CIP協議等等。而多種通訊協議裡面,基於乙太網通訊的居多。乙太網通訊的裡面,通用協議除了CIP協議,就屬於Modbus TCP協議了。

接下來的內容,我會以從頭開發一個簡單的基於modbus tcp通訊的案例,來實現一個基礎的通訊功能。

 

有關環境:

開發環境: VS 2022企業版

執行環境: Win 10 專業版

.NET 環境版本: .NET 6

 

【備註】 原始碼在文末 

 

1、新建一個基於.NET 6帶控制器的webapi專案,以及一個類庫專案。如下圖所示,新建以後的專案目錄結構。

 

 

2、由於modbus tcp通訊實際上就是一個socket通訊,所以在類庫專案下,先建立了一個Modbus服務類,並且提供一個基於socket通訊連線的方法。socket連線以後,需要返回socket例項拿來使用。

 

 

3、為了方便一點,再新增一個通用的返回資訊類,用於儲存一些返回資訊使用。

 

 

 

4、基於以上的返回資訊類,我們對連線方法進行稍微改造一下,讓它看起來更方便一點。這樣可以用來驗證連線是否正常,以及返回對應的異常資訊,好做進一步處理。

 

 

 

5、Modbus TCP請求的報文規則,一些解析資訊如下:

站地址:預設0x01, 除非PLC告訴我們其他站地址。

功能碼:代表讀寫資料時候指定的讀寫方法等。例如讀取線圈的功能碼是0x01。

地址和讀取長度:地址目前個人在施耐德物理的PLC環境上,不能超過30000。同時,單次讀寫長度不能超過248個byte,否則PLC可能會飄。當然,也可能將來一些PLC可以支援更長的批量資料讀寫,目前在施耐德PLC環境下不支援(具體型號忘記了,有點久了,當前身邊沒得PLC了,等下會使用模擬工具來做環境)。

頭部校驗(訊息唯一識別碼):0~65535,用於PLC服務端進行區分不同的客戶端而使用的一組資料標識,不同的客戶端必須保證標識碼不重合。例如多個客戶端同時存在時候,發起的通訊請求,必須保持不一樣的識別碼,否則Modbus服務端有可能會因為不知是哪個客戶端發起的請求而導致資訊亂了。

無(協議標識):預設0,代表是Modbus協議。

資料長度:傳送的報文的長度,剛好是6位,所以可以寫成固定值0x06。(寫入的規則不一樣,此處固定值只當作讀取時候使用)

 

 

 

6、根據協議的一些具體內容,寫一個儲存功能碼和異常返回碼的資料類,用於後期做通訊時候傳參和通訊資料驗證使用。有關協議具體內容,如下程式碼所示。

 

 

7、由於異常碼是byte資料,直接驗證可能會麻煩一點,為了可以直觀一些,此處再新增一個用於解析Modbus返回的異常資訊的方法,用於備用。

 

 

 

8、根據協議規則,提供一些引數,並先搭建一個簡單的方法框架,用來可以進行讀取線圈的功能。包含簡單的報文資料拆分以及報文傳送和接收。由於傳送報文長度不能超過248byte(1 bool大小 == 1 byte,如果是其他型別,需要做其他長度換算),所以當長度超過時候,做個簡單的演算法進行拆分再傳送,防止發生不必要的異常。以下做一個讀取線圈(Bool型別資料)的簡單方法。

 

 

 

 

9、根據上方提供的協議報文組裝規則,進行開發一個通用的報文組織方法。有高低位之分,所以對於佔用2byte的資料,需要進行"倒裝"。

 

 

 

10、傳送報文以後,返回的報文含有校驗資訊:傳送的資料包文的第7位的資料,加上 0x80 以後,跟返回的報文的第7位byte資料如果一致,則代表當前通訊上可能有異常。異常碼在接收的響應報文的第8位。

所以可以繼續寫一個驗證是否成功的校驗方法:

 

 

11、由於返回的資料也都是byte資料,以上讀取的線圈值(布林值),就需要提供一個資料型別轉換的功能用於把byte陣列轉換為bool陣列。

 

 

12、對讀取線圈的最開始的方法,進行一些完善以後的程式碼如下。響應報文長度是 傳送資料長度*2+9 。

 

 

13、接下來做一個簡單的測試。準備一下模擬環境,進行本地的測試,看看是否可以連通。先準備兩個工具,一個是 modbus poll,另一個是modbus slave。一個用來模擬服務端環境,另一個可用來模擬資料收發驗證。

備註】:由於網上存在很多爬蟲爬取部落格文章到各個地方的,所以如果有需要這倆工具的小夥伴,可以點選該文章的 原文連結:【https://www.cnblogs.com/weskynet/p/16121383.html】的最下方的QQ群號進行加群進行獲取,或者在文章最後提供的個人微訊號,加我個人微信私發也可以。

 

 

 

14、兩邊都設定為讀寫單個線圈的功能,用於測試以上線圈讀取的程式碼的功能。

 

 

15、兩邊都設定為modbus tcp連線方式。Slave站點啟動以後,預設為本地,poll工具上的IP地址選擇本地即可。如果是真實PLC環境,則填寫真實PLC地址。

 

 

 

16、測試兩邊是否通訊上。給任意一個地址寫入一個true,可以看到另一邊也同步更新,說明通訊是通的了。

【注意】modbus工具,poll和slave工具預設佔用了訊息唯一標識碼,大概是1~5左右的固定值,所以使用該工具期間,建議程式上的唯一訊息識別碼設定為5以上,以防止通訊干擾。

 

 

 

17、接下來就可以繼續完善程式碼進行驗證了。先新增ModbusService的介面IModbusService,用於實現依賴注入。然後在program.cs檔案裡面進行服務註冊。

 

 

 

18、新建一個控制器,用來進行模擬實驗。有關程式碼和註釋如圖所示。

 

 

 

19、進行讀取一個長度試試效果。結果是資料不支援,說明報文有問題。

 

 

 

20、通過斷點,找到問題所在,上面的程式碼裡面,length經過簡單演算法計算以後等於0,此處需要用的應該是newLength變數的值。

 

 

 

21、再次測試,地址從1開始,讀取兩個地址,結果符合預期。

 

 

22、再測試一下,從0開始讀取30個,並隨即設定若干個是True的值。

 

 

 

23、其他的寫入、以及其他型別讀寫,基本類似。由於篇幅有限,就不繼續進行一步一步操作的截圖了。讀取的,選好型別,報文格式都是一樣的,唯一有差別的是寫入的報文。下面是寫入單個線圈值的報文。線圈當前僅支援一個一個寫入。

 

 

 

24、寫入暫存器的規則會有些偏差,協議規則如下圖。

 

【備註】以上圖的標題,我寫錯了,應該是 “寫入暫存器”報文協議,懶得換圖了,大佬們看的時候自己辨別哈~

 讀取線圈當作引導,其他型別也都異曲同工,大佬們可以自行嘗試。

 

 另外說點,如果是生產環境下使用,建議把客戶端連線做成【長連線】,不然重複建立連線比較耗費資源,耗時也會因為新建連線而佔用一大半。同時,如果是多執行緒訪問,使用同一個客戶端連線,必須加鎖,否則會干擾資料;如果是多執行緒,不同客戶端,就要保證每個訊息識別碼必須不同,如果存在同一個識別碼,很容易發生資料異常等情況。

 

有關原始碼:

ModbusService原始碼:

【.NET6+Modbus】Modbus TCP協議解析、模擬環境以及基於.NET實現基礎通訊
 public class ModbusService: IModbusService
    {

        public ResultInformation<Socket> ConnectModbusTcpService( IPAddress ip, int port)
        {
            ResultInformation<Socket> client = new();
            try
            {
                client.Result = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
                client.Result.Connect(new IPEndPoint(ip, port));
                client.IsSucceed = true;
            }
            catch (Exception ex)
            {
                client.IsSucceed=false;
                client.Message = ex.Message;
            }
            return client;
        }

        /// <summary>
        /// 讀取線圈值(Bool)
        /// </summary>
        /// <param name="client">客戶端</param>
        /// <param name="headCode">頭部標識</param>
        /// <param name="station">站地址</param>
        /// <param name="address">地址</param>
        /// <param name="length">長度</param>
        /// <returns></returns>
        public ResultInformation<bool[]> ReadCoils(Socket client,ushort headCode,byte station, ushort address, ushort length)
        {
            ResultInformation<bool[]> result = new();
            int resultIndex = 0;
            ushort newLength = 0;
            ushort realLength = length;  // 儲存實際長度
            try
            {
                List<byte> byteResult = new List<byte>(); // 儲存實際讀取到的所有有效的byte資料
                while (length > 0)
                {
                    if (length > 248)  // 長度限制,不能超過248
                    {
                        length = (ushort)(length - 248);
                        newLength = 248;
                    }
                    else
                    {
                        newLength = length;
                        length = 0;
                    }
                    resultIndex += newLength;
                    byte[] sendBuffers = BindByteData(headCode,station,FunctionCode.ReadCoil,address,newLength); // 組裝報文
                    client.Send(sendBuffers);
                    byte[] receiveBuffers = new byte[newLength * 2 + 9]; 
                    int count = client.Receive(receiveBuffers); // 等待接收報文
                    var checkResult = CheckReceiveBuffer(sendBuffers, receiveBuffers); // 驗證訊息傳送成功與否
                    if (checkResult.IsSucceed)
                    {
                        // 成功,如果長度超出單次讀取長度,進行繼續讀取,然後對資料進行拼接
                        List<byte> byteList = new List<byte>(receiveBuffers);
                        byteList.RemoveRange(0, 9); // 去除前面9個非資料位
                        byteResult.AddRange(byteList); // 讀取到的資料進行新增進集合
                        address += newLength; // 下一個起始地址
                    }
                    else
                    {
                        throw new Exception(checkResult.Message);
                    }
                }
                result.IsSucceed = true;
                result.Result = ByteToBoolean(byteResult.ToArray(), realLength);
            }
            catch (Exception ex)
            {
                result.IsSucceed = false;
                result.Result = new bool[0];
                result.Message = ex.Message;
            }
            return result;
        }

        private bool[] ByteToBoolean(byte[] data,int length)
        {
            if (data == null)
            {
                return new bool[0];
            }
            if (length > data.Length * 8) length = data.Length * 8;
            bool[] result = new bool[length];
            for (int i = 0; i < length; i++)
            {
                int index = i / 8;
                int offect = i % 8;
                byte temp = 0;
                switch (offect)
                {
                    case 0: temp = 0x01; break;
                    case 1: temp = 0x02; break;
                    case 2: temp = 0x04; break;
                    case 3: temp = 0x08; break;
                    case 4: temp = 0x10; break;
                    case 5: temp = 0x20; break;
                    case 6: temp = 0x40; break;
                    case 7: temp = 0x80; break;
                    default: break;
                }

                if ((data[index] & temp) == temp)
                {
                    result[i] = true;
                }
            }
            return result;
        }

        private byte[] BindByteData(ushort headCode,byte station,byte functionCode,ushort address, ushort length)
        {
            byte[] head = new byte[6];
            head[0] = station; // 站地址
            head[1] = functionCode; // 功能碼
            head[2] = BitConverter.GetBytes(address)[1]; // 起始地址
            head[3] = BitConverter.GetBytes(address)[0];
            head[4] = BitConverter.GetBytes(length)[1]; // 長度
            head[5] = BitConverter.GetBytes(length)[0];

            return GetSocketBytes(headCode,head);

        }

        private byte[] GetSocketBytes(ushort headCode,byte[] head)
        {
            byte[] buffers = new byte[head.Length+6]; 
            buffers[0] = BitConverter.GetBytes(headCode)[1];
            buffers[1] = BitConverter.GetBytes(headCode)[0];
            // 2 和 3位置預設,所以不需要賦值
            buffers[4] = BitConverter.GetBytes(head.Length)[1];
            buffers[5] = BitConverter.GetBytes(head.Length)[0];

            head.CopyTo(buffers, 6);

            return buffers;
        }

        private ResultInformation<string> CheckReceiveBuffer(byte[] send,byte[] receive)
        {
            ResultInformation<string> result = new();
            if ((send[7] + 0x80) == receive[7])
            {
                var str = FunctionCode.GetDescriptionByErrorCode(receive[8]);
                result.IsSucceed = false;
                result.Message = str;
            }
            else
            {
                result.IsSucceed = true;
            }

            return result;
        }

    }
View Code

控制器原始碼:

【.NET6+Modbus】Modbus TCP協議解析、模擬環境以及基於.NET實現基礎通訊
[Route("api/[controller]/[action]")]
    [ApiController]
    public class TestModbusController : ControllerBase
    {
        IModbusService _service;
        public TestModbusController(IModbusService modbusService)
        {
            _service = modbusService;
        }
        [HttpPost]
        public IActionResult ReadCoil(ushort address, ushort length)
        {
            var ip = IPAddress.Parse("127.0.0.1"); // ip地址
             int port = 502; // modbus tcp通訊,預設埠
            byte station = (byte)((short)1); // 站地址為1

            var connectResult = _service.ConnectModbusTcpService(ip,port);
            if (connectResult.IsSucceed)
            {
                // socket連線建立成功
                var readResult = _service.ReadCoils(connectResult.Result,6,station,address,length);  // 唯一訊息碼設為6(大於5,且不重複即可)
                if (readResult.IsSucceed)
                {
                    if (readResult.Result.Any())
                    {
                        StringBuilder sb = new StringBuilder();
                        for(int i = 0; i < readResult.Result.Length; i++)
                        {
                            sb.AppendLine($"[{i}]:{readResult.Result[i]}");
                        }
                        return Ok(sb.ToString());
                    }
                }
                else
                {
                    return Ok(readResult.Message);
                }
            }
            else
            {
                return Ok(connectResult.Message);
            }

            return Ok();
        }
    }
View Code

功能碼和異常碼:

【.NET6+Modbus】Modbus TCP協議解析、模擬環境以及基於.NET實現基礎通訊
 public class FunctionCode
    {
        #region 功能碼
        public const byte ReadCoil = 0x01; // 讀取線圈狀態  暫存器PLC地址 00001 - 09999
        public const byte ReadInputDiscrete = 0x02; // 讀取 可輸入的離散量  暫存器PLC地址 10001 - 19999
        public const byte ReadRegister = 0x03; // 讀取 保持暫存器  40001 - 49999
        public const byte ReadInputRegister = 0x04; // 讀取 可輸入暫存器  30001 - 39999
        public const byte WriteSingleCoil = 0x05; // 寫單個 線圈  00001 - 09999
        public const byte WriteSingleRegister = 0x06; // 寫單個 保持暫存器  40001 - 49999
        public const byte WriteMultiCoil = 0x0F;  // 寫多個 線圈  00001 - 09999
        public const byte WriteMultiRegister = 0x10; // 寫多個 保持暫存器  40001 - 49999
        public const byte SelectSlave = 0x11; //  查詢從站狀態資訊  (串列埠通訊使用)
        #endregion

        #region 異常碼
        public const byte FunctionCodeNotSupport = 0x01;// 非法功能碼
        public const byte DataAddressNotSupport = 0x02;// 非法資料地址
        public const byte DataValueNotSupport = 0x03;// 非法資料值
        public const byte DeviceNotWork = 0x04;// 從站裝置異常
        public const byte LongTimeResponse = 0x05;// 請求需要更長時間才能進行處理請求
        public const byte DeviceBusy = 0x06;// 裝置繁忙
        public const byte OddEvenError = 0x08;// 奇偶性錯誤
        public const byte GatewayNotSupport = 0x0A;// 閘道器錯誤
        public const byte GatewayDeviceResponseTimeout = 0x0B;// 閘道器裝置響應失敗
        #endregion

        public static string GetDescriptionByErrorCode(byte code)
        {
            switch (code)
            {
                case FunctionCodeNotSupport:
                    return "FunctionCodeNotSupport";
                case DataAddressNotSupport:
                    return "DataAddressNotSupport";
                case DataValueNotSupport:
                    return "DataValueNotSupport";
                case DeviceNotWork:
                    return "DeviceNotWork";
                case LongTimeResponse:
                    return "LongTimeResponse";
                case DeviceBusy:
                    return "DeviceBusy";
                case OddEvenError:
                    return "OddEvenError";
                case GatewayNotSupport:
                    return "GatewayNotSupport";
                case GatewayDeviceResponseTimeout:
                    return "GatewayDeviceResponseTimeout";
                default:
                    return "UnknownError";
            }
        }

    }
View Code

 

 

 

好了,以上就是該文章的全部內容。如果覺得有幫助,歡迎一鍵三連啊~~ 如果有興趣,也可以加我私人微信,歡迎大佬來我微信群做客。私人微信:【WeskyNet001】

 

相關文章