理解MODBUS RTU协议

MODBUS与MODBUS RTU协议简介。这篇博客展示了MODBUS RTU请求与响应数据包的结构,并展示了如何使用AVR汇编对进行CRC-16计算。

--by Captdam @ Aug 20, 2023

[en] Here is the English version of this article

关于

最近我在开发一个从PLC中读取数据到一个自定义硬件系统,并在这个自定义硬件系统中处理数据的项目。

最开始我的计划是针对PLC上面的RS-232或者RS-485接口来编写一个自定义方程。比如说,当我发送0x01给PLC,PLC就返回一个方程1结果的数据包,当我发送0x02给PLC,PLC就返回一个方程2结果的数据包。

但是在研究PLC的说明文档时,我没有找到太多关于直接读写PLC的串口(RS-232或RS-485)的详细资料。但是,在这个读资料的过程中,MODBUS这个关键字吸引了我的注意。

通过阅读MODBUS的Wikipedia页面MODBUS的官网我发现MODBUS正是我所需要的。串口通讯版本的MODBUS(MODBUS RTU,即MODBUS Remote Terminal Unit)最早由Modicon(今天的施耐德电气)于1979年开发的一套非常“优雅”的协议。这套协议是开放的,并且已经成为了不少PLC和工业设备的标准支持协议。

为什么说非常“优雅”呢?因为MODBUS的结构非常简单且数据密度很高。MODBUS RTU数据包由三部分组成:

MODBUS packet structure
MODBUS包结构 - MODBUS应用协议规范V1.1b3截图

一个数据包(ADU,即Application Data Unit)包括了地址、数据与校验,其总长度最大为256字节。因为地址与校验分别占用1与2字节,数据部分(PDU,即Protocol Data Unit)最大可支持253字节。

对于多字节的数据,MODBUS定义高位先被发送。例如,一个16-bit比特的整数0x1234,首先发送0x12,再发送0x34。

我们可以定义一个256字节大小且256字节对齐的内存区块作为缓冲区。对于8-bit的单片机(AVR或者8051的XRAM)来说,我们可以使用一个高位被固定的16-bit指针。在微优化中,我们可以在准备好缓冲后就开始处理数据。我们每处理完一个数据后只给指针低位+1并在低位为0时结束处理。对于8-bit机来说,只测试指针低位要比完全测试节约不少算力。具体参考代码。

优雅,真是太优雅了!

MODBUS正确传输
MODBUS正确传输 - MODBUS应用协议规范V1.1b3截图

如很多常见的架构一样,MODBUS RTU是一个“请求-响应”类型的架构。主机(master)向特定从机(salve)发送一个请求,该从机就会返回一个响应。主机的发送端连接了所有从机的接收端,所有从机的发送端被连接在一条线路上并连接主机的接收端。

一次传输可分为两部分:

MODBUS错误传输
MODBUS错误传输 - MODBUS应用协议规范V1.1b3截图

当然,主机与从机会对数据包的准确性进行检测。此外,主机还包含了超时与重试功能以应对丢包的情况。

结构上,请求与响应数据包都拥有相似的地址部分与校验部分,但是在数据部分上,两者有所不同。

因为单个数据的读写操作在功能性上面等于在进行多个数据读写操作时只读写一个数据,本文我们只讨论多数据的读写操作。当然,在实际开发软件时,我们可以也包括对单数据读写操作的支持以降低MODBUS总线带宽需求。具体的细节可以参考MODBUS手册

读取目标PLC的数据

主机的请求数据:一个1字节长的功能代码,一个2字节长的起始地址,一个2字节长的数据长度(注意这里的单位是数据的个数而不是字节)。

从机的响应数据:一个1字节长的与请求包中相同的功能代码,一个1字节长的数据长度(注意这里的单位是数据的字节而不是个数),然后就是实际读取出来的数据。

对目标PLC写入数据

主机的请求数据:一个1字节长的功能代码,一个2字节长的起始地址,一个2字节长的数据长度(注意这里的单位是数据的个数),一个1字节长的数据长度(注意这里的单位是数据的字节),然后就是实际写进去的数据。

从机的响应数据:一个1字节长的与请求包中相同的功能代码,一个2字节长的起始地址,一个2字节长的数据长度(注意这里的单位是数据的个数而不是字节)。

MODBUS协议在1979年被发明,因此它不支持一些现代的高级功能。不过我要做的这个项目也不需要什么高级功能,MODBUS刚好够用。

示例学习

