C#Modbus串口开发心得

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共享同一个串口对象,这就会导致串口意外关闭!
相关推荐
电商API_1800790524740 分钟前
电商商品价格监控工具淘宝京东商品价格抓取API项目实操分享
java·大数据·开发语言·前端·数据挖掘
zzzll111141 分钟前
深度剖析:HashMap 并发死循环与 ConcurrentHashMap 两代演进全解
java·开发语言
如意猴43 分钟前
【C++】001--C++入门(1)
开发语言·c++
CSDN_RTKLIB44 分钟前
const与readonly关键字
c#
传奇开心果编程1 小时前
【Rust入门知识点学与练】第10课:Option 和 Result(错误处理入门)
开发语言·学习·rust
leonkay1 小时前
C# 锁机制——【2】深入原理
开发语言·后端·spring·c#·个人开发
坐吃山猪2 小时前
【多线程】CompletableFuture使用
java·开发语言·多线程
CHPCWWHSU2 小时前
DeepSeek-Harness-Qt将浏览器端Agent装入桌面应用
开发语言·qt
前端 贾公子2 小时前
第10章:RAG(2)
开发语言·python