Modbus协议理解与嵌入式编程实现

前言

通讯协议是很实用的一个部分,是保证通讯可靠性的重要工具之一。有很多经典的通讯协议值得学习。
以我参加的一个比赛为例,当年有幸参加了Robomasters的第一届和第二届比赛。当时一辆步兵车的设计中,底盘电机需要每个配上一个控制器,主控为stm32;云台部分需要有一块芯片管理,采用的主控也是stm32;而在第二届比赛中,为了实现视觉的功能,需要能运行Linux系统的arm处理器来跑opencv。我当时主要编写的是电机控制程序,其他部分的程序由其他同学完成,而步兵车的主体控制程序在云台的控制板里面,因此,需要将云台控制板需要对其他部分发出指令,而其他部分也需要对云台控制器反馈数据。初期,由于新手没经验,我们是采用直接将数据指令发送出去,因此,自然出现了很多通讯的问题,同时,在此模式下,每个电机控制器和视觉模块需要专门配一个串口来传送数据。后面,我们逐渐加入了通讯协议,保证了通讯的稳定性,同时也节约了串口。
像485通讯、SCI通讯、SPI通讯这些通讯,本身只是一个通讯的硬件层面的实现。它们可以作为各种通讯协议的载体来进行。像modbus协议搭载到485这些通讯上的时候,主要作用就是起到两个,一个是包含了通讯的校验部分,确保数据的准确性,就是通讯传来的数据有校验码;一个是决定通讯的主机和从机之间的关系,决定哪台机子起到作用。
与此同时,CAN通讯、以太网通讯,硬件层面自身带有通讯协议,相对485等通讯而言比较可靠,可以建立在这些协议的基础上搭载自定义的通讯协议,如以太网的modbus-tcp协议等、can的canopen协议。
最近看到,matlab的工具箱有modbus函数,调试使用很方便。

modbus协议解析

modbus协议有三类,modbus-tcp,modbus-ascii,modbus-rtu等。在串口通讯中,一般用modbus-rtu较多。

modbus的主从架构-ID

百度百科上介绍的这一段说明了modbus的架构:
Modbus协议是一个master/slave架构的协议。有一个节点是master节点,其他使用Modbus协议参与通信的节点是slave节点。每一个slave设备都有一个唯一的地址。在串行和MB+网络中,只有被指定为主节点的节点可以启动一个命令(在以太网上,任何一个设备都能发送一个Modbus命令,但是通常也只有一个主节点设备启动指令)。
所以modbus每条指令都有ID编号,这样可以确定主机是对哪个从机进行读写操作

modbus的操作对象-功能码与寄存器

modbus中,制定的操作就是围绕了读写而来,而读写的对象就是寄存器。
modbus最初是为了plc设计开发,因此通讯指令的对象围绕的是线圈寄存器、保持寄存器这些为主,并在针对这些寄存器的读写操作,对应有很多功能码。
博文《modbus协议中的线圈、寄存器等的解释》介绍的很详细。
线圈寄存器:实际上就可以类比为开关量(继电器状态),每一个bit对应一个信号的开关状态。所以一个byte就可以同时控制8路的信号。比如控制外部8路io的高低。 线圈寄存器支持读也支持写,写在功能码里面又分为写单个线圈寄存器和写多个线圈寄存器。对应上面的功能码也就是:0x01 0x05 、0x0f
离散输入寄存器:如果线圈寄存器理解了这个自然也明白了。离散输入寄存器就相当于线圈寄存器的只读模式,他也是每个bit表示一个开关量,而他的开关量只能读取输入的开关信号,是不能够写的。比如我读取外部按键的按下还是松开。所以功能码也简单就一个读的 0x02
保持寄存器:这个寄存器的单位不再是bit而是两个byte,也就是可以存放具体的数据量的,并且是可读写的。一般对应参数设置,比如设置时间年月日,不但可以写也可以读出来现在的时间。写也分为单个写和多个写,所以功能码有对应的三个:0x03、0x06、0x10
输入寄存器:这个和保持寄存器类似,但是也是只支持读而不能写,一般是读取各种实时数据。一个寄存器也是占据两个byte的空间。类比通过读取输入寄存器获取现在的AD采集值。对应的功能码也就一个 0x04
数据:根据功能码规定好具体格式,主要有读写寄存器地址,数据个数,具体数据字节
总结上面的寄存器与功能码:
0x01: 读线圈寄存器
0x02: 读离散输入寄存器
0x03: 读保持寄存器
0x04: 读输入寄存器
0x05: 写单个线圈寄存器
0x06: 写单个保持寄存器
0x0f: 写多个线圈寄存器
0x10: 写多个保持寄存器