虽然大家总是说不要重复造轮子,但是很多时候学习已经造好的轮子可能比重新造轮子花费更多的时间与精力。而且,这也是一个学习的过程,对于自己造的轮子也会有更彻底的理解,在轮子不转时也知道该怎么修。

或许最好的学习方法之一就是使用一台支持MODBUS的PLC并研究这台PLC发送的请求,再将这个请求原封不动(或者修改几个比特看看错误数据会造成什么)地返回给PLC看看能给出什么响应。

在PLC上启用MODBUS

首先,我们要设置PLC让它支持MODBUS。

在PLC上启用MODBUS的程序
在PLC上启用MODBUS的程序

这个程序设置PLC,在串口上以9600 BAUD的速率启动MODBUS接口。这个程序定义了这台PLC在接收到MODBUS请求时的自机ID,以及在作为主机发送请求时的重试、间隔、超时设置。

观察请求包

假设,在现在,我们完全不知道一个MODBUS请求包长什么样子。我们写了一个PLC程序来发送一个“读取多个内存数据”的请求给我们的电脑。当然,我们的电脑不会返回任何响应。在现在,我们只关心这个请求长什么样子,至于响应长什么样子,那是下一步的任务。

在PLC上发送MODBUS请求的程序
在PLC上发送MODBUS请求的程序

这个程序让PLC的输入觉7接收到一个上升脉冲信号时发送一个Read registers指令来从slave 2读取3个数据。要读取的数据起始于从机的内存地址address 16,结果将会写入主机的address 32。

我们观测到了如下的数据:

0x02 0x03 0x00 0x10 0x00 0x03 0x04 0x3D

对于多字节的数据,MODBUS先发送高位,所以我们可以这样来看这个数据包:

  1. 0x02: 从机ID

  2. 0x03: 功能码 = 读取多个数据

  3. 0x0010: 起始地址 = 16

  4. 0x0003: 数据长度 = 3个数据

  5. 0x043D: 校验 = 0x3D40

观察响应包(传输无错误)

现在,我们已经有个一个请求包,我们把这个请求包发送回PLC来读取PLC的数据。

发送请求后,我们得到了如下响应:

0x02 0x03 0x06 0x30 0x39 0x00 0xF4 0x00 0xF3 0xAD 0xC7

即:

  1. 0x02: 从机ID

  2. 0x03: 功能码 = 读取多个数据

  3. 0x06: 数据长度 = 6字节

  4. 0x3039: 数据 @ register 16 = 12345

  5. 0x00F4: 数据 @ register 17 = 0x00F4

  6. 0x00F3: 数据 @ register 18 = 0x00F3

  7. 0xADC7: 校验 = 0xC7AD

观察响应包(传输有错误)

在修改一些比特后再次发送请求,我们得到了如下响应:

0x02 0x03 0x01 0x70 0xF0

即:

  1. 0x02: 从机ID

  2. 0x03: 功能码 = 读取多个数据

  3. 0x01: 错误码 = 01

  4. 0x70F0: 检验 = 0xF070

参考手册

接下来让我们对比MODBUS的手册与我们的试验结果。

MODBUS传输示例
MODBUS传输示例 - MODBUS应用协议规范V1.1b3截图

时序

现在,我们已经知道了MODBUS的响应中包括一个1字节长的响应的长度。通过这个数据,我们就可以推算出相应包的结尾(换句话说,在开始接收后的什么时候结束接收响应数据并进行下一步操作)。

然而,我们仍需要面对两个问题:

这将会导致我们的程序进入一个等待的死循环。

另一个方法是设置一个看门狗。当这个看门狗超时后,我们便判定为从机掉线或响应数据包结束。MODBUS规范指出,两个数据包之间的时间应大于发送3.5个字符的时长。

逻辑分析仪上MODBUS信号
逻辑分析仪上MODBUS信号

上图是逻辑分析仪记录的一个MODBUS数据传输回合。在该图中,MODBUS的波特率被设置为19200。可以看到,从机花费了约20个字符的时长来响应这个MODBUS请求。实际测量这个响应时长为12ms。而对于同一个包中每一个字符之间的间隔时间,大概为1-2比特。

由此可见,我们将需要两个不同的看门狗时钟。

CRC计算

MODBUS RTU是一个很基础的数据读写协议,数据包结构很好理解。唯一有意义在这里说的就是CRC校验的计算了。

MODBUS RTU使用的初始值为0xFFFF的CRC-16 0xA001AVR GCC提供了相关的库可以直接使用。关于CRC-16的具体程序代码,网上也有很多示例。参考如下AVR嵌入式的C代码:


