工业通信的开源项目 HslCommunication 介绍

前言:


本项目的孵化说来也是机缘巧合的事,本人于13年杭州某大学毕业后去了一家大型的国企工作,慢慢的走上了工业软件,上位机软件开发的道路。于14年正式开发基于windows的软件,当时可选的技术栈就是MFC和C#的winform,后来就发现C#的更为简单一些,那就直接干,先做再说。需要做一些界面相关的软件,就直接采用了C#的winform,基础不够,百度来凑。后来领导就下达了一个任务,开发一个硫化机系统的上位机,对某个车间共计五六十台硫化机进行监控和曲线查看。由于没有可参考的界面程序,开发起来就比较费劲,具体有什么功能,都是干嘛的,工艺需要什么等等都是未知数,没办法,只有硬着头皮和现场的工艺人员,电气人员,来回沟通交流,加上一些我自己的理解,正式踏上了工业软件开发的道路。

 

开始做项目的时候,硫化机设备是采用PLC作为主控制器的,第一道拦路虎就是如何将三菱的PLC(逻辑控制器,通常作为设备的核心控制单元)的数据给拿到我的软件中来呢?这真是一个棘手的问题啊,首先就是百度,搜索到了MX component组件,初步试了试,真的比较麻烦,关键还没弄通。然后就去看看有没有其他的方式实现,后来就在工厂的备件库里看到了三菱的以太网模块QJ71E71-100,然后就搜索支持的通讯说明,在三菱的官网上找到了通信说明,打开一看,我去,这么长篇大论。那也没有办法,按照边测试边开发,勉勉强强读到了我想要读的数据(当然,这时候的代码基本都是写死的),又开始解析数据成真实的数据,然后研究如何存入数据库中去,再研究怎么显示曲线,到这里为止,这个项目的基本技术难题基本算是攻克了,持续的迭代,那是后话了。

 

在接下来的两三年里,接触并开发了好几个类似的项目,发现通常工业软件的需求是采集,分析,存数据库,显示。后来对通信的理解深入,由单机软件发展成了CS架构的软件,微软的数据库SQL Server本来就支持局域网访问。后来在17年趁着换工作和考驾照的间隙,梳理了上份工作积累的经验,和实际的需求,再加上自己的代码水平也稍微进步了一点点,就整理成了HslCommunication,并将之开源出来,初步的功能是三菱PLC的数据读写,C#软件之间的数据通信。后来又集成了modbus协议,西门子,欧姆龙,ab plc,三菱串口等等,发现写库的要求和写简单程序的要求并不是等同的,要写成库的话,需要保证功能灵活性,你写的代码基本符合大多数人的使用需求,而不是某种特定的情况。也就是说,有些人可能简单的使用而已,而有些人会深入使用,压榨性能。然后就是代码了,所有写代码的标准的最终目的都是为了让代码可读性增强,可维护性增强,方便快速的理解,升级,查错。这方便确实却要经验积累。

 

做这个项目(HslCommunication)的目标和开源的初衷是方便广大的像我这种的在工厂一线的软件工程师,我一直觉得我们不应该把自己看做是程序员,程序员的角色更像是码农,主要工作就是敲代码,而软件工程师应该是更大的定义,设计软件的整体架构和开发的。这几年大多数工作都开始意识到工业软件,上位机软件,数据追述系统,SCADA软件,MES软件开发的重要性,所以像我这样的有通信需求的人应该不在少数,况且开源有助于别人来一起改进,和代码测试。所以在开源之后,在博客园就陆陆续续的写了一些文章,比如如何使用C#和三菱PLC通讯,C#和西门子通讯等等。从博客园的点击量来看,确实有大量的工厂的程序员有这方便的需求,而直接采用socket来开发,比较晦涩难懂,坑又比较多,事实上确实有很多人来报告了bug。帮助我修复了这个组件,提高了稳定性。再次感谢所有使用或是报告bug的万千网友,没有你们的支持就没有本项目的今天。

 

由于我也是这个项目的使用者之一,实际上我自己在工作或是其他方面的使用也是很频繁的,在开发项目上就会站在使用者的角度出发,比如我想读取三菱PLC的D100的数据,能不能有个组件一两行代码就可以实现?伪代码的逻辑就是

1. 实例化

2. 读

这样才算是简单的操作,本着这样设计思想,最终有了现在的开源项目。

 

