跳至内容

Modbus 学习笔记

2026-11-07

Modbus 是什么

Modbus 于 1979 年由 Modicon(现施耐德)发布的主从式串行通信协议,运行在 OSI 模型的应用层。它开放、免费、实现简单,是工业现场 PLC、变频器、传感器的事实标准通信协议。上位机(PC/工控机)通常作为 Master(主站/客户端),主动轮询下位机设备。

有以下分类:

Modbus RTUModbus ASCIIModbus TCP
载体串口串口以太网
编码方式二进制ASCII 字符二进制(封装于 TCP)
地址字段从站地址(1~247)同左单元标识符
校验CRC 16LRC无(TCP 自带可靠性)
默认端口不适用不适用502
特点最常用、效率高可读性好、效率低主流新设备

Modbus TCP 没有 CRC,是因为 TCP 层已经做了校验和与重传;而串口链路层不可靠,所以 RTU 必须自带 CRC。

数据传输的帧结构

在 OSI 七层参考模型中,帧(Frame)是数据链路层(第二层)的协议数据单元(PDU),也是该层的传送单位;它由帧头、数据部分和帧尾组成,用于在相邻节点之间传输数据。

RTU 帧

Modbus RTU 帧没有帧头、帧尾,通过时间间隔区分帧边界。其结构如下:

字段长度说明
地址1 字节从站地址 1–247,0 为广播地址
功能码1 字节指示操作类型,如 0 x 03 读保持寄存器
数据N 字节根据功能码不同,内容与长度不同
CRC 校验(CRC-16)2 字节检查整帧数据在传输中是否出错

字节序:与数据域的大端序不同,CRC 校验码在报文中以小端模式 (Little-Endian) 发送,即低字节在前,高字节在后。

[从站地址 1B][功能码 1B][数据区 N B][校验 2B]   最少 4 字节

帧边界与定时规则

RTU 帧没有起始、结束字符,而是完全依赖时间间隔来区分报文。

  • 起始帧:总线空闲时间达到至少 3.5 个字符的传输时间 (T 3.5) 后,接收到的第一个字节即被视为新帧的开始。
  • 结束帧:CRC 校验码的最后一个字节传输完毕后,总线再次进入超过 T 3.5 的空闲状态,标志着本帧结束。
  • 帧内连续性:一帧内部,字符之间的间隔必须小于 1.5 个字符的传输时间 (T 1.5),以确保接收方将其识别为一个连续的帧

帧示例

下面以功能码 0x03(读保持寄存器)为例,展示请求与响应的完整帧结构。

主站请求(读取从站 0x01,起始地址 0x02 开始的 4 个寄存器):

01 03 00 02 00 04 E5 C9
  • 01:从站地址 = 1(目标设备,1–247,0x00 为广播)
  • 03:功能码 = 读保持寄存器
  • 00 02:起始地址 = 0x02(大端序)
  • 00 04:寄存器数量 = 4(大端序)
  • E5 C9:CRC-16 = 0xC9E5,按“低字节在前”存放为 E5 C9

从站响应:

01 03 08 00 00 00 37 00 00 00 00 60 13
  • 01:从站地址 = 1(与请求一致)
  • 03:功能码 = 读保持寄存器(与请求一致)
  • 08:字节数 = 8,表示后续有 8 个字节的寄存器数据
  • 00 00 00 37 00 00 00 00:4 个寄存器的具体值(每个 2 字节,大端序)
  • 60 13:CRC-16 = 0x1360,按“低字节在前”存放为 60 13

TCP 帧

在 Modbus TCP 中,一个完整的数据帧被称为应用数据单元 (ADU),由两部分拼接而成:

组成部分长度核心功能
MBAP 报文头7 字节负责网络传输的“物流信息”
PDU2 ~ 255 字节包含“功能码”和“数据”,是设备真正要执行的操作指令
[事务标识 2B][协议标识 2B][长度 2B][单元标识 1B][功能码 1B][数据区 N B]
| MBAP Header(7B)                          | PDU

MBAP 报文头

字段长度说明
事务处理标识符2 字节客户端生成的请求编号,服务器必须原样回传,用于把响应和请求配对
协议标识符2 字节固定 0x0000,表示 Modbus 协议
长度2 字节后续数据的字节数。它精确地告诉接收方,在长度字段之后还有多少个字节(即单元标识符 + PDU 的总长度)
单元标识符1 字节相当于“从站地址”。在 TCP/IP 网络中,它主要用于通过网关访问串行链路上的特定从站设备

长度字段不包含 MBAP 头自身的前 6 个字节,只计算它后面的内容。例如,长度值为 0x0006,意味着紧随其后的单元标识符(1 字节)+ PDU(5 字节)一共 6 个字节。

PDU(Protocol Data Unit)

字段长度说明
功能码1 字节决定了设备要执行的操作
数据域N 字节根据功能码不同,内容与长度不同

数据域的长度和内容完全由功能码决定。例如,对于“读保持寄存器(0 x 03)”的请求,数据域通常包含起始地址(2 字节)和要读取的寄存器数量(2 字节)。

如果没有数据需要传递,该字段可以不存在。

帧示例

