从零吃透 Modbus 通信|第 2 章:Modbus RTU 帧结构超精讲,每一字节都要彻底读懂
2026/8/27 12:29:36 网站建设 项目流程

前言

上一章我们建立了 Modbus 的全局认知:主从一问一答、地址识别、功能码操作、寄存器读写、CRC 校验

但真正写代码时,这些概念还不够。

你必须能看懂每一帧数据长什么样子:

  • 主机发出去的请求帧到底是怎么组成的?
  • 从机返回的应答帧每一字节代表什么?
  • 为什么有时候设备会返回异常码?
  • CRC 校验错了,是哪一环节出了问题?

本章我们进入 Modbus RTU 的核心:帧结构

读完本章,你不需要再靠 “猜协议” 调试,而是能直接根据串口数据判断:这帧是读寄存器、写寄存器、异常响应,还是 CRC 错误。


1. Modbus RTU 帧总览

1.1 一帧数据到底由什么组成?

Modbus RTU 一帧通常由以下几部分组成:

字节字段作用
第 1 字节从站地址告诉总线要和哪个设备通信
第 2 字节功能码告诉设备要执行什么操作
第 3~4 字节寄存器起始地址从哪个寄存器开始读 / 写
第 5~6 字节寄存器数量 / 写入数据读多少个寄存器,或写入什么数据
最后 2 字节CRC 校验校验整帧数据是否正确

注意: 不同功能码的帧结构会有差异。 其中最常见、最容易踩坑的是:

  • 03 功能码:读保持寄存器
  • 06 功能码:写单个寄存器
  • 16 功能码:写多个寄存器
  • 异常响应帧

2. 最经典案例:03 功能码读保持寄存器

2.1 应用场景

03 功能码是工程中使用频率最高的功能码。

作用:

读取从设备的保持寄存器数据。

例如:

  • 读温度
  • 读电压
  • 读电流
  • 读转速
  • 读设备状态
  • 读 PID 参数

2.2 主机请求帧格式

主机发送一帧:

字节含义示例
1从站地址0x01
2功能码0x03
3起始地址高字节0x00
4起始地址低字节0x00
5寄存器数量高字节0x00
6寄存器数量低字节0x02
7CRC 低字节0x04
8CRC 高字节0x0B

示例解析:

01 03 00 00 00 02 04 0B

含义:

  • 从站地址:0x01
  • 功能码:0x03,读保持寄存器
  • 起始寄存器地址:0x0000
  • 读取寄存器数量:0x0002
  • CRC 校验:0x0B04

注意: Modbus 中多字节数据通常采用大端模式,也就是高字节在前,低字节在后。

所以:

00 00

代表寄存器起始地址:

0x0000

而不是 0x0000 反过来。


2.3 从机正常应答帧格式

如果从机收到合法请求,会返回数据。

应答帧格式如下:

字节含义示例
1从站地址0x01
2功能码0x03
3字节数0x04
4~5寄存器 1 数据0x00 0x0A
6~7寄存器 2 数据0x00 0x14
8CRC 低字节0xXX
9CRC 高字节0xXX

示例:

01 03 04 00 0A 00 14 XX XX

解析:

  • 从站地址:0x01
  • 功能码:0x03
  • 后续数据字节数:0x04
  • 第 1 个寄存器:0x000A
  • 第 2 个寄存器:0x0014
  • 最后两字节为 CRC 校验

这里非常关键:

读 N 个寄存器,返回的数据字节数 = N × 2。

因为一个寄存器是 2 字节。

例如:

  • 读 1 个寄存器 → 返回 2 字节数据
  • 读 2 个寄存器 → 返回 4 字节数据
  • 读 10 个寄存器 → 返回 20 字节数据

这一点写代码时必须用循环处理,不能写死长度。


3. 06 功能码写单个寄存器

3.1 应用场景

06 功能码用于写入单个寄存器。

常见用途:

  • 修改设备地址
  • 修改波特率
  • 设置转速
  • 设置 PID 参数
  • 控制设备启停
  • 修改报警阈值