HslCommunication 能干什么?


相比大多数人比较关心这个问题,综合前言的介绍,这个组件主要是用于工业通信的,也有两个程序之间的通讯,还有其他杂七杂八的功能,更像是我的工具插件。各种小功能,扩展的小功能等等。直接上图:

demo

这是这个开源项目的demo程序,基本上将80%的功能列举出来了,当然还有一些小功能没有列举。大多数支持的设备都在上面进行显示了,可以方便的进行测试,看看是不是可以实现读写的操作(对现场实际在生产的设备应当注意写入不正确的数据会导致意外事故发生)。比如我们来看看三菱的PLC的demo程序:

melsec

其他的截图画面就不一一举例了,都是类似或是基本类似的。可以方便的使用demo进行测试。

特别注意,本组件实现的所有的通讯都是基于socket直接实现的,通信部分不依赖任何第三方通讯库或是组件安装,也就是说,你拿个dll可以直接和PLC通讯,这对于部署,开发调试,升级都是非常方便的。

当你需要进行PLC通信时,可以先用demo程序进行测试,如果demo程序可以读写,那么用本组件也就绝对可以读写,有些PLC的参数如果不清楚,就需要联系电气工程师进行确认。比如AB PLC的slot,不知道该写什么,就尝试为0,如果不行,就只能联系电气工程师解决这个问题。

 

demo项目的意义:当我开发了三菱PLC的通讯程序和西门子的通讯程序之后,我发现如果我想要测试一个新的PLC通不通?或是简单的通过代码读PLC的某个地址的程序的时候,就好费劲,需要经常创建一些小项目,这些小项目本身并没有什么实际的意义,就是简单的读个数据之类的。后来就想把这部分内容做成一个通用的测试,于是就有了demo项目,将本项目支持的各种设备都往界面上罗列,做成一个测试环境的demo程序,这样当大家也有这样的需求的时候,并不需要再新建一些无用的小项目了,本demo就基本上满足大家所有的需求了。

demo项目的彩蛋:在18年11月之后,demo项目实现了版本控制和自动升级,12月之后实现了统计全球的使用情况,下图就是demo项目 v5.6.2-最新 的2018年12月到2019年2月中旬的全球使用情况(这是不完全统计,旧版未统计,大量的旧版不支持自动更新,有些demo屏蔽了检测,实际使用量应该远超图片所示)

 

 

整体框架说明


整个框架的项目结构如下:

首先文件夹 TestProject 里面的项目都是一些demo项目,当然最重要的就是 HslCommunicationDemo 项目了。就是最上面的demo项目的截图,Hsl具体能干什么可以参照这个。

本项目使用了三个框架的项目,也就是说,本项目提供dll文件包含了三个框架版本:

  • .net framework 3.5
  • .net framework 4.5
  • .net standard 2.0

维护三份源代码显然是什么痛苦的,所以我采用了维护一份源代码,也就是 .Net 4.5的代码,其他两个项目引用.net 4.5的代码,如果有不一致的地方,就用预编译指令进行区分。例如在modbusserver类中

 而 HslCommunication_Net45.Test 项目是一个单元测试项目,包含了一些代码类的测试,还有示例代码的编写。所以我们的重点来看看 .net 4.5的项目即可,整体的结构如下图:

 

BasicFramework 放些了一些基于的小工具的类,比如SoftBasic提供了大量小的静态辅助方法,帮助你快速开发实现一些基础的小功能的。

Core 里放置了一些本项目的核心代码,所有网络通信类的基础类,基础功能实现都在Core里。

Enthernet 里放置了一些高级程序语言之间的通信,比如两个exe间通信,或是局域网两台电脑通信,或是多个电脑程序通信。

LogNet 是实现了本项目的日志工具,可以方便的存储日志信息。

ModBus 实现了基于网络的modbus-tcp协议,modbus-rtu协议,modbus-server,modbus-ascii协议的通信。

Profinet 实现了三菱,西门子,欧姆龙,松下,ab  plc的数据通信。

 

 

OperateResult 类说明


 

这个类为什么拿出来出来说呢?因为这个类贯穿了HSL整个项目,是本开源项目的思想之一。对这个类的理解,和对于本项目的理解至关重要。

 

左边也即是这个类的位置,右边是这个类的定义,在项目最初的开发阶段,我遇到了一个问题,这也是软件开发过程中大家都会遇到的问题,比如我要实现一个读取PLC一个数据的操作,读取成功了自然皆大欢喜,如果读取失败了呢?

