【.NET6+Modbus】Modbus TCP协议解析、仿真环境以及基于.NET实现基础通信

接下来的内容,我会以从头开发一个简单的基于modbus tcp通信的案例,来实现一个基础的通信功能。

有关环境:

开发环境:VS 2022企业版

运行环境:Win 10 专业版

.NET 环境版本:.NET 6

【备注】 源码在文末 

1、新建一个基于.NET 6带控制器的webapi项目,以及一个类库项目。如下图所示,新建以后的项目目录结构。

 68588d6988e9f87ed5f4eec4028c606b.png

2、由于modbus tcp通信实际上就是一个socket通信,所以在类库项目下,先创建了一个Modbus服务类,并且提供一个基于socket通信连接的方法。socket连接以后,需要返回socket实例拿来使用。

 08eacc90cfedce1b7b9007be4f093b85.png

3、为了方便一点,再新增一个通用的返回信息类,用于存储一些返回信息使用。

 5546a0ef6e06739a26b4efc7ea6525a5.png

4、基于以上的返回信息类,咱对连接方法进行稍微改造一下,让它看起来更方便一点。这样可以用来验证连接是否正常,以及返回对应的异常信息,好做进一步处理。

 098bc0edc3508261194b43309148aca0.png

5、Modbus TCP请求的报文规则,一些解析信息如下:

站地址:默认0x01, 除非PLC告诉我们其他站地址。

功能码:代表读写数据时候指定的读写方法等。例如读取线圈的功能码是0x01。

地址和读取长度:地址目前个人在施耐德物理的PLC环境上,不能超过30000。同时,单次读写长度不能超过248个byte,否则PLC可能会飘。当然,也可能将来一些PLC可以支持更长的批量数据读写,目前在施耐德PLC环境下不支持(具体型号忘记了,有点久了,当前身边没得PLC了,等下会使用仿真工具来做环境)。

头部校验(消息唯一识别码):0~65535,用于PLC服务端进行区分不同的客户端而使用的一组数据标识,不同的客户端必须保证标识码不重合。例如多个客户端同时存在时候,发起的通信请求,必须保持不一样的识别码,否则Modbus服务端有可能会因为不知是哪个客户端发起的请求而导致信息乱了。

无(协议标识):默认0,代表是Modbus协议。

数据长度:发送的报文的长度,刚好是6位,所以可以写成固定值0x06。(写入的规则不一样,此处固定值只当作读取时候使用)

 f64662d7d6df759219a7df759cd3c71f.png

6、根据协议的一些具体内容,写一个存储功能码和异常返回码的数据类,用于后期做通信时候传参和通信数据验证使用。有关协议具体内容,如下代码所示。

 d858fea1b1b3788f0700bce7007c77cf.png

7、由于异常码是byte数据,直接验证可能会麻烦一点,为了可以直观一些,此处再新增一个用于解析Modbus返回的异常信息的方法,用于备用。

 46d272f08707868d5cc0c5d6091be66a.png

8、根据协议规则,提供一些参数,并先搭建一个简单的方法框架,用来可以进行读取线圈的功能。包含简单的报文数据拆分以及报文发送和接收。由于发送报文长度不能超过248byte(1 bool大小 == 1 byte,如果是其他类型,需要做其他长度换算),所以当长度超过时候,做个简单的算法进行拆分再发送,防止发生不必要的异常。以下做一个读取线圈(Bool类型数据)的简单方法。

 6c135fdd09f3edfac2554d9a50b9f14b.png

9、根据上方提供的协议报文组装规则,进行开发一个通用的报文组织方法。有高低位之分,所以对于占用2byte的数据,需要进行"倒装"。

 814b024e3a03493653428ae9e51532cf.png

10、发送报文以后,返回的报文含有校验信息:发送的数据报文的第7位的数据,加上 0x80 以后,跟返回的报文的第7位byte数据如果一致,则代表当前通信上可能有异常。异常码在接收的响应报文的第8位。

所以可以继续写一个验证是否成功的校验方法:

 156efa11cf23195e23cf17f92374657a.png

