MODBUS介绍
Modbus 是一种串行通信协议 ,于 1979 年由 Modicon(现为施耐德电气旗下)为使用可编程逻辑控制器(PLC)而发布。它已成为工业自动化领域事实上的标准通信协议,广泛应用于连接工业电子设备。
核心特点
- 简单开放:协议规范公开免费,无需授权,易于实现和部署
- 主从架构:采用主站(Master/Client)请求,从站(Slave/Server)响应的通信模式
- 跨平台:支持多种物理层(串口、以太网等)
- 广泛应用:PLC、HMI、传感器、驱动器、仪表等工业设备普遍支持
所谓的主从其实就C/S通信模式。主是客户端,从是服务端。
协议变体
1. Modbus RTU (Remote Terminal Unit):串口二进制
- 物理层:RS-232 / RS-485 串行通信
- 数据格式:二进制,紧凑高效
- 特点:最常用,每个消息包含 CRC 校验
- 典型场景:工厂自动化、楼宇控制
2. Modbus ASCII:串口文本
- 物理层:RS-232 / RS-485
- 数据格式:可读的 ASCII 字符
- 特点:消息以冒号(:)开始,回车换行结束,可读性强但效率低
- 典型场景:调试、对可读性要求高的环境
3. Modbus TCP
- 物理层:以太网
- 传输协议 :TCP,默认端口 502
- 特点:基于 TCP/IP 协议栈,无需校验和(由 TCP 层保证)
- 典型场景:工厂网络、远程监控、物联网
数据模型
Modbus 定义了四种基本数据类型,均以16位寄存器 或1位线圈为单位:
| 类型 | 名称 | 访问方式 | 地址范围(传统) | PLC 寻址 | 说明 |
|---|---|---|---|---|---|
| 线圈 | 离散输出 | 读写 | 00001-09999 | 0x | 布尔值,可控制(如继电器) |
| 离散输入 | 离散输入 | 只读 | 10001-19999 | 1x | 布尔值,只读(如开关状态) |
| 保持寄存器 | 模拟输出 | 读写 | 40001-49999 | 4x | 16位值,可读写(如设定值) |
| 输入寄存器 | 模拟输入 | 只读 | 30001-39999 | 3x | 16位值,只读(如传感器读数) |
modbus协议中,可以简单总结:
线圈用于打开/关闭开关(如继电器开关);
离散输入用于读取开关状态;
保持寄存器用于设定数值(如空调温度);
输入寄存器用于读取传感器数值
modbus串口设备联调常见错误
modbus读寄存器超时
- 串口参数没对齐,比如波特率不对
- 从站ID不对
- 点位地址不对也可能会超时
- 串口关闭也可能导致超时,如果现象是先超时,最后报错:The port is closed,那大概率就是串口被无意中关闭了。
NModbus源码分析
命令发送
ModbusMaster类只是一个门面,干活的其实是ModbusTransport,见ModbusMaster的方法:
c#
public ushort[] ReadHoldingRegisters(
byte slaveAddress,
ushort startAddress,
ushort numberOfPoints)
{
ModbusMaster.ValidateNumberOfPoints(nameof (numberOfPoints), numberOfPoints, (ushort) 125);
return this.PerformReadRegisters(new ReadHoldingInputRegistersRequest((byte) 3, slaveAddress, startAddress, numberOfPoints));
}
private ushort[] PerformReadRegisters(ReadHoldingInputRegistersRequest request)
{
return this.Transport.UnicastMessage<ReadHoldingInputRegistersResponse>((IModbusMessage) request).Data.Take<ushort>((int) request.NumberOfPoints).ToArray<ushort>();
}
ModbusTransport.UnicastMessage函数是命令发送入口:
c#
public abstract class ModbusTransport : IModbusTransport, IDisposable
{
private readonly object _syncLock = new object();
private int _retries = 3; //默认重试3次
private int _waitToRetryMilliseconds = 250; //重试间隔250ms
......
public virtual T UnicastMessage<T>(IModbusMessage message) where T : IModbusMessage, new()
{
IModbusMessage response = (IModbusMessage) null;
int num = 1;
bool flag1 = false;
do
{
try
{
// 发送的地方是有同步锁的!
lock (this._syncLock)
{
// 发送命令,将IModbusMessage代表的命令写到具体载体流上(串口是SerialPort, tcp就是tcp连接)
this.Write(message);
bool flag2;
do
{
flag2 = false;
// 解析响应报文
response = this.ReadResponse<T>();
if (response is SlaveExceptionResponse slaveExceptionResponse)
{
flag2 = slaveExceptionResponse.SlaveExceptionCode == (byte) 5;
if (!flag2)
throw new SlaveException(slaveExceptionResponse);
this.Logger.Debug($"Received ACKNOWLEDGE slave exception response, waiting {this._waitToRetryMilliseconds} milliseconds and retrying to read response.");
ModbusTransport.Sleep(this.WaitToRetryMilliseconds);
}
else if (this.ShouldRetryResponse(message, response))
flag2 = true;
}
while (flag2);
}
this.ValidateResponse(message, response);
flag1 = true;
}
catch (SlaveException ex)
{
if (ex.SlaveExceptionCode != (byte) 6)
throw;
if (this.SlaveBusyUsesRetryCount && num++ > this._retries)
throw;
this.Logger.Warning($"Received SLAVE_DEVICE_BUSY exception response, waiting {this._waitToRetryMilliseconds} milliseconds and resubmitting request.");
ModbusTransport.Sleep(this.WaitToRetryMilliseconds);
}
catch (Exception ex)
{
if (ex is SocketException socketException && socketException.SocketErrorCode != SocketError.TimedOut || ex.InnerException is SocketException innerException && innerException.SocketErrorCode != SocketError.TimedOut)
throw;
switch (ex)
{
// 其它异常会重试
case FormatException _:
case NotImplementedException _:
case TimeoutException _:
case IOException _:
this.Logger.Error($"{ex.GetType().Name}, {this._retries - num + 1} retries remaining - {ex}");
if (num++ > this._retries)
throw;
// 重试前,sleep重试间隔
ModbusTransport.Sleep(this.WaitToRetryMilliseconds);
break;
default:
throw;
}
}
}
while (!flag1);
return (T) response;
}
ModbusSerialTransport.Write实现
c#
public override void Write(IModbusMessage message)
{
// 每次发送命令前都会调用DiscardInBuffer,避免历史堆积的垃圾报文的影响
this.DiscardInBuffer();
// 拼接字节
byte[] numArray = this.BuildMessageFrame(message);
this.Logger.LogFrameTx(numArray);
// 这里其实就是SerialPort.Write
this.StreamResource.Write(numArray, 0, numArray.Length);
}
生命周期
ModbusMaster和其所持有的ModbusTransport及更底层的串口、TCP连接的生命周期是一致的,也就是说,ModbusMaster dispose时,其所持有的ModbusTransport及更底层的串口、TCP连接都会被释放,它们之间可不只是引用关系而已。
这是ModbusMaster的Dispose方法
c#
public void Dispose()
{
this.Dispose(true);
GC.SuppressFinalize((object) this);
}
/// <summary>
/// Releases unmanaged and - optionally - managed resources.
/// </summary>
/// <param name="disposing">
/// <c>true</c> to release both managed and unmanaged resources;
/// <c>false</c> to release only unmanaged resources.
/// </param>
protected virtual void Dispose(bool disposing)
{
if (!disposing)
return;
// 这里会Dispose ModbusTransport对象
DisposableUtility.Dispose<IModbusTransport>(ref this._transport);
}
ModbusTransport的Dispose方法:
c#
protected virtual void Dispose(bool disposing)
{
if (!disposing)
return;
// 如果是串口,就会销毁SerialPort对象;如果是TCP连接,则销毁TcpClient对象。
DisposableUtility.Dispose<IStreamResource>(ref this._streamResource);
}
源码分析的一些结论:
- 所谓的DiscardInBuffer解决串口频繁超时问题,纯属AI在想当然。ModbusSerialTransport每次发送命令前都会调用,压根不需要应用层额外去做这个事。
C# 串口编程注意事项
- 因串口是独占的,同一个串口只能open一次,重复open(不管波特率等参数是否一样、也不管是否新建一个SerialPort对象)就会抛UnauthorizedAccessException异常。要二次open成功,必须先close该串口才行。因此,串口在内核里有一个独占对象,SerialPort只是内核串口对象在C#层面的表示。
- 对同一串口的读写操作要加锁
- 配套串口的ModbusMaster必须是唯一的,而不能建立多个ModbusMaster共享同一个串口对象,这就会导致串口意外关闭!