理解MODBUS RTU协议
MODBUS与MODBUS RTU协议简介。这篇博客展示了MODBUS RTU请求与响应数据包的结构,并展示了如何使用AVR汇编对进行CRC-16计算。
MODBUS, MODBUS RTU, PLC, 串口, 工业控制, CRC
--by Captdam @ Aug 20, 2023关于
最近我在开发一个从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数据包由三部分组成:
一个1字节长的地址。
一串长度待定的数据。
一个2字节长的CRC校验。
一个数据包(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 RTU是一个“请求-响应”类型的架构。主机(master)向特定从机(salve)发送一个请求,该从机就会返回一个响应。主机的发送端连接了所有从机的接收端,所有从机的发送端被连接在一条线路上并连接主机的接收端。
一次传输可分为两部分:
主机发送请求。
被指定的从机处理请求并返回响应。
当然,主机与从机会对数据包的准确性进行检测。此外,主机还包含了超时与重试功能以应对丢包的情况。
结构上,请求与响应数据包都拥有相似的地址部分与校验部分,但是在数据部分上,两者有所不同。
因为单个数据的读写操作在功能性上面等于在进行多个数据读写操作时只读写一个数据,本文我们只讨论多数据的读写操作。当然,在实际开发软件时,我们可以也包括对单数据读写操作的支持以降低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,在串口上以9600 BAUD的速率启动MODBUS接口。这个程序定义了这台PLC在接收到MODBUS请求时的自机ID,以及在作为主机发送请求时的重试、间隔、超时设置。
观察请求包
假设,在现在,我们完全不知道一个MODBUS请求包长什么样子。我们写了一个PLC程序来发送一个“读取多个内存数据”的请求给我们的电脑。当然,我们的电脑不会返回任何响应。在现在,我们只关心这个请求长什么样子,至于响应长什么样子,那是下一步的任务。
这个程序让PLC的输入觉7接收到一个上升脉冲信号时发送一个Read registers
指令来从slave 2
读取3
个数据。要读取的数据起始于从机的内存地址address 16
,结果将会写入主机的address 32。
我们观测到了如下的数据:
0x02
0x03
0x00 0x10
0x00 0x03
0x04 0x3D
对于多字节的数据,MODBUS先发送高位,所以我们可以这样来看这个数据包:
-
0x02: 从机ID
-
0x03: 功能码 = 读取多个数据
-
0x0010: 起始地址 = 16
-
0x0003: 数据长度 = 3个数据
-
0x043D: 校验 = 0x3D40
观察响应包(传输无错误)
现在,我们已经有个一个请求包,我们把这个请求包发送回PLC来读取PLC的数据。
发送请求后,我们得到了如下响应:
0x02
0x03
0x06
0x30 0x39
0x00 0xF4
0x00 0xF3
0xAD 0xC7
即:
-
0x02: 从机ID
-
0x03: 功能码 = 读取多个数据
-
0x06: 数据长度 = 6字节
-
0x3039: 数据 @ register 16 = 12345
-
0x00F4: 数据 @ register 17 = 0x00F4
-
0x00F3: 数据 @ register 18 = 0x00F3
-
0xADC7: 校验 = 0xC7AD
观察响应包(传输有错误)
在修改一些比特后再次发送请求,我们得到了如下响应:
0x02
0x03
0x01
0x70 0xF0
即:
-
0x02: 从机ID
-
0x03: 功能码 = 读取多个数据
-
0x01: 错误码 = 01
-
0x70F0: 检验 = 0xF070
参考手册
接下来让我们对比MODBUS的手册与我们的试验结果。
时序
现在,我们已经知道了MODBUS的响应中包括一个1字节长的响应的长度。通过这个数据,我们就可以推算出相应包的结尾(换句话说,在开始接收后的什么时候结束接收响应数据并进行下一步操作)。
然而,我们仍需要面对两个问题:
-
如果从机掉线了或是UART总线断开导致没有返回数据怎么办?
-
如果这个长度数据被损坏使我们得到了一个错误的长度怎么办?
这将会导致我们的程序进入一个等待的死循环。
另一个方法是设置一个看门狗。当这个看门狗超时后,我们便判定为从机掉线或响应数据包结束。MODBUS规范指出,两个数据包之间的时间应大于发送3.5个字符的时长。
上图是逻辑分析仪记录的一个MODBUS数据传输回合。在该图中,MODBUS的波特率被设置为19200。可以看到,从机花费了约20个字符的时长来响应这个MODBUS请求。实际测量这个响应时长为12ms。而对于同一个包中每一个字符之间的间隔时间,大概为1-2比特。
由此可见,我们将需要两个不同的看门狗时钟。
-
看门狗1:响应超时。这个时间并没有在MODBUS规范中指出,实际需要参考从机的技术手册或进行一些试验。
-
看门狗2:数据包结束。这个时间可以设置为3.5字节时长。
CRC计算
MODBUS RTU是一个很基础的数据读写协议,数据包结构很好理解。唯一有意义在这里说的就是CRC校验的计算了。
MODBUS RTU使用的初始值为0xFFFF
的CRC-16 0xA001
。AVR 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
同样,我们先向缓冲写入测试包。在这一段汇编代码中,我们做了如下优化:
-
将循环8次的对字节内每个比特进行计算的循环展开以节省循环控制的消耗。
-
将缓冲区以256字节对齐,且在写入包缓冲时让最后一个字节位于内存地址0xNFF。这样就可以通过判断指针低位是否为0(速度最快的判断)判断是否达到包尾。
-
检测每个比特决定是否在为位移后进行相乘(XOR)时,先位移,然后判断是否有进位。对于AVR架构,在位移时会将最低为写入Carry,有基于Carry进行跳转的指令。
-
正确地使用寄存器。(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
效率提高了一倍多!
优雅,真是太优雅了!