11、由于返回的数据也都是byte数据,以上读取的线圈值(布尔值),就需要提供一个数据类型转换的功能用于把byte数组转换为bool数组。

 ada7de925f5c1a515d440fc09380ce49.png

12、对读取线圈的最开始的方法,进行一些完善以后的代码如下。响应报文长度是 发送数据长度*2+9 。

 b5585b0f79c08e473719cb7f084f8a7f.png

13、接下来做一个简单的测试。准备一下仿真环境,进行本地的测试,看看是否可以连通。先准备两个工具,一个是 modbus poll,另一个是modbus slave。一个用来模拟服务端环境,另一个可用来模拟数据收发验证。

 6b63dc23e40591aaf3385d73aea15f33.png

14、两边都设置为读写单个线圈的功能,用于测试以上线圈读取的代码的功能。

 c930a895ebf37481a05089a7b0b3275d.png

15、两边都设置为modbus tcp连接方式。Slave站点启动以后,默认为本地,poll工具上的IP地址选择本地即可。如果是真实PLC环境,则填写真实PLC地址。

 6cc63d6b22f781779bede7f0260169e2.png

16、测试两边是否通信上。给任意一个地址写入一个true,可以看到另一边也同步更新,说明通信是通的了。

【注意】modbus工具,poll和slave工具默认占用了消息唯一标识码,大概是1~5左右的固定值,所以使用该工具期间,建议程序上的唯一消息识别码设置为5以上,以防止通信干扰。

 073e32f61c543583d337c245a33d6083.png

17、接下来就可以继续完善代码进行验证了。先新增ModbusService的接口IModbusService,用于实现依赖注入。然后在program.cs文件里面进行服务注册。

 69c62bb13cd8404929da34d040681067.png

18、新建一个控制器,用来进行模拟实验。有关代码和注释如图所示。

 d449f4e80b7e71f9c2e37437f9ed6fa3.png

19、进行读取一个长度试试效果。结果是数据不支持,说明报文有问题。

 113d3eff8a85bb908cd57e079ab7bb02.png

20、通过断点,找到问题所在,上面的代码里面,length经过简单算法计算以后等于0,此处需要用的应该是newLength变量的值。

 0ca26cf0426c5c6b1794dd13c3362c63.png

21、再次测试,地址从1开始,读取两个地址,结果符合预期。

 bf9411bf70225b5725ff4e3b93dd4e3f.png

22、再测试一下,从0开始读取30个,并随即设置若干个是True的值。

 75a961cb4eefc0b61446b68ae2e14a15.png

23、其他的写入、以及其他类型读写,基本类似。由于篇幅有限,就不继续进行一步一步操作的截图了。读取的,选好类型,报文格式都是一样的,唯一有差别的是写入的报文。下面是写入单个线圈值的报文。线圈当前仅支持一个一个写入。

 c69f2d35974ad69cf41d0694d8fb7c18.png

24、写入寄存器的规则会有些偏差,协议规则如下图。

 88828cf896425a1af00557543299a22f.png

【备注】以上图的标题,我写错了,应该是 “写入寄存器”报文协议,懒得换图了,大佬们看的时候自己辨别哈~

 读取线圈当作引导,其他类型也都异曲同工,大佬们可以自行尝试。

 另外说点,如果是生产环境下使用,建议把客户端连接做成【长连接】,不然重复创建连接比较耗费资源,耗时也会因为新建连接而占用一大半。同时,如果是多线程访问,使用同一个客户端连接,必须加锁,否则会干扰数据;如果是多线程,不同客户端,就要保证每个消息识别码必须不同,如果存在同一个识别码,很容易发生数据异常等情况。

有关源码:

ModbusService源码:

66f5117f35d1af52a42b3f331e0ff585.jpeg

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;}}

340f7e8600ff2cd17c5b1992fb4f79cd.jpeg

控制器源码:

e47ee2dce8608a2dbc07b88151e52f85.jpeg

[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();}}

b4d5a644f9a1d1c1e3b04d3425b5658a.jpeg

功能码和异常码:

5451ed8fe0032a15ba61dab1325876ab.jpeg

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";}}}