#include 
#include 

volatile uint8_t modbus_buf[256] __attribute__ ((aligned(256)));

int main(void) {
	// Init
	modbus_buf[0xF8] = 0x02;
	modbus_buf[0xF9] = 0x03;
	modbus_buf[0xFA] = 0x00;
	modbus_buf[0xFB] = 0x10;
	modbus_buf[0xFC] = 0x00;
	modbus_buf[0xFD] = 0x03;
	modbus_buf[0xFE] = 0x04;
	modbus_buf[0xFF] = 0x3D;
	
	// Process
	uint16_t val = 0xFFFF;
	for (volatile uint8_t* ptr = modbus_buf + 0xF8; ptr < modbus_buf + 0x100; ptr++) {
		val ^= *ptr;
		for (uint8_t i = 0; i < 8; i++) {
			if (val & 0x0001) {
				val >>= 1;
				val ^= 0xA001;
			} else {
				val >>= 1;
			}
		}
		PORTD = val;
	}
}
	

我们首先将一个测试包写入我们为MODBUS分配的缓冲。这一个包包含了6个字节的数据与2字节的CRC校验。在循环中,我们特意加入了PORTD = val;来强制编译器输出校验和。因为数据长度为6,所以当第6次执行到PORTD = val;这一行代码时,16-bit的val将会包含这一个包的CRC-16校验。如果我们使用的这个测试包没有错误的话,那么当第8次执行到PORTD = val;这一行代码时,val的值应该是0x0000。

接下来进行编译。这里我们使用release mode来生成优化的程序。下面是生成的程序的反编译代码,这里只包括计算校验的核心部分,写入测试数据的步骤已被省略:


0000005D e8.ef                LDI R30,0xF8		Load immediate 
0000005E f1.e0                LDI R31,0x01		Load immediate 
0000005F 8f.ef                SER R24			Set Register 
00000060 9f.ef                SER R25			Set Register 
00000061 22.e0                LDI R18,0x02		Load immediate 
00000062 e0.30                CPI R30,0x00		Compare with immediate 
00000063 f2.07                CPC R31,R18		Compare with carry 
00000064 a0.f4                BRCC PC+0x15		Branch if carry cleared 
00000065 20.81                LDD R18,Z+0		Load indirect with displacement 
00000066 82.27                EOR R24,R18		Exclusive OR 
00000067 28.e0                LDI R18,0x08		Load immediate 
00000068 ac.01                MOVW R20,R24		Copy register pair 
00000069 56.95                LSR R21			Logical shift right 
0000006A 47.95                ROR R20			Rotate right through carry 
0000006B 80.ff                SBRS R24,0		Skip if bit in register set 
0000006C 06.c0                RJMP PC+0x0007		Relative jump 
0000006D ca.01                MOVW R24,R20		Copy register pair 
0000006E 31.e0                LDI R19,0x01		Load immediate 
0000006F 83.27                EOR R24,R19		Exclusive OR 
00000070 30.ea                LDI R19,0xA0		Load immediate 
00000071 93.27                EOR R25,R19		Exclusive OR 
00000072 01.c0                RJMP PC+0x0002		Relative jump 
00000073 ca.01                MOVW R24,R20		Copy register pair 
00000074 21.50                SUBI R18,0x01		Subtract immediate 
00000075 91.f7                BRNE PC-0x0D		Branch if not equal 
00000076 8b.b9                OUT 0x0B,R24		Out to I/O location 
00000077 31.96                ADIW R30,0x01		Add immediate to word 
00000078 e8.cf                RJMP PC-0x0017		Relative jump 
00000079 80.e0                LDI R24,0x00		Load immediate 
0000007A 90.e0                LDI R25,0x00		Load immediate 
0000007B 08.95                RET 			Subroutine return 
0000007C f8.94                CLI 			Global Interrupt Disable 
0000007D ff.cf                RJMP PC-0x0000		Relative jump 
0000007E ff.ff                NOP 			Undefined
	

接下来在模拟器里面执行这个程序。下面记录了执行时关键步骤时的时间(cycle time),PC与CRC值:


