3、RTU帧格式:起始位、地址码、功能码、数据区、CRC校验
咱们搞Modbus RTU,说白了就是在跟一串字节流打交道。这串字节流怎么组织、怎么解析,就是帧格式要解决的问题。我刚开始接触RTU时,总觉得这东西玄乎,后来亲手抓了几次波形,才发现——嗯,其实就是个“信封”结构。
一句话总结:RTU帧 = 地址码 + 功能码 + 数据区 + CRC校验。没有起始位和停止位标记,靠“静默时间”来切分帧。
3.1 帧结构总览
先看一张我手绘的结构图,帮你建立整体印象:
你看,整个帧就是这四部分拼起来的。没有额外的起始位和停止位——别误会,物理层上的起始位/停止位是UART的事,跟Modbus协议层无关。协议层的“帧起始”靠的是总线空闲时间来判断。
我的习惯:调试时先用示波器看UART波形,确认物理层没问题,再分析协议层帧格式。两步走,不慌。
3.2 地址码——谁在说话?
地址码占1个字节,范围0~247。0是广播地址,所有从机都得听,但不回复。1~247是单播地址,每个从机一个。
我在项目里遇到过一个问题:有人把地址设成了0,结果从机死活不回复。查了半天,原来是广播地址用错了场景。你想想看,广播地址是主机发命令让所有从机同时执行,比如“全部复位”。但从机收到广播后不能回数据,否则总线就打架了。
| 地址值 | 用途 | 从机行为 |
|---|---|---|
| 0 | 广播地址 | 执行命令,不回复 |
| 1~247 | 单播地址 | 执行命令,回复响应 |
| 248~255 | 保留 | 不建议使用 |
注意:地址码是帧的第一个字节。主机发请求时填目标从机地址,从机回复时填自己的地址。千万别搞反了——我曾经见过有人把主机地址填进去,结果从机根本不认。
3.3 功能码——要干什么?
功能码1个字节,告诉从机要执行什么操作。常用的就那么几个:
- 0x01:读线圈状态(DO)
- 0x02:读离散输入(DI)
- 0x03:读保持寄存器(AO)
- 0x04:读输入寄存器(AI)
- 0x05:写单个线圈
- 0x06:写单个寄存器
- 0x0F:写多个线圈
- 0x10:写多个寄存器
功能码还有个“异常镜像”机制。如果从机执行出错,会把功能码的最高位置1,比如0x03变成0x83,然后后面跟一个错误码。我调试时经常用这个来快速定位问题——从机回0x83,说明它不支持这个功能或者参数不对。
3.4 数据区——具体参数
数据区长度可变,0~252字节。具体内容取决于功能码:
- 读操作:数据区包含起始地址和读取数量。比如读寄存器0x0000开始的10个寄存器,数据区就是
00 00 00 0A。 - 写操作:数据区包含地址、数据值。比如写单个寄存器,数据区是
00 01 00 FF(地址0x0001,值0x00FF)。 - 响应帧:数据区包含字节计数和实际数据。比如读10个寄存器成功,数据区是
14(20个字节)后面跟20个字节的数据。
避坑指南:我曾经在数据区里把地址搞成了“寄存器编号”而不是“偏移地址”。比如Modbus地址40001对应偏移0x0000,有人直接填40001,结果从机根本找不到。记住:数据区里填的是偏移地址,不是PLC里的点号。
3.5 CRC校验——数据对不对?
CRC校验占2个字节,校验范围是从地址码开始到数据区结束的所有字节。算法是CRC-16(Modbus),多项式0x8005,初始值0xFFFF。
这里有个坑:CRC在帧里是低字节在前,高字节在后。比如计算出的CRC是0x1234,那么帧里先发0x34,再发0x12。我刚开始写程序时没注意字节序,结果校验一直不过,折腾了一下午。
给你一个C语言参考实现:
uint16_t crc16_modbus(uint8_t *data, uint16_t len) {
uint16_t crc = 0xFFFF;
for (uint16_t i = 0; i < len; i++) {
crc ^= data[i];
for (uint8_t j = 0; j < 8; j++) {
if (crc & 0x0001) {
crc = (crc >> 1) ^ 0xA001;
} else {
crc >>= 1;
}
}
}
return crc;
}
我的经验:调试时先用PC串口助手抓一帧完整数据,然后用这个算法算一遍CRC,跟抓到的CRC对比。如果一致,说明通信链路没问题;如果不一致,八成是字节序或者计算范围搞错了。
3.6 帧间隔——怎么切分帧?
RTU没有帧起始和结束标记,靠的是“静默时间”。标准规定:
- 帧间间隔:至少3.5个字符时间(波特率相关,9600bps下约3.5ms)
- 帧内间隔:不能超过1.5个字符时间
说白了,如果总线空闲超过3.5个字符时间,就认为上一帧结束了,下一帧开始。如果帧内两个字节间隔超过1.5个字符时间,接收方就认为帧出错了,直接丢弃。
注意:有些廉价的USB转485模块,发送完一帧后会产生一个“尾巴”电平抖动,导致接收方误判为下一帧开始。我遇到过好几次,最后在软件里加了个5ms的延时才解决。硬件上建议用带自动换向的485芯片。
3.7 完整帧示例
看一个实际例子:主机读取地址为1的从机,从寄存器0x0000开始读2个寄存器。
请求帧:
01 03 00 00 00 02 C4 0B
| 字节 | 值 | 含义 |
|---|---|---|
| 1 | 0x01 | 从机地址 |
| 2 | 0x03 | 功能码:读保持寄存器 |
| 3~4 | 0x0000 | 起始地址 |
| 5~6 | 0x0002 | 读取数量 |
| 7~8 | 0xC40B | CRC校验(低字节0x0B在前) |
响应帧:
01 03 04 00 0A 00 14 3A 9F
| 字节 | 值 | 含义 |
|---|---|---|
| 1 | 0x01 | 从机地址 |
| 2 | 0x03 | 功能码 |
| 3 | 0x04 | 数据字节数(4个字节) |
| 4~5 | 0x000A | 寄存器0的值(10) |
| 6~7 | 0x0014 | 寄存器1的值(20) |
| 8~9 | 0x3A9F | CRC校验 |
你看,整个结构非常清晰。只要把地址、功能码、数据、CRC这四块拼好,就是一帧完整的RTU报文。调试时我习惯先用十六进制看原始数据,确认帧结构没问题,再解析具体数值。
最后说一句:RTU帧格式看着简单,但细节决定成败。字节序、CRC算法、帧间隔,任何一个地方出问题,通信就废了。我建议你手边常备一个Modbus调试工具,抓几帧数据对照着看,比死记硬背强得多。
公众号:蓝海资料掘金营,微信deep3321