前言
这篇文章是我从零认识MODBUS过程中的一点笔记,主要讲解了我学习和使用MODBUS的思路。代码可能帮不到你,但是如果你是和我一样的初学者,建议你认真阅读。毕竟学习是一个思考练习的过程,如果只会Ctrl+C,那么就没什么意义了。作者水平有限,有错误敬请指出,互相学习,共同进步。
1.ModBus协议简介
MODBUS协议是一种串行通信协议,由Modicon公司(施耐德公司前身)发表,由于其公开发表且无版权要求,易于部署和维护,在工业界广泛应用。MODBUS采用主从通信(Master/Slave),MODBUS有三种报文格式:ASCII、RTU、TCP,本文主要讨论RTU。
如下图所示,串行通信上的MODBUS协议主要由地址,功能码,数据,CRC校验四部分数据帧构成。主机和从机的串行通信设备要求一致,参数要求一致,即假设使用串口,主从机波特率,奇偶校验等参数需一致。
主机端状态图(标准流程)
从机端状态图(标准流程)
RTU报文数据格式:
从机地址:1 byte 功能码:1byte 数据:0-252byte(s) CRC校验:2 byte 低位在前,高位在后
2.MODBUS_RTU在STM32上的实现
撸代码之前,我们先来看看MODBUS的数据模型,见下图。因为MODBUS通常用在PLC上,所以他的数据模型与PLC息息相关。这里我们不用管。直接看我给出的表格,便于理解,离散量输入即只读的位类型变量,与开关量一样。比如从机接了一个行程开关(按钮),我要读取开关的状态(只有0和1),而我们是无法直接操作开关的。再看"线圈",我的从机接了一个LED灯,我可以直接操作IO口控制灯的亮灭,也可以查询IO口电平知道灯的亮灭,所以他是可以读写的数据。"输入寄存器"理解为从机设备上有个温度传感器,传感器的数值是外界的温度,我们只能读取这个温度,所以是只读变量。"保持寄存器"理解为类似于从机ID一类信息,既可以被读取,也可以被改写。
万事俱备,只欠东风,开搞。 我用的两块STM32F407的板子,一主一从,主机上什么外设也没有,假设从机上接了一个温度传感器,8个LED灯,以及8个按键。我们来写一个主机读取从机按键状态的MODBUS协议。
2.1 功能码举例
这里以功能码0x01为例,说明如何运用。完整的RTU报文读取线圈数据帧格式如下图所示。我们就主机端和从机端分别来写这个功能码。(其他功能码可以查看参考文献中的国标)
主机端:向从机端请求线圈状态。根据上面的图片(参考国标中功能码0x01的说明)。主机向从机请求线圈状态时,发送的一帧数据包括从机地址,功能码,读取线圈的起始地址,读取线圈的数量。以及CRC校验码。按照这个思路,我们写一个void Read_Coil_State(uint8_t addr, uint16_t addr_read,uint16_t num)函数,它的参数从机地址,线圈地址以及线圈数量。
/* 函数名: 读线圈指令
* 参 数: -*-addr 从机地址
-*-addr_read 起始地址
-*-num 线圈数量
* 返回值: None
* 描 述: 向从机发送读线圈状态请求
*/
void Read_Coil_State(uint8_t addr, uint16_t addr_read,uint16_t num)
{
uint8_t i;
uint8_t Read_Coil[8];
if(num >2000){
return;}
Read_Coil[0] = addr;
Read_Coil[1] = 0x01;
Read_Coil[2] = addr_read>>8; //高位在前,低位在后
Read_Coil[3] = addr_read;
Read_Coil[4] = num>>8;
Read_Coil[5] = num;
Read_Coil[6] = CRC16(Read_Coil,4);
Read_Coil[7] = CRC16(Read_Coil,4)>>8;
for(i=0;i<=7;i++)
{
Uart_SendByte(Read_Coil[i]); //串口发送
}
}
其中CRC16()是CRC校验函数,需要进行校验的数据包含地址码、功能码、数据,也就是CRC前面的堆数据都需要校验。关于CRC检验的具体原理,读者可以自行研究。这里采用的是官方推荐的查表校验法。下面的表格分别是CRC低位和CRC高位。最下面是CRC16()函数本身。
High-Order Byte Table
/* Table of CRC values for high–order byte */
static unsigned char auchCRCHi[] = {
0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81,
0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0,
0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01,
0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41,
0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81,
0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0,
0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01,
0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40,
0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81,
0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0,
0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01,
0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41,
0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81,
0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0,
0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01,
0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81, 0x40, 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41,
0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, 0x01, 0xC0, 0x80, 0x41, 0x00, 0xC1, 0x81,
0x40
} ;
Low-Order Byte Table
/* Table of CRC values for low–order byte */
static char auchCRCLo[] = {
0x00, 0xC0, 0xC1, 0x01, 0xC3, 0x03, 0x02, 0xC2, 0xC6, 0x06, 0x07, 0xC7, 0x05, 0xC5, 0xC4,
0x04, 0xCC, 0x0C, 0x0D, 0xCD, 0x0F, 0xCF, 0xCE, 0x0E, 0x0A, 0xCA, 0xCB, 0x0B, 0xC9, 0x09,
0x08, 0xC8, 0xD8, 0x18, 0x19, 0xD9, 0x1B, 0xDB, 0xDA, 0x1A, 0x1E, 0xDE, 0xDF, 0x1F, 0xDD,
0x1D, 0x1C, 0xDC, 0x14, 0xD4, 0xD5, 0x15, 0xD7, 0x17, 0x16, 0xD6, 0xD2, 0x12, 0x13, 0xD3,
0x11, 0xD1, 0xD0, 0x10, 0xF0, 0x30, 0x31, 0xF1, 0x33, 0xF3, 0xF2, 0x32, 0x36, 0xF6, 0xF7,
0x37, 0xF5, 0x35, 0x34, 0xF4, 0x3C, 0xFC, 0xFD, 0x3D, 0xFF, 0x3F, 0x3E, 0xFE, 0xFA, 0x3A,
0x3B, 0xFB, 0x39, 0xF9, 0xF8, 0x38, 0x28, 0xE8, 0xE9, 0x29, 0xEB, 0x2B, 0x2A, 0xEA, 0xEE,
0x2E, 0x2F, 0xEF, 0x2D, 0xED, 0xEC, 0x2C, 0xE4, 0x24, 0x25, 0xE5, 0x27, 0xE7, 0xE6, 0x26,
0x22, 0xE2, 0xE3, 0x23, 0xE1, 0x21, 0x20, 0xE0, 0xA0, 0x60, 0x61, 0xA1, 0x63, 0xA3, 0xA2,
0x62, 0x66, 0xA6, 0xA7, 0x67, 0xA5, 0x65, 0x64, 0xA4, 0x6C, 0xAC, 0xAD, 0x6D, 0xAF, 0x6F,
0x6E, 0xAE, 0xAA, 0x6A, 0x6B, 0xAB, 0x69, 0xA9, 0xA8, 0x68, 0x78, 0xB8, 0xB9, 0x79, 0xBB,
0x7B, 0x7A, 0xBA, 0xBE, 0x7E, 0x7F, 0xBF, 0x7D, 0xBD, 0xBC, 0x7C, 0xB4, 0x74, 0x75, 0xB5,
0x77, 0xB7, 0xB6, 0x76, 0x72, 0xB2, 0xB3, 0x73, 0xB1, 0x71, 0x70, 0xB0, 0x50, 0x90, 0x91,
0x51, 0x93, 0x53, 0x52, 0x92, 0x96, 0x56, 0x57, 0x97, 0x55, 0x95, 0x94, 0x54, 0x9C, 0x5C,
0x5D, 0x9D, 0x5F, 0x9F, 0x9E, 0x5E, 0x5A, 0x9A, 0x9B, 0x5B, 0x99, 0x59, 0x58, 0x98, 0x88,
0x48, 0x49, 0x89, 0x4B, 0x8B, 0x8A, 0x4A, 0x4E, 0x8E, 0x8F, 0x4F, 0x8D, 0x4D, 0x4C, 0x8C,
0x44, 0x84, 0x85, 0x45, 0x87, 0x47, 0x46, 0x86, 0x82, 0x42, 0x43, 0x83, 0x41, 0x81, 0x80,
0x40
};
unsigned short CRC16 (unsigned char *puchMsg, unsigned short usDataLen) /* The function returns the CRC as a unsigned short type */
{
/* message to calculate CRC upon */
/* quantity of bytes in message */
{
unsigned char uchCRCHi = 0xFF ; /* high byte of CRC initialized */
unsigned char uchCRCLo = 0xFF ; /* low byte of CRC initialized */
unsigned uIndex ; /* will index into CRC lookup table */
while (usDataLen--) /* pass through message buffer */
{
uIndex = uchCRCLo ^ *puchMsg++ ; /* calculate the CRC */
uchCRCLo = uchCRCHi ^ auchCRCHi[uIndex] ;
uchCRCHi = auchCRCLo[uIndex] ;
}
return (uchCRCHi << 8 | uchCRCLo) ;
}
}
}
主机端就完成了,接下来写一个从机端MODBUS的线圈状态响应函数。从机在确认自己的地址是主机请求的地址之后,会将自己指定地址的线圈状态打包成数据发回主机。下面是从机端返回线圈状态的函数。
/* 函数名: 返回线圈状态
* 参 数: None
* 返回值: None
* 描 述: 将线圈状态打包发给主机
*/
#define MAX_CoilState_LEN 255
uint8_t Send_Buffer[MAX_CoilState_LEN];
void Read_Coil_Response(void)
{
uint8_t i,j;
uint16_t num;
uint16_t addr;
uint16_t crc,crc_send;
addr = (USART_RX_BUF[2]<<8) + USART_RX_BUF[3]; //获取地址
num = ((USART_RX_BUF[4]<<8) + USART_RX_BUF[5]); //字节数,这里应该是向上取整,读者自行研究
crc = CRC16(USART_RX_BUF,8); //CRC校验
// if(addr > 0xffff || num>2000 || crc!= (USART_RX_BUF[6]<<8) + USART_RX_BUF[7]) {return;} //返回异常码0x03数据值无效
//
Send_Buffer[i++] = ADDR_SELF; //本机地址
Send_Buffer[i++] = 0x01; // 功能码
Send_Buffer[i++] = num; // 字节数
// 示例:读取并将线圈状态打包-多个字节
// for(i=3;i<3+num;i++)
// {
// Send_Buffer[i] = Coil_Get();
// }
Send_Buffer[i++] = 0xec; // 线圈状态
crc_send = CRC16(Send_Buffer,i); // 计算CRC
Send_Buffer[i++] = crc_send; // CRC低位
Send_Buffer[i++] = crc_send>>8;// CRC高位
for(j=0;j<i;j++)
{
Uart_SendByte(Send_Buffer[j]); //串口发送
}
}
主机和从机写完了,我们还需要在从机的串口中断中调用ModBus事务状态处理函数,因为不同的功能码有不同的处理函数,由下图的流程图,我给出了一个简单的例子,因为只写了读线圈这个功能码,所以只有一个调用。
u8 error_code;
void Modbus_Rec_Process(void)
{
//地址不正确,丢弃,不予回应
if(USART_RX_BUF[0] != ADDR_SELF)
{
return;
}
switch (USART_RX_BUF[1]){
case 0x01:{ // 读线圈
Read_Coil_Response();
}break;
case 0x02:{ //
}break;
case 0x03:{
}break;
case 0x04:{
}break;
case 0x05:{
}break;
case 0x06:{
}break;
case 0x0f:{
}break;
case 0x10:{
}break;
default:
error_code = 0x01; // 无效功能码,返回错误码0x01
//break; //
}
}
下面是我的串口中断函数,使用了空闲接收。空闲接收可以一帧一帧的接收数据,MODBUS自己没带帧头帧尾,只想到这个办法。
//串口1中断服务程序
uint16_t length;
void USART1_IRQHandler(void)
{
if(USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) //接收中断(接收到的数据必须是0x0d 0x0a结尾)
{
USART_RX_BUF[length++] =USART_ReceiveData(USART1);//(USART1->DR); //读取接收到的数
}
else if(USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) //接收到一帧数据
{
USART1->SR;//先读SR
USART1->DR;//再读DRUSART1->SR;//先读SR
length = 0; //一帧数据完,索引归零
USART_IDLE_STA = 1;
Modbus_Rec_Process();//调用modbus事务处理函数
}
}
3.实验结果
这里使用的是Modbus的调试工具ModScan32,在各大网站都可以下载到,打开软件连接串口,按照图示配置,我在例子里默认回复8个位的状态信息,且状态为0xec,可以看到软件读取到了正确的数据。说明我们的Modbus协议成功。
参考文献:
【1】.GBZ 19582.1-2004 基于Modbus协议的工业自动化网络规范 第1部分:Modbus应用协议
【2】.Modbus_over_serial_line_V1_02(www.modbus.org)
【3】.modbus协议中的寄存器理解(https://blog.csdn.net/bijinsong/article/details/79373621)
【4】.串口接收不定长数据的几种方式(https://blog.csdn.net/zb774095236/article/details/82781749)