我如何将读取失败,或是写入失败,或是操作失败的信息传递给调用者呢?除了失败的信息之外,应该还要包含一个为什么失败的信息,PLC本身的失败会返回一个错误码,那就也需要一个错误码。所以就有了 OperateResult 的雏形:

        /// <summary>
        /// 指示本次访问是否成功
        /// </summary>
        public bool IsSuccess { get; set; }
        
        /// <summary>
        /// 具体的错误描述
        /// </summary>
        public string Message { get; set; } = StringResources.Language.UnknownError;
        
        /// <summary>
        /// 具体的错误代码
        /// </summary>
        public int ErrorCode { get; set; } = 10000;
        

 

于是就有了上面的三个属性内容,但是这时候还有一点需要注意,返回的结果对象应该是可以带内容的,比如你读取了一个int数据,应该带一个int的结果,读取了一个short的数据,就应该带一个short类型的数据,如果需要这个结果对象支持多类型的内容的话,查了查书,发现有个泛型的功能刚好合适,但是之后又发现,万一我想要带2个不同类型的结果对象时,那怎么办?这时候就需要定义多个不同类型的 OperateResult 类型了。

 

此处定义多达十个的泛型对象,满足绝大多数的情况请用。这个类型对象除了能返回带有错误信息的结果对象之外,还允许进行结果路由,我们来看看这个项目里的一个方法:

        /// <summary>
        /// 使用底层的数据报文来通讯,传入需要发送的消息,返回最终的数据结果,被拆分成了头子节和内容字节信息
        /// </summary>
        /// <param name="socket">网络套接字</param>
        /// <param name="send">发送的数据</param>
        /// <returns>结果对象</returns>
        /// <remarks>
        /// 当子类重写InitializationOnConnect方法和ExtraOnDisconnect方法时,需要和设备进行数据交互后,必须用本方法来数据交互,因为本方法是无锁的。
        /// </remarks>
        protected OperateResult<byte[], byte[]> ReadFromCoreServerBase(Socket socket, byte[] send )
        {
            LogNet?.WriteDebug( ToString( ), StringResources.Language.Send + " : " + BasicFramework.SoftBasic.ByteToHexString( send, ' ' ) );

            TNetMessage netMsg = new TNetMessage
            {
                SendBytes = send
            };

            // 发送数据信息
            OperateResult sendResult = Send( socket, send );
            if (!sendResult.IsSuccess)
            {
                socket?.Close( );
                return OperateResult.CreateFailedResult<byte[], byte[]>( sendResult );
            }

            // 接收超时时间大于0时才允许接收远程的数据
            if (receiveTimeOut >= 0)
            {
                // 接收数据信息
                OperateResult<TNetMessage> resultReceive = ReceiveMessage(socket, receiveTimeOut, netMsg);
                if (!resultReceive.IsSuccess)
                {
                    socket?.Close( );
                    return new OperateResult<byte[], byte[]>( StringResources.Language.ReceiveDataTimeout + receiveTimeOut );
                }

                LogNet?.WriteDebug( ToString( ), StringResources.Language.Receive + " : " +
                    BasicFramework.SoftBasic.ByteToHexString( BasicFramework.SoftBasic.SpliceTwoByteArray( resultReceive.Content.HeadBytes,
                    resultReceive.Content.ContentBytes ), ' ' ) );

                // Success
                return OperateResult.CreateSuccessResult( resultReceive.Content.HeadBytes, resultReceive.Content.ContentBytes );
            }
            else
            {
                // Not need receive
                return OperateResult.CreateSuccessResult( new byte[0], new byte[0] );
            }
        }

 

我们看到,方法里面的错误信息,可以由结果路由进行层层上传,最终抛给调用者,代码里需要做的就是发生错误的时候处理好后续的逻辑即可。这个类提供了几个静态方法快速的处理结果路由

 

 

通讯核心说明


讲完了结果路由再来说说,整个网络类的核心在于 NetworkBase类,在项目的开发过来中,尤其是开发了几个不同的PLC和C#程序之间的服务器客户端通信之后,发现有些底层代码是有些重复的,所以经过不断的提炼代码形成了所有网络的底层基类,这个类呢,只是提供了一个socket相关通用的操作逻辑,比如,创建并连接的socket对象,接收指定长度的数据,发送字节数据,关闭,接收流,发送流等等操作。

 

