http://lixingcong.github.io/2020/02/12/modbus-frame/

Modbus协议是通讯协议,广泛应用在设备之间的主从通讯。主站发送requset,从站作为response。

Slave: 工业自动化用语;响应请求; Master:工业自动化用语;发送请求; Server:IT用语;响应请求; Client:IT用语;发送请求; 在Modbus中,Slave和Server意思相同,Master和Client意思相同。

从机是有唯一的设备地址的,而主机本身是没有地址的。

在标准的modbus系统中,只有一个master设备,和最多247个slave设备(信息来源

每个slave设备有一个唯一的地址,用一个字节表示,0表示广播地址,其余地址(1~247,最大支持247个slave设备,248 ~255是被保留着的)为其它设备所用。地址将出现在modbus帧中,用于区分本帧是发给哪个slave设备,地址有时候被称为slave ID,下文用slave ID来表示这个值。

Modbus协议与底层物理层无关。其底层物理层常是RS232,RS422或RS485实现,也使用TCP或者UDP通讯。

PLC术语定义

PLC英文术语理解数据类型
Coil线圈开关输出信号,可读写布尔型
Discrete Input离散量输入信号,不能被写入布尔型
Input Register输入寄存器只能读,不能被写入WORD,2字节
Holding Register保持寄存器可读写WORD,2字节

Modbus中定义的两种数据类型:

  • Coil:位(bit)变量
  • Register:整型(Word,即16-bit)变量

PDU

Protocol Data Units简称PDU,是Modbus协议帧最小单元,由“功能码”+“数据”两个字段组成。

PDU与ADU关系

PDU封包完成后,对PDU进行更高一层的封包叫ADU,ADU直接发送给目标设备。

他们两者关系是:

  • PDU = Function Code + Data
  • ADU(ASCII或RTU) = Slave ID + PDU + Error check
  • ADU(TCP) = MBAP + PDU

请求帧PDU

请求帧PDU的“功能码”字段,用于指示从设备要执行哪种操作。不同的功能码,“数据”字段有不同定义

以03H功能码为例,请求帧PDU的“数据”字段需要包含以下信息:

  • 起始寄存器地址
  • 要读多少个寄存器

回应帧PDU

以03H功能码为例

如果从站能正确处理请求,response的“功能码”字段与requset功能码一致,回应帧PDU“数据”字段需要包含以下信息:

  • 有几个寄存器
  • 这几个寄存器的内容

正常的回复0x03, 0x08, 0x00, 0x00, 0x00, 0x01, 0x00, 0x02, 0x00, 0x03,返回了4个寄存器的内容。

如果slave不能处理请求,或者发生错误,则slave会将错误代码替代原来的功能码回复给master端。错误代码的存在意义是让master端确认消息有效。

回应帧PDU的“功能码”字段错误码为request功能号的最高bit置1得到。因此大于0x80的功能号都是错误功能号。“数据”字段需要包含以下信息

常见的错误代码有:不支持的功能号(01H),错误的地址(02H)

出现错误的回复如0x83 0x01

slave对master的正确和错误命令的回应

装置对主站的正确命令的回应:

装置地址功能码数据区CRC校验
1个字节1个字节和主站下发命令的功能码相同N个字节2个字节(16位循环冗余校验码)

装置对主站的错误命令的回应:

装置地址功能码数据区CRC校验
1个字节1个字节,最高位置一即 = 功能码 | 0x801个字节错误编码2个字节(16位循环冗余校验码)

错误编码:

编码含义
1无效的报文类型
2无效的数据地址,包含数据长度越界
3写入的数据值无效
6装置忙

数据传输模式

数据传输这过程,又叫ADU封包,此时不考虑PDU。

常见的三种传输模式(transmission mode)为ASCII、RTU、TCP

ASCII

数据每个字节(8bit)会被编码成两个字节(16bit)的ASCII字符。使用LRC作为校验和。

有效码元hex值
'0'-'9'30H-31H
'A'-'F'41H-46H

该传输模式的主要优点是易于人类阅读究竟传输了什么字节,便于调试。

采用ASCII编码的ADU帧结构如下表

位置起始slave ID功能码数据N字节LRC末尾
帧内容':'2个ASCII2个ASCII2N个ASCII2个ASCII'\r\n'

每一帧以冒号(:)字符(3AH)开头,并以回车换行符(CRLF)作为结束(0DH和0AH)。

封包步骤

  1. 封装好PDU,即功能码加数据
  2. 将slaveID插到PDU首部,得到的二进制数据(称为A流)
  3. A流作为LRC的输入,得到LRC。点击下载LRC实现
  4. 将LRC追加到A流末尾,得到B流
  5. 将B流逐字节编码为ASCII字符,得到C流
  6. 将冒号(:)和CRLF(0x0D0A)分别加到C流头部尾部,完成封包

RTU

数据直接为二进制内容,报文必须以连续流的形式发送。使用CRC作为校验和。

采用RTU编码的ADU帧结构如下表

位置起始slave ID功能码数据N字节CRC末尾
帧内容3.5字符时间1字节1字节N字节2字节3.5字符时间

上表提到的字符时间对于串口传输有要求

  • 整个消息帧必须作为连续流传输。 如果在完成帧之前发生超过1.5个字符时间的静默间隔,则接收设备将丢弃未完成的消息,并假定下一个字节将是新消息的地址字段。
  • 如果新消息在前一条消息之后的3.5个字符时间之前开始,则接收设备将认为它是前一条消息的延续。会返回modbus错误。

封包步骤

  1. 封装好PDU,即功能码加数据
  2. 将slaveID插到PDU首部,得到的二进制数据(称为A流)
  3. A流作为CRC的输入,得到一个16bit的CRC。点击下载CRC实现
  4. 将CRC追加到A流末尾(注意Modbus协议规定CRC封包规则是小端序,即u16的低字节位放在内存地址前面),完成封包

TCP

基于以太网TCP/IP的TCP传输模式,适用于TCP或者UDP连接,默认端口为502。

要学习ModbusTCP,需要先了解MBAP包头,MBAP是ModbusTCP帧前7个字节。

MBAP主要有以下几个字段

字段字节数功能
Transaction ID2事务ID,默认值0,通常在于并发通讯中,区分不同的事务。常用于master在某时刻并发发送多个请求,而不必每次请求都要按顺序阻塞等待slave回复的场合
Protocol ID2协议ID,默认值0,用于区分自定义的协议,这个字段很像HTTP1.0、1.1、2.0的区别,用户可以根据值不同,自行实现特定的协议,如: 值0表示标准的Modbus TCP协议,类似HTTP1.0 值1表示用户实现加长版的数据帧,支持每帧10000字节传输,类似HTTP1.1 值2表示用户实现的一帧实现5个非连续的寄存器读取,类似HTTP2.0
Length2表示从长度字段后开始计算,本帧有多少字节
Unit ID1默认值0,类似于RTU中的Slave ID,用于非TCP/IP协议栈的场合,如桥接网卡下的区分对端。在标准TCP/IP协议下,此字段的值可以忽略,因为TCP/IP模型默认已经是一对一通讯,不需要区分对端。

Modbus TCP数据帧实际上是PDU加上7字节的MBAP而成。下图的左下角展示了Modbus TCP与PDU的关系,图的右上方展示了如何从串口RTU/ASCII帧去掉头部尾部得到PDU。

帧格式

位置Tran. IDProt. IDLengthUnit ID功能码数据N字节
帧内容2字节2字节2字节1字节1字节N字节

封包步骤

  1. 封装好PDU,即功能码加数据
  2. 封装好MBAP,其字段Transaction ID可以根据实际需要进行计数器自增
  3. MBAP放在PDU前,得到二进制流,完成封包

备注

  • Modbus TCP中PDU的编码方式,没有特别标明为ASCII还是RTU。为了提高传输效率,默认情况下为RTU,即直接二进制发送,而不经过复杂ASCII编码。
  • Modbus TCP协议有几种变种,如Modbus RTU over TCP/IP,其定义为Modbus TCP加上CRC末尾。不同变种可以参考维基百科

实践

以功能号04H为例,假设欲读取的slave ID为16,起始寄存器地址0,读取个数为2。

使用三种传输格式,均为大端存储,封包完成的二进制流对比:

字段RTUASCIITCP
头部':'MBAP
Slave ID0x0F"0F"0x0F
功能号0x04"04"0x04
起始地址H0x00"00"0x00
起始地址L0x00"00"0x04
寄存器个数H0x00"00"0x00
寄存器个数L0x02"02"0x02
校验和H0x71"F9"
校验和L0xCB
尾部CRLF

上面表格来源:Function 04 (04hex) Read Input Registers

常见功能码

功能码基本上是根据PLC术语定义的。

PLC英文术语理解数据类型
Coil线圈开关输出信号,可读写布尔型
Discrete Input离散量输入信号,不能被写入布尔型
Input Register输入寄存器只能读,不能被写入WORD,2字节
Holding Register保持寄存器可读写WORD,2字节

我习惯按读取数据类型分类功能号。参考常见的功能号

下表是读写布尔型(点击中文,电梯直达):

功能十进制十六进制英文中文最小数据单位
0101Read Coils读多个线圈bit(布尔值)
0505Write Single Coil写单个线圈bit(布尔值)
150FWrite Multiple Coils写多个线圈bit(布尔值)
0202Read Discrete Inputs读多个离散输入bit(布尔值)

下表是读写16bit数据(点击中文,电梯直达):

功能十进制十六进制英文中文最小数据单位
0404Read Input Registers读多个输入寄存器16bit
0303Read Holding Registers读多个保持寄存器16bit
0606Write Single Register写单个保持寄存器16bit
1610Write Multiple Registers写多个保持寄存器16bit

这些各种骚操作都是在PDU层,即只考虑“功能码”+“数据”,与ASCII和RTU等传输模式无关。

当PDU构造好后,才进行对应的ASCII、RTU、TCP等通讯模式封包。

读写布尔型

读多个线圈

功能号01H

读取从20到56号的线圈状态

master请求帧PDU

01 0014 0025
  • 01: 功能号01H
  • 0014: 14H是20号,作起始地址
  • 0025: 25H是十进制37,20到56共有37个线圈。注意该字段有效的值范围为0~2000

slave回应帧PDU

01 05 CD6BB20E1B
  • 01: 功能号01H
  • 05: 接下来有5个字节(37/8=5bytes)
  • CD: 线圈 27 - 20 (1100 1101) 高位为线圈27,低位为线圈20
  • 6B: 线圈 35 - 28 (0110 1011)
  • B2: 线圈 43 - 36 (1011 0010)
  • 0E: 线圈 51 - 44 (0000 1110)
  • 1B: 线圈 56 - 52 (0001 1011) 最后一字节仅提供低5位信息,其余位用0填充

返回的线圈每字节的高位比特,表示线圈数较大的布尔值;低位比特,表示线圈较小的布尔值。如上回复中,线圈43为高电平,线圈36为低电平。

写单个线圈

功能号05H

写线圈173号,设置为高电平(ON)

master请求帧PDU

05 00AD FF00
  • 05: 功能号05H
  • 00AD: ADH对应十进制173,作起始地址
  • FF00: 高电平,( FF00 = ON, 0000 = OFF )

若slave回应正常,则返回与master请求的相同PDU给master

05 00AD FF00

写多个线圈

功能号0FH

将线圈20到29写入新的值

master请求帧PDU

0F 0014 000A 02 CD01
  • 0F: 功能号0FH
  • 0014: 14H对应十进制20,作起始地址
  • 000A: 0AH对应十进制10,写入10个线圈(从20到29)。注意该字段有效的值范围为0~1968
  • 02: 接下来有2个字节(10/8=2bytes)
  • CD: 线圈 27 - 20 (1100 1101) 高位为线圈27,低位为线圈20
  • 01: 线圈 29 - 28 (0000 0001) 最后一字节仅提供低2位信息,其余位用0填充

返回的线圈每字节的高位比特,表示线圈数较大的布尔值;低位比特,表示线圈较小的布尔值。如上请求中,线圈20为高电平,线圈21为低电平。

slave回应帧PDU

0F 0014 000A
  • 0F: 功能号0FH
  • 0014: 14H对应十进制20,作起始地址
  • 000A: 0AH对应十进制10,写入10个线圈(从20到29)

读多个离散输入

功能号02H

PDU请求与答复,与读多个线圈操作一致。

读写16位

读多个输入寄存器

功能号04H

读取2个输入寄存器的值,从8开始。

master请求帧PDU

04 0008 0002
  • 04: 功能号04H
  • 0008: 8H对应十进制8,作起始地址
  • 0002: 读取2个寄存器。注意该字段有效的值范围为0~125

slave回应帧PDU

04 04 000A0003
  • 04: 功能号04H
  • 04: 接下来有4个字节,对应2个输入寄存器(16bit x 2 = 32bit)
  • 000A: 输入寄存器8的值
  • 0003: 输入寄存器9的值

读多个保持寄存器

功能号为03H

PDU请求与答复,与读多个输入寄存器操作一致。

写单个保持寄存器

功能号06H

向保持寄存器1写入新的值

master请求帧PDU

06 0001 0003

06: 功能号06H 0001: 1H对应十进制1,作起始地址 0003: 新的值

若slave回应正常,则返回与master请求的相同PDU给master

06 0001 0003

写多个保持寄存器

功能号10H

向PLC保持寄存器1和2写入新的值。

master请求帧PDU

10 0001 0002 04 000A 0102

10: 功能号10H 0001: 1H对应十进制1,作起始地址 0002: 寄存器个数=2。注意该字段有效的值范围为0~123 04: 接下来有4个字节 (2寄存器 x 2字节 = 4) 000A: 寄存器 1 的新值 0102: 寄存器 2 的新值

slave回应帧PDU

10 0001 0002

10: 功能号10H 0001: 1H对应十进制1,作起始地址 0002: 寄存器个数=2

数据字节序

8位数据

建议使用16位数据表示,因为Modbus协议最小数据都是16位的,若强行使用8位数据可能有数组越界风险,如char u8[11]由于是11字节,Modbus读写按照16位对齐,那么最后字节容易越界。

如果采用一个Modbus地址(u16数据)表示一个8位数据,那么就浪费了一个字节的通讯空间。本质上与直接使用寄存器没有什么区别。还不如直接使用u16数据类型,能表示的数值范围更大(65535比255要大不少)。

unsigned char  numbers[4] = {0x12, 0x34, 0x56, 0x78};
unsigned short regs[4]    = {numbers[0], numbers[1], numbers[2], numbers[3]};
writeMultiRegister(regs, 4);

如果采用一个Modbus地址(u16数据)表示两个8位数据,这样就不会有内存浪费。那么常用函数就是memcpy(dst, src, size),这样的应用场景有以下两种:

  • 字符串的传输
  • 紧凑型的结构体的传输(前提是发送方与接收方的字节序一致)
// 字符串的传输
unsigned char  str[4] = "abc";
unsigned short regs[2];
assert(sizeof(regs) >= sizeof(str));
memcpy(regs, str, sizeof(str));
writeMultiRegister(regs, 2);

// 紧凑型结构体传输
struct Data {
    int   a;
    float b;
};
Data data;
unsigned short regs[4];
assert(sizeof(regs) >= sizeof(data));
memcpy(regs, data, sizeof(data));
writeMultiRegister(regs, 4);

16位数据

Modbus官方协议文档中有一段话:

Data Encoding: Modbus uses a big-Endian representation for addresses and data items. This means that when a numerical quantity larger than a single byte is transmitted, the most significant byte is sent first.

在Modbus数据帧中,存储格式是大端,即对一个u16(unsigned short)的寄存器,值为0x1234,参考我的用C实现对u16大端编码解码,对应char数组应该是:

u8 data[2]={0x12, 0x34};

在下文数据传输模式内提到的封包步骤,需要遵循这个大端存储的规定。

32位数据

在满足u16寄存器存储遵循以上大端字节序条件下,也就是确保用03H功能码读取多个寄存器,每个u16值都是正确的,我们来考虑u32数据类型:

32位数据常见以下几种

  • long
  • int32/uint32
  • float

这里以无符号32位整数为例,u32需要用到两个u16寄存器,值分别为0x1234和0x5678,我们将这两个寄存器定义为

u16 reg[2]={0x1234, 0x5678};

两个寄存器内,从左到右四个字节定义为ABCD,即A=12, B=34, C=56, D=78。获取这四个字节的正确方法应该是用右移操作符,而不是扔一个8位指针指向这reg地址。与用C实现对u16大端编码解码对应一致的操作,下面代码用CPP验证一下。

#include <iostream>
// https://github.com/stephane/libmodbus/blob/master/src/modbus-data.c
int main()
{
	std::cout << std::hex << std::showbase;

	const unsigned short reg[2] = {0x1234, 0x5678};
	const char           name[] = "ABCD";

	std::cout << "There are two U16 registers: reg[0]=" << reg[0] << ", reg[1]=" << reg[1] << std::endl;

	std::cout << std::endl << "On Little-Endian PC, use u8 pointer to get ABCD is wrong:" << std::endl;
	const unsigned char* p8 = (const unsigned char*) (reg);
	for (int i = 0; i < 4; ++i)
		std::cout << name[i] << "=" << (int) *(p8++) << std::endl;

	std::cout << std::endl << "Please use right-shift and bitwise-AND to get ABCD:" << std::endl;
	std::cout << name[0] << "=" << (int) (reg[0] >> 8) << std::endl;
	std::cout << name[1] << "=" << (int) (reg[0] & 0xff) << std::endl;
	std::cout << name[2] << "=" << (int) (reg[1] >> 8) << std::endl;
	std::cout << name[3] << "=" << (int) (reg[1] & 0xff) << std::endl;

	return 0;
}

对A、B、C、D的四种组合有ABCD、CDBA、BADC、DCBA。那么对于这两个寄存器的值,会有四种不同的编码解码结果:

u32的字节序字母组合十六进制十进制
Big-endianABCD0x12345678305419896
Little-endianDCBA0x785634122018915346
Big-endian swap bytesBADC0x34127856873625686
Little-endian swap bytesCDAB0x567812341450709556

在仿真器软件Modbus Slave 6.2.0设置Format菜单中,对于long数据(32位),就有如下图几种解析两个寄存器的方式(7.0以上版本界面可能没有ABCD字样,但是是同样的表示):

因此在实际应用中,若使用两个寄存器来表示一个32位的数据,必须让master和slave都要采用同一套表示方法,否则得出的结果是错误的。

最常用的场合:x86架构采用Little-Endian数据表示法,即上文的DCBA组合。

64位数据

64位数据常见以下几种

  • int64/uint64
  • double

64位数据需要四个寄存器:

u16 reg[4]={0x0123, 0x4567, 0x89ab, 0xcdef};

// A=01, B=23, C=45, D=67
// E=89, F=ab, G=cd, H=ef 

与32位类似,这里就不重复讲,一表胜千言:

u64的字节序字母组合十六进制十进制
Big-endianABCDEFGH0x0123456789abcdef8.1985529e+16
Little-endianHGFEDCBA0xefcdab89674523011.7279656e+19
Big-endian swap bytesBADCFEHG0x23016745ab89efcd2.5224108e+18
Little-endian swap bytesGHEFCDAB0xcdef89ab456701231.4839231e+19

同理,若使用四个寄存器来表示一个64位的数据,必须让master和slave都要采用同一套表示方法。

最常用的场合:x86架构采用Little-Endian数据表示法,即上文的HGFEDCBA组合。

后记

  • 在PLC中,大部份地址1表示第一个线圈或者寄存器,而在PDU封包过程中,地址0表示第一个。因此实际可能有一定的偏移值,都是可以自定义的。
  • 如果整个主从设备都是自己设计,就无所谓什么读保持寄存器还是写保持寄存器,可能只用到两个功能号(03H和10H),如果是主从设备其中一个是别人做的,大家就必须要遵守Modbus通用协议了。

参考文章

MODBUS PROTOCOL

Modbus interface tutorial

PDF - INTRODUCTION TO MODBUS TCP/IP

仿真软件:Modbus Tool