modbus的数据格式

写入或读写的寄存器值时,需要考虑支持的数据类型。
modbus支持的数据类型很多,8bit到32bit都有,包括字节、整型数、浮点数等类型,主要是需要上下约定一致。

modbus的校验部分

modbus采用的是crc校验,这种校验方式保证了传输数据的可靠性。由于bit数目的不同,crc校验也有crc4、crc16等多种。有博客《CRC校验》介绍了多种CRC校验方式。

modbus的主从应答

一般而言,通讯协议都是要求应答的,这样确保从机收到了主机的指令。如16指令:
在这里插入图片描述

modbus rtu与tcp的区别

modbus tcp里面不需要crc校验,因为它的载体tcp可靠性较高,传输的数据不需要额外的crc校验环节了,tcp本身就有校验。
此外,modbus tcp多了MBAP报文头
事务处理标识 2字节
协议标识 2字节
长度 2字节
单元标识符 1字节(这里就是前面modbus rtu协议帧里面的地址那一个字节)
mbap报文头后加上功能码这些,去掉crc校验,就和rtu一样了。

modbus调试工具

在网上很多调试工具,如modbus-poll,modbus-slave,mthings等,利用这些软件可以有助于检验编写的modbus程序的准确性。
其中,modbus-poll可以用于模拟主机,modbus-slave可以用于模拟从机,mthings可以用于模拟modbus的主机与从机。
在没有实际设备时,可以用:Virtual Serial Port Driver(VSPD)、Virtual Serial Port、Free Virtual Serial Ports等虚拟串口软件,模拟串口,这样利用电脑就可以调试基本的程序了。
顺便提一下,如果想制作自己的调试工具,网上可以寻找到许多modbus开源的库,如NMODBUS等,这时候要可以学习下上位机是如何制作了,c#,LabVIEW这些都是常用的上位机开发语言。

modbus从机代码实现-手写方式

数据接收

这是我以前写过的modbus协议的程序,运行在stm32上。程序是参照贴吧中别人分享的代码修改而成,只实现了部分指令。
此部分程序是属于从机,可以看有一个函数在对接收到的上位机发来的字节数据进行解析。一般通讯中,以中断方式接收数据,而在while(1)循环,通过标志位Uart1_rev_flag判断来解析数据,并回发给上位机,在解析中,关闭中断,以保证解析过程不被打扰。这一块解析程序也可以由RTOS开辟一个任务专门进行。

int main(void)
{	
	RCC_Configuration();
	Init_Config();		 
	SysTick_Init();	
	while(1)
	{

		if(Uart1_rev_flag == 0x01)
		{	
			Uart1_rev_flag = 0x00;//数据接收标志位  
			ParseRecieve8();//数据帧处理函数
			USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);//中断使能标志
		}
    }
}

通讯中断程序,通过定时器计数判断是否超时,以此来判断是否结束数据接收。当然了,这是通用的判断数据结束办法,如果是stm32,其实有一个专门判断超时的IDLE中断可以处理,这样可以省下一个定时器。这一版本代码参照了贴吧共享的代码,采用的是定时器方案,最后定时器中断中将接收完成标志位Uart1_rev_flag置位。

void USART1_IRQHandler(void)
{	
	if(USART_GetITStatus(USART1,USART_IT_RXNE)!=RESET)
	{
		USART_ClearITPendingBit(USART1,USART_IT_RXNE);
		if(Uart1_rev_flag != 1)
		{
			if(recenum < 12)//本例中数据有8位和16位两种
			{
				ReceBuf[recenum] = USART1->DR;
				recenum++;
				TIM_Cmd(TIM2, ENABLE);
				TIM_SetCounter(TIM2, 0);
				TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE);	
			}
		}

	}		
	if(USART_GetFlagStatus(USART1,USART_FLAG_ORE)!=RESET)
	{
		USART_ClearFlag(USART1,USART_FLAG_ORE);	
		USART_ReceiveData(USART1);			
	} 
}