CYCLE	PC	OP		val	Comments
1575	0x5B	STS 0x01FF,R24	-	准备完成,开始计算CRC
1693	0x76	OUT 0x0B,R24	0x8E31	计算完第1字节
1815	0x76	OUT 0x0B,R24	0xD140	计算完第2字节
1917	0x76	OUT 0x0B,R24	0xF0D0	计算完第3字节
2014	0x76	OUT 0x0B,R24	0x50F0	计算完第4字节
2116	0x76	OUT 0x0B,R24	0x4450	计算完第5字节
2223	0x76	OUT 0x0B,R24	0x3D04	计算完第6字节,该值将作为发送的数据包的CRC值
2315	0x76	OUT 0x0B,R24	0x003D	计算完第7字节
2407	0x76	OUT 0x0B,R24	0x0000	计算完第8字节,结果为0x0000则说明收到的数据包没有错误
CYCLE USED: 832 / (8 x 8) = 13 cycles/bit
	

可以看到,这个代码一点也不优雅。

既然MODBUS是1979年开发的协议,那么我们就应该用1979年的方法来写我们的代码才够优雅。下面是AVR嵌入式的汇编代码:


.dseg
.org	0x0200	$	MODBUS_RTU_BUFFER:	.byte	0x100

.cseg
.org	0x0000

.macro	lsr16 ; high-byte, low-byte
	lsr	@0
	ror	@1
.endmacro
.macro	eor16 ; dest-high-byte, dest-low-byte, src-high-byte, src-low-byte
	eor	@0, @2
	eor	@1, @3
.endmacro

.org	0x34					; ATmega328
reset:
	ldi	r16, 0x02
	sts	0x02F8, r16
	ldi	r16, 0x03
	sts	0x02F9, r16
	ldi	r16, 0x00
	sts	0x02FA, r16
	ldi	r16, 0x10
	sts	0x02FB, r16
	ldi	r16, 0x00
	sts	0x02FC, r16
	ldi	r16, 0x03
	sts	0x02FD, r16
	ldi	r16, 0x04
	sts	0x02FE, r16
	ldi	r16, 0x3D
	sts	0x02FF, r16

eth_check:
	ldi	r19, 0xA0			; R19:R18 = CRC 0xA001
	ldi	r18, 0x01
	ser	r17				; R17:R16 = Init value 0xFFFF
	ser	r16
	ldi	r31, high(MODBUS_RTU_BUFFER)
	ldi	r30, 0xF8
	eth_check_crc:
	ld	r0, z+
	eor	r16, r0
	lsr16	r17,r16				; crc = crc >> 1;
	brcc	eth_check_crc_b0		; crc >= (crc&1) ? 0xA001 0x0000 (previous bit 1 now in carry)
	eor16	r17,r16, r19,r18
	eth_check_crc_b0:
	lsr16	r17,r16
	brcc	eth_check_crc_b1
	eor16	r17,r16, r19,r18
	eth_check_crc_b1:
	lsr16	r17,r16
	brcc	eth_check_crc_b2
	eor16	r17,r16, r19,r18
	eth_check_crc_b2:
	lsr16	r17,r16
	brcc	eth_check_crc_b3
	eor16	r17,r16, r19,r18
	eth_check_crc_b3:
	lsr16	r17,r16
	brcc	eth_check_crc_b4
	eor16	r17,r16, r19,r18
	eth_check_crc_b4:
	lsr16	r17,r16
	brcc	eth_check_crc_b5
	eor16	r17,r16, r19,r18
	eth_check_crc_b5:
	lsr16	r17,r16
	brcc	eth_check_crc_b6
	eor16	r17,r16, r19,r18
	eth_check_crc_b6:
	lsr16	r17,r16
	brcc	eth_check_crc_b7
	eor16	r17,r16, r19,r18
	eth_check_crc_b7:
	tst	r30
	brne	eth_check_crc

	rjmp	eth_check
	

同样,我们先向缓冲写入测试包。在这一段汇编代码中,我们做了如下优化:

  1. 将循环8次的对字节内每个比特进行计算的循环展开以节省循环控制的消耗。

  2. 将缓冲区以256字节对齐,且在写入包缓冲时让最后一个字节位于内存地址0xNFF。这样就可以通过判断指针低位是否为0(速度最快的判断)判断是否达到包尾。

  3. 检测每个比特决定是否在为位移后进行相乘(XOR)时,先位移,然后判断是否有进位。对于AVR架构,在位移时会将最低为写入Carry,有基于Carry进行跳转的指令。

  4. 正确地使用寄存器。(AVR GCC基于一套规则分配寄存器,但是策略并不智能)

接下来进行编译:


