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通讯 }