下面以功能码 0x03(读保持寄存器)为例,展示请求与响应的完整帧结构。

客户端请求 (读取从站地址 0x02 开始的 4 个寄存器):

00 01 00 00 00 06 01 03 00 02 00 04
  • 00 01:事务处理标识符 = 1
  • 00 00:协议标识符 = Modbus
  • 00 06:长度 = 6
  • 01:单元标识符 = 1
  • 03:功能码 = 读保持寄存器
  • 00 02:起始地址 = 0x02
  • 00 04:寄存器数量 = 4

服务器响应:

00 01 00 00 00 0B 01 03 08 00 00 00 37 00 00 00 00
  • 00 01:事务处理标识符 = 1(与请求一致)
  • 00 00:协议标识符 = Modbus(与请求一致)
  • 00 0B:长度 = 11
  • 01:单元标识符 = 1(与请求一致)
  • 03:功能码 = 读保持寄存器
  • 08:字节数 = 8,表示后续有 8 个字节的寄存器数据
  • 00 00 00 37 00 00 00 00:4 个寄存器的具体值(每个 2 字节)

常用功能码

功能码操作对象
0x01 / 0x02读线圈 / 读离散输入位操作
0x03 / 0x04读保持寄存器 / 读输入寄存器字操作(16 位)
0x05 / 0x06写单个线圈 / 写单个寄存器单个
0x0F / 0x10写多个线圈 / 写多个寄存器多个

代码示例

以下示例基于 NModbus 的 3.0.83 版本。

dotnet add package NModbus --version 3.0.83

Modbus RTU 读取

串口 RTU 的核心是先创建并配置 SerialPort,再通过 new ModbusFactory().CreateRtuMaster 生成主站。

Note

这里需要单独在安装一个 NModbus.Serial 库,使用其提供的扩展方法,以适配串口数据流。这是因为 NModbus 库并没有提供基于串口的创建方法,需要再安装一个扩展库。

dotnet add package NModbus.Serial --version 3.0.83
using System.IO.Ports;
using NModbus;
using NModbus.Serial;

// 1. 创建并配置串口:波特率、数据位、校验、停止位必须与从站一致
var port = new SerialPort("COM1")
{
    BaudRate = 9600,
    Parity = Parity.None,
    DataBits = 8,
    StopBits = StopBits.One
};
// 打开串口
port.Open();

// 2. 基于串口创建 RTU 主站;using 保证 Dispose 时释放资源
using IModbusSerialMaster master = new ModbusFactory().CreateRtuMaster(port);

// 3. 读从站 1 的保持寄存器(功能码 0x03),起始地址 0,读 4 个
//    NModbus 内部自动完成 RTU 帧封装、CRC 计算与校验
ushort[] registers = await master.ReadHoldingRegistersAsync(1, 0, 4);

ReadHoldingRegisters(slaveId, startAddress, count) 直接映射到功能码 0x03,NModbus 会自动完成 RTU 帧的 CRC 计算与校验。

Modbus TCP 读取

TCP 场景下,先建立 TcpClient 连接,再通过 new ModbusFactory().CreateMaster(client) 创建主站。

using System.Net.Sockets;
using NModbus;

// 1. 创建 TCP 连接;502 是 Modbus TCP 标准端口
var client = new TcpClient();
await client.ConnectAsync("127.0.0.1", 502);

// 2. 基于 TcpClient 创建 TCP 主站
using IModbusMaster master = new ModbusFactory().CreateMaster(client);

// 3. 单元 ID 为 1,读保持寄存器,起始地址 0,读 4 个
//    NModbus 内部自动封装 MBAP 头
ushort[] registers = await master.ReadHoldingRegistersAsync(1, 0, 4);

ModbusIpMaster 会自动封装 MBAP 报文头,这里只需关心单元标识符、起始地址和数量。

轮询主循环骨架

Modubs 是请求 - 响应式协议,从站不会主动上报数据,需要主站自己按照固定的时间间隔,反复发起请求,以此持续获取设备状态,而这个过程就是轮询。

在下面的代码中,采用 while 循环 + CancellationToken 来控制轮询的生命周期,每次开始前检查一次取消信号。如果取消,就退出循环。

while (!_cts.Token.IsCancellationRequested)
{
    // 逐台设备处理,单台失败不影响其他设备
    foreach (var node in _nodes)
    {
        // 离线设备只尝试重连,只尝试重连,避免因为离线设备触发长时间的超时连接
        if (!node.Online) { await TryReconnectAsync(node); continue; }
        try
        {
            // 读取保持寄存器
            var regs = await _client.ReadHoldingRegistersAsync(
                node.Id, node.Addr, node.Count);
            // 解析寄存器返回的数据
            var data = Parse(node, regs);
            // 切回 UI 线程;内部需切回 UI 线程(Dispatcher.Invoke)
            UpdateUi(data);                
            node.LastOk = DateTime.Now;
        }
        catch
        {
            // 降级为离线,UI 显示“通讯中断”;下一轮走重连分支
            node.Online = false;           
        }
    }
	// 轮询周期;传入 token 保证取消时无需等满周期即可退出
    await Task.Delay(_pollIntervalMs, _cts.Token);
}
最后更新于