前言
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通讯
}
总结
本文详细介绍了如何使用C#编程语言实现Modbus-RTU协议。通过具体的代码示例和步骤说明,可以学习到如何在C#环境中搭建Modbus-RTU通信,包括初始化串口、发送和接收数据包、解析响应等关键环节。文章还提供了常见问题的解决方法和最佳实践,帮助大家快速掌握并应用于实际项目中。
最后
如果你觉得这篇文章对你有帮助,不妨点个赞支持一下!你的支持是我继续分享知识的动力。如果有任何疑问或需要进一步的帮助,欢迎随时留言。
也可以加入微信公众号 [DotNet技术匠] 社区,与其他热爱技术的同行一起交流心得,共同成长!
优秀是一种习惯,欢迎大家留言学习!
作者:7嗨嗨
出处:cnblogs.com/7haihai/p/15788372.html
声明:网络内容,仅供学习,尊重版权,侵权速删,歉意致谢!