中小企业网站建设教程,淮南百姓网,建设部网站备案,免费看的logo图片原子级操作手把手搞懂modbus协议文章目录[toc]1 modbus协议基础概念1.1 使用场所1.2 主从协议站1.3 modbus帧描述1.4 数据模式1.5 modbus状态机2 modbus协议2.1 功能码2.2 公共功能码2.3 数据域格式3 modbus从站程序设计3.1 接口初始化3.2 数据处理部分查表法设置超时时间3.2 主…原子级操作手把手搞懂modbus协议文章目录[toc]1 modbus协议基础概念1.1 使用场所1.2 主从协议站1.3 modbus帧描述1.4 数据模式1.5 modbus状态机2 modbus协议2.1 功能码2.2 公共功能码2.3 数据域格式3 modbus从站程序设计3.1 接口初始化3.2 数据处理部分查表法设置超时时间3.2 主循环查询3.3 协议解析MODS_01H向上取整按位记录数据错误消息处理CRC校验bsp_PutMsg 双指针环状消息队列
本文主要参考安富莱老师的modbus资料做了一些自己的思考与感悟可以方便新手同学的快速入门建议看完本文后再对原文进行学习彻底搞懂modbus协议传送门如下安富莱modbus协议
1 modbus协议基础概念
1.1 使用场所
modbuds主要配合RS485总线使用主要解决的是主从栈协议的数据收发问题。
通俗理解就是RS485的数据规定比较简单只规定了一些电气特征主要是物理层的一些东西如高低电平的电压值等
但是对数据层的一些数据没有进行说明这就导致了以下的一些问题 控制器如何获取传感器的数据怎样能在正确的时间区分不同的传感器数据各传感器数据如何上传只有一条总线假如存在10个传感器这些传感器如何上传自己的数据才不会造成线路的拥堵如何才能区分不同传感器节点的数据 归根到底传感器节点比较多但总线只有一条怎样进行传感器数据的上传才不会造成数据的冲突和总线的拥挤
为了解决这个问题引入了主从站的概念。
1.2 主从协议站
主从协议栈的特点如下所示 同一时刻总线中只有一个主节点存在总线中最多有247个子节点GB-TB19582-2008中规定通讯时总是由主节点发起子节点不主动上传数据子节点之间不进行互相通讯子节点必须有唯一的地址1-247 主从协议栈中主要存在两种数据模式 单播模式主节点通过特定地址访问特定子节点发起请求从结点收到请求后进行应答返回报文广播模式主节点向所有的节点发起请求从结点无需应答 广播模式一般用于写请求中所有子节点原则上必须接受广播模式的写功能地址0用于广播数据子节点地址禁止占用地址0 1.3 modbus帧描述 modbus帧如上所示 首先是地址域用于主节点请求特定的节点数据也用于从节点应答时主节点区分不同的从节点
功能码和数据域实现modbus的主要功能如对 特定节点 写入 特定数据
CRC校验码查看数据是否正确的校验段
1.4 数据模式
一般modbus的数据有两种数据可选RTU16进制 和 ASCII 二者的数据密度比较 假设表示127这个数RTU需要 0001 1111(7E)一个字节表示,ASCII需要发送1 2 7三个字节表示所以RTU的数据密度较高这样的话就可以节省数据发送及传输的任务量大大减小总线的负担。 字节(注意这是每个字节的数据格式每个bit可以代表一个电平只有二进制的0/1)格式如下所示 每字节bit流如上图所示说明如下 起始位bit1 数据位bit2~bit9 校验位有的话bit10 停止位有校验的话bit11没有校验的话bit10~bit11 帧数据的格式如下所示 总线报文格式 数据在总线上发送的时候必须以连续的数据帧格式进行发送帧内若两个字符之间的数据时间间隔小于1.5字符时间的话。 总线报文格式 数据帧的话之间的空闲时间至少需要3.5个字符时间。 1.5 modbus状态机 1、初始态初始化t3.5超过3.5个字符传播的时间如果超过t3.5的话那就证明时间超时了就进入空闲态 2、空闲态就是总线上没有数据传输的状态 3、发送态主从栈进行数据发送的状态发送完成启动t3.5 4、接收态接收时启动t1.5和t3.5接收时会有这两种时间但二者的话肯定时t1.5先达到因此t1.5来临的时候进入一种新的状态控制和等待状态 5、控制和等待状态当t1.5超时之后有两种可能一种是这一帧数据不完整此时的话校验位肯定不对另一种时数据完整校验位没问题再等两个字符时间后到达t3.5后进入空闲状态。 2 modbus协议
2.1 功能码
首先modbus协议对功能码做了严格的定义有一些是功能码是公共功能码具体如下所示 公共功能码是modbus定义好的功能不能进行修改用户自定义的功能码只能是65-72和100-110的功能码段。
2.2 公共功能码
公共功能码主要功能如下所示 可以看见数据访问有比特访问16比特访问和文件记录访问,还能传文件感觉挺有意思。 不过常用的功能码有01 02 03 04 05 15 16 2.3 数据域格式 仅对功能码01进行说明 PS:其实个人感觉安富莱老师的资料中写的非常好了本文中仅对01进行说明以便本文观看的连续性更完整的内容建议读一下原文。 01H读取线圈的状态 主机查询报文如下所示 从机响应的值如下所示 对应的线圈状态如下所示 3 modbus从站程序设计
程序设计的流程图如下所示下面的程序设计也按照下面的流程设计
3.1 接口初始化
首先是程序接口使用的是RS485因此需要的是初始化485的接口用于接受和发送数据 这部分代码的优势并不是很明显个人只是感觉这一段的串口结构体写的非常好可以看一下具体使用的时候可以根据功能的收发进行裁剪。 先进行Bsp_uart_fifo.h重要代码说明
/* 串口485的配置 *//* RS485芯片发送使能GPIO, PB2 */
#define RCC_RS485_TXEN RCC_AHB1Periph_GPIOB
#define PORT_RS485_TXEN GPIOB
#define PIN_RS485_TXEN GPIO_Pin_2#define RS485_RX_EN() PORT_RS485_TXEN-BSRRH PIN_RS485_TXEN
#define RS485_TX_EN() PORT_RS485_TXEN-BSRRL PIN_RS485_TXEN/* 串口3的基本参数 */
#if UART3_FIFO_EN 1#define UART3_BAUD 9600#define UART3_TX_BUF_SIZE 1*1024#define UART3_RX_BUF_SIZE 1*1024
#endif/* 串口设备结构体,个人感觉安富莱这个结构体做的挺好的所以在此提一下 */
typedef struct
{USART_TypeDef *uart; /* STM32内部串口设备指针 */uint8_t *pTxBuf; /* 发送缓冲区 */uint8_t * ; /* 接收缓冲区 */uint16_t usTxBufSize; /* 发送缓冲区大小 */uint16_t usRxBufSize; /* 接收缓冲区大小 */__IO uint16_t usTxWrite; /* 发送缓冲区写指针 */__IO uint16_t usTxRead; /* 发送缓冲区读指针 */__IO uint16_t usTxCount; /* 等待发送的数据个数 */__IO uint16_t usRxWrite; /* 接收缓冲区写指针 */__IO uint16_t usRxRead; /* 接收缓冲区读指针 */__IO uint16_t usRxCount; /* 还未读取的新数据个数 */void (*SendBefor)(void); /* 开始发送之前的回调函数指针主要用于RS485切换到发送模式 */void (*SendOver)(void); /* 发送完毕的回调函数指针主要用于RS485将发送模式切换为接收模式 */void (*ReciveNew)(uint8_t _byte); /* 串口收到数据的回调函数指针 */
}UART_T;
Bsp_uart_fifo.c重要代码说明: 先进行发送和缓存区的全局变量声明 发送指针和接收指针使用的是相对位置是数组的索引值所以写入的时候的时候采用的是以下的方法 ch USART_ReceiveData(_pUart-uart);
_pUart-pRxBuf[_pUart-usRxWrite] ch;
if (_pUart-usRxWrite _pUart-usRxBufSize)
{_pUart-usRxWrite 0;
}
if (_pUart-usRxCount _pUart-usRxBufSize)
{_pUart-usRxCount;
}初始化代码隐藏了硬件初始化中断配置的代码也没啥好说的值得注意一点的是全局变量的初始化部分的代码吧
/* 先定义全局变量进行缓存区的定义 */
#if UART3_FIFO_EN 1static UART_T g_tUart3;static uint8_t g_TxBuf3[UART3_TX_BUF_SIZE]; /* 发送缓冲区 */static uint8_t g_RxBuf3[UART3_RX_BUF_SIZE]; /* 接收缓冲区 */
#endifvoid bsp_InitUart(void)
{UartVarInit(); /* 必须先初始化全局变量,再配置硬件 */InitHardUart(); /* 配置串口的硬件参数(波特率等) */RS485_InitTXE(); /* 配置RS485芯片的发送使能硬件配置为推挽输出 */ConfigUartNVIC(); /* 配置串口中断 */
}static void UartVarInit(void)
{
#if UART3_FIFO_EN 1g_tUart3.uart USART3; /* STM32 串口设备 */g_tUart3.pTxBuf g_TxBuf3; /* 发送缓冲区指针 */g_tUart3.pRxBuf g_RxBuf3; /* 接收缓冲区指针 */g_tUart3.usTxBufSize UART3_TX_BUF_SIZE; /* 发送缓冲区大小 */g_tUart3.usRxBufSize UART3_RX_BUF_SIZE; /* 接收缓冲区大小 */g_tUart3.usTxWrite 0; /* 发送FIFO写索引 */g_tUart3.usTxRead 0; /* 发送FIFO读索引 */g_tUart3.usRxWrite 0; /* 接收FIFO写索引 */g_tUart3.usRxRead 0; /* 接收FIFO读索引 */g_tUart3.usRxCount 0; /* 接收到的新数据个数 */g_tUart3.usTxCount 0; /* 待发送的数据个数 */g_tUart3.SendBefor RS485_SendBefor; /* RS485发送数据前的回调函数 */g_tUart3.SendOver RS485_SendOver; /* RS485发送完毕后的回调函数 */g_tUart3.ReciveNew RS485_ReciveNew; /* RS485接收到新数据后的回调函数 */
#endif
}void RS485_SendBefor(void)
{RS485_TX_EN(); /* 切换RS485收发芯片为发送模式 */
}void RS485_SendOver(void)
{RS485_RX_EN(); /* 切换RS485收发芯片为接收模式 */
}void RS485_SendBuf(uint8_t *_ucaBuf, uint16_t _usLen)
{comSendBuf(COM3, _ucaBuf, _usLen);
}/* 注意硬件配置的时候每个字符是11位的配置不要出错 */
USART_InitStructure.USART_WordLength USART_WordLength_8b;
USART_InitStructure.USART_StopBits USART_StopBits_2;
USART_InitStructure.USART_Parity USART_Parity_No ;重要的是接受的过程中写的过程 先写入数据若数组的索引出现越界的情况记得要将索引清零
/*接收中断中调用RS485_ReciveNew*/
/*
*********************************************************************************************************
* 函 数 名: UartIRQ
* 功能说明: 供中断服务程序调用通用串口中断处理函数
* 形 参: _pUart : 串口设备
* 返 回 值: 无
*********************************************************************************************************
*/
static void UartIRQ(UART_T *_pUart)
{/* 处理接收中断 */if (USART_GetITStatus(_pUart-uart, USART_IT_RXNE) ! RESET){/* 从串口接收数据寄存器读取数据存放到接收FIFO */uint8_t ch;ch USART_ReceiveData(_pUart-uart);_pUart-pRxBuf[_pUart-usRxWrite] ch;if (_pUart-usRxWrite _pUart-usRxBufSize){_pUart-usRxWrite 0;}if (_pUart-usRxCount _pUart-usRxBufSize){_pUart-usRxCount;}/* 回调函数,通知应用程序收到新数据,一般是发送1个消息或者设置一个标记 *///if (_pUart-usRxWrite _pUart-usRxRead)//if (_pUart-usRxCount 1){if (_pUart-ReciveNew){_pUart-ReciveNew(ch);}}}
}3.2 数据处理部分
查表法设置超时时间 首先设置表格如下所示 /*
Baud rate Bit rate Bit time Character time 3.5 character times2400 2400 bits/s 417 us 4.6 ms 16 ms4800 4800 bits/s 208 us 2.3 ms 8.0 ms9600 9600 bits/s 104 us 1.2 ms 4.0 ms19200 19200 bits/s 52 us 573 us 2.0 ms38400 38400 bits/s 26 us 286 us 1.75 ms(1.0 ms)115200 115200 bit/s 8.7 us 95 us 1.75 ms(0.33 ms) 后面固定都为1750us
*/typedef struct
{uint32_t Bps;uint32_t usTimeOut;
}MODBUSBPS_T;const MODBUSBPS_T ModbusBaudRate[]
{ {2400, 16000}, /* 波特率2400bps, 3.5字符延迟时间16000us */{4800, 8000}, {9600, 4000},{19200, 2000},{38400, 1750},{115200, 1750},{128000, 1750},{230400, 1750},
};然后使用查表法获取超时t3.5时间 /* 根据波特率获取需要延迟的时间 */
for(i 0; i (sizeof(ModbusBaudRate)/sizeof(ModbusBaudRate[0])); i)
{if(SBAUD485 ModbusBaudRate[i].Bps){break;}
}下面是完整的数据接收函数
然后函数应该比较易懂就是接收到消息之后开启一次t3.5定时然后将数据添加到RxBuf中。 若t3.5超时的话可以设置一个标志位或者信号量然后通知其他线程一帧的数据已经接收完毕 static void MODS_RxTimeOut(void)
{g_mods_timeout 1;
}void MODS_ReciveNew(uint8_t _byte)
{/*3.5个字符的时间间隔只是用在RTU模式下面因为RTU模式没有开始符和结束符两个数据包之间只能靠时间间隔来区分Modbus定义在不同的波特率下间隔时间是不一样的详情看此C文件开头*/uint8_t i;/* 根据波特率获取需要延迟的时间 */for(i 0; i (sizeof(ModbusBaudRate)/sizeof(ModbusBaudRate[0])); i){if(SBAUD485 ModbusBaudRate[i].Bps){break;} }g_mods_timeout 0;/* 硬件定时中断定时精度us 硬件定时器1用于MODBUS从机, 定时器2用于MODBUS主机如果超时的话会调用回调函数MODS_RxTimeOut*/bsp_StartHardTimer(1, ModbusBaudRate[i].usTimeOut, (void *)MODS_RxTimeOut);/* 将数据加入到RxBuf中 */if (g_tModS.RxCount S_RX_BUF_SIZE){g_tModS.RxBuf[g_tModS.RxCount] _byte;}
}
3.2 主循环查询
主循环通过bsp_Idle查询t3.5的标志位g_mods_timeout是否超时如果超时的话证明一帧数据已经发送完成。 main.c中的程序 int main()
{...while(1){...bsp_Idle(); /* Modbus解析在此函数里面 */...}
}modbus_slave.c中MODS_Poll函数主要好的点有以下的点 不对的命令直接return进行函数的结束 巧用goto如果接收错误的话直接通过指针进行恢复就行了 void MODS_Poll(void)
{uint16_t addr;uint16_t crc1;/* 超过3.5个字符时间后执行MODH_RxTimeOut()函数。全局变量 g_rtu_timeout 1; 通知主程序开始解码 */if (g_mods_timeout 0) {return; /* 没有超时继续接收。不要清零 g_tModS.RxCount */}g_mods_timeout 0; /* 清标志 */if (g_tModS.RxCount 4) /* 接收到的数据小于4个字节就认为错误地址8bit指令8bit操作寄存器16bit */{goto err_ret;}/* 计算CRC校验和这里是将接收到的数据包含CRC16值一起做CRC16结果是0表示正确接收 */crc1 CRC16_Modbus(g_tModS.RxBuf, g_tModS.RxCount);if (crc1 ! 0){goto err_ret;}/* 站地址 (1字节 */addr g_tModS.RxBuf[0]; /* 第1字节 站号 */if (addr ! SADDR485) /* 判断主机发送的命令地址SADDR485是否符合 */{goto err_ret;}/* 分析应用层协议 */MODS_AnalyzeApp(); err_ret:g_tModS.RxCount 0; /* 必须清零计数器方便下次帧同步 */
}3.3 协议解析
承接前面的解析函数进行数据分析
可以看见MODS_AnalyzeApp中对于根据地址找到的相应的消息处理之后主要是两个函数
static void MODS_AnalyzeApp(void)
{switch (g_tModS.RxBuf[1]) /* 第2个字节 功能码 */{case 0x01: /* 读取线圈状态此例程用led代替*/MODS_01H();bsp_PutMsg(MSG_MODS_01H, 0); /* 发送消息,主程序处理 */break;case 0x02: /* 读取输入状态按键状态*/...case 0x03: /* 读取保持寄存器此例程存在g_tVar中*/... case 0x04: /* 读取输入寄存器ADC的值*/... case 0x05: /* 强制单线圈设置led*/... case 0x06: /* 写单个保存寄存器*/ ... case 0x10: /* 写多个保存寄存器*/ ... default:...}
}MODS_01H
向上取整
num个bit需要多少个字节来储存数据感觉这个方法很巧妙
m (num 7) / 8;按位记录数据
for (i 0; i num; i)
{if (bsp_IsLedOn(i 1 reg - REG_D01)) /* 读LED的状态写入状态寄存器的每一位 */{ status[i / 8] | (1 (i % 8));}
}错误消息处理 static void MODS_SendAckErr(uint8_t _ucErrCode)
{uint8_t txbuf[3];txbuf[0] g_tModS.RxBuf[0]; /* 485地址 */txbuf[1] g_tModS.RxBuf[1] | 0x80; /* 异常的功能码,最高位置1 */txbuf[2] _ucErrCode; /* 错误代码(01,02,03,04) */MODS_SendWithCRC(txbuf, 3);
}CRC校验
这个也是根据查表法获取的CRC校验码网上资源较多不进行展示了。
static void MODS_01H(void)
{/*举例主机发送:11 从机地址01 功能码00 寄存器起始地址高字节13 寄存器起始地址低字节00 寄存器数量高字节25 寄存器数量低字节0E CRC校验高字节84 CRC校验低字节从机应答: 1代表ON0代表OFF。若返回的线圈数不为8的倍数则在最后数据字节未尾使用0代替. BIT0对应第1个11 从机地址01 功能码05 返回字节数CD 数据1(线圈0013H-线圈001AH)6B 数据2(线圈001BH-线圈0022H)B2 数据3(线圈0023H-线圈002AH)0E 数据4(线圈0032H-线圈002BH)1B 数据5(线圈0037H-线圈0033H)45 CRC校验高字节E6 CRC校验低字节例子:01 01 10 01 00 03 29 0B --- 查询D01开始的3个继电器状态01 01 10 03 00 01 09 0A --- 查询D03继电器的状态*/uint16_t reg;uint16_t num;uint16_t i;uint16_t m;uint8_t status[10];g_tModS.RspCode RSP_OK;/* 没有外部继电器直接应答错误 */if (g_tModS.RxCount ! 8){g_tModS.RspCode RSP_ERR_VALUE; /* 数据值域错误 */return;}reg BEBufToUint16(g_tModS.RxBuf[2]); /* 寄存器号 */num BEBufToUint16(g_tModS.RxBuf[4]); /* 寄存器个数 */m (num 7) / 8;if ((reg REG_D01) (num 0) (reg num REG_DXX 1)){for (i 0; i m; i){status[i] 0;}for (i 0; i num; i){if (bsp_IsLedOn(i 1 reg - REG_D01)) /* 读LED的状态写入状态寄存器的每一位 */{ status[i / 8] | (1 (i % 8));}}}else{g_tModS.RspCode RSP_ERR_REG_ADDR; /* 寄存器地址错误 */}if (g_tModS.RspCode RSP_OK) /* 正确应答 */{g_tModS.TxCount 0;g_tModS.TxBuf[g_tModS.TxCount] g_tModS.RxBuf[0];g_tModS.TxBuf[g_tModS.TxCount] g_tModS.RxBuf[1];g_tModS.TxBuf[g_tModS.TxCount] m; /* 返回字节数 */for (i 0; i m; i){g_tModS.TxBuf[g_tModS.TxCount] status[i]; /* 继电器状态 */}MODS_SendWithCRC(g_tModS.TxBuf, g_tModS.TxCount);}else{MODS_SendAckErr(g_tModS.RspCode); /* 告诉主机命令错误 */}
}
bsp_PutMsg 双指针环状消息队列
这个函数本身在本项目中作用不大仅仅是记录一下接收到的消息ID但是本节所涉及到的双指针环状消息队列的设计和使用比较有意思展示如下 先是bsp.h中定义一些基本的量 #define MSG_FIFO_SIZE 40 /* 消息个数 */enum
{MSG_NONE 0,MSG_MODS_01H,MSG_MODS_02H,MSG_MODS_03H,MSG_MODS_04H,MSG_MODS_05H,MSG_MODS_06H,MSG_MODS_10H,
};/* 按键FIFO用到变量 */
typedef struct
{uint16_t MsgCode; /* 消息代码 */uint32_t MsgParam; /* 消息的数据体, 也可以是指针强制转化 */
}MSG_T;/* 变量 */
typedef struct
{MSG_T Buf[MSG_FIFO_SIZE]; /* 消息缓冲区 */uint8_t Read; /* 缓冲区读指针1 */uint8_t Write; /* 缓冲区写指针是个数buf的个数 */uint8_t Read2; /* 缓冲区读指针2 */
}MSG_FIFO_T;然后bsp.c中定义一些常用的操作: 感觉下边的写入挺有意思的可以参考 g_tMsg.Buf[g_tMsg.Write].MsgCode _MsgCode;读取的时候采用的指针一定要先初始化然后使用避免野指针的产生 MSG_T *p;
...
p g_tMsg.Buf[g_tMsg.Read];
..
p g_tMsg.Buf[g_tMsg.Read];if (g_tMsg.Read MSG_FIFO_SIZE)
{g_tMsg.Read 0;
} /*
* 功能说明: 将1个消息压入消息FIFO缓冲区。
*/
void bsp_PutMsg(uint16_t _MsgCode, uint32_t _MsgParam)
{g_tMsg.Buf[g_tMsg.Write].MsgCode _MsgCode; //压栈进来的结构体消息代码g_tMsg.Buf[g_tMsg.Write].MsgParam _MsgParam;if (g_tMsg.Write MSG_FIFO_SIZE){g_tMsg.Write 0;}
}/*
* 功能说明: 从消息FIFO缓冲区读取一个键值。
*/
uint8_t bsp_GetMsg(MSG_T *_pMsg)
{MSG_T *p;//注意只有等于符号没有大小的关系if (g_tMsg.Read g_tMsg.Write){return 0;}else{p g_tMsg.Buf[g_tMsg.Read];if (g_tMsg.Read MSG_FIFO_SIZE){g_tMsg.Read 0;}_pMsg-MsgCode p-MsgCode;_pMsg-MsgParam p-MsgParam;return 1;}
}/*
* 功能说明: 从消息FIFO缓冲区读取一个键值。使用第2个读指针。可以2个进程同时访问消息区。
*/
uint8_t bsp_GetMsg2(MSG_T *_pMsg)
{MSG_T *p;if (g_tMsg.Read2 g_tMsg.Write){return 0;}else{p g_tMsg.Buf[g_tMsg.Read2];if (g_tMsg.Read2 MSG_FIFO_SIZE){g_tMsg.Read2 0;}_pMsg-MsgCode p-MsgCode;_pMsg-MsgParam p-MsgParam;return 1;}
}/*
* 功能说明: 清空消息FIFO缓冲区
*/
void bsp_ClearMsg(void)
{g_tMsg.Read g_tMsg.Write;
}