一句话说清楚:Modbus RTU 一帧 = 从机地址(1) + 功能码(1) + 数据(N) + CRC16校验(2),帧与帧之间靠至少 3.5 个字符时间的总线静默来分隔。主站发请求、从站原样回应,CRC16 用多项式 0xA001 算出来,且低字节在前、高字节在后。报文拼错一个字节,从机根本不搭理你。
01RTU 帧到底长什么样?
很多人第一次调 485,发了一串字节过去从机毫无反应,问题八成出在帧格式上。RTU 模式下字节是紧凑连续发送的,帧内字节间隔不能超过 1.5 个字符时间,否则接收方会认为这一帧断了。
帧结构就四段,没有起始符、没有结束符——这是它和 ASCII 模式最大的区别,分帧全靠时间:
| 字段 | 长度 | 说明 |
|---|---|---|
地址 | 1 字节 | 从机站号 1~247,0 是广播 |
功能码 | 1 字节 | 告诉从机干什么,如 0x03 读保持寄存器 |
数据 | N 字节 | 寄存器地址、数量、写入值等 |
CRC16 | 2 字节 | 前面所有字节的校验,低字节先发 |
02读 10 个保持寄存器:一帧报文逐字节拆
看个最常见的例子。主站要读 1 号从机、从寄存器地址 0 开始、连续 10 个保持寄存器,发出去的 8 个字节是:
逐块解释:00 00 是起始寄存器地址,注意寄存器地址占两个字节、高字节在前;00 0A 是数量,0x0A 就是十进制 10。最后两个字节 CRC 用后面讲的算法算,结果是 0xCDC5——但发送顺序反过来,先发 0xC5 再发 0xCD。
从机回应长这样(假设 10 个寄存器值都是 0):
响应里多了一个字节数字段:10 个寄存器每个 2 字节,0x14 即 20。解析响应时按这个长度取数据,别按请求里的寄存器数量自己想当然,虽然两者等价,但以从机给的字节数为准更稳。
03写操作报文怎么拼?06 和 16 别用混
写单个寄存器用 0x06,写多个用 0x10(十进制 16)。报文差异不小:
| 操作 | 功能码 | 数据段内容 |
|---|---|---|
| 写单个 | 0x06 | 寄存器地址(2) + 写入值(2) |
| 写多个 | 0x10 | 起始地址(2) + 数量(2) + 字节数(1) + 值(N) |
比如给 1 号从机的地址 1 寄存器写值 0x0005,请求帧:01 06 00 01 00 05 + CRC。注意 06 的响应是原封不动把请求 echo 回来,收到和发送一模一样的帧才算成功。
写多个的帧里那个字节数 = 寄存器数量 × 2,这是新手最容易漏的字节。我早年调一个变频器,写频率死活不成功,查了半小时就是漏了这个字节数——从机收到帧长度对不上,直接静默丢弃。
04CRC16 到底怎么算?先说原理
CRC 的思路是把整帧数据当成一个超长二进制数,对一个约定好的生成多项式做模 2 除(异或运算,没有进位),余下来的 16 位就是校验值。
- Modbus 用的多项式正常写法是
0x8005(二进制 1000 0000 0000 0101); - 但因为 RTU 规定低位先传输,算法实现时位序反转,就变成了大家代码里常见的
0xA001; - 初始值 0xFFFF,结果不取反、不异或最终掩码。
05C# 完整实现:逐位计算法
逐位法最直观,8 个字节的帧算起来毫无性能压力,调试阶段建议先用它:
public static ushort Crc16(byte[] buffer, int start, int len) { ushort crc = 0xFFFF; for (int i = start; i < start + len; i++) { crc ^= buffer[i]; for (int j = 0; j < 8; j++) { // 最低位为1:右移后异或多项式 if ((crc & 0x0001) != 0) crc = (ushort)((crc >> 1) ^ 0xA001); else crc >>= 1; } } return crc; } // 拼帧时注意顺序:返回值低字节在前 ushort c = Crc16(frame, 0, frame.Length); frame[frameLen] = (byte)(c & 0xFF); // 低字节 frame[frameLen + 1] = (byte)(c >> 8); // 高字节
拿前面的例子验证:对 01 03 00 00 00 0A 这 6 个字节跑函数,返回值应该是 0xCD C5,低字节 0xC5 放前面。对不上就说明实现或输入有问题。
帧多、设备多时用查表法:把每个字节对应的 CRC 值预先算进一张 256 项的表,每字节一次异或加查表,省掉内层 8 次循环。结果一样,速度快好几倍,代码就不贴了,表可以让程序启动时用上面的函数生成,不用手抄。
06字节序的两个坑,踩中一个就不通
你可能会问,为什么一张协议里两种字节序并存?历史遗留。CRC 随传输习惯低位先算,数据字段按大端书写,当年就这么定的,后来者只能记住。
07分帧时间怎么定?3.5 字符到底是多久
RTU 靠静默时间分帧:帧间至少 3.5 个字符时间,帧内字节间隔不超过 1.5 个字符时间。一个字符 11 位(1 起始 + 8 数据 + 1 校验 + 1 停止),所以:
| 波特率 | 3.5 字符时间 |
|---|---|
9600 | 约 4 ms |
19200 | 约 2 ms |
≥115200 | 协议建议固定取 1.75 ms |
实际工程里,串口接收别死磕精确的 3.5 字符。更靠谱的做法是:收到第一字节启动定时器,后续字节不断重置,超时(按上面值留点余量)就认为一帧结束,然后先验 CRC、再解析。超时设太死,USB 转串口的调度抖动就会让你把好帧切成两半。
还有个 485 物理层的细节:半双工总线发送完要及时切换收发方向,切早了最后一个字节发不全,切晚了从机回应的开头被你自己挡掉。等发送缓冲区空(TC/TXE 标志)再翻转 DE/RE,这也是常见哑巴故障。
08怎么验证自己拼的报文对不对?
- 先算 CRC 自测:用标准例帧 01 03 00 00 00 0A C5 CD 验证你的函数;
- 抓波形或抓包:USB 转 485 加串口助手,或在主站回环口 hexdump,逐字节对比;
- 日志同时记录收发:带时间戳,超时重发的帧也记,现场排查全靠它;
- 看异常码:从机回的功能码最高位被置 1(如 0x83),后面跟异常码 02 说明你地址读了不存在的寄存器,这时候至少物理链路是通的。
今天就可以动手做一件事:把上面那段 Crc16 函数敲进你的工程,喂进 01 03 00 00 00 0A,确认吐出 0xCDC5。这一步过了,再把你要发的真实功能码和寄存器地址代进去,逐字节打印出来,对着设备手册一格一格核对。Modbus 调试没有玄学,每一个不通的字节,最终都会在 hexdump 里现出原形。