void TIM2_IRQHandler(void)
{ 
   if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET)
   {
		TIM_Cmd(TIM2,DISABLE); 
		TIM_SetCounter(TIM2, 0);
		if(recenum >= 8)
		{
			Uart1_rev_flag = 1;
			usDataLen=recenum-2;
			USART_ITConfig(USART1, USART_IT_RXNE, DISABLE);
		}
		recenum = 0;
		TIM_ClearITPendingBit(TIM2, TIM_IT_Update);
		TIM_ClearFlag(TIM2, TIM_FLAG_Update);
	}
}

数据解析

通讯的解析程序如下,这一函数包含了CRC校验、功能码分析等部分。从机程序会先根据ReceBuf[0]判断从机编号,以此确定主机发送的数据是否针对自己。而后是进行数据校验、最后根据功能码来判断回发的数据。

void ParseRecieve8(void)
{	
	u8 crcDataHi;
	u8 crcDataLo;
	u8 count;
	u8 sendny;
	u8 statusnum;
	if(ReceBuf[0] == 0x01)   
	{	
		crcData = crc16(ReceBuf,usDataLen);
		crcDataLo = crcData/256; 
		crcDataHi = crcData%256; 
		if((crcDataHi == ReceBuf[7])&&(crcDataLo == ReceBuf[6]))
		{
			switch(ReceBuf[1])
			{
				case 0x02:
					switch(ReceBuf[3]) 
					{
						case 0x00://aotomodbus
							sendnum = 5+(ReceBuf[4] * 256 + ReceBuf[5])/8;
							sendny=(ReceBuf[4] * 256 + ReceBuf[5])%8;
							if(sendny) sendnum+=1;
							usDataLen = sendnum - 2;
							SendBuf[0] = ReceBuf[0];
							SendBuf[1] = ReceBuf[1];
							SendBuf[2] =sendnum-5;
							statusnum=SendBuf[2];
							for(count = 3;statusnum>0;statusnum--)
							{
								SendBuf[count] = 0x01;
								count++;
							}
							crcData = crc16(SendBuf,usDataLen);
							crcDataLo = crcData/256; 
							crcDataHi = crcData%256; 
							SendBuf[sendnum - 1] = crcDataHi;
							SendBuf[sendnum - 2] = crcDataLo;
							for(i = 0;i < sendnum;i++)
							{	
								USART_SendData(USART1,SendBuf[i]);
								while(!(USART1->SR & USART_FLAG_TXE));
							}
							sendnum = 0;
							break;
					}
					break;
				case 0x04:
					switch(ReceBuf[3]) 
					{
						case 0x0b://aotomodbus
							sendnum = 5+(ReceBuf[4] * 256 + ReceBuf[5])*2;
							usDataLen = sendnum - 2;
							SendBuf[0] = ReceBuf[0];·
							SendBuf[1] = ReceBuf[1];
							SendBuf[2] = sendnum - 5;
							for(count = 3;ReceBuf[5] > 0;ReceBuf[5]--)
							{
								SendBuf[count] =  XBuf[count - 3]/256;
								SendBuf[count+1] = XBuf[count - 3]%256;
								count = count + 2;
							}
							crcData = crc16(SendBuf,usDataLen);
							crcDataLo = crcData/256; 
							crcDataHi = crcData%256; 
							SendBuf[sendnum - 1] = crcDataHi;
							SendBuf[sendnum - 2] = crcDataLo;
							for(i = 0;i < sendnum;i++)
							{	
								USART_SendData(USART1,SendBuf[i]);
								while(!(USART1->SR & USART_FLAG_TXE));
							}
							sendnum = 0;
							break;
					}
					break;
				case 0x03:
					switch(ReceBuf[3]) 
					{
						case 0x00:
							sendnum = 5+(ReceBuf[4] * 256 + ReceBuf[5])*2;
							usDataLen = sendnum - 2;
							SendBuf[0] = ReceBuf[0];
							SendBuf[1] = ReceBuf[1];
							SendBuf[2] = sendnum - 5;
							for(count = 3;ReceBuf[5] > 0;ReceBuf[5]--)
							{
								SendBuf[count] =  XBuf[count - 3]/256;
								SendBuf[count+1] = XBuf[count - 3]%256;
								count = count + 2;
							}
							crcData = crc16(SendBuf,usDataLen);
							crcDataLo = crcData/256; 
							crcDataHi = crcData%256; 
							SendBuf[sendnum - 1] = crcDataHi;
							SendBuf[sendnum - 2] = crcDataLo;
							for(i = 0;i < sendnum;i++)
							{	
								USART_SendData(USART1,SendBuf[i]);
								while(!(USART1->SR & USART_FLAG_TXE));
							}
							sendnum = 0;
							break;
					}
					break;
			}
		}
	}
}

