$ hexdump -C /dev/ttyUSB0  # RS485 抓一帧看看

Modbus RTU 报文怎么拼?CRC16 校验从多项式到 C# 代码:一帧报文逐字节拆给你看

2026-09-25  //  纯技术 · 串口协议 · C# · RS485
首页 / 行业资讯 / Modbus RTU报文怎么拼

一句话说清楚:Modbus RTU 一帧 = 从机地址(1) + 功能码(1) + 数据(N) + CRC16校验(2),帧与帧之间靠至少 3.5 个字符时间的总线静默来分隔。主站发请求、从站原样回应,CRC16 用多项式 0xA001 算出来,且低字节在前、高字节在后。报文拼错一个字节,从机根本不搭理你。

01RTU 帧到底长什么样?

很多人第一次调 485,发了一串字节过去从机毫无反应,问题八成出在帧格式上。RTU 模式下字节是紧凑连续发送的,帧内字节间隔不能超过 1.5 个字符时间,否则接收方会认为这一帧断了。

帧结构就四段,没有起始符、没有结束符——这是它和 ASCII 模式最大的区别,分帧全靠时间:

字段长度说明
地址1 字节从机站号 1~247,0 是广播
功能码1 字节告诉从机干什么,如 0x03 读保持寄存器
数据N 字节寄存器地址、数量、写入值等
CRC162 字节前面所有字节的校验,低字节先发

02读 10 个保持寄存器:一帧报文逐字节拆

看个最常见的例子。主站要读 1 号从机、从寄存器地址 0 开始、连续 10 个保持寄存器,发出去的 8 个字节是:

01从机地址
03功能码
00 00起始地址
00 0A寄存器数量
C5 CDCRC16

逐块解释:00 00 是起始寄存器地址,注意寄存器地址占两个字节、高字节在前;00 0A 是数量,0x0A 就是十进制 10。最后两个字节 CRC 用后面讲的算法算,结果是 0xCDC5——但发送顺序反过来,先发 0xC5 再发 0xCD。

从机回应长这样(假设 10 个寄存器值都是 0):

01地址原样回
03功能码原样回
14字节数=20
00 00 ...20字节数据
.. ..CRC16

响应里多了一个字节数字段: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 位就是校验值。

TIP // 0x8005 和 0xA001 的关系两个数不是两种校验,就是同一个多项式的位序镜像。你代码里用 0xA001 逐位算,和查 Modbus 官方文档的 0x8005 结果完全一致。别在网上抄到别的多项式(比如 0x1021 是 CRC-CCITT 的),算出来全错还找不到原因。

05C# 完整实现:逐位计算法

逐位法最直观,8 个字节的帧算起来毫无性能压力,调试阶段建议先用它:

Crc16Modbus.cs
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字节序的两个坑,踩中一个就不通

WARN // 坑一:CRC 字节顺序函数返回 ushort 后,必须拆成低字节在前塞进帧尾。图省事直接 BitConverter.GetBytes(crc) 在小端机器上恰好是对的,但那是碰运气——代码换个环境或加个翻转,通信立马挂。老老实实按位与拆分。
WARN // 坑二:寄存器地址和数据是大端和 CRC 正好相反,寄存器地址、数量、寄存器值全部高字节在前(Modbus 叫 big-endian)。读地址 256 要发 01 00,不是 00 01。而 32 位数据跨两个寄存器时顺序更乱(ABCD/CDAB/BADC/DCBA 四种排列都有设备用),以设备手册为准。

你可能会问,为什么一张协议里两种字节序并存?历史遗留。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怎么验证自己拼的报文对不对?

  1. 先算 CRC 自测:用标准例帧 01 03 00 00 00 0A C5 CD 验证你的函数;
  2. 抓波形或抓包:USB 转 485 加串口助手,或在主站回环口 hexdump,逐字节对比;
  3. 日志同时记录收发:带时间戳,超时重发的帧也记,现场排查全靠它;
  4. 看异常码:从机回的功能码最高位被置 1(如 0x83),后面跟异常码 02 说明你地址读了不存在的寄存器,这时候至少物理链路是通的。
TIP // 调试顺序从机静默 → 先查站号、波特率、校验位、485 方向;回异常码 → 查功能码、地址、数量;偶尔超时 → 查帧间隔、接地、终端电阻。按这个顺序排,十次有八次五分钟内解决。

今天就可以动手做一件事:把上面那段 Crc16 函数敲进你的工程,喂进 01 03 00 00 00 0A,确认吐出 0xCDC5。这一步过了,再把你要发的真实功能码和寄存器地址代进去,逐字节打印出来,对着设备手册一格一格核对。Modbus 调试没有玄学,每一个不通的字节,最终都会在 hexdump 里现出原形。

// tags: modbus-rtu · crc16 · rs485 · serialport · protocol · csharp