这个类实现了基础的字节收发功能和连接断开功能。接下来就是 NetworkDoubleBase 类的实现,实现了长短连接的操作,在我们实际读写设备的过程中,网络状况往往是差别很大,所以本项目的初衷就是同时支持长连接和短连接。根据大家需求的不同,

所谓的短连接是读取的时候再连接,读取完成就关闭连接。缺点就是连接打开和关闭耗时,影响读取速率,优点就是对网络状况反馈即使,读取失败了就说明网络断了,适合频率较低的读写。

长连接就是读取开始前连接一次,就不再关闭,进行频繁的读取,最后再关闭,好处当然是高速了,缺点就是网络状况不是那么好的时候,效率比较低下,对网络状况反应也不及时。

短连接就是直接的实例化,然后读取写入操作,每一次操作都是一次完整的通信过程。

切换长连接有两种办法,效果是一致的,

1. 对象读写前调用ConnectServer();

2. 对象读写前调用SetPersistentConnection( );

这两个方法都是双模式类里支持并实现的。所有的派生类都符合这个调用机制。

 

实现了长短的连接后,还要实现设备的BCL类型的读写,本质是基于byte数组和C#基础类型的转换,但是这里有个问题,不同的PLC,modbus协议对于转换的格式不是固定的,有可能是一样的,有可能不是一样的,所以又抽象出来一个 IByteTransform 接口

 

 这个接口集成到了下面的设备交互的基类 NetworkDeviceBase 里,这个基类实现了一些基础的类型的数据读写。

所以到这里可以看到,从NetworkDeviceBase类继承出去的设备类(大部分的设备通信协议都是从这个继承出去的),其基本的读写代码都是一致的,关于解析协议,通信的底层都是封装完毕,

 

 

通讯举例说明


先举例说明三菱PLC的读写操作:

            // 实例化对象,指定PLC的ip地址和端口号
            MelsecMcNet melsecMc = new MelsecMcNet( "192.168.1.110", 6000 );

            // 连接对象
            OperateResult connect = melsecMc.ConnectServer( );
            if (!connect.IsSuccess)
            {
                Console.WriteLine( "connect failed:" + connect.Message );
                return;
            }

            // 举例读取D100的值
            short D100 = melsecMc.ReadInt16( "D100" ).Content;

            melsecMc.ConnectClose( );

经过层层封装后,读写的逻辑精简为,实例化,连接,读写,关闭。无论是三菱的PLC,还是西门子的PLC,都是一致的,因为基类的模型都是一致的。

            // 实例化对象,指定PLC的ip地址和端口号
            SiemensS7Net siemens = new SiemensS7Net( SiemensPLCS.S1200, " 192.168.1.110" );

            // 连接对象
            OperateResult connect = siemens.ConnectServer( );
            if (!connect.IsSuccess)
            {
                Console.WriteLine( "connect failed:" + connect.Message );
                return;
            }

            // 举例读取M100的值
            short M100 = siemens.ReadInt16( "M100" ).Content;

            siemens.ConnectClose( );