72e134e4654c4524186df96d6da1a2e0.jpeg

好了,以上就是该文章的全部内容。如果觉得有帮助,欢迎转发、在看和点赞。也欢迎关注我的公众号:Dotnet Dancer

谢谢大家~

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/283981.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

源码深度剖析Eureka与Ribbon服务发现原理

本文基于 spring cloud dalston&#xff0c;同时文章较长&#xff0c;请选择舒服姿势进行阅读。 Eureka 与 Ribbon 是什么&#xff1f;和服务发现什么关系&#xff1f; Eureka 与 Ribbon 都是 Netflix 提供的微服务组件&#xff0c;分别用于服务注册与发现、负载均衡。同时&a…

std的find和reverse_iterator联合使用

上代码&#xff1a; // test2013.cpp : 定义控制台应用程序的入口点。 //#include "stdafx.h" #include <stdlib.h> #include <stdio.h> #include<iostream> #include<vector> #include<map> #include<string> using namespace …

论如何提升学习的能力

为啥要学习如果有一件事情是能改变你自己的&#xff0c;我想这件事情必然就是学习&#xff0c;我的人生重要的转折点也是从学习这件事情始发的&#xff0c;那么&#xff0c;我们就从这里开始。学习不仅仅是为了找到答案&#xff0c;而是为了找到方法&#xff0c;找到一个可以找…

CSS布局解决方案(终结版)

前端布局非常重要的一环就是页面框架的搭建&#xff0c;也是最基础的一环。在页面框架的搭建之中&#xff0c;又有居中布局、多列布局以及全局布局&#xff0c;今天我们就来总结总结前端干货中的CSS布局。 居中布局 水平居中 1&#xff09;使用inline-blocktext-align&#xff…

基于ABP和Magicodes实现Excel导出操作

前端使用的vue-element-admin框架&#xff0c;后端使用ABP框架&#xff0c;Excel导出使用的Magicodes.IE.Excel.Abp库。Excel导入和导出操作几乎一样&#xff0c;不再介绍。文本主要介绍Excel导出操作和过程中遇到的坑&#xff0c;主要是Excel文件导出后无法打开的问题。一.Mag…

消息模式在实际开发应用中的优势

曾经.NET面试过程中经常问的一个问题是&#xff0c;如果程序集A&#xff0c;引用B &#xff0c;B 引用C&#xff0c;那么C怎么去访问A中的方法呢。 这个问题初学.net可能一时想不出该咋处理&#xff0c;这涉及到循环引用问题。但有点经验的可能就简单了&#xff0c;通过委托的方…

微服务:注册中心ZooKeeper、Eureka、Consul 、Nacos对比

前言 服务注册中心本质上是为了解耦服务提供者和服务消费者。对于任何一个微服务&#xff0c;原则上都应存在或者支持多个提供者&#xff0c;这是由微服务的分布式属性决定的。更进一步&#xff0c;为了支持弹性扩缩容特性&#xff0c;一个微服务的提供者的数量和分布往往是动…

为了高性能、超大规模的模型训练,这个组合“出道”了

点击上方蓝字关注我们&#xff08;本文阅读时间&#xff1a;3分钟)近年来&#xff0c;在大量数据上训练的基于 transformer 的大规模深度学习模型在多项认知任务中取得了很好的成果&#xff0c;并且被使用到一些新产品和功能背后&#xff0c;进一步增强了人类的能力。在过去五年…

SpringCloud必会知识点大全

为什么要学习Spring Cloud 在项目开发中随着业务越来越多&#xff0c;导致功能之间耦合性高、开发效率低、系统运行缓慢难以维护、不稳定。微服务 架构可以解决这些问题&#xff0c;而Spring Cloud是微服务架构最流行的实现. 1.微服务 微服务架构是使用一套小服务来开发单个应用…

30分钟掌握 C#7

1. out 变量&#xff08;out variables&#xff09; 以前我们使用out变量必须在使用前进行声明&#xff0c;C# 7.0 给我们提供了一种更简洁的语法 “使用时进行内联声明” 。如下所示&#xff1a; 1 var input ReadLine(); 2 if (int.TryParse(input, out var result)) 3 …

