前言:
之前的博客介绍了如何用C#来读写modbus tcp服务器的数据,文章:http://www.cnblogs.com/dathlin/p/7885368.html
当然也有如何创建一个服务器文章:http://www.cnblogs.com/dathlin/p/7782315.html
但是上面的两篇文章是已经封装好的API,只要调用就可以实现功能了,对于想了解modbus tcp的原理的人可能就不适合了,最近有不少网友的想要了解这个协议,所以在这里再写一篇介绍Modbus tcp的文章,不过这篇文章是简易版本的,未来我再研究深入的话,再开一篇高级版,在简易版中,就略去了成功标志位及其他数据标志,这些到等到后面再说。
先分享一下,我自己学习的地址来源:http://blog.****.net/thebestleo/article/details/52269999 声明:本文并非转载,并非照搬原文章,是在我参照原博客的基础上,理解了基本的modbus通讯,并结合自己的理解,重新写一篇更好入门的文章,此处贴出原作者的帖子以示尊重知识产权,原文章有些地方有一点错误,而且早就停止更新了,也没有提供方便的测试工具,官方的modbus 测试工具是试验版本的,需要购买序列号才可以,所以此处提供我自己的测试工具,地址如下,下面的介绍的例子都是基于这个工具来实现的。
https://github.com/dathlin/ModBusTcpTools/raw/master/download/download.zip
关于该测试工具也是开放源代码的,如果想要查看源代码:https://github.com/dathlin/ModBusTcpTools
联系作者及加群方式(激活码在群里发放):http://www.hslcommunication.cn/Cooperation
准备条件:
在上面的测试工具下载之前,需要一些额外的知识补充,此处不管你是学习什么语言的,对于socket通信层来说,其实是一样的,下面的讲解的内容是直接基于底层的,无关语法的操作。
但是需要你对字节概念非常清晰,一般都是byte数组,一个byte有8个位,这个也要非常的清晰,如果连byte是什么都搞不清楚,那么对本文下面的内容理解会非常的吃力,那么还是建议你再看看计算机原理这些书,对于socket通信,每种语言都有不同的写法,但是所有的语言都有两个共同点,都能实现把数据发送到socket和从socket接收数据,至于这个如果去做,就参照你自己需要使用的语言了,此处不做这方面的说明了。
关于十六进制文本,在本文的下面的内容上,所有的byte字节数组都表示成十六进制形式,比如 FF 10 代表2个字节,一个是255,另一个是16。
byte[] temp = new byte[2]; temp[0] = 0xFF; temp[1] = 0x10;
如果将上述的temp看作是读取到的线圈的数据,那么转换规则如下:
先将上述数据转化成二进制 : 1111 1111 (第一个byte,我们从高位写到地位) 0001 0000 (第二个byte,我们从高位写到地位)
对应的线圈就是,线圈7-线圈0,,,,第二个byte对应的线圈是,线圈15-线圈8 这里一定要好好理解,从byte上来说,temp[0]是地位,temp[1]是高位,深入到每个byte里面的二进制,高位在前,低位在后。
在C#里等同于下面的代码,和C语言,java也是非常的相近,还算比较好理解。
如果我说,发送 00 00 00 00 00 06 FF 01 00 00 00 01 到socket上去,那么也就是:
byte[] temp = new temp[12]; temp[0] = 0x00; temp[1] = 0x00; temp[2] = 0x00; temp[3] = 0x00; temp[4] = 0x00; temp[5] = 0x06; temp[6] = 0xFF; temp[7] = 0x01; temp[8] = 0x00; temp[9] = 0x00; temp[10] = 0x00; temp[11] = 0x01; socket.Send(temp);
先不要管上面的数据是什么含义,知道上面的代码是啥含义就行了。接下来就是下载上面的测试工具,开始真正的学习modbus tcp协议了!
测试工具初始化
先运行Server.exe文件,端口里输入502,然后点击启动服务即可,如下:
如果你还是觉得麻烦,可以使用我的云服务器的modbus,IP地址为 118.24.36.220 端口为 502 不一定保证可以访问,如不可以访问,请耐心等待,
然后运行Client.exe程序,在Ip地址里输入127.0.0.1,端口里输入502,点击配置即可,我们看到,如果你的服务器程序运行在了别的电脑上,甚至是云端,只要客户端的ip修改成服务器的ip,端口号对应上,就可以访问到服务器的数据了。
特殊测试不用去管,和我们现在学习的东西不一致。
功能码详细解释
对于modbus来说,涉及的功能码也就是0x01,0x02,0x03,0x05,0x06,0x0F,0x10了,其实分类来说,就只有两种,线圈和寄存器,也就是位读写和字读写,首先需要清楚的是功能码不一样,对应数据的解析规则也不一样,下面就针对不同的情况来说。
首先说明的是,modbus协议呢,最终目的还是为了实现数据交互,既然是数据交互,那就是包含了数据读和写,我们把我们的想法转化成一串数据,发送给设备(或者叫服务器),它返回一串数据,根据规则解析出来,这样就得到了我们真正想要的数据。下面就来第一个想法实现吧。
另外,在modbus服务器端,数据是使用地址的方式来公开的,这很好理解,服务器端保存了很多数据,你想要访问某个数据肯定需要指定唯一的身份标识,从连续的地址来区分数据是最常用的做法,不仅好理解,还便于扩展,比如你还可以读取连续地址的数据块。如果采用字符串名字来标识数据,就没有这个特点。
对于位操作来说(各种线圈和离散量),一个地址代表了一个bool变量,即 0 和 1,要么通要么断,就好比一些普通的开关。
对于寄存器来说,一个地址代表了2个byte,共有65536种方式,可以满足大多数日常使用了,比如我们读取地址0的寄存器,返回 00 00 及代表寄存器0数据为0,如果返回 01 00 ,那么代表寄存器0数据为 256
功能码0x01:
我不直接上一串数据,这样看着也累,我们从例子出发,现在我们需要读取线圈(离散量)操作,我想读取地址0的线圈是否是通还是断的。我们有了这个功能需求后,就可以根据需求来写出特殊的指令了。根据协议指定,需要填写长度为12的byte数组
byte[0] byte[1] byte[2] byte[3] byte[4] byte[5] byte[6] byte[7] byte[8] byte[9] byte[10] byte[11]
byte[0] byte[1] : 消息号---------随便指定,服务器返回的数据的前两个字和这个一样
byte[2] byte[3] :modbus标识,强制为0即可
byte[4] byte[5] :指示排在byte[5]后面所有字节的个数,也就是总长度-6
byte[6]: 站号,对于TCP协议来说,某些情况不重要,可以随便指定,对于rtu及ascii来说,就需要选择设备的站号信息。
byte[7] : 功能码,这里就需要填入我们的真正的想法了
byte[8] byte[9] :起始地址,比如我们想读取地址0的数据,就填 00 00 ,如果我们想读取地址1000的数据,怎么办,填入 03 E8 ,也就是将1000转化十六进制填进去。
byte[10] byte[11] :指定想读取的数据长度,比如我们就想读取地址0的一个数据,这里就写 00 01,如果我们想读取地址0-999共计一个数据的长度,就写 03 E8。和起始地址是一样的。
有了上面的格式之后,接下来我们就按照格式来填写数据吧,我们需要读取地址0的数据,那么指定如下
00 00 00 00 00 06 FF 01 00 00 00 01
消息号设为0,站号FF,功能码01,地址01,长度01:将上面的指令在客户端程序里进行输入,点击发送,这样就在下面的响应框里接收到服务器反馈的数据,我们最终需要的信息就在反馈的数据里了。
前面是接收到数据的时间,自动忽略,那么返回的数据就是 00 00 00 00 00 04 FF 01 01 00 共计10个字节的数据,ok,这玩意到底是什么意思呢,我们来分别解析下:
byte[0] byte[1] : 消息号,我们之前写发送指令的时候,是多少,这里就是多少。
byte[2] byte[3]: 必须都为0,代表这是modbus 通信
byte[4] byte[5]: 指示byte[5]后面的所有字节数,你数数看是不是4个?所以这里是00 04,如果后面共有100个,那么这里就是 00 64
byte[6]: 站号,之前我们写了FF,那么这里也就是FF
byte[7]: 功能码,我们之前写了01的功能码,这里也是01,和我们发送的指令是一致的
byte[8]: 指示byte[8]后面跟随的字节数量,因为跟在byte[8]后面的就是真实的数据,我们最终想要的结果就在byte[8]后面
byte[9]: 真实的数据,哈哈,这肯定就是我们真正想要的东西了,我们知道一个byte有8位,但是我们只读取了一个位数据,所有这里的有效值只是byte[9]的最低位,二进制为 0000 0000 我们看到最低位为0,所以最终我们读取的地址0的线圈为断。
假设我们读取地址10,开始的共10个线圈呢,那么会返回什么?所以我们发送 00 00 00 00 00 06 FF 01 00 0A 00 0A
我们接收到了:00 00 00 00 00 05 FF 01 02 79 01 前面的8个字节的信息参照上面的分析,是一致的,我们就针对后面三个字节着重分析。我们读取了10个位,那么一个字节可以表示8个位,那么我们的结果至少需要2个byte才能表示完,所以最终的数据肯定是2个字节,那么02就是后面的字节数量,也就是真实的数据长度。
要想从 79 01 数据中分析出我们真实想要的数据,还需要经过最后一次数据转换。先转为二进制:
0111 1001 0000 0001
第二步:按每八位进行分割,上述其实已经分割好了,中间空格多的是分割,以字为单位,将二进制顺序颠倒:
1001 1110 1000000
第三步:最终数据就是 线圈10-线圈19的通断情况是:通,断,断,通,通,通,通,断,通,断 再后面的0都是无效的
至此我们获取到了我们最终的数据!因为此处服务器都是0,所以所有的线圈都是断,等会可以结合05功能码写线圈进行联合测试。
功能码0x02:
这个功能码和上面的一致,在本服务器里不支持这个功能码。发送和解析规则和上面的一致,不再赘述。
功能码0x05:
我们先讲解05功能码,这个功能码是实现数据写入,它能实现什么功能呢,我们可以利用这个功能码来指定某个线圈通或断,具体怎么操作呢,有了之前01功能码的经验,下面的代码看起来就顺利多了。
比如我要指定地址0的寄存器为通: 00 00 00 00 00 06 FF 05 00 00 FF 00 前面的含义都是一致的,我们就分析 05 00 00 FF 00
05 是功能码, 00 00 是我们指定的地址,如果我们想写地址1000为通,那么就为 03 E8,至于FF 00是规定的数据,如果你想地址线圈通,就填这个值,想指定线圈为断,就填 00 00 ,其他任何的值都对结果无效。
然后我们看看写入的操作服务器返回了什么 ? 我们看到也是 00 00 00 00 00 06 FF 05 00 00 FF 00 因为在你写入的操作中,是不带读取数据的,所以服务器会直接复制一遍你的指令并返回。
下面再举例一些方便理解(我们只需要指定地址及是否通断的情况即可):
写入地址100为通: 00 00 00 00 00 06 FF 05 00 64 FF 00
写入地址1000为断:00 00 00 00 00 06 FF 05 03 E8 00 00
功能码0x0F:
我们已经实现了0x05来单个的线圈写入,我们可以指定线圈100为通,其实就两个信息需要指定,线圈地址是什么,通还是端,然后我们就可以自然而然的写出指令码了,但是现在我们需要实现一个功能时,将地址0-999共计1000个线圈全部为off,这怎么搞?
按照我们之前的经验,可以发送一千次的0x05功能码的指令来实现,大不了写1000次么。。。。。(写到第100次的时候估计已经吐血了)
所以我们就继续研究有没有其他的功能码来实现,突然发现0x0F这一个神奇的功能码,这个功能码是什么意思呢,就是为了批量写入而存在的,就比如上面的例子0-999都为off,那么指令是什么呢。
00 00 00 00 00 84 FF 0F 00 00 03 E8 7D ...(后面跟125个byte,都是00)
上面的指令就实现了我们的需求,现在来详细解释下,它怎么就实现了我们的需求。分析之前,我们发现不同的功能码,的前8个字节的规律是一模一样了,都是标识号+modbus号+长度+站号,后面基本是跟地址和长度,或是直接是地址和数据。
00 00 消息标识号,随便写什么,反正你写什么数据,服务器就复制一遍而已。
00 00 modbus标志号,都是00 就对了。
00 84 我们先转化为十进制,0x0084转化十进制就是132,也就是说,00 84(不包含00 84)后面跟了132个字节
FF 站号,其实也是随便写,反正服务器返回一样的
00 00 起始地址,此处就是0,如果起始地址为100,那么就写00 64,如果起始地址为1000,那么就写03 E8
03 E8 我们需要写的数据长度,因为我们需要写1000个线圈,就是03 E8,如果我们写999个线圈,那么就是03 E7。
7D 这个字节代表后面跟随的真实写入的数据的长度,为125个字节。
125个字节 真实的数据,我们写1000个位,那么一个字节为8位,那么刚好125个字节可以塞完数据,那么问题来了,如果我们想实现000-998共计999个地址都是off。那怎么搞。
那么指令为 00 00 00 00 00 84 FF 0F 00 00 03 E7 7D ...(后面125个byte,都是00)咦,怎么还是125个,原来无论写多少个,比如x个,如果是8的倍数,刚好x/8个byte,如果除不尽怎么办,就是x/8+1个字节,这样才能装满我们需要写的数据。
既然后面都是125个字节,那么写1000个还是999个,那么区分的关键就在于长度,03 E8还是03 E7。
大致的数据在上面已经说明了,具体怎么写数据看下面,比如我们写入地址10-地址19共计10个长度的线圈,要求的结果分别是,On,Off,Off,On,On,On,On,Off,On,Off,也就是 通,断,断,通,通,通,通,断,通,断,接下来转换0和1,如下:
1001111010
接下来就是关键了,怎么转化成真实的byte,这样我们就可以最终写出来指令了。
第一步:以8个8个为单位进行切割,结果为 10011110 10
第二步:第一步的字单位,每个单位前后顺序颠倒,不然不足8位,前面补零,结果为 0111 1001 0000 0001
第三步:这下可以写成真实的数据了,79 01
那么最接下来我们就可以写最终的指令了,实现写入地址10-19为:通,断,断,通,通,通,通,断,通,断 也即 00 00 00 00 00 09 FF 0F 00 0A 00 0A 02 79 01 (地址10-19的线圈分别为 通,断,断,通,通,通,通,断,通,断)
注意上述第二步为什么要顺序颠倒,那是因为在计算机的单个byte存储中,高位在前,地位在后,而对于多个连续的byte来说,地位在前,高位在后,所以需要颠倒,如果还是不明白,就先死记,终有一天会恍然大悟。
现在应该能实现任何的连续线圈的写入了吧。
写入之后,看看了服务器返回了什么:
00 00 00 00 00 06 FF 0F 00 0A 00 0A :现在再来看这个数据就很简单了,就是返回了我们写入数据的前12分字节,然后把00 09长度更改为实际的长度 00 06,因为是写入操作,所以返回的数据没什么意义。
功能码0x03:
该功能码实现寄存器的数据读取,我们需要知道的是,一个寄存器占2个byte,而且是高位在前,地位在后,那么如果寄存器0的数据为1000,那么我们读取到的数据就是03 E8,这是我们最终想要的东西,03功能码和01功能码很接近,就是功能码替换一下,返回的数据解析不一样而已,比如我们需要读取地址0的寄存器数据:
00 00 00 00 00 06 FF 03 00 00 00 01 是不是很熟悉?,当你看到这个的时候,脑力里马上就是功能码03,读取寄存器,地址0,长度1 返回如下:
00 00 00 00 00 05 FF 03 02 03 E8 主要就是看功能码后面的数据了,我们想要的真实数据肯定藏在后面,也就是 02 03 E8,不是说一个寄存器返回2个字节嘛,怎么就变成3个了?事实上第一个字节不是代表数据,而是代表后面的字节长度是2个字节,那么03 E8就是真实的数据了,代表了寄存器0存储了1000这个数据。
未完待续...
上述的说明有点问题,再经过了一段时间的深入理解,包括了modbus-tcp和modbus-rtu,还有modbus-ascii 重新整理
2019年1月22日 20:03:13 重新说明!
如果要讲解modbus协议,首先要清楚什么是协议,通常来说,协议是用来实现数据交流的,再通常的说,人与人之间的聊天也是基于一个协议的,当一个人和你说某某地点有金矿,叫你去挖金矿的时候,就发生了数据信息的传播,你就知道对方的意思了,你为什么能理解呢?因为两者遵循同一种协议,这个协议就是中文,如果另一个人和你说英文,你就听不懂了,这个是协议不通,如果你懂好几种语言,那么你就兼容很多种协议。
如果要深入的将协议,那么必须要有载体,协议不是凭空存在的。是需要有载体的,载体是什么?比如语言是协议的话,喉咙是发声音的载体,是用来发送数据的,而耳朵是用来接收数据的载体。那么计算机协议的载体是什么?就是发送数据流和接收数据流,我们知道计算机的世界都是二进制的,那么载体之上都是Byte数组。
对于TCP来说,socket就是载体,可以用来发送和接收byte数组,对于串口来说 serialport就是载体,也可以用来发送和接收数据。上面说到了,协议是用来信息交流的,计算机的Byte数组怎么用来信息交流呢?答案就是协议。
举个例子,在socket上可以随意的发送byte数组,接收byte数组。如果我们要对方给我发一个数据,0-100的一个数组,对方可以发 0x64 过来,就是100,那么100这个地址的含义是什么?这个就要双方的约定啦,我们双方都规定这是温度,那么这就是温度,如果对方要给我们发送一个双字节的数据,那么100是 00 64 还是 64 00 呢?这个还是要双方规定的,如果有一方已经制定了规则,我们就根据对方的规则就行了。
我们在升级下例子,如果你是设备,你要支持别人来你这读取数据,如果你的数据只有一个,比如0-100,那么对方想你请求数据时,你直接回复一个字节就可以了。但是通常你的数据有很多,几百个,几千个,甚至上万个,总不能一次性都给对方吧,这个数据是 00 64 64 64 64 64 。。。。然后你得做一张表说明每个数据是啥意思,不然别人不理解呀。别人可能只要其中的一个数据而已,却给了对方一堆没用的。所以我们需要引入一个地址的概念,我们给所有的数据排排队,我们规定一个地址对应一个字节,那么10000个数据,就是0-9999的地址,对方请求数据前至少要告诉我地址信息,如果给我发 00 64 ,我就知道对方是要地址100的数据,我们就给他返回地址100的数据。但是呢,如果想读2个数据呢?我们在协议中再加入一个长度的概念,对方想读数据的时候,除了告诉我地址,还应该告诉我长度,这个长度应该是2个字节的,这样范围就比较大了,也比较灵活。我们规定 00 64 00 02 这个信息就是读取地址100开始2个地址的数据。然后我们给他返回2个字节。
上面的例子已经充分说明了一些情况,如果再考虑的复杂点,如果对方请求了不存在的地址怎么办?请求长度太长怎么办?我们需要引入返回错误码的机制,我们可以规定返回第一个字节是错误码,为0 ,后面才跟着正确的数据,如果我们想支持对方写入数据,这时候就要引入功能码的概念了,如果我们想支持总线,就是多设备的组网,那么就要引入站号的概念了。至此,modbus协议诞生了。
首先,这个协议规定了数据地址,数据都是存储在内存里的,数据地址就好比是内存地址,一个地址对应了2个字节的数据,还规定了高地位,比如1000数据,是 03 E8 ,那么有人就会问了,那么一个4字节的数据,在modbus协议怎么存储的呢?比如float浮点数,答案是:modbus协议根本没有规定foat的存储规则。无非就是占用2个地址而已,服务器爱怎么存就怎么存,所以其他的数据类型就要具体服务器具体分析了。
比如 01 03 00 64 00 01 就是标准的modbus协议,规定了一个字节是站号,用来总线里区分设备的,第二个是功能码(要不然怎么知道是读数据还是写数据呢?),第三和第四是地址,2个字节的范围是0-65535,第五和第六字节是长度,也是0-65535,到此为止是不是基本涵盖了你所有想读的数据?这真是一个很精简的协议了,简单明了。