当然,支持大多数的C#类型数据读写

            MelsecMcNet melsec_net = new MelsecMcNet( "192.168.0.100", 6000 );

            // 此处以D寄存器作为示例
            short short_D1000 = melsec_net.ReadInt16( "D1000" ).Content;         // 读取D1000的short值 
            ushort ushort_D1000 = melsec_net.ReadUInt16( "D1000" ).Content;      // 读取D1000的ushort值
            int int_D1000 = melsec_net.ReadInt32( "D1000" ).Content;             // 读取D1000-D1001组成的int数据
            uint uint_D1000 = melsec_net.ReadUInt32( "D1000" ).Content;          // 读取D1000-D1001组成的uint数据
            float float_D1000 = melsec_net.ReadFloat( "D1000" ).Content;         // 读取D1000-D1001组成的float数据
            long long_D1000 = melsec_net.ReadInt64( "D1000" ).Content;           // 读取D1000-D1003组成的long数据
            ulong ulong_D1000 = melsec_net.ReadUInt64( "D1000" ).Content;        // 读取D1000-D1003组成的long数据
            double double_D1000 = melsec_net.ReadDouble( "D1000" ).Content;      // 读取D1000-D1003组成的double数据
            string str_D1000 = melsec_net.ReadString( "D1000", 10 ).Content;     // 读取D1000-D1009组成的条码数据

            // 读取数组
            short[] short_D1000_array = melsec_net.ReadInt16( "D1000", 10 ).Content;         // 读取D1000的short值 
            ushort[] ushort_D1000_array = melsec_net.ReadUInt16( "D1000", 10 ).Content;      // 读取D1000的ushort值
            int[] int_D1000_array = melsec_net.ReadInt32( "D1000", 10 ).Content;             // 读取D1000-D1001组成的int数据
            uint[] uint_D1000_array = melsec_net.ReadUInt32( "D1000", 10 ).Content;          // 读取D1000-D1001组成的uint数据
            float[] float_D1000_array = melsec_net.ReadFloat( "D1000", 10 ).Content;         // 读取D1000-D1001组成的float数据
            long[] long_D1000_array = melsec_net.ReadInt64( "D1000", 10 ).Content;           // 读取D1000-D1003组成的long数据
            ulong[] ulong_D1000_array = melsec_net.ReadUInt64( "D1000", 10 ).Content;        // 读取D1000-D1003组成的long数据
            double[] double_D1000_array = melsec_net.ReadDouble( "D1000", 10 ).Content;      // 读取D1000-D1003组成的double数据

写入的操作:

            MelsecMcNet melsec_net = new MelsecMcNet( "192.168.0.100", 6000 );

            // 此处以D寄存器作为示例
            melsec_net.Write( "D1000", (short)1234 );                // 写入D1000  short值  ,W3C0,R3C0 效果是一样的
            melsec_net.Write( "D1000", (ushort)45678 );              // 写入D1000  ushort值
            melsec_net.Write( "D1000", 1234566 );                    // 写入D1000  int值
            melsec_net.Write( "D1000", (uint)1234566 );               // 写入D1000  uint值
            melsec_net.Write( "D1000", 123.456f );                    // 写入D1000  float值
            melsec_net.Write( "D1000", 123.456d );                    // 写入D1000  double值
            melsec_net.Write( "D1000", 123456661235123534L );          // 写入D1000  long值
            melsec_net.Write( "D1000", 523456661235123534UL );          // 写入D1000  ulong值
            melsec_net.Write( "D1000", "K123456789" );                // 写入D1000  string值

            // 读取数组
            melsec_net.Write( "D1000", new short[] { 123, 3566, -123 } );                // 写入D1000  short值  ,W3C0,R3C0 效果是一样的
            melsec_net.Write( "D1000", new ushort[] { 12242, 42321, 12323 } );              // 写入D1000  ushort值
            melsec_net.Write( "D1000", new int[] { 1234312312, 12312312, -1237213 } );                    // 写入D1000  int值
            melsec_net.Write( "D1000", new uint[] { 523123212, 213,13123 } );               // 写入D1000  uint值
            melsec_net.Write( "D1000", new float[] { 123.456f, 35.3f, -675.2f } );                    // 写入D1000  float值
            melsec_net.Write( "D1000", new double[] { 12343.542312d, 213123.123d, -231232.53432d } );                    // 写入D1000  double值
            melsec_net.Write( "D1000", new long[] { 1231231242312,34312312323214,-1283862312631823 } );          // 写入D1000  long值
            melsec_net.Write( "D1000", new ulong[] { 1231231242312, 34312312323214, 9731283862312631823 } );          // 写入D1000  ulong值

这里举例了三菱的PLC,实际上各种PLC的操作都是类似的。

 Redis实现


 

除了上述的基本的设备通信,还实现了redis数据库读写操作,分了两个类实现,下图为一般的通信功能

同时demo中实现了一个浏览redis服务器的界面功能

redis

 

最后的总结


 本通信库实现了.net 3.5 和 .net 4.5的框架,还附带了一些简单的控件,此外还实现了.net standard版本,已在linux测试成功,由于官方在.net core2.2中还未实现串口类,所以暂时没有实现串口相关的。

未来的方向,希望继续优化代码,架构,集成实现更多设备通信,方便广大的网友直接开发测试。

 

开源地址:https://github.com/dathlin/HslCommunication

官网:http://www.hslcommunication.cn/

 更多详细的内容请查看源代码的readme文件。

 

posted @ 2019-02-17 10:27  dathlin  阅读(...)  评论(... 编辑 收藏