Modbus 协议详解
内容要点:
梳理 Modbus 的历史,Modbus RTU 和 TCP 的区别,还有技术要点。
上一篇文章给上位机通讯画了一份地图,了解了各种物理接口、通讯协议。
这篇文章,将挑选几个最重要的通讯协议,深入学习。
入门必学。不管你是连温控器、变频器还是低端 PLC,它是唯一的通用语言。
1979 年(比 HTTP、TCP/IP 都要老,真是爷爷辈的), Modicon 公司(现在的施耐德电气),发明了世界上第一台 PLC,为了让 PLC 能和电脑说话,他们发明了这个协议。并且完全免费、公开发布这个协议的标准。任何人都可以拿去用,不需要交专利费。
经过 40 多年的积累,市场上已经有几十亿台设备支持它。同时它的规则也简单,加上免费开源,Modbus 已经成为了工业界最流行的协议。
Modbus RTU (Remote Terminal Unit 远程终端单元)。一开始(70-80年代),设备少,仅靠串口就可以连接所有设备,进行通讯。这一阶段的 Modbus 协议都是 Modbus RTU 。
Modbus TCP (Transmission Control Protocol 远程终端单元)。后来(90年代-现在), 互联网爆发,设备越来越多,为了快、为了远程联网,换成网线连接。协议包了一层壳,变成了 Modbus TCP 。
现在,Modbus RTU 、TCP,都是市场上广泛使用的协议,因为便宜,前者并没有被淘汰。也可总结为,“网口就是 TCP,串口就是 RTU”。
Modbus 是一种“一问一答”的对话规则。
角色分工:
对话内容:
Modbus 设备里面有四个存储数据的地方:
在 C# 编程中,用得最多的就是 保持寄存器 (Holding Registers, 4区)。因为现代设备为了方便,经常把“只读数据”也映射到这个区里,让你读又能写,省得换区。
| 区域名称 | 英文代号 | 俗称 | 读写权限 | 数据类型 | 典型用途 | 备注 |
|---|---|---|---|---|---|---|
| 线圈 | Coils (0x) | DO (输出) | 读 / 写 | Bit (位) 只有 ON/OFF | 控制开关: 启动电机、打开阀门、亮红灯。 | 最常用 地址通常以 0 开头 (如 00001) |
| 离散输入 | Discrete Inputs (1x) | DI (输入) | 只读 | Bit (位) 只有 ON/OFF | 读取状态: 按钮按下没?光电开关遮挡没? | 地址通常以 1 开头 (如 10001) |
| 输入寄存器 | Input Registers (3x) | AI (模拟量入) | 只读 | 16位 Word (字) 数值 (0~65535) | 读取数值: 温度传感器读数、流量计读数。 | 地址通常以 3 开头 (如 30001) |
| 保持寄存器 | Holding Registers (4x) | AO/参数 | 读 / 写 | 16位 Word (字) 数值 (0~65535) | 设定参数: 设定温度 100℃、设定转速。 | 由它统治世界! 绝大多数数据都放这。 地址通常以 4 开头 (如 40001) |
上位机发给设备的指令里,有一个字节专门告诉设备“我要干什么”,这就是功能码。 虽然标准定义了很多,但工业现场90% 的情况只用这 4 个:
| 代码 (Hex) | 功能名称 | 针对哪个存储区? | 作用描述 | C# 方法 (NModbus4示例) |
|---|---|---|---|---|
| 01 | Read Coils | 线圈 (0x) | 读开关状态 | ReadCoils() |
| 02 | Read Discrete Inputs | 离散输入 (1x) | 读输入状态 | ReadInputs() |
| 03 | Read Holding Registers | 保持寄存器 (4x) | 读数值 | ReadHoldingRegisters() (这是最常用的指令,没有之一) |
| 06 | Write Single Register | 保持寄存器 (4x) | 写一个数值 | WriteSingleRegister() |
| 10 (16) | Write Multiple Registers | 保持寄存器 (4x) | 写多个数值 | WriteMultipleRegisters() |
以 Modbus RTU 为例,看一个最经典的交互过程:
场景: 上位机(主站 01)想读取 变频器(从站 01)的输出频率(假设在寄存器 2000 处,读 1 个)。
发送 (Request):上位机 -> 设备
报文:01 03 07 D0 00 01 85 CF
| 字节 | 含义 | 解释 |
|---|---|---|
| 01 | 从站地址 | 喂,1号设备,我在跟你说话。 |
| 03 | 功能码 | 我要读保持寄存器。 |
| 07 D0 | 起始地址 | 从第 2000 号地址开始读 (0x07D0 = 2000)。 |
| 00 01 | 数量 | 我只要读 1 个寄存器。 |
| 85 CF | CRC 校验 | 这是为了防止数据传输出错算出来的校验码。 |
接收 (Response):设备 -> 上位机
报文:01 03 02 13 88 B5 12
| 字节 | 含义 | 解释 |
|---|---|---|
| 01 | 从站地址 | 我是1号,我回话了。 |
| 03 | 功能码 | 这是针对你刚才“读寄存器”的回复。 |
| 02 | 字节数 | 后面跟着 2 个字节的数据(因为1个寄存器=2字节)。 |
| 13 88 | 数据内容 | 0x1388 = 十进制 5000 (代表 50.00 Hz)。 |
| B5 12 | CRC 校验 | 校验码。 |
发送同样的数据包内容,RTU 和 TCP 的区别如下:
| 组成部分 | 1. TCP 专用报文头 (MBAP) | 2. 从站 ID / 单元标识符 | 3. PDU (功能码 + 数据) (完全一致的核心) | 4. RTU 专用 校验尾巴(CRC) |
|---|---|---|---|---|
| Modbus TCP | 00 01 00 00 00 06 | 01 | 03 00 00 00 01 | (无) |
| Modbus RTU | (无) | 01 | 03 00 00 00 01 | 84 0A |
| 加头: 也就是 MBAP 头。 用来在网络上管理这包数据。 | 身份: 我是 1 号设备。 (TCP里叫 Unit ID,RTU里叫 Slave ID) | 核心指令: 03: 读保持寄存器 00 00: 从地址 0 开始 00 01: 读 1 个 | 去尾: TCP 底层自带校验, 所以把 CRC 扔了。 |
跑在串口上 (RS-485 / RS-232),带宽不大,数据是紧凑的十六进制 (Hex)。
结构: [从站ID] + [PDU] + [CRC校验]
特点:
时间间隔敏感: 它靠“时间停顿”来判断一句话说没说完(3.5个字符时间)。
校验强: 使用 CRC (循环冗余校验),算错一位数就丢弃。
场景: 也就是我们说的“串口派”。连接温控器、变频器等低成本设备。
跑在网口上,带宽大,给 RTU 的数据穿了一层 TCP 的马甲(MBAP)。
结构: [MBAP 报文头] + [PDU]
特点:
场景: 也就是我们说的“网口派”。连接 PLC、远程 IO 模块。
第一步:配置参数
这是新手最容易忽略,也是最容易导致“连不上”的原因。两边的设置必须一模一样!
如果是串口 (RS-485):
9600。如果一个是 9600 一个是 19200,那就是鸡同鸭讲。8-N-1(8数据位,无校验,1停止位)。
如果是网口 (TCP):
192.168.1.5,你的电脑必须是 192.168.1.X。
第二步:敲门(发起请求)
Modbus 是**“主从模式”**,如果上位机不发送内容,设备不会回复。
发起了第一次“读写指令”。
在 C# 代码里,这一步就是:
打开端口: serialPort.Open() 或 client.Connect()。
发送指令: master.ReadHoldingRegisters(...)。
第三步:验证
验证是否发送成功:
看代码返回值
成功: 你的 C# 方法顺利返回了一个数组,里面的数字不是 0(或者是你预期的 0)。没有报错(Exception)。
失败: 程序直接崩了,抛出 TimeoutException(超时异常)。
看硬件指示灯)
用“替身”测试
在写代码前,先用现成的软件(Modbus Poll)连一下。
Timeout Error,先去查线和参数。
新建项目: Visual Studio -> 新建控制台应用 (.NET Framework 或 .NET 6/7/8 均可)。
安装库: 在“解决方案资源管理器” -> 右键项目 -> 管理 NuGet 程序包 -> 搜索并安装 NModbus4。
注:NModbus4 是最经典的开源库,虽然好久没更新,但极其稳定,适合教学。
适用场景: USB转485 连接温控器、传感器、变频器。
using System;
using System.IO.Ports;
using Modbus.Device; // 核心命名空间,来自 NModbus4
public class ModbusRtuService
{
private SerialPort _serialPort;
private IModbusSerialMaster _master;
/// <summary>
/// 初始化串口连接
/// </summary>
/// <param name="portName">COM口名称,如 "COM3"</param>
/// <param name="baudRate">波特率,如 9600</param>
public void Connect(string portName, int baudRate)
{
try
{
// 1. 配置物理层 (SerialPort)
_serialPort = new SerialPort(portName)
{
BaudRate = baudRate,
DataBits = 8,
Parity = Parity.None,
StopBits = StopBits.One
};
_serialPort.Open(); // 打开串口
// 2. 创建协议层 (Modbus Master)
var factory = new ModbusFactory();
_master = factory.CreateRtuMaster(_serialPort);
// 设置读取超时时间 (重要!防止设备掉线后程序卡死)
_master.Transport.ReadTimeout = 1000; // 1秒超时
Console.WriteLine($"[RTU] 串口 {portName} 打开成功!");
}
catch (Exception ex)
{
Console.WriteLine($"[RTU] 连接失败: {ex.Message}");
}
}
/// <summary>
/// 读取保持寄存器 (功能码 03)
/// </summary>
/// <param name="slaveId">从站地址 (1-247)</param>
/// <param name="startAddress">起始地址 (说明书地址 - 1)</param>
/// <param name="numRegisters">读取数量</param>
public ushort[] ReadHoldingRegisters(byte slaveId, ushort startAddress, ushort numRegisters)
{
try
{
if (_master == null) return null;
// 发送指令并等待返回
// 参数:站号, 起始地址, 数量
return _master.ReadHoldingRegisters(slaveId, startAddress, numRegisters);
}
catch (Exception ex)
{
Console.WriteLine($"[RTU] 读取失败: {ex.Message}");
return null;
}
}
/// <summary>
/// 写入单个寄存器 (功能码 06)
/// </summary>
public void WriteSingleRegister(byte slaveId, ushort address, ushort value)
{
try
{
_master?.WriteSingleRegister(slaveId, address, value);
Console.WriteLine($"[RTU] 写入成功: 地址 {address} -> 值 {value}");
}
catch (Exception ex)
{
Console.WriteLine($"[RTU] 写入失败: {ex.Message}");
}
}
/// <summary>
/// 关闭连接
/// </summary>
public void Disconnect()
{
_serialPort?.Close();
_master?.Dispose();
Console.WriteLine("[RTU] 连接已关闭");
}
}
适用场景: 网线连接 PLC、远程 IO 模块。
using System;
using System.Net.Sockets;
using Modbus.Device;
public class ModbusTcpService
{
private TcpClient _tcpClient;
private ModbusIpMaster _master;
/// <summary>
/// 连接到 Modbus TCP 设备
/// </summary>
/// <param name="ip">设备的 IP 地址</param>
/// <param name="port">端口号,默认 502</param>
public void Connect(string ip, int port = 502)
{
try
{
// 1. 配置物理层 (TcpClient)
_tcpClient = new TcpClient();
_tcpClient.Connect(ip, port); // 建立 TCP 连接
// 2. 创建协议层 (Modbus Master)
var factory = new ModbusFactory();
_master = factory.CreateMaster(_tcpClient);
// 设置超时
_master.Transport.ReadTimeout = 2000;
Console.WriteLine($"[TCP] 连接 {ip}:{port} 成功!");
}
catch (Exception ex)
{
Console.WriteLine($"[TCP] 连接失败: {ex.Message}");
}
}
/// <summary>
/// 读取保持寄存器 (功能码 03)
/// </summary>
public ushort[] ReadHoldingRegisters(byte slaveId, ushort startAddress, ushort numRegisters)
{
try
{
// TCP 模式下,slaveId 对应报文里的 Unit Identifier
// 如果直连 PLC,通常填 1 或 0 都可以
return _master?.ReadHoldingRegisters(slaveId, startAddress, numRegisters);
}
catch (Exception ex)
{
Console.WriteLine($"[TCP] 读取失败: {ex.Message}");
return null;
}
}
public void Disconnect()
{
_tcpClient?.Close();
_master?.Dispose();
Console.WriteLine("[TCP] 连接已关闭");
}
}
Modbus 读回来的是 ushort[] (16位整数数组)。如果你要读温度 25.5,设备通常是用 两个寄存器 拼成一个 32位浮点数 存的。
public static class ModbusUtils
{
/// <summary>
/// 将两个寄存器 (ushort) 转换为 浮点数 (float)
/// </summary>
/// <param name="registers">Modbus读回来的数组 (至少2个长度)</param>
/// <returns>转换后的浮点数</returns>
public static float ConvertToFloat(ushort[] registers)
{
if (registers == null || registers.Length < 2) return 0.0f;
// 1. 拿到高位和低位 (注意:这里可能有 ABCD 或 CDAB 的字节序问题)
// 假设顺序是 CDAB (低位寄存器在前,高位在后) -> 这是最常见的工控格式
ushort low = registers[0];
ushort high = registers[1];
// 2. 转换成字节数组
byte[] lowBytes = BitConverter.GetBytes(low);
byte[] highBytes = BitConverter.GetBytes(high);
// 3. 拼成一个 4 字节数组 (float 是 4 字节)
byte[] floatBytes = new byte[4];
// 这里的顺序决定了是 ABCD 还是 CDAB,如果读数不对,就调整这里的赋值顺序
floatBytes[0] = lowBytes[0];
floatBytes[1] = lowBytes[1];
floatBytes[2] = highBytes[0];
floatBytes[3] = highBytes[1];
// 4. 转换成 float
return BitConverter.ToSingle(floatBytes, 0);
}
}
连接一个串口设备,读取地址 40001 (温度) 和 40002 (湿度)。
class Program
{
static void Main(string[] args)
{
// ---------------------------------------------
// 场景 A: 测试 Modbus RTU (串口)
// ---------------------------------------------
ModbusRtuService rtu = new ModbusRtuService();
// 1. 连接 (请去设备管理器看你的 COM 口号)
rtu.Connect("COM3", 9600);
// 2. 读取数据
// 假设说明书说:温度在 40001,湿度在 40002
// 代码里地址要减 1 -> 0, 1
// 我们一次读 2 个
byte slaveId = 1;
ushort startAddr = 0;
ushort count = 2;
Console.WriteLine("\n正在读取数据...");
var data = rtu.ReadHoldingRegisters(slaveId, startAddr, count);
if (data != null)
{
Console.WriteLine($"[原始数据] 寄存器0: {data[0]}, 寄存器1: {data[1]}");
// 假设厂家说:读出来的数除以 10 就是真实温度
double temp = data[0] / 10.0;
Console.WriteLine($"[解析后] 当前温度: {temp} ℃");
}
// 3. 写入数据
// 假设 40010 是设定温度,我们要设为 100
rtu.WriteSingleRegister(slaveId, 9, 100);
// 4. 断开
Console.ReadLine(); // 暂停看一下
rtu.Disconnect();
}
}
说明书上写“频率地址 40001”,你代码里读 40001,结果报错或者读到了错误的数据(比如读到了 40002 的值)。
PLC/文档地址(逻辑地址)通常是从 1 开始计数的。
协议/代码地址(物理地址)是从 0 开始计数的。
代码地址 = 说明书地址 - 1
40001 -> 代码填 040100 -> 代码填 990x2000),通常不需要减 1,直接用即可。
读一个压力值,本来应该是 25.5,结果读出来是 1.34e-38 或者一个几十亿的巨大整数。
Modbus 寄存器是 16 位的(2字节)。
当你读 32 位数据(如 float 或 int)时,需要读 2 个寄存器(4字节)。
坑在于顺序: 设备发过来的 4 个字节是 A B C D 顺序,还是 C D A B 顺序?标准没有强制规定,各家厂家随便乱来。
先用调试软件(Modbus Poll)连上。
在软件设置里改 Byte Order(字节序),试 ABCD、CDAB、BADC... 直到看到正常的数字为止。
然后在 C# 代码里做同样的字节交换处理。
线插上了,死活连不上,RX 灯不闪。
232 和 485 的接法逻辑完全相反。
RS-232(交叉接): 电脑的 TX(发)要接设备的 RX(收),RX 接 TX。
RS-485(直连): A 接 A(正接正),B 接 B(负接负)。
通讯极其不稳定,发 10 次成功 5 次;或者运行一小时后电脑蓝屏/串口消失。
淘宝上那种 5 块钱蓝色的 USB 转 485,抗干扰能力极差,且容易因为发热导致电平漂移。
贵点,买个带 FTDI 芯片 或者 隔离保护 的转换器。
检查有没有接地线(GND)。485 虽然是两根线,但在强干扰环境下,把屏蔽层接地能救命。
程序刚运行挺好,过一会设备就掉线,或者界面卡死,或者设备报错。
while(true) 循环里读得太快了!
刚发完一条指令,设备还没来得及处理数据,下一条指令又过去了。
或者你在 UI 线程里直接写循环,导致界面卡死。
必须加延时: 每次读取之间,至少 Thread.Sleep(20) 到 50 毫秒。给设备一点反应时间。
设置超时: 必须给串口或 TCP 设置 ReadTimeout(比如 1000ms),并用 try-catch 捕获超时异常,防止程序崩溃。
485 总线上挂了 3 个传感器,单独连都正常,接在一起数据就乱跳,校验错误。
忘了修改站号(Slave ID),它们出厂默认都是 1 号。当你喊“1号”时,3 个设备同时在总线上向你回话,电信号就撞车了。
上机安装前,必须单独连接每一个设备,把 ID 改成不同的(1, 2, 3...),并在设备外壳上贴个标签写好 ID。