3.2 主机写请求帧格式

字节含义示例
1从站地址0x01
2功能码0x06
3寄存器地址高字节0x00
4寄存器地址低字节0x01
5写入数据高字节0x00
6写入数据低字节0x0A
7CRC 低字节0xXX
8CRC 高字节0xXX

示例:

01 06 00 01 00 0A XX XX

含义:

  • 从站地址:0x01
  • 功能码:0x06
  • 寄存器地址:0x0001
  • 写入数据:0x000A
  • CRC 校验

3.3 从机正常应答

写成功后,从机通常会回显相同内容:

01 06 00 01 00 0A XX XX

这代表:

我已经收到并执行了你的写入命令。

注意: 不是所有设备都会严格回显,但标准 Modbus 设备通常会返回原写帧作为确认。


4. 16 功能码写多个寄存器

4.1 应用场景

06 功能码只能写单个寄存器。

如果需要批量写入多个参数,就要用16 功能码

典型场景:

  • 批量写入 PID 参数
  • 批量写入控制指令
  • 批量写入配置参数
  • 批量校准设备参数

4.2 主机请求帧格式

字节含义
1从站地址
2功能码:0x10
3起始地址高字节
4起始地址低字节
5寄存器数量高字节
6寄存器数量低字节
7写入数据字节数
8~多组寄存器数据
最后 2 字节CRC 校验

示例:

01 10 00 10 00 02 04 00 01 00 02 XX XX

解析:

  • 从站地址:0x01
  • 功能码:0x10
  • 起始地址:0x0010
  • 写入寄存器数量:0x0002
  • 后续数据字节数:0x04
  • 第 1 个寄存器写入:0x0001
  • 第 2 个寄存器写入:0x0002
  • CRC 校验

4.3 从机应答

写成功后,从机通常返回:

01 10 00 10 00 02 XX XX

含义:

  • 已确认从地址 0x0010 开始,写入 2 个寄存器。

5. 异常响应帧:设备为什么报错?

5.1 异常响应长什么样?

当主机发送非法指令时,从机不会正常返回数据,而是返回异常帧。

异常帧格式:

字节含义
1从站地址
2功能码最高位置 1
3异常码
4CRC 低字节
5CRC 高字节

例如:

01 83 02 XX XX

解析:

  • 从站地址:0x01
  • 功能码:0x83,说明原来的 0x03 请求发生异常
  • 异常码:0x02
  • CRC 校验

注意: 异常响应的功能码 = 原功能码 + 0x80。

例如:

  • 原功能码 0x03 → 异常码 0x83
  • 原功能码 0x06 → 异常码 0x86
  • 原功能码 0x10 → 异常码 0x90

这是写代码时判断异常响应的重要依据。


5.2 常见异常码对照表

异常码含义常见原因
0x01非法功能码设备不支持该功能码
0x02非法数据地址寄存器地址超出范围
0x03非法数据值写入数值不合法
0x04从设备故障设备内部错误
0x05确认设备正在处理,需要稍后重试
0x06从设备忙设备忙,主机稍后再发
0x08内存奇偶校验错误存储数据异常
0x0A不可用网关路径网关路径不可用
0x0B网关目标设备响应失败目标设备无响应

工程中最常见的三个异常码:

0x02:非法数据地址

通常说明:

  • 你读的寄存器地址设备没有
  • 寄存器地址越界
  • 起始地址 + 读取数量超过设备寄存器范围

例如:

设备只支持地址 0x0000~0x0009,你却读了 0x000A,就会返回 0x02。

0x03:非法数据值

通常说明:

  • 写入的数据超出设备允许范围
  • 参数格式错误
  • 设备不接受该数值

例如:

设备转速范围是 0~3000,你写入 6000,可能返回 0x03。

0x06:从设备忙

说明设备正在处理其他任务,主机应该稍后重试。

这也是主机程序必须做超时和重试机制的原因。


6. CRC16 校验:Modbus 的数据安全底线

6.1 CRC 在帧中的位置

Modbus RTU 每一帧最后两字节是 CRC 校验。