CRC校验程序

程序的判断语句,这个校验的函数,我是直接用了别人的。crc校验函数已经是一个标准算法了,调用它,就能算出来两个字节的数字。crc16校验,是把除了校验码以外,通讯准备发送的全部数据送入这个校验函数,然后由它生成两个字节的校验码。这个函数,在主机和从机中是一样的。如果主机和从机根据收发的数据算出的校验码一样,就认为数据传输没有问题。在判断完成数据以后,就是属于功能代码部分。

		crcData = crc16(ReceBuf,usDataLen);
		crcDataLo = crcData/256; 
		crcDataHi = crcData%256; 

CRC校验函数中生成的校验码分为高字节和低字节部分。可以采用很多方法实现,这里是除法和取余运算实现,也可以用移位运算实现。其实,在c语言中有位域的概念,这样的实现就无需计算。

union FLOAT_REG
{
    int          all;
    struct INT_BYTES     byte;
}CommData;
struct  INT_BYTES// bytes  description
{
    Uint16    BYTE0:8;// 7:0 
    Uint16    BYTE1:8;// 15:8 
};

只需要把CRC16结果赋值给all变量,BYTE0和BYTE1自然可以得到高低位的数据。同理可以用于浮点数的传输。

功能代码

这个功能代码就是属于通讯协议里面的自定义部分,如ReceBuf[3]代表功能码等,在switch语句中,规定了下面当其值为00时,下位机执行的操作(如led_on函数),以及应当回复的数据。准备回发的数据,同样按协议规定填充地址、功能代码、返回的数据以及CRC校验码,最后用同样的硬件载体将数据回复。此处我是用了uart接收的数据,这个就是stm32里面的sci模块,回复给主机的时候,用的还是uart,这几句代码就是uart的发送函数。

					switch(ReceBuf[3]) 
					{
						case 0x00://aotomodbus
							led_on();
							sendnum = 5+(ReceBuf[4] * 256 + ReceBuf[5])/8;
							sendny=(ReceBuf[4] * 256 + ReceBuf[5])%8;
							if(sendny) sendnum+=1;
							usDataLen = sendnum - 2;
							SendBuf[0] = ReceBuf[0];
							SendBuf[1] = ReceBuf[1];
							SendBuf[2] =sendnum-5;
							statusnum=SendBuf[2];
							for(count = 3;statusnum>0;statusnum--)
							{
								SendBuf[count] = 0x01;
								count++;
							}
							crcData = crc16(SendBuf,usDataLen);
							crcDataLo = crcData/256; 
							crcDataHi = crcData%256; 
							SendBuf[sendnum - 1] = crcDataHi;
							SendBuf[sendnum - 2] = crcDataLo;
							for(i = 0;i < sendnum;i++)
							{	
								USART_SendData(USART1,SendBuf[i]);
								while(!(USART1->SR & USART_FLAG_TXE));
							}
							sendnum = 0;
							break;
					}
					break;
					}

modbus从机代码实现-基于freemodbus

实际上,modbus作为一个广泛使用的协议,已经有适用于嵌入式的通用库函数。FreeMODBUS是一个奥地利人写的Modbus协议。它是一个针对嵌入式应用的一个免费(自由)的通用MODBUS协议的移植。
在网上有许多例子是基于freemodbus的,实现了modbus的了RTU/ASCII 传输模式及TCP协议支持。因此,无需从头自己编写modbus的程序,只需注意移植时和串口、定时器部分进行匹配

modbus的matlab调试

matlab的工具箱有modbus函数,可以创建modbus对象
在这里插入图片描述
另外提供了read和write函数
在这里插入图片描述
和我调试助手里能对应:
在这里插入图片描述
使用标准协议,在很多地方挺方便

  • 5
    点赞
  • 49
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值