在 C# 中如何检查参数是否为 null

前言前不久&#xff0c;微软宣布从 C# 11 中移除参数空值检查功能&#xff0c;该功能允许在方法开始执行之前&#xff0c;在参数名称的末尾提供参数空值检查&#xff08;!!操作符&#xff09;。那么&#xff0c;在 C# 中如何检查参数是否为 null 呢&#xff1f;1. null这个可能…

带你剖析WebGis的世界奥秘----Geojson数据加载(高级)

前言&#xff1a;前两周我带你们分析了WebGis中关键步骤瓦片加载点击事件&#xff08;具体的看前两篇文章&#xff09;&#xff0c;下面呢&#xff0c;我带大家来看看Geojson的加载及其点击事件 Geojson数据解析 GeoJSON是一种对各种地理数据结构进行编码的格式。GeoJSON对象可…

「Docker入门指北」容器很难理解?带你从头到尾捋一遍

文章目录 1. 初始虚拟化 &#x1f351; 虚拟化概念&#x1f351; 硬件虚拟化2. Docker容器 &#x1f351; Docker技术的诞生&#x1f351; 容器与虚拟化&#x1f351; 性能差别&#x1f351; Docker优势 编排有序高效易迁移快速部署3. 容器生态系统 &#x1f351; 核心技术 容器…

微服务:事务管理

几乎所有的信息管理系统都会涉及到事务&#xff0c;事务的目的是为了保证数据的一致性&#xff0c;这里说的一致性是数据库状态的一致性。说到数据库状态的一致性&#xff0c;相信大家都会想到 ACID &#xff1a;原子性&#xff08;Atomic&#xff09;&#xff1a;在一个事件的…

Redis的那些事:一文入门Redis的基础操作

Redis是什么Redis&#xff0c;全称是Remote Dictionary Service,翻译过来就是&#xff0c;远程字典服务。redis属于nosql非关系型数据库。Nosql常见的数据关系&#xff0c;基本上是以key-value键值对形式存在的。Key-value: 就像翻阅中文字典或者单词字典&#xff0c;通过指定的…

10种提问型爆文标题句式 直接套用

如果你用1天的时间来写篇好文章&#xff0c;那你花掉半天时间想一个好标题都不过分&#xff01; 你是不是觉得我有点言过其实了&#xff1f;没关系&#xff0c;先来问你2个问题&#xff1a; 1、花了很长时间&#xff0c;写了一篇很牛的卖货推文&#xff0c;定稿后&#xff0c…

2016 China Joy抢先看,文末有彩蛋!

这里只有你想不到的&#xff0c;没有你看不到的。 2016 China Joy开幕在即&#xff0c;天气成了最折磨各种媒体、展商和观众的小妖精&#xff0c;一会艳阳天&#xff0c;一会大暴雨&#xff0c;轩轩现在是这样的&#xff01; 七月底的魔都&#xff0c;热的那叫一个销魂&#x…

JdbcTemplate+PageImpl实现多表分页查询

一、基础实体  MappedSuperclass public abstract class AbsIdEntity implements Serializable {private static final long serialVersionUID 7988377299341530426L;public final static int IS_DELETE_YES 1;// 标记删除public final static int IS_DELETE_NO 0;// 未删除…

消息队列选型手册

前言 消息队列中间件重要吗&#xff1f;面试必问问题之一&#xff0c;你说重不重要。我有时会问同事&#xff0c;为啥你用 RabbitMQ&#xff0c;不用 Kafka&#xff0c;或者 RocketMQ 呢&#xff1f; 他给我的回答&#xff1a;“因为公司用的就是这个&#xff0c;大家都这么用…

Jenkins 持续集成国产嵌入式操作系统 RT-Thread 的CI

我们直接在Jenkins的镜像基础上进行集成RT-Thread 的编译环境&#xff0c; 这样直接使用Shell 命令 最直接了当&#xff0c; 通过 第三方docker等插件&#xff0c; 尝试了计重方案&#xff0c; 没有找到理想中的感觉&#xff0c; 如果其他人有想法可以告知一二。 我们有现成的镜…