0000004C 30.ea                LDI R19,0xA0		Load immediate 
0000004D 21.e0                LDI R18,0x01		Load immediate 
0000004E 1f.ef                SER R17			Set Register 
0000004F 0f.ef                SER R16			Set Register 
00000050 f2.e0                LDI R31,0x02		Load immediate 
00000051 e8.ef                LDI R30,0xF8		Load immediate 

00000052 01.90                LD R0,Z+			Load indirect and postincrement 
00000053 00.25                EOR R16,R0		Exclusive OR 
00000054 16.95                LSR R17			Logical shift right 
00000055 07.95                ROR R16			Rotate right through carry 
00000056 10.f4                BRCC PC+0x03		Branch if carry cleared 
00000057 13.27                EOR R17,R19		Exclusive OR 
00000058 02.27                EOR R16,R18		Exclusive OR 
00000059 16.95                LSR R17			Logical shift right 
0000005A 07.95                ROR R16			Rotate right through carry 
0000005B 10.f4                BRCC PC+0x03		Branch if carry cleared 
0000005C 13.27                EOR R17,R19		Exclusive OR 
0000005D 02.27                EOR R16,R18		Exclusive OR 
0000005E 16.95                LSR R17			Logical shift right 
0000005F 07.95                ROR R16			Rotate right through carry 
00000060 10.f4                BRCC PC+0x03		Branch if carry cleared 
00000061 13.27                EOR R17,R19		Exclusive OR 
00000062 02.27                EOR R16,R18		Exclusive OR 
00000063 16.95                LSR R17			Logical shift right 
00000064 07.95                ROR R16			Rotate right through carry 
00000065 10.f4                BRCC PC+0x03		Branch if carry cleared 
00000066 13.27                EOR R17,R19		Exclusive OR 
00000067 02.27                EOR R16,R18		Exclusive OR 
00000068 16.95                LSR R17			Logical shift right 
00000069 07.95                ROR R16			Rotate right through carry 
0000006A 10.f4                BRCC PC+0x03		Branch if carry cleared 
0000006B 13.27                EOR R17,R19		Exclusive OR 
0000006C 02.27                EOR R16,R18		Exclusive OR 
0000006D 16.95                LSR R17			Logical shift right 
0000006E 07.95                ROR R16			Rotate right through carry 
0000006F 10.f4                BRCC PC+0x03		Branch if carry cleared 
00000070 13.27                EOR R17,R19		Exclusive OR 
00000071 02.27                EOR R16,R18		Exclusive OR 
00000072 16.95                LSR R17			Logical shift right 
00000073 07.95                ROR R16			Rotate right through carry 
00000074 10.f4                BRCC PC+0x03		Branch if carry cleared 
00000075 13.27                EOR R17,R19		Exclusive OR 
00000076 02.27                EOR R16,R18		Exclusive OR 
00000077 16.95                LSR R17			Logical shift right 
00000078 07.95                ROR R16			Rotate right through carry 
00000079 10.f4                BRCC PC+0x03		Branch if carry cleared 
0000007A 13.27                EOR R17,R19		Exclusive OR 
0000007B 02.27                EOR R16,R18		Exclusive OR 
0000007C ee.23                TST R30			Test for Zero or Minus 
0000007D a1.f6                BRNE PC-0x2B		Branch if not equal 
0000007E cd.cf                RJMP PC-0x0032		Relative jump 
0000007F ff.ff                NOP 			Undefined 
	

接下来在模拟器里面执行这个程序。下面记录了执行时关键步骤时的时间(cycle time),PC与CRC值:


CYCLE	PC	OP		val	Comments
31	0x51	LDI R30,0xF8	0xFFFF	准备完成,开始计算CRC
73	0x7D	BRNE PC-0x2B	0x8E31	计算完第1字节
117	0x76	BRNE PC-0x2B	0xD140	计算完第2字节
157	0x76	BRNE PC-0x2B	0xF0D0	计算完第3字节
196	0x76	BRNE PC-0x2B	0x50F0	计算完第4字节
236	0x76	BRNE PC-0x2B	0x4450	计算完第5字节
227	0x76	BRNE PC-0x2B	0x3D04	计算完第6字节,该值将作为发送的数据包的CRC值
315	0x76	BRNE PC-0x2B	0x003D	计算完第7字节
353	0x76	BRNE PC-0x2B	0x0000	计算完第8字节,结果为0x0000则说明收到的数据包没有错误
CYCLE USED: 322 / (8 x 8) = 5.03 cycles/bit
	

效率提高了一倍多!

优雅,真是太优雅了!