接下來的內容,我會以從頭開發一個簡單的基于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。一個用來模擬服務端環境,另一個可用來模擬數據收發驗證。
?
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源碼:
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;}}
控制器源碼:
[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); // 站地址為1var 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();}}
功能碼和異常碼:
public class FunctionCode{ #region 功能碼 public const byte ReadCoil = 0x01; // 讀取線圈狀態 寄存器PLC地址 00001 - 09999public const byte ReadInputDiscrete = 0x02; // 讀取 可輸入的離散量 寄存器PLC地址 10001 - 19999public const byte ReadRegister = 0x03; // 讀取 保持寄存器 40001 - 49999public const byte ReadInputRegister = 0x04; // 讀取 可輸入寄存器 30001 - 39999public const byte WriteSingleCoil = 0x05; // 寫單個 線圈 00001 - 09999public const byte WriteSingleRegister = 0x06; // 寫單個 保持寄存器 40001 - 49999public const byte WriteMultiCoil = 0x0F; // 寫多個 線圈 00001 - 09999public const byte WriteMultiRegister = 0x10; // 寫多個 保持寄存器 40001 - 49999public 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;// 網關設備響應失敗#endregionpublic 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";}}}
好了,以上就是該文章的全部內容。如果覺得有幫助,歡迎轉發、在看和點贊。也歡迎關注我的公眾號:Dotnet Dancer
謝謝大家~