例如:

01 03 00 00 00 02 04 0B

最后两字节:

04 0B

但注意,Modbus RTU 中 CRC 存储方式是:

低字节在前,高字节在后。

所以实际 CRC 值是:

0x0B04

而不是 0x040B。

这一点新手极容易写错。


6.2 CRC16-MODBUS 标准算法

Modbus 使用标准 CRC16-MODBUS 算法。

初始值:

0xFFFF

多项式:

0xA001

计算范围:

从从站地址开始,到 CRC 之前的所有字节。

6.3 直接可用的 C 语言 CRC 代码

#include <stdint.h> uint16_t Modbus_CRC16(uint8_t *data, uint16_t length) { uint16_t crc = 0xFFFF; uint16_t i, j; for(i = 0; i < length; i++) { crc ^= data[i]; for(j = 0; j < 8; j++) { if(crc & 0x0001) { crc >>= 1; crc ^= 0xA001; } else { crc >>= 1; } } } return crc; }

6.4 如何使用?

例如你构造了一帧请求:

uint8_t tx_buf[] = {0x01, 0x03, 0x00, 0x00, 0x00, 0x02};

计算 CRC:

uint16_t crc = Modbus_CRC16(tx_buf, sizeof(tx_buf));

然后把 CRC 低字节和高字节追加到帧尾:

tx_buf[6] = crc & 0xFF; tx_buf[7] = (crc >> 8) & 0xFF;

注意顺序:

tx_buf[6] = crc_low tx_buf[7] = crc_high

这是 Modbus RTU 标准要求。


7. 主机如何判断一帧数据是否合法?

7.1 主机接收判断流程

主机收到从机应答后,必须按以下顺序判断:

第一步:判断帧长度是否足够

Modbus RTU 最短帧至少也要包含:

  • 从站地址
  • 功能码
  • CRC 低字节
  • CRC 高字节

如果长度不足,直接丢弃。

第二步:判断从站地址是否匹配

收到的地址必须等于目标从站地址,或为广播地址。

第三步:判断是否为异常响应

如果功能码最高位为 1,说明是异常响应。

例如:

if(rx_buf[1] & 0x80) { // 异常响应 }

第四步:重新计算 CRC 并对比

主机必须重新计算接收帧的 CRC,再和帧尾 CRC 对比。

只有相等,才认为数据有效。

第五步:根据功能码解析数据

如果是 03 功能码,按读寄存器解析。

如果是 06 功能码,按写确认解析。


7.2 主机解析 03 功能码示例代码

uint8_t Modbus_Parse_Read_Holding(uint8_t *rx_buf, uint16_t rx_len, uint16_t *reg_data, uint16_t reg_num) { uint16_t crc_calc; uint16_t crc_rx; uint16_t i; if(rx_len < 5) { return 0; } if(rx_buf[0] != 0x01) { return 0; } if(rx_buf[1] == 0x83) { // 异常响应 return rx_buf[2]; } if(rx_buf[1] != 0x03) { return 0; } if(rx_buf[2] != reg_num * 2) { return 0; } crc_calc = Modbus_CRC16(rx_buf, rx_len - 2); crc_rx = (uint16_t)rx_buf[rx_len - 2] | ((uint16_t)rx_buf[rx_len - 1] << 8); if(crc_calc != crc_rx) { return 0; } for(i = 0; i < reg_num; i++) { reg_data[i] = ((uint16_t)rx_buf[3 + i * 2] << 8) | rx_buf[4 + i * 2]; } return 1; }

这段代码已经具备工程雏形。

它判断了:

  • 帧长度
  • 地址
  • 异常响应
  • 功能码
  • 数据字节数
  • CRC 校验
  • 寄存器数据解析

8. 从机如何处理主机请求?

8.1 从机处理流程

从机收到一帧后,通常按以下流程处理:

1. 判断地址是否匹配

如果不是本机地址,也不是广播地址,直接忽略。

2. 计算 CRC

如果 CRC 错误,直接丢弃。

3. 判断功能码

根据功能码进入不同处理函数。

4. 判断寄存器地址是否合法

如果地址越界,返回异常码 0x02。

5. 判断写入数据是否合法

如果数据范围错误,返回异常码 0x03。

6. 执行读 / 写操作

读取寄存器或写入寄存器。

7. 构造应答帧

按照 Modbus 格式返回应答。


8.2 从机处理 03 功能码示例代码

uint8_t Modbus_Slave_Process(uint8_t *rx_buf, uint16_t rx_len, uint8_t *tx_buf, uint16_t *tx_len) { uint16_t crc_calc; uint16_t crc_rx; uint16_t start_addr; uint16_t quantity; uint16_t i; if(rx_len < 8) { return 0; } if(rx_buf[0] != 0x01) { return 0; } crc_calc = Modbus_CRC16(rx_buf, rx_len - 2); crc_rx = (uint16_t)rx_buf[rx_len - 2] | ((uint16_t)rx_buf[rx_len - 1] << 8); if(crc_calc != crc_rx) { return 0; } if(rx_buf[1] == 0x03) { start_addr = ((uint16_t)rx_buf[2] << 8) | rx_buf[3]; quantity = ((uint16_t)rx_buf[4] << 8) | rx_buf[5]; if(start_addr + quantity > 10) { tx_buf[0] = 0x01; tx_buf[1] = 0x83; tx_buf[2] = 0x02; *tx_len = 5; Modbus_Append_CRC(tx_buf, tx_len); return 1; } tx_buf[0] = 0x01; tx_buf[1] = 0x03; tx_buf[2] = quantity * 2; for(i = 0; i < quantity; i++) { tx_buf[3 + i * 2] = 0x00; tx_buf[4 + i * 2] = Holding_Reg[start_addr + i]; } *tx_len = 3 + quantity * 2; Modbus_Append_CRC(tx_buf, tx_len); return 1; } return 0; }

其中补充一个追加 CRC 的函数:

void Modbus_Append_CRC(uint8_t *buf, uint16_t *len) { uint16_t crc = Modbus_CRC16(buf, *len); buf[*len] = crc & 0xFF; buf[*len + 1] = (crc >> 8) & 0xFF; *len += 2; }

9. 新手最容易写错的 5 个点

9.1 CRC 顺序错了

Modbus RTU 中:

CRC低字节在前 CRC高字节在后

很多人写成:

tx_buf[6] = (crc >> 8) & 0xFF; tx_buf[7] = crc & 0xFF;

这会导致从机校验失败,不响应。

9.2 寄存器地址没有加偏移

很多设备手册里写的寄存器地址是1 起始地址

例如手册写:

温度寄存器地址:00001

实际代码里通常要减 1,变成:

0x0000

否则会多读一个地址偏移。

9.3 没有处理异常响应

从机返回异常码后,主机如果继续按正常帧解析,会导致数据错误。

9.4 写死帧长度

读 1 个寄存器和读 10 个寄存器的帧长度不同。

代码必须根据数据字节数动态解析。

9.5 没有超时机制

从机离线后,主机如果一直等待,程序会卡死。

主机必须设置超时:

#define MODBUS_TIMEOUT_MS 100

超过 100ms 没有应答,判定失败。


10. 本章总结

本章我们彻底拆解了 Modbus RTU 最核心的帧结构。

你现在应该能读懂:

  • 03 功能码读保持寄存器请求帧
  • 03 功能码应答帧
  • 06 功能码写单个寄存器
  • 16 功能码写多个寄存器
  • 异常响应帧
  • CRC 校验帧

核心记忆点:

  1. Modbus RTU 帧 = 地址 + 功能码 + 数据 + CRC
  2. 03 功能码最常用,用于读传感器、电压、转速、参数
  3. 06 功能码用于写单个寄存器
  4. 16 功能码用于批量写多个寄存器
  5. 异常响应功能码 = 原功能码 + 0x80
  6. CRC 必须低字节在前,高字节在后
  7. 从机不主动说话,主机必须做超时判断

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询