Modbus 学习笔记
Modbus 是什么
Modbus 于 1979 年由 Modicon(现施耐德)发布的主从式串行通信协议,运行在 OSI 模型的应用层。它开放、免费、实现简单,是工业现场 PLC、变频器、传感器的事实标准通信协议。上位机(PC/工控机)通常作为 Master(主站/客户端),主动轮询下位机设备。
有以下分类:
| Modbus RTU | Modbus ASCII | Modbus TCP | |
|---|---|---|---|
| 载体 | 串口 | 串口 | 以太网 |
| 编码方式 | 二进制 | ASCII 字符 | 二进制(封装于 TCP) |
| 地址字段 | 从站地址(1~247) | 同左 | 单元标识符 |
| 校验 | CRC 16 | LRC | 无(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 C901:从站地址 = 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 1301:从站地址 = 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 字节 | 负责网络传输的“物流信息” |
| PDU | 2 ~ 255 字节 | 包含“功能码”和“数据”,是设备真正要执行的操作指令 |
[事务标识 2B][协议标识 2B][长度 2B][单元标识 1B][功能码 1B][数据区 N B]
| MBAP Header(7B) | PDUMBAP 报文头
| 字段 | 长度 | 说明 |
|---|---|---|
| 事务处理标识符 | 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 0400 01:事务处理标识符 = 100 00:协议标识符 = Modbus00 06:长度 = 601:单元标识符 = 103:功能码 = 读保持寄存器00 02:起始地址 =0x0200 04:寄存器数量 = 4
服务器响应:
00 01 00 00 00 0B 01 03 08 00 00 00 37 00 00 00 0000 01:事务处理标识符 = 1(与请求一致)00 00:协议标识符 = Modbus(与请求一致)00 0B:长度 = 1101:单元标识符 = 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.83Modbus RTU 读取
串口 RTU 的核心是先创建并配置 SerialPort,再通过 new ModbusFactory().CreateRtuMaster 生成主站。
Note
这里需要单独在安装一个 NModbus.Serial 库,使用其提供的扩展方法,以适配串口数据流。这是因为 NModbus 库并没有提供基于串口的创建方法,需要再安装一个扩展库。
dotnet add package NModbus.Serial --version 3.0.83using 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);
}