1简述,modbus是一种工业用的多设备之间的主从通信协议。只要两台设备之间,是采用modbus协议的主从关系,并连接到相同网络,即可互相通信。因为Modbus只是协议,而且只规定了数据帧,底层连接,可以是232,485或者以太网。设备一般采用232和485进行通信,因为成本低。当然要是考虑远距离传输和多卖钱的话,也会采用以太网,不过应该就会相应复杂一些了。
2模式,modbus有两种模式,一种叫RTU模式,另一种叫acsii模式,RTU模式是纯二进制的,而acsii模式,一个信息中的每8位字节作为2个ascii字符传输的,这种模式的主要优点时允许字符之间的时间间隔长达1秒,也不会出现错误。而较acsii模式,RTU模式的优点是用最少的字节,表达更多的内容。但同时也要求设备必须连续传输。
3通讯,modbus属于主从通讯,可以是一主一从或者一主多从。通讯的方式为主机向从机发送命令(或者叫请求)从机向主机发送响应。主机不发送,从机不返回,一发,一收,不发不收。而且一个时间,只有一个机器发送请求或者响应,否则的话,则会出错。
4信息帧,由于项目上没有涉及到acsii模式,所以本文只讨论RTU模式,不讨论acsii模式,以后如果要是用的上,肯定会继续讨论。用不上,就不讨论了。RTU帧,开始时,必须要有3.5个静止的时间,也就是时间间隔,用来区分上一帧和下一帧,如果没有时间间隔的话,则会分辨不出哪里是帧开始,哪里是帧结束了。3.5个时间间隔依据波特率不同而不同。同样,结束时也需要时间。除了时间以外,还有地址,功能码,数据,crc校验四个部分,每个部分的字节数不同,地址功能码各1个字节,crc是2个字节其完整表达如下:
开始 地址 功能 数据 校验 结束 3.5t 1字节 8b 1字节 8b n字节 n*8b 2字节16b 3.5t
4.1、地址:主要用于区分从机,在下位机程序中,的宏定义中设置不同的从机地址。
#define Modbus_addr0x01
设备响应时,第一位也是本机地址。地址的范围是从0-247,地址0为广播地址,所有机器均可以识别。
4.2、功能码:表示主机要命令这个设备的什么功能,执行什么程序。我看了一下正规的modbus的功能码多达24个,不同厂家生产的不同型号的设备,可能会支持不同的功能码,所以买之前需要注意一下。具体功能如下:
01 读线圈状态 02 读输入状态 03 读保持寄存器 04 读输入寄存器 05 强制单个线圈
06 预置单个寄存器 07 读不正常状态 08 诊断 09 程序484 10 查询484
11 通讯事件控制 12 通讯事件记录 13 程序控制器 14 查询控制器 15 强制多个寄存器
16 预置多个寄存器 17 报告从机id 18 程序884/M84 19 通讯链路复位 20 读通用参考值
21 写通用参考值 22 Mask Write 4X Register 23 Read/Write 4X Registers 24 Read FIFO 队列
虽然看着功能很多,但实际上有用的,只有01 02 03 04 05 06 15 和16功能码。
4.3、数据区,根据功能码的不同数据的长度是不同的。
4.4、crc校验 包含两个字节,发送端发送时,一帧的所有数据统一计算出一个crc校验码,然后加在一帧的最后两位中,然后等到发送到接收端时接收端重新计算一次除最后两位的一帧所有数据,然后根据两个数据的对比,来判断接收到的数据是否正确。
5、程序,以下位机为程序对象,主要使用c语言编写,首先,先从变量入手,既然modbus接受以帧为单位,所以就要设置两个缓冲区,用来接收数据,我们这里使用数组来存储接收来的数据Modbus_send_buf[Modbus_max_send_buf];//数据发送缓冲 和 Modbus_recevie_buf[Modbus_max_recevie_buf];//数据接收缓冲 ,其中Modbus_max_send_buf,和Modbus_max_recevie_buf ,为宏定义,这样可以方便的修改一帧最大的存储数据。有了发送接收缓冲,就可以写中断函数了,进入中断后,首先做一些必要的工作,清ES ,判断IR,清IR,做完后,就可以开始接收数据了,但有个问题?如果设备处于空闲状态,那么接收数据后按命令执行,但如果当设备正在执行指令的时候,则不应该再继续的接收指令,那样的话,会让程序进入混乱状态。所以要在基础工作做完后,增加一个判断,来确定设备的忙闲。if((Modbus_cmd_flag == 0) && (Modbus_exe_flag == 0)),判断完以后就可以继续下面的工作了。如果通讯中包含奇偶校验的话,那么则判断奇偶校验。下面就是接收数据。Modbus_recevie_buf[Modbus_recevie_count] = SBUF; ,将接收来的数据存入数组并记录存入的数据个数Modbus_recevie_count,由于modbus是通过时间来判断一帧的结束的,所以在程序中,必须要有一个定时器函数,这个定时器用来判断程序是正在接受,还是已经接受完成了。所以中断的最后所做的是计数器自加Modbus_recevie_count++;,定时器清0 Modbus_timeout_cnt = 0; ,将设备状态转入接收状态Modbus_recevie_flag = 1;。此时,串口中断的工作就完成了。
下面开始分析定时器,定时器的目的其实就1个,判断一帧是否接收完毕,如果完毕,则进入下一步。在定时器中断函数中,首先要对定时器值进行初始化,这个就不多说了,然后是判断程序是否处于接受状态if(Modbus_recevie_flag == 1),这个状态只有在串口中断函数中才会被置位,其他的情况不会被置位。若程序不是接收状态,则直接跳出定时器中断,若程序处于接收状态,则定时计数自加Modbus_timeout_cnt++;,自加后进入判断if(Modbus_timeout_cnt >= Modbus_max_timeout_cnt),判断的值即为modbus接收一帧传输完成所需要的时间间隔。至于是多少时间,可以通过修改Modbus_max_timeout_cnt来确定。可以将定时器终端设置为1ms1次,在9600的情况下将超时时间设为4,#define Modbus_max_timeout_cnt 4,这样如果串口中断不在接收数据时,定时计数将不会清0,当到达设定的超时时间后即判断接收结束,转向命令解析状态。
接收来的数据可以经过一个函数来执行,同时也可以经过两个函数,解析与执行两步来分别执行。我喜欢后者,因为这样可以把解析的过程和执行的过程分开来写。程序显得更加清晰与明朗。
在主函数中就执行1个函数,
while(1)
{
Modbus_proc();
}
这个函数是经过打包的两个函数,进入这个函数
void Modbus_proc()
{
Modbus_cmd();
Modbus_exe();
}
可以看到,程序分为cmd解析,exe执行。
Cmd 命令解析函数
有这么几个问题是需要判断的,命令解析状态,接收来的数据个数,crc,地址,这几个问题是命令解析时需要注意的,顺序可以稍做变化。但最好是这个顺序。
首先判断程序是否处于命令解析状态if(Modbus_cmd_flag == 1)。命令解析状态标志只有在超时后置位,其他情况下不置位。之后是判断接收数据是否大于4字节,if(Modbus_recevie_count > 4)。当程序接收数据小于4字节则说明接收发生错误,抛弃它。下一步则是判断crc校验,由于crc在一帧的最后两位,所以crc应该取缓冲的最后两位
modbus_crc_h=Modbus_recevie_buf[Modbus_recevie_count-2];
modbus_crc_l = Modbus_recevie_buf[Modbus_recevie_count-1];
然后将取来的数据合并成一个16位数据,得到接收的crc
modbus_crc = ((unsigned int)(modbus_crc_h) << 8) | modbus_crc_l;
重新计算1帧的crc,得到自己的crc
modbus_crc_b = crc16(Modbus_recevie_buf,Modbus_recevie_count - 2);
最后进行对比,将自己算的crc和接收的crc进行比较,来判断接收的数据是否正确。
if( modbus_crc_b == modbus_crc )
在crc判断正确后,就可以判断地址了
if(Modbus_recevie_buf[0] == Modbus_addr) // Modbus_addr为一个宏定义的本机地址,若多机可以在此处修改。
当地址,crc,等全判断正确以后,就可以判断最重要的功能码了。由于功能码很多,所以1可以用宏定义来定义功能码增加程序的可读性,2可以利用switch来命令的模式
#define Modbus_read_coil0x01//功能码01 读可读写数字量寄存器(线圈状态):
switch (Modbus_recevie_buf[1])
{
case Modbus_read_coil:
Modbus_mode = Modbus_read_coil;
break;
……
default: //非法命令准备报异常
return ;
break;
}
Modbus_exe_flag = 1;
解析后,将执行标志置位即可。
Exe 执行函数,
执行函数在解析函数后面,而不是在里面,所以,若没有解析,照样可以进入执行函数,但由于执行函数中有判断执行标志位if( modbus_crc_b == modbus_crc ),所以若标志为0,则直接退出函数。若标志为1,则执行Modbus_mode中对应的函数函数中依然用switch来选择具体功能函数
switch(Modbus_mode) //通过判断模式来进行对响应的发送
{
case Modbus_read_coil:
read_coil_proc();
break;
……
default:
return;
break;
}
这样的做的话,就可以吧解析函数,执行函数和具体的实施函数分开来弄,层次多多少少要清晰一些
下面就是针对01,02,03,04,05,06,15,16几个功能码的执行及返回进行说明
在说明各功能函数之前,先说说响应。
上面说的那两个函数只不过是对一帧的外围进行解析与判断,至于具体的参数,还需要功能函数去解析与返回,功能函数要做的事情有3个,1个是参数的解析,2是执行,3是返回响应。
先说响应,响应是有特点的,第一个字节肯定是自己的本机地址,第二个字节肯定是功能码,最后两个字节肯定是crc校验,所以说,在发送缓冲中,基本上4个字节已经定死了
Modbus_send_buf[0] = Modbus_addr;
Modbus_send_buf[1] = Modbus_read_input_reg; //相应的功能码,每个功能寒暑都不一样
再经过执行函数最后算crc
modbus_crc = crc16(Modbus_send_buf,temp); //计算发送crc数据
Modbus_send_buf[temp] = modbus_crc >> 8; //计算
temp++;
Modbus_send_buf[temp] = modbus_crc & 0xff; //returnnum 高位
5.101 读线圈状态
#define Modbus_read_coil0x01
其实表面上挺难理解的,啥线圈啥的,但你仔细看看就可以了解,就是读输出数字量,如果你写下位机的话,其实就是控制读取输出io,说白了,就是把目前的io输出状态返回给主机。这些io连接的可能是继电器,也可能是一些开关之类的东西,也就是些数字信号。读数字输出信号。
计算机发送命令:[设备地址] [命令号01] [起始寄存器地址高8位] [低8位] [读取的寄存器数高8位] [低8位]
设备响应:[设备地址] [命令号01] [返回的字节个数][数据1][数据2]...[数据n][CRC校验的低8位] [CRC校验的高8位]
简单的说就是返回所有的输出io的值,放在一个或者几个字节里,可以用判断的方法来实现,当然,也可以用与或的方式实现。
if(P1_0 == 1)
{
temp |= (1<<8);
}
else
{
temp &= (1<<8);
}
将temp的值放入第四个缓冲区,当然这根据设备的io口,编程时就已经确定了的。接下来就可以进行crc计算了。最后发送即可。
Modbus_send_buf[3] = temp;
modbus_crc = crc16(Modbus_send_buf,4);
Modbus_send_buf[4] = modbus_crc >> 8;
Modbus_send_buf[5] = modbus_crc & 0xff; //returnnum 高位
5.202 读只可读数字量寄存器(输入状态)
基本上和01意思差不多,只不过这个功能码返回的数据是输入io的数据,和01的区别是01可读可改,而02只可读不可改。也就是输入的状态。数据不可由设备本身控制。程序方面和01程序一样。
5.303读可读写模拟量寄存器(保持寄存器)
说简单点就是读da,da属于模拟量,也可以输出,但是以模拟量的方式来进行传输的
计算机发送命令:[设备地址] [命令号03] [起始寄存器地址高8位] [低8位] [读取的寄存器数高8位] [低8位] [CRC校验的低8位] [CRC校验的高8位]
设备响应:[设备地址] [命令号03] [返回的字节个数][数据1][数据2]...[数据n][CRC校验的低8位] [CRC校验的高8位]
其中返回字节个数,为读取寄存器数乘2
写程序时,首先要注意数据个数,temp = Modbus_recevie_buf[5];一般寄存器个数不会超过255,个数取读取寄存器个数的低八位即可。返回即乘2,temp = temp << 1;,下面要做的就是一个循环for(i = 0;i < temp ; i += 2),把需要的数据放入发送数组。其内容是
Modbus_send_buf[i+3]=(data_v&0xff00)>>8;
Modbus_send_buf[i+4]=data_v&0x0ff;
由于帧的前面3个是地址,功能码,和返回字节个数,所以循环从第四个数据开始存放。data_v为读取的数据,在程序中还需要其他语句配合。比如:data_v = updateValue();
循环后就可以进入crc校验了可以利用返回字节数来确定crc的校验个数temp = temp + 3;,最后计算发送字节的个数
send_cnt = Modbus_recevie_buf[5]*2 + 5 ; //数据发送个数 数据+地址+命令+返回数据个数+crc低+crc高
最后将数据发送出去即可。
5.404读只可读模拟量寄存器(输入寄存器)
和03的区别是04就是读ad,ad输入输入模拟两,只能读,不能改,同样也是以模拟两的方式来进行传输的。其程序 与03类似
5.505写数字量(线圈状态)
05则是修改io口输出状态,数字量输出。
计算机发送命令:[设备地址] [命令号05] [需下置的寄存器地址高8位] [低8位] [下置的数据高8位] [低8位] [CRC校验的低8位] [CRC校验的高8位]
设备响应:若执行成功,则原样返回
写程序时,首先确定需要修改的io口,然后根据0xff00或0x0000来置位或清零该数据位。执行完成后,将接收到的数据重新发送即可 Uart0_senddata(Modbus_recevie_buf,8);
5.606写单个模拟量寄存器(保持寄存器)
06为修改设备da数据,模拟量传输数据。
计算机发送命令:[设备地址] [命令号06] [需下置的寄存器地址高8位] [低8位] [下置的数据高8位] [低8位] [CRC校验的低8位] [CRC校验的高8位]
设备响应:若执行成功,原样返回即可
5.716主机设置寄存器
简单的说,就是一次设置多个da,以一个偏移量为准,一次设置多个输出模拟里量
计算机发送命令:[设备地址] [命令号10] [开始地址高8位] [低8位] [寄存器个数高8位] [低8位] [第一个寄存器数据高][第一个寄存器数据低][第二个寄存器数据高][第二个寄存器数据低]……[CRC校验的低8位] [CRC校验的高8位]
命令响应:功能码[0x10],寄存器起始地址高字节,低字节,要写的寄存器数量的高字节,低字节,CRC校验低字节,高字节
在程序中,首先要获取寄存器个数
num = Modbus_recevie_buf[6] - 2;
然后进入循环,一次把寄存器数据提取出来for(i = 0; i < num; i = i + 2)
在循环的内部提取数据temp = (((unsigned int)(Modbus_recevie_buf[i+7])<<8)|(Modbus_recevie_buf[i+8]));
以上就是我在项目中涉及到的一点modbus的通讯的下位机程序,不全,但总体的思路,接收数据并解析,解析后提取数据在设备上加载或采集,然后再按照响应的方式发送回去。
下回改进的方向,1,增加功能码2,增加宏定义及编译定义,3增加单片主机的程序,和pc主从机的程序。4,增加ascii的程序,和rtu同时设置。Pc机程序,采用c#号编写。