C#写Modbus-RTU协议

  Modbus是一种串行通信协议,是Modicon公司(现在的施耐德电气 Schneider Electric)于1979年为使用可编程逻辑控制器(PLC)通信而发表。Modbus已经成为工业领域通信协议的业界标准(De facto),并且现在是工业电子设备之间常用的连接方式。

  对于串行连接,存在两个变种,它们在数值数据表示不同和协议细节上略有不同。Modbus RTU是一种紧凑的,采用二进制表示数据的方式,Modbus ASCII是一种人类可读的,冗长的表示方式。这两个变种都使用串行通信(serial communication)方式。RTU格式后续的命令/数据带有循环冗余校验的校验和,而ASCII格式采用纵向冗余校验的校验和。被配置为RTU变种的节点不会和设置为ASCII变种的节点通信,反之亦然。

  对于通过TCP/IP(例如以太网)的连接,存在多个Modbus/TCP变种,这种方式不需要校验和计算。

  对于所有的这三种通信协议在数据模型和功能调用上都是相同的,只有封装方式是不同的。

  这篇主要讲解下在C#下的RTU格式请求与解析。

  通用Modbus帧如下所示,即通用十六进制报文:

 

  各部分功能如下:

  地址域:Modbus从机地址,为了区分串口总线上各设备的地址,即多个设备可以并联(手拉手)式的接入同一个总线;

  功能码:区分此请求/应答报文的功能,比如0x01读线圈数据,0x03读多个(内部存储器或输出寄存器)寄存器等;

  数据域:报文附加信息,主从机(服务端、客户端)使用这个信息执行功能码定义的操作;

  差错校验:此报文的校验码,接收端可以以此来验证报文完整、合法性;

 

  Modbus数据模型如下,离散量或线圈为bit数据,即占一位,如功能码0x01;输入/输出寄存器占16个bit,即两个字节,按照short/ushort解析数据,如功能码0x03;

 

  Modbus常见功能码如下所示,常用功能码有:0x01读线圈、0x05写单个线圈、0x03读输出寄存器、0x04读输入寄存器、0x06写单个寄存器、0x10写多个寄存器等。

  以下举例说明了部分功能码的报文格式。

  0x01-读线圈,各线圈占一个bit位,示例如下:

  请求报文:03 01 00 00 00 07 xx xx

       03 -- 从机地址

       01 -- 功能码

       00 00 -- 线圈起始地址

       00 07 -- 线圈数量,查询线圈的个数

       xx xx -- CRC校验码

  应答报文:03 01 01 23 xx xx

       03 -- 从机地址

       01 -- 功能码

       01 -- 数据字节数,即后面(不包含校验码)跟了多少个字节数据

       23 -- 数据内容,此指线圈状态,

       xx xx -- CRC校验

  实时数据解析,主机从0x0000起始地址请求0x07个线圈数据,由于每个线圈状态占1位(bit),从机返回0x01个字节的数据,0x23即为这7个线圈状态,0x23转成二进制0010 0011,从左到右是bit7->bit0,寄存器地址为0x0000的线圈状态为bit0,值为1;寄存器地址为0x0001的线圈状态为bit1,值为1;寄存器地址为0x0002的线圈状态为bit2,值为0......

   0x03-读保持寄存器,各寄存器值占二个字节,示例如下:

 

  请求报文:01 03 00 00 00 03 xx xx

       01 -- 从机地址

       03 -- 功能码

       00 00 -- 寄存器起始地址

       00 03 -- 寄存器数量,查询保持寄存器的个数

       xx xx -- CRC校验码

  应答报文:01 03 06 00 01 00 02 00 03 xx xx

       01 -- 从机地址

       03 -- 功能码

       06 -- 数据字节数,即后面(不包含校验码)跟了多少个字节数据,一个寄存器值占2个字节,请求3个寄存器,返回6个字节

       00 01 00 02 00 03 -- 数据内容

       xx xx -- CRC校验

  实时数据解析,主机从0x0000起始地址请求0x07个寄存器数据,由于每个寄存器数据占2个字节,从机返回0x06个字节的数据,寄存器地址为0x0000的寄存器数据为0x0001;寄存器地址为0x0001的寄存器数据为0x0002;寄存器地址为0x0000的寄存器数据为0x0001......注意数据类型,各协议文档有些大小端区别等。。。

  0x06-写单个保持寄存器,各寄存器值占二个字节,示例如下:

 

  请求报文:01 06 00 00 00 03 xx xx

       01 -- 从机地址

       06 -- 功能码

       00 00 -- 寄存器地址

       00 03 -- 寄存器值

       xx xx -- CRC校验码

  应答报文:01 06 06 00 01 00 02 00 03 xx xx

       01 -- 从机地址

       06 -- 功能码

       00 00 -- 寄存器地址

       00 03 -- 寄存器值

       xx xx -- CRC校验

  数据解析,主机写寄存器地址为0x0000值,写入值为0x0003......注意数据类型,各协议文档有些大小端区别等。。。

 其他功能码参考文档分析了。

   Modbus协议接入,在嵌入式软件里一般通过串口连接到开发板,打开串口,读写数据。

  在物联网接入方面一般需要借助其他设备(串口转以太网,串口服务器)转接到服务器里,本文主要着重讲解下此方法,串口转以太网(局域网)的设备可以使用有人的设备USR-N540、USR-N5x0系列,通过透传的方式透传到以太网,可通过交换机以Socket通信方式接入到物联网平台。有人的设备如下:

  .net程序通过Socket通信和串口服务器通信,透传通过RS485转发到串口设备,即实现串口设备和物联网平台的通信。

  在此处,平台做客户端连接串口服务器的服务端。核心主要是平台组合生成请求数据包(查询寄存器值、控制),发送到串口设备,串口设备回复应答后平台进行解析、业务处理。

  一、数据库表结构如下:

-- Modbus控制表
-- DROP TABLE IF EXISTS `protocolmodbuscontrol`;
CREATE TABLE `protocolmodbuscontrol` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `protocolid` smallint(5) NOT NULL COMMENT '协议ID',
  `canid` int(11) NOT NULL COMMENT 'CANID',
  `controlsignalid` int(11) NOT NULL COMMENT '控制信号ID',
  `registeraddr` int(11) NOT NULL COMMENT '控制寄存器地址',
  `slaveaddr` smallint(5) NOT NULL COMMENT '从机地址',
  `minvalue` double(15,3) NOT NULL COMMENT '最小值,需加变比比较',
  `maxvalue` double(15,3) NOT NULL COMMENT '最大值,需加变比比较',
  `operatetype` smallint(5) NOT NULL COMMENT '运算类型,1-乘',
  `ratio` double(15,3) NOT NULL COMMENT '变比值,*ratio+offset',
  `offset` double(15,3) NOT NULL COMMENT '偏移量,只加',
  `datatype` smallint(5) NOT NULL COMMENT '数据类型',
  `functiontype` smallint(5) NOT NULL COMMENT '功能码区分',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

-- Modbus请求数据包表
-- DROP TABLE IF EXISTS `protocolmodbuspackage`;
CREATE TABLE `protocolmodbuspackage` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `protocolid` smallint(5) NOT NULL COMMENT '协议ID',
  `slaveaddr` smallint(5) NOT NULL COMMENT '从机地址',
  `packageid` int(11) NOT NULL COMMENT '包索引',
  `registerstartaddr` int(11) NOT NULL COMMENT '寄存器起始地址',
  `registercount` smallint(5) NOT NULL COMMENT '寄存器个数',
  `functiontype` smallint(5) NOT NULL COMMENT '功能码',
  `devicetype` smallint(5) NOT NULL COMMENT '设备类型',
  `canid` int(11) NOT NULL COMMENT 'CANID',
  PRIMARY KEY (`id`)
) ENGINE=MyISAM AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

-- Modbus信号表
-- DROP TABLE IF EXISTS `protocolmodbussignal`;
CREATE TABLE `protocolmodbussignal` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `protocolid` smallint(5) NOT NULL COMMENT '协议ID',
  `slaveaddr` smallint(5) NOT NULL COMMENT '从机地址',
  `packageid` int(11) NOT NULL COMMENT '包索引',
  `startindex` smallint(5) NOT NULL COMMENT '寄存器索引*2 = byte索引,从0开始',
  `startbit` smallint(5) NOT NULL COMMENT 'bit的起始地址,从0开始',
  `datalength` smallint(5) NOT NULL COMMENT '数据长度',
  `datatype` smallint(5) NOT NULL COMMENT '数据类型',
  `signalid` int(11) NOT NULL COMMENT '信号ID',
  `operatetype` smallint(5) NOT NULL COMMENT '运算类型,1-乘',
  `ratio` double(15,3) NOT NULL COMMENT '变比,x*ratio+offset',
  `offset` double(15,3) NOT NULL COMMENT '偏移,只做+处理',
  PRIMARY KEY (`id`)
) ENGINE=MyISAM AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

   二、寄存器数据查询

   通过获取数据库中protocolmodbuspackage数据,轮询发送数据查询

    /// <summary>
    /// 发送请求
    /// </summary>
    /// <param name="package"></param>
    void SendRealRequest(Protocolmodbuspackage package)
    {
        LogEvent.Default.InfoFormat("接收到实时数据请求:{0}", JsonConvert.SerializeObject(package));
        RegisterRequest? request = ConvertToModbusRequest(package);
        byte[]? data = request?.ObjectToByte();
        if (data is null)
        {
            return;
        }

        SendMessage(package.Slaveaddr, data, EnumFunctionType.ReadKeepRegister);
        // 解析实时数据
        ParseRealData(package);
    }

  //
request?.ObjectToByte();
  // 方法的核心是组合数据域数组,根据表protocolmodbuspackage中寄存器起始registerstartaddr、寄存器个数registercount组成,如寄存器地址是0x0000,寄存器个数是10,那么此数组数据为【0x00, 0x00, 0x00, 0x0A】

 

   发送数据查询请求

   /// <summary>
    /// 发送消息
    /// </summary>
    /// <param name="addr"></param>
    /// <param name="data"></param>
    /// <param name="messageType"></param>
    void SendMessage(byte addr, byte[] data, EnumFunctionType messageType)
    {
        MessageInfo messageInfo = new MessageInfo(addr, (byte)messageType, data);
        byte[] sendData = messageInfo.ObjectToByte();
        SendEvent?.Invoke(sendData);
        LogEvent.Default.InfoFormat("发送成功:\r\n{0}", DataCopy.ToHexString(sendData));
    }
  
  //
messageInfo.ObjectToByte();
  // 方法核心是组合整个数据包,根据表protocolmodbuspackage中从机地址slaveaddr、功能码functiontype和上面数据域数组,组成完成的数据包,如从机地址是0x01,功能码是0x03读保持寄存器,那么此数组数据为【0x01, 0x03, 0x00, 0x00, 0x00, 0x0A, xx, xx】,xx xx表示校验位。
  // 组成完整数据包后通过SendEvent?.Invoke(sendData);方法发送到Socket(即串口服务器服务端)。

 

   接收Socket服务端(串口服务器)应答

    /// <summary>
    /// 处理实时数据
    /// </summary>
    /// <param name="package"></param>
    void ParseRealData(Protocolmodbuspackage package)
    {
        MessageInfo? messageInfo = WaitTcpAck(); // 等待接收数据

        // 为 null,异常数据,功能码不相等,
        if (messageInfo is null || !messageInfo.Valid || messageInfo.MessageType != package.Functiontype)
        {
            return;
        }

     // 获取包对应的信号点,如上读取从地址0x0000起始的10个寄存器的值,这10个数据如分别为:电压、电流等...... List
<Protocolmodbussignal> signalList = SignalList.FindAll(x => x.Packageid == package.Packageid && x.Slaveaddr == package.Slaveaddr); string deviceCode = $"{_stationCode}|{package.Canid}"; ResponseMessage response = new ResponseMessage(messageInfo.Data, deviceCode, signalList); if (!response.Valid) { LogEvent.Default.Error($"响应无效:\n{messageInfo}"); return; } // TODO 业务
     // xx xx xx
}

  //
ResponseMessage response = new ResponseMessage(messageInfo.Data, deviceCode, signalList);
  // protocolmodbussignal中配置了各信号对应的起始字节、起始bit位、数据长度、数据类型等,如A相电压是寄存器0的值,起始字节是0,数据类型是无符号ushort、A相电流是寄存器3的值,起始字节是6,数据类型是有符号short.....
  // 根据各个信号的配置即可进行解析,之后进行业务处理。

 

   接收Socket服务端应答,由于RS485是一发一收,发送实时数据请求后,阻塞等待串口设备应答。

    /// <summary>
    /// 接收Tcp返回数据
    /// </summary>
    MessageInfo? WaitTcpAck()
    {
        MessageInfo? messageInfo = null;
        for (int i = 0; i < PrivateConstValue.AckTimeOutCount; i++)
        {
            // 未获取到有效数据,继续等待
            byte[]? receive = GetAckEvent?.Invoke();
            if (receive is not null && receive.Any())
            {
                messageInfo = new MessageInfo(receive);
            }

            if (messageInfo is null)
            {
                Thread.Sleep(PrivateConstValue.AckTimeOutInterval);
            }
            else
            {
                break;
            }
        }

        return messageInfo;
    }

 

   三、控制,写寄存器

    /// <summary>
    /// 发送控制
    /// </summary>
    /// <param name="control"></param>
    private void SendControl(DeviceControlReq control)
    {
        try
        {
            LogEvent.Default.InfoFormat("接收到控制:{0}", JsonConvert.SerializeObject(control));

            string[] code = control.DeviceCode.Split(':');
            if (code.Length < 2)
            {
                LogEvent.Default.InfoFormat("设备编码异常:{0}", control.DeviceCode);
                return;
            }

            int canId = int.Parse(code[1]);
            Protocolmodbuscontrol? protocolControl = ControlList.Find(x => x.Canid == canId && x.Controlsignalid == control.SignalId);
            if (protocolControl is null)
            {
                LogEvent.Default.ErrorFormat("未找到相关控制点:{0}", JsonConvert.SerializeObject(control));
                return;
            }

            ModbusControl? modbusControl = ConvertToModbusControl(control, protocolControl);
            if (modbusControl is null)
            {
                return;
            }

            byte[] sendData = modbusControl.ObjectToByte();
            SendMessage((byte)protocolControl.Slaveaddr, sendData, (EnumFunctionType)protocolControl.Functiontype);

            // 等待接收数据
            MessageInfo? messageInfo = WaitTcpAck();
            ParseControlAck(messageInfo, control);
        }
        catch (Exception ex)
        {
            LogEvent.Default.Fatal("控制下发异常", ex);
        }
    }

  // 其中有些业务处理.......
  // 根据protocolmodbuscontrol中从机地址slaveaddr、寄存器地址registeraddr、功能码functiontype、数据类型datatype组合成待发送的写寄存器请求包,在发送完之后接收串口设备发送的应答并进行相应业务处理。

 

   Socket客户端代码如下:

using LogHelper;
using System.Net.Sockets;

namespace Protocol_ModbusRtuClient.Business;

/// <summary>
/// TCP通讯客户端
/// </summary>
public class TcpClient
{
    #region 属性

    /// <summary>
    /// TCP通讯Socket套接字
    /// </summary>
    private Socket? _socket;

    /// <summary>
    /// 通讯IP
    /// </summary>
    private readonly string _ip;

    /// <summary>
    /// 通讯端口
    /// </summary>
    private readonly int _port;

    /// <summary>
    /// 通讯已连接处理委托
    /// </summary>
    public delegate void ConnectedHandler();
    
    /// <summary>
    /// 通讯已连接处理事件
    /// </summary>
    public event ConnectedHandler? ConnectedEvent;

    #endregion 属性

    #region 构造

    /// <summary>
    /// 构造函数初始化
    /// </summary>
    /// <param name="configuration">配置信息</param>
    /// <exception cref="Exception">配置获取异常</exception>
    public TcpClient(IConfiguration configuration)
    {
        _ip = configuration.GetSection("TcpServer:IP").Value ?? throw new Exception("TCP服务IP未配置");
        _port = int.Parse(configuration.GetSection("TcpServer:Port").Value ?? throw new Exception("TCP服务端口未配置"));
        _socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        if (_port > 65535 || string.IsNullOrEmpty(_ip))
        {
            LogEvent.Default.Fatal("配置文件中TcpServer不正确或未配置");
            throw new Exception("配置文件中TcpServer不正确或未配置");
        }
        
        new Task(ConnectTh).Start();
    }

    #endregion 构造

    #region 通讯收发数据包

    /// <summary>
    /// 接收数据包
    /// </summary>
    public byte[]? ReceiveData()
    {
        byte[] receive = new byte[65535];
        try
        {
            if (IsConnected() is false)
            {
                // 通讯连接断开,重新连接
                Reconnect();
            }

            _socket?.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReceiveTimeout, 50);
            int byteLen = _socket?.Receive(receive, receive.Length, SocketFlags.None) ?? 0;
            if (byteLen == 0)
            {
                return null;
            }

            byte[] receiveData = new byte[byteLen];
            Array.Copy(receive, 0, receiveData, 0, byteLen);
            return receiveData;
        }
        catch (SocketException socketEx)
        {
            if (socketEx.ErrorCode == 10060)
            {
                LogEvent.Default.Fatal("通讯被拒绝", socketEx);
            }
        }
        catch (Exception e)
        {
            LogEvent.Default.Fatal(e.Message);
        }

        return null;
    }

    /// <summary>
    /// 发送数据包
    /// </summary>
    /// <param name="sendData">数据包</param>
    /// <returns>true-发送成功</returns>
    public bool SendData(byte[] sendData)
    {
        if (_socket is null || IsConnected() is false)
        {
            return false;
        }

        try
        {
            int count = _socket.Send(sendData);
            if (count <= 0)
            {
                _socket.Dispose();
            }

            return count > 0;
        }
        catch (Exception e)
        {
            LogEvent.Default.Fatal(e.Message);
            return false;
        }
    }

    #endregion 通讯收发数据包

    #region Socket通讯

    /// <summary>
    /// 保持通讯连接
    /// </summary>
    private void ConnectTh()
    {
        while (true)
        {
            try
            {
                // 已连接且可通信
                if (IsConnected())
                {
                    continue;
                }

                // 已连接不可通信,回收
                if (_socket is { Connected: true })
                {
                    _socket.Dispose();
                }
                
                Connect();
            }
            catch (Exception e)
            {
                LogEvent.Default.Fatal(e.Message);
            }
            finally
            {
                Thread.Sleep(1000);
            }
        }
    }

    /// <summary>
    /// 判断通讯是否已连接
    /// </summary>
    /// <returns>true-已连接</returns>
    private bool IsConnected()
    {
        if (_socket is null)
        {
            return false;
        }
        
        // 套接字阻塞状态
        bool blockingState = _socket.Blocking;
        try
        {
            //尝试发送数据包
            byte[] tmp = new byte[1];
            _socket.Blocking = false;
            _socket.Send(tmp, 0, 0);
            return true;
        }
        catch (SocketException e)
        {
            // 连接被阻止
            return e.NativeErrorCode.Equals(10035);
        }
        catch (Exception e)
        {
            // 其他异常
            LogEvent.Default.Fatal(e.Message);
            return false;
        }
        finally
        {
            // 恢复套接字状态
            _socket.Blocking = blockingState;
        }
    }

    /// <summary>
    /// 通讯套接字连接
    /// </summary>
    private void Connect()
    {
        if (_socket is null)
        {
            _socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        }
        else
        {
            _socket.Dispose();
            _socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        }

        try
        {
            if (_socket.Connected is false)
            {
                // 连接服务器
                _socket.Connect(_ip, _port);
            }

            if (IsConnected())
            {
                // 连接成功后执行事件
                ConnectedEvent?.Invoke();
            }
        }
        catch (Exception e)
        {
            LogEvent.Default.Fatal(e.Message);
        }
    }

    /// <summary>
    /// 重新连接
    /// </summary>
    private void Reconnect()
    {
        if (IsConnected())
        {
            // 已连接且可通讯
            return;
        }

        try
        {
            if (_socket is not null)
            {
                if (_socket.Connected)
                {
                    _socket.Shutdown(SocketShutdown.Both);
                    _socket.Disconnect(true);
                }

                // 未连接或不可通讯
                _socket.Close();
                _socket.Dispose();
            }

            Connect();
        }
        catch (Exception e)
        {
            LogEvent.Default.Fatal(e.Message);
        }
        finally
        {
            if (IsConnected() is false)
            {
                LogEvent.Default.InfoFormat("ReciveRDataTh中2次判断断线,调用Reconnect重连:通讯状态:{0}", _socket?.Connected);
            }
        }
    }
    
    #endregion Socket通讯
}

 

posted @ 2024-01-27 17:38  7嗨嗨  阅读(2069)  评论(3编辑  收藏  举报