这里分享一下在嵌入式设备与PLC通讯中的一种很常见的通讯协议:Modbus通讯。它具体的实现方式
一、搭建环境&简介
- 采用的是stm32f103RBT6为例
- 从机实现采用 FreeModbus库
- Modbus TCP通讯是基于 W5500 实现的
这里主要讲解的是Modbus从机(Server)的实现。因为实际产品常用于采集数据或做动作处理,一般是作为从设备,接入Plc(Modbus主机)。
二、Modbus主机(Master)
Modbus主机一般很少用到。主要用途,一般用于:实际产品分为前后台,后台做所有的数据处理,作为Modbus从机;而前台主要用于做界面显示,即作为Modbus主机,界面数据修改通过串口Modbus通讯。
由于是前后台的内置通讯,常用的Modbus主机 当然是采用方便、可靠的 Modbus RTU通讯。
当然由于前后台通讯占用一个硬件串口Modbus通讯。因此,常见的Modbus从机,往往是具有多串口Modbus通讯,共享一套地址处理数据(当然,可能前后台内置通讯在实际产品说明书中被隐藏)。
Modbus主机,在网上是没有开源,只有收费版本的。其实只要根据Modbus协议,很容易就能写一个Modbus主机。
唯一需要注意的点:每个数据帧发送之间的间隔为 3.5T;防止从机接收到的数据黏合。
但是由于Modbus主机一般要处理其他事宜,往往本身每个数据帧发送之间的间隔都 > 3.5T。因此实际上,写得不标准也能用;当然严谨一点的话,写一个定时器,发送就开启定时器,定时器溢出置标志位才能再次发送也是可以的。
三、Modbus从机(Slave)
这里的Modbus从机是基于 FreeModbus库 实现的。
Freemodbus库的代码是写得很好的,如果C语言学得好,且对Modbus协议了解的话,建议直接看源码,多看多观察可以提高自己的代码水平。
3.1 FreeModbus移植(RTU模式)
FreeModbus详细移植方法可以参照以下博主:
3.2 如何计算RTU模式的 3.5T 超时时间?
波特率:每秒钟通过信道传输的信息量称为位传输速率,也就是每秒钟传送的二进制位数,简称比特率。
比特率:表示有效数据的传输速率,用b/s 、bit/s、比特/秒,读作:比特每秒。
通常的串口桢格式为10位:开始位1bit + 数据位8bit + 停止位1bit
如9600b/s:指总线上每秒可以传输9600个bit;也就是说:在9600的波特率下,每秒可以传输出的桢数为:9600 / (1 + 8 + 1) = 960桢/秒,即960字节/秒(实际数据速率);
反推:一帧或一字节数据需要的时间是多少呢?
1s / 960 = 1.4ms
而ModBus协议中超时时间定为:3.5个帧长度为超时时间;
- 超时时间
- = 3.5 * 1 / BaudRate / 10 秒
- = 3.5 * 10 / BaudRate 秒
- = 3.5 * 10 * 2 / BaudRate * 2 秒
- = 70 / BaudRate * 2 秒
FreeModBus是这样实现的:
1 | /* If baudrate > 19200 then we should use the fixed timer values |
- 波特率大于19200使用定值:1800us
由于将usTimerT35_50us = 35;
直接带入,在定时器驱动初始化实际代入的计算值为 (35 = 36-1),因此实际的定时时间为:36 * 50(基值) = 1800 us。
- 波特率小于19200使用定值:
1
usTimerT35_50us = ( 7UL * 220000UL ) / ( 2UL * ulBaudRate );
解析:由于Modbus的RTU模式是串口帧格式为11位,故
- 超时时间 (50us为单位)
- = 3.5 * 1 / BaudRate / 11 秒
- = 3.5 * 11 / BaudRate 秒
- = 3.5 * 11 * 2 / BaudRate * 2 秒
- = 7 * 11 / BaudRate * 2 秒
- = 7 * 11 * 1000000 / 50 / BaudRate * 2 (50us为单位)
- = 7 * 220000 / BaudRate * 2 (50us为单位)
这usTimerT35_50us一个单位为50uS,将这个超时时间计算结果用于定时器驱动初始化。每中断一次为50us * usTimerT35_50us 微秒;且每次更改Modbus通信的波特率,Modbus的定时器驱动初始化都要更新一次,更新定时器计数溢出值。
四、 FreeModbus库源码
3.1所提到的移植,是把RTU模式通讯的底层给移植好,报文中的具体数据单元处理功能函数还是得自己继续写。
但暂先不讲具体的数据单元处理功能函数写法,先讲解整个FreeModbus源码的实现过程,使其后面更清晰如何写数据单元处理功能函数,甚至可以自己移植、扩展改动(ASCII、TCP模式)。
4.1 实现的核心原理
每一帧的数据区分是采用3.5T的方法。那么MCU具体的实现方法思路如下:
使能串口接收中断,一但接收到数据(触发串口接收中断),就开启定时器,每一次重新触发串口接收中断,就会重启定时器&重新计数;直到不触发串口接收中断,导致定时器溢出,此时视为接收一帧完整数据,开始解析数据。再根据自己喜好响应主机的信息。
以上就是整体思路,你甚至可以根据这个思路自己实现Modbus通讯的从机部分。接下来就是FreeModbus同样思路实现方式的讲解
4.2 FreeModbus库通讯实现讲解(RTU模式为例)
- Modbus通讯驱动初始化后,(使能函数)将 串口接收中断 置为 初始化状态
- 串口接收中断 在初始化状态下,打开定时器
- 等待定时器溢出后,触发定时器中断,关闭定时器,定时器中断处理 串口接收中断状态机,发现是 初始化状态 。此时再将 串口接收中断 置为 空闲状态,同时将 将
eMBPoll
的 事件状态机 置为 初始化。 - 以上就是初始化完成,接下来就是数据收发的流程了
- 串口接收中断触发,空闲状态下打开定时器,且获取一个接收字节。此时 Modbus串口接收中断状态机 置为 接收状态。
- 每次串口接收中断触发,刷新定时器时间,防止定时器溢出。接收状态下,接收字节如果不溢出,则继续接收(溢出报错)。
- 当串口接收中断延迟一段时间(或者不再接收到数据),该时间让定时器溢出。此时定时器中断,并将 Modbus串口接收中断 置为 空闲状态
- 由于定时器溢出,视为接收一帧完整的数据帧。将 Poll的 事件状态机 置为 接收完成。
- eMBPoll的 事件状态机 为
Frame received
接收完成,开始核对数据。数据长度>4(ID+功能码+校验) && CRC校验成功 - 核对成功后,(用指针方式获取数据PDU,数据长度= 总长度-地址域(ID 1字节)-CRC(检验 2字节)。核对失败,则报
MB_EIO
的错误。 - 经过核对,校验成功后,如果ID正确 或 为0。则将eMBPoll的 事件状态机 置为
Execute function
执行数据函数(事件)。开始对数据进行处理。 - 这里对功能码的选择处理,是采用一个结构体数组,每个结构体成员内含 1个功能码+功能码对应要执行的(回调)函数。然后for循环,匹配出对应的功能码,并进行处理;如果功能码为0则直接跳出。
- 如果ID号不是广播地址0,则从机会进行响应。之前进行对应功能码处理函数 得出结果,如果报错,则后续的响应
功能码|0x80 + 错误代码
。 - 举例,返回 01 83 02 C0 F1。即 读错误(0x03&0x80),非法数据地址(0x02),后面两个为CRC校验。
- 在处理完后会返回一个enum状态值。如果状态 不为
MB_ENOERR
,即内部使用的错误代码,根据内部使用的enum
错误代码,switch
生成 对外的错误代码 - 返回的CRC会在发送前先把CRC校验完成。
- 发送前,检查能不能数据接收,能接收则报硬件错误
MB_EIO
,因为协议规定只能单向收发 - 发送一个字节数据,然后开启发送中断,一直发送,直到完成
- 发送完成后,将 eMBPoll的 事件状态机 置为 发送完成
- 事件状态机 为 发送完成状态,该状态触发 将 发送中断状态机 置为 发送空闲
以上就是FreeModbus库实现Modbus RTU
通讯的方式。其他模式就大同小异,里面很多具体的实现,采用了指针的方式,尤其是函数指针,在初始化函数内可见一斑。这个库源码写的很好,如果是嵌入式新手,这份源码还是很推荐看的。
如果顺着程序缕是能看得懂的,这里再留一份函数==笔记==,方便小白初次看时疑惑,可查阅一下。
4.3 数据单元处理功能函数
这里的写法思路分为3个部分:
- 第一部分函数用于对应FreeModbus库编写的处理函数
- 例如,库里面处理到最后,总是地址+1,;可以在这里去掉
- 这里用来区分 读、写
- 第二部分函数用于自己应用层的地址约束
- 并可以对不同地址块进行不同处理
- 第三部分函数用于具体地址的详细操作
4.3.1 数据单元初步处理
编写具体如下四个函数对应FreeModbus库的处理:
- eMBRegCoilsCB
- eMBRegHoldingCB
- eMBRegDiscreteCB
- eMBRegInputCB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52/********************************************************************************
* 函数名 : eMBRegCoilsCB
* 功 能 : 线圈回复函数
* 说 明 : none
* 入 参 : *pucRegBuffer : 要添加到协议中的数据
* usAddress : 线圈地址(PLC地址)
* usNRegs : 要访问线圈的个数
* eMode : 访问类型(MB_REG_READ为读线圈状态,MB_REG_WRITE为写线圈)
* 返 回 : eStatus : 处理结果
********************************************************************************/
eMBErrorCode eMBRegCoilsCB( UCHAR * pucRegBuffer,
USHORT usAddress,
USHORT usNCoils,
eMBRegisterMode eMode )
{
eMBErrorCode eStatus = MB_ENOERR;
usAddress--;// 由PLC地址转为协议地址
switch ( eMode )
{
case MB_REG_READ:
eStatus = AppFMD_RdCoils(usNCoils,usAddress,pucRegBuffer);break;
case MB_REG_WRITE:
eStatus = AppFMD_WrCoils(usNCoils,usAddress,pucRegBuffer);break;
default:break;
}
return eStatus;
}
/********************************************************************************
* 函数名 : eMBRegHoldingCB
* 功 能 : 保持寄存器回复函数
* 说 明 : none
* 入 参 : *pucRegBuffer : 要添加到协议中的数据
* usAddress : 寄存器地址
* usNRegs : 访问寄存器的个数
* eMode : 访问类型(MB_REG_READ为读保持寄存器,MB_REG_WRITE为写保持寄存器)
* 返 回 : eStatus : 处理结果
********************************************************************************/
eMBErrorCode eMBRegHoldingCB( UCHAR * pucRegBuffer,
USHORT usAddress,
USHORT usNRegs,
eMBRegisterMode eMode )
{
eMBErrorCode eStatus = MB_ENOERR;
usAddress--;// 由PLC地址转为协议地址
if (eMode == MB_REG_READ)
eStatus = AppFMD_RdRegs(usNRegs,usAddress,pucRegBuffer);
if(eMode == MB_REG_WRITE)
eStatus = AppFMD_WrRegs(usNRegs,usAddress,pucRegBuffer);
return eStatus;
}
4.3.2 线圈和寄存器处理
根据 4.3.1,只编写可读写线圈和寄存器的函数,具体对应函数如下:
- eMBErrorCode AppFMD_RdCoils(uint32_t si_num, uint32_t uiAddr, uint8_t *puc_txpointer)
- eMBErrorCode AppFMD_WrCoils(uint32_t si_num, uint32_t uiAddr,uint8_t *puc_txpointer)
- 最好在这块函数进行总的地址划分
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38/********************************************************************************
* 函数名 :
* 功 能 :
* 说 明 : none
* 入 参 : si_num : 待读的 线圈|寄存器 个数
* uiAddr : 地址索引 从0起始
* *puc_txpointer : 输出缓存
* 返 回 : eStatus : 处理结果
********************************************************************************/
eMBErrorCode AppFMD_RdRegs(uint32_t lNum, uint32_t ulAddr,uint8_t *puc_txpointer)
{
eMBErrorCode eStatus = MB_ENOERR;
//内部参数
if(ulAddr<1000)
{
while(lNum)
{
puc_txpointer = BuiltReadWord(ulAddr, puc_txpointer);
lNum -= 1;
ulAddr += 1;
}
}
//用户自定义参数
else if(ulAddr>=1000 && ulAddr<1400)
{
while(lNum)
{
puc_txpointer = BuiltReadWord_Double(ulAddr, puc_txpointer);
lNum -= 2;
ulAddr += 2;
}
}
else
eStatus = MB_ENOREG;
return eStatus;
}
4.3.3 用户自定义的 线圈 & 寄存器 处理
根据 4.3.2,在对应的函数写具体到某个地址位的操作就行了。
五、通讯报文讲解
这里是方便没接触过Modbus的人,或者是长时间没用急用,直接来查阅Modbus报文的;好清楚是哪里出的问题(主机 or 从机?)。
5.1 报文格式总结
首先,如果从机是返回很短的(报错)报文,直接看功能码位,例如 0x83 ;去掉 & 0x80 的操作,那就是 功能码 0x03 出现错误。
- 主机的读取数据命令(长度)是固定的:ID + 功能码 + 地址 + 数据长度 + CRC16
- 从机返回的数据格式不是固定的:
- 读取长度为1:ID + 功能码 + 数据长度 + 数据1 + CRC16
- 读取长度为2:ID + 功能码 + 数据长度 + 数据1 + 数据2 + CRC16
- 主机的写入数据格式不是固定的
- 从机返回数据格式(长度)是固定的(与上面的相反)
5.2 报文举例
功能码0x03,读可读写模拟量寄存器:
- (主机)发送命令格式:
- [设备地址] [功能码03] [起始寄存器地址高8位] [低8位] [读取的寄存器数高8位] [低8位] [CRC校验低8位] [CRC校验高8位]
- 例:[11][03][00][6B][00][03][CRC低][CRC高]
- 意义如下:
- 11:设备地址,例子中的地址是11;
- 03:读模拟量的命令号固定为03,这是Modbus协议规定的。
- 00、6B:起始地址高8位(00)、低8位(6B):表示想读取的模拟量的起始地址,比如例子中的起始地址为107。这个006B表示一个完整的地址,注意这里的地址是高8位在前,低8位在后。
- 00、03:寄存器数高8位(00)、低8位(03):表示从起始地址开始读多少个模拟量(返回的每一个模拟量是用两个字节表示的)。例子中为3个模拟量。注意,在返回的信息中一个模拟量需要返回两个字节同时这里的地址也是高8位在前,低8位在后。
- [CRC低][CRC高]:帧尾的CRC-16校验,尤其需要注意的一点是校验结果的低8位在前,高8位在后,这个顺序不同于起始地址以及读取深度的地址顺序。
- (从机)设备响应:
- [设备地址] [命令号03] [返回的字节个数][数据1][数据2]…[数据n][CRC校验的低8位] [CRC校验的高8位]
- 例:[11][03][06][02][2B][00][00][00][64][CRC低][CRC高]
- 意义如下:
- 11:设备地址(从机地址);
- 03:功能码;
- 06:返回的字节个数(不高扩两字节的校验码):表示数据的字节个数,也就是数据1,2…n中的n的值。例子中返回了3个模拟量的数据,因为一个模拟量需要2个字节所以共6个字节。
数据1…n:其中[数据1][数据2]分别是第1个模拟量的高8位和低8位,[数据3][数据4]是第2个模拟量的高8位和低8位,以此类推。例子中返回的值分别是555,0,100。 - [CRC低][CRC高]:CRC校验同上。
六、基于W5500的Modbus TCP
如果是理解了上面源码的讲解,那么这里将会异常简单。由于W5500芯片,集成了硬件TCP/IP协议,数据接收完成与否的判断,也在W5500内完成;因此,FreeModbus库内的TCP函数大部分都不需要用到(例如,初始化函数),只需要FreeModbus库的 TCP_Poll事件状态机(初始化要置位事件状态)。
先根据W5500的数据手册、或者例程,先编写好W5500的通讯驱动;程序中,读取W5500中断,如果有产生接收完成中断,就把TCP_Poll事件状态机置为接收完成,然后接下来就是TCP_Poll自己处理了,沿用同一套的数据单元处理功能函数。这样就完成了!
七、FreeModbus库_拓展
Freemodbus库虽然写得很好,但是它的思路框架是以1个通讯接口实现的。如果是在 Modbus主机
一节提到的:有多串口Modbus通讯,共享一套地址处理数据。当你多串口RTU通讯,且波特率不相同,定时器的配置也就要变动一下。甚至还有,一个实际产品,它不仅有Modbus 多串口通讯,它还可能要有Modbus TCP通讯,而且也还是共享一套地址处理数据。