Modbus协议
一、Modbus通信协议是什么?
关于Mod,因为这种协议最早被用在PLC控制器中,准确的说是Modicon公司的PLC控制器,这也是Mod-Bus名称的由来。后来Modicon被施耐德(Schneider)收购,Modbus协议广泛应用在工业控制器、HMI和传感器上,逐渐被其他厂商所接受,成为了一种主流的通讯协议,用于和外围设备进行通讯。
Modbus协议作为当今工业控制领域的通用通讯协议,在无数物联网产品中得到应用,工业、农业等物联网解决方案中都有其身影。ModBus网络是一个工业通信系统,由智能终端的可编程序控制器和计算机,通过公用线路或局部专用线路连接而成。其系统结构既包括硬件、亦包括软件,应用于各种数据采集和过程监控。
通过Modbus协议,控制器相互之间、或控制器经由网络(如以太网)可以和其它设备之间进行通信。
Modbus协议使用的是主从通讯技术,即由主设备主动查询和操作从设备。一般将主控设备方所使用的协议称为Modbus Master
,从设备方使用的协议称为Modbus Slave
。典型的主设备包括工控机和工业控制器等;典型的从设备如PLC可编程控制器等。Modbus通讯物理接口可以选用串口(包括RS232和RS485
),也可以选择以太网口。
Modbus在7层OSI参考模型中属于第七层应用层,数据链路层有两种:基于标准串口协议和TCP协议,物理层可使用3线232、2线485、4线422,或光纤、网线、无线等多种传输介质。
ModBus网络只有一个主机,发出通讯信号,多个从机,网络可支持247个之多的远程从属控制器,但实际所支持的从机数要由所用通信设备决定。采用这个系统,各PC可以和中心主机交换信息而不影响各PC执行本身的控制任务。
Modbus协议包括ASCII、RTU、TCP等,并没有规定物理层。此协议定义了控制器能够认识和使用的消息结构,而不管它们是经过何种网络进行通信的。
二、三种传输模式
1979年,Modicon 首先推出了串行Modbus标准,后来由于网络的普及,需要更高的传输速度,1997年制定了基于TCP网络的Modbus标准。
所以总的可分为两个传输模式:基于串行链路的和基于以太网TCP/IP的。但是我总结为3种传输模式,方便理解
🔹 基于串口的Modbus-RTU 数据按照标准串口协议进行编码,是使用最广泛的一种Modbus协议,采用CRC-16_Modbus校验算法。
🔹 基于串口的Modbus-ASCII 所有数据都是ASCII格式,一个字节的原始数据需要两个字符来表示,效率低,采用LRC校验算法。
🔹 基于网口的Modbus-TCP Modbus-TCP基于TCP/IP协议,占用502端口,数据帧主要包括两部分:MBAP(报文头)+PDU(帧结构),数据块与串行链路是一致的。
所以当我们提及Modbus协议时,要确定是哪种模式:RTU、ASCII或TCP,3种模式区别还是很大的。
有些设备支持多种Modbus模式,有些设备只支持其中一种,实际使用要根据设备使用手册来选择采用哪种模式。
对于所有的这三种通信协议在数据模型和功能调用上都是相同的,只有封装方式是不同的。
Modbus的ASCII、RTU协议规定了消息、数据的结构、命令和就答的方式,数据通讯采用Maser/Slave方式,Master端发出数据请求消息,Slave端接收到正确消息后就可以发送数据到Master端以响应请求;
Master端也可以直接发消息修改Slave端的数据,实现双向读写。
Modbus协议需要对数据进行校验,串行协议中除有奇偶校验外,ASCII模式采用LRC校验,RTU模式采用16位CRC校验,但TCP模式没有额外规定校验,因为TCP协议是一个面向连接的可靠协议。
另外,Modbus采用主从方式定时收发数据,在实际使用中如果某Slave站点断开后(如故障或关机),Master端可以诊断出来,而当故障修复后,网络又可自动接通,Modbus协议的可靠性较好。
对于Modbus的ASCII、RTU和TCP协议来说,其中TCP和RTU协议非常类似,只要把RTU协议的两个字节的校验码去掉,然后在RTU协议的开始加上5个0和一个6并通过TCP/IP网络协议发送出去即可。
三、Modbus 通信协议特点
(1)标准、开放,用户可以免费、放心地使用Modbus协议,不需要交纳许可证费,也不会侵犯知识产权。
(2)Modbus可以支持多种电气接口,如RS-232、RS-485等,还可以在各种介质上传送,如双绞线、光纤、无线等。
(3)Modbus的帧格式简单、紧凑,通俗易懂。用户使用容易,厂商开发简单。
四、Modbus4种数据类型和功能码
Modbus协议规定,进行读写操作的数据类型,按照读写属性和类型可分为以下4种:
离散量输入(Discretes Input )
:1位,只读
线圈(Coils)
:1位,读写
输入寄存器(Input Registers )
:16位,只读
保持寄存器(Holding Registers)
:16位,读写
主设备通过功能码指定操作类型,常见功能码
包括:
01:读线圈
02:读离散输入
03:读保持寄存器
04:读输入寄存器
05:写单个线圈
06:写单个保持寄存器
15:写多个线圈
16:写多个保持寄存器
五、常用的MODBUS通讯方式
有两种,一种是MODBUS ASCII
,一种是MODBUS RTU
。每个设备必须都有相同的传输模式。所有设备都支持RTU模式,ASCII传输模式是选项。
ASCII传输方式
Modbus串行链路的设备被配置为使用ASCII模式通信时,报文中的每8位字节以两个ASCII字符
发送。例:字节0X5B会被编码为两个字符:0x35和0x42进行传送(ASCII编码0x35=“5”,0x42=“B”),这样传输效率会降低。
在ASCII模式,报文用特殊的字符区分帧起始和帧结束。一个报文必须以一个‘冒号’(:)(ASCII十六进制3A)起始,以‘回车-换行’(CRLF)对(ASCII十六进制0D和0A)结束
。设备连续的监视总线上的‘冒号’字符。当收到这个字符后,每个设备解码后续的字符一直到帧结束。报文中字符间的时间间隔可以达一秒。如果有更大的间隔,则接受设备认为发生了错误。
RTU传输方式
当设备使用RTU(RemoteTerminalUnit)模式在Modbus串行链路通信,报文中每个8位字节
含有两个4位十六进制字符
。这种模式的主要优点是较高的数据密度,在相同的波特率下比ASCII模式有更高的传输效率。每个报文必须以连续的字符流传送。
六、Modbus总结
总的来说,简单是 Modbus 如此普遍的主要原因。消息检查是 Modbus 如此受欢迎的另一个原因。CRC 和 LRC 检查意味着检查传输错误的准确率达到 99%。概括来讲,Modbus就是一种用于工业控制的协议,具有免费使用、上手简单、修改方便三大特点,已经被广泛使用。
此协议支持传统的RS-232、RS-485、RS-422和以太网设备
。许多工业设备,包括PLC,DCS,智能仪表,变频器等都在使用Modbus协议作为他们之间的通讯标准。有了它,不同厂商生产的控制设备可以连成工业网络,进行集中监控。
七、读写寄存器示例
/// <summary>
/// Modbus通信方式
/// </summary>
internal class Program
{
static void Main(string[] args)
{
int flag = 7;
#region NModbus4
if (flag == 1)
{
//SerialPort serialPort = new SerialPort();
创建串口对象
//serialPort = new SerialPort();
设置连接串口名称
//serialPort.PortName = "COM2";
设置波特率
//serialPort.BaudRate = 9600;
设置数据位
//serialPort.DataBits = 8;
设置停止位
//serialPort.StopBits = StopBits.One;
设置校验位
//serialPort.Parity = Parity.None;
打开串口
//serialPort.Open();
//ModbusSerialMaster Master = ModbusSerialMaster.CreateRtu(serialPort);
读取保持寄存器
//ushort[] values = Master.ReadHoldingRegisters(1, 0, 1);
}
#endregion
#region ModbusRTU 读取保持寄存器报文处理
if (flag == 2)
{
List<byte> registers = new List<byte>();
// 组装报文 (报文什么样子,不知道怎么组装呢?)
registers.Add(0x01); // 从站地址
registers.Add(0x03); // 功能码
ushort startAddr = 4; // 起始地址
// 0100 0010
// 0001 0000
registers.Add((byte)(startAddr / 256));
registers.Add((byte)(startAddr % 256));
ushort len = 2; // 读取寄存器数量
// 0100 0010
// 0001 0000
registers.Add((byte)(len / 256));
registers.Add((byte)(len % 256));
// CRC校验
registers = CRC16(registers);
// 发送报文给串口
SerialPort serialPort = new SerialPort("COM2", 9600, Parity.None, 8, StopBits.One);
serialPort.Open();
serialPort.Write(registers.ToArray(), 0, registers.Count);
Thread.Sleep(1000);
// 获取串口响应给我们结果
byte[] buffer = new byte[len * 2 + 5];
serialPort.Read(buffer, 0, registers.Count);
#region 读取整数
// 通过响应结果解析我们的数据
//for (int i = 3; i < buffer.Length - 2; i += 2)
//{
// // 因为一个寄存器是2个2个字节,所有读取一个数据需要2个字节组成
// // Modbus slave大端处理方式
// byte[] value = new byte[] { buffer[i + 1], buffer[i] };
// ushort v = BitConverter.ToUInt16(value);
// Console.WriteLine($"结果为:{v}");
//}
#endregion
#region 读取浮点数 读取温度计数据
// 选择是 AB CD
// BA DC
// Slave 是大端处理: 高位放前面
for (int i = 3; i < buffer.Length - 2; i += 4)
{
byte[] vb = new byte[]
{
buffer[i + 2], // C
buffer[i + 3], // D
buffer[i], // A
buffer[i+1], // B
};
float v = BitConverter.ToSingle(vb);
Console.WriteLine($"结果为:{v}");
}
#endregion
}
#endregion
#region 保持寄存器写入单值
if (flag == 3)
{
var bytes = new List<byte>();
bytes.Add(0x01);
bytes.Add(0x06);
// 定义起始地址
ushort startAddr = 3;
bytes.Add((byte)(startAddr / 256));
bytes.Add((byte)(startAddr % 256));
// 定义写入寄存器的值
ushort value = 88;
bytes.Add((byte)(value / 256));
bytes.Add((byte)(value % 256));
// CRC16校验
bytes = CRC16(bytes);
// 连接串口
SerialPort serialPort = new SerialPort("COM2", 9600, Parity.None, 8, StopBits.One);
serialPort.Open();
serialPort.Write(bytes.ToArray(), 0, bytes.Count);
}
#endregion
#region 保持寄存器写入多个值
if (flag == 4)
{
/*
请求报文
从站地址:01
功能码:10
起始地址: 00 03
写人寄存器的数量: 00 04
需要写入的数据字节数:08
写入内容:00 16 00 21 00 2C 00 37
CRC16校验: F9 A7
响应报文
从站地址:01
功能码:10
起始地址: 00 03
写人寄存器的数量: 00 04
CRC16校验:31 CA
*/
var bytes = new List<byte>();
bytes.Add(0x01); // 从站地址
bytes.Add(0x10); // 功能码
// 定义起始地址
ushort startAddr = 3;
bytes.Add((byte)(startAddr / 256));
bytes.Add((byte)(startAddr % 256));
#region 写入多个整数
定义写入寄存器的值
//var values = new List<ushort>();
//values.Add(10);
//values.Add(20);
//values.Add(30);
//values.Add(40);
定义写入寄存器的数量
//bytes.Add((byte)(values.Count / 256));
//bytes.Add((byte)(values.Count % 256));
定需要写入字节数大小
//bytes.Add((byte)(values.Count * 2));
处理写入数据大小端数据问题
//for (int i = 0; i < values.Count; i++)
//{
// // 把一个ushort拆成高低位两个字节
// bytes.Add((byte)(values[i] / 256));
// bytes.Add((byte)(values[i] % 256));
//}
#endregion
#region 写入多个float数
定义写入寄存器的值
//var values = new List<float>();
//values.Add(3.2f);
//values.Add(4.2f);
//values.Add(2.3f);
定义写入寄存器的数量
//bytes.Add((byte)(values.Count * 2 / 256));
//bytes.Add((byte)(values.Count * 2 % 256));
定需要写入字节数大小
//bytes.Add((byte)(values.Count * 4));
处理写入数据大小端数据问题
//for (int i = 0; i < values.Count; i++)
//{
// // 把一个float拆成四个个字节
// bytes.Add(BitConverter.GetBytes(values[i])[3]);
// bytes.Add(BitConverter.GetBytes(values[i])[2]);
// bytes.Add(BitConverter.GetBytes(values[i])[1]);
// bytes.Add(BitConverter.GetBytes(values[i])[0]);
//}
#endregion
#region 写入数据类型不确定 ushort, float
// 定义写入寄存器的值
var values = new List<dynamic>();
ushort v1 = 24;
values.Add(v1);
values.Add(41.2f);
// 获取集合中值总共字节数
var temp = new List<byte>();
for (int i = 0; i < values.Count; i++)
{
var dBytes = new List<byte>(BitConverter.GetBytes(values[i]));
// 0x50 0x00 =>0x00 0x50
dBytes.Reverse();
temp.AddRange(dBytes);
}
// 定义写入寄存器的数量
bytes.Add((byte)(temp.Count / 2 / 256));
bytes.Add((byte)(temp.Count / 2 % 256));
// 定需要写入字节数大小
bytes.Add((byte)(temp.Count));
// 需要写入数据字节数据
bytes.AddRange(temp);
#endregion
// CRC16校验
bytes = CRC16(bytes);
// 连接串口
SerialPort serialPort = new SerialPort("COM2", 9600, Parity.None, 8, StopBits.One);
serialPort.Open();
serialPort.Write(bytes.ToArray(), 0, bytes.Count);
}
#endregion
#region 读线圈状态 (开关量,是位方式操作)
if (flag == 5)
{
var datas = new List<byte>();
datas.Add(0x01);
datas.Add(0x01);
ushort startAddr = 0;
datas.Add((byte)(startAddr / 256));
datas.Add((byte)(startAddr % 256));
ushort len = 9;
datas.Add((byte)(len / 256));
datas.Add((byte)(len % 256));
datas = CRC16(datas);
// 发送报文给串口
SerialPort serialPort = new SerialPort("COM2", 9600, Parity.None, 8, StopBits.One);
serialPort.Open();
serialPort.Write(datas.ToArray(), 0, datas.Count);
Thread.Sleep(1000);
// 获取串口响应给我们结果
byte[] buffer = new byte[(int)(Math.Ceiling(len * 1.0 / 8)+5)];
serialPort.Read(buffer, 0, buffer.Length);
// 解析结果
var temp = new List<byte>();
for (int i = 3; i < buffer.Length - 2; i++)
{
temp.Add(buffer[i]); // 拿出2个字节
}
int index = 0;
for (int i = 0; i < temp.Count; i++)
{
// 把一个字节拆成八个位
for (int k = 0; k < 8; k++)
{
byte tempByte = (byte)(1 << k);
// 0010 0101
// 0000 0100
// 101
//
bool result = (temp[i] & tempByte) != 0;
Console.WriteLine(result? "1": "0");
index++;
if (index == len)
{
break;
}
}
}
}
#endregion
#region 写单线圈
if (flag == 6)
{
var datas = new List<byte>();
datas.Add(0x01);
datas.Add(0x05);
ushort startAddr = 10;
datas.Add((byte)(startAddr / 256));
datas.Add((byte)(startAddr % 256));
// 规定,事件情况根据你的设备参数而定
// on 0xFF 0x00
// off 0x00 0x00
datas.Add(0xFF);
datas.Add(0x00);
datas = CRC16(datas);
// 发送报文给串口
SerialPort serialPort = new SerialPort("COM2", 9600, Parity.None, 8, StopBits.One);
serialPort.Open();
serialPort.Write(datas.ToArray(), 0, datas.Count);
}
#endregion
#region 写多线圈
if (flag == 7)
{
var datas = new List<byte>();
datas.Add(0x01); // 从站地址
datas.Add(0x0F); // 功能码15
ushort startAddr = 10; // 写入起始地址
datas.Add((byte)(startAddr / 256));
datas.Add((byte)(startAddr % 256));
// 准备写入的值
var status = new List<bool>
{ true, false, true, true, true, false, true, false, false };
// 写入寄存器的数量
datas.Add((byte)(status.Count / 256));
datas.Add((byte)(status.Count % 256));
var temp = new List<byte>();
int index = 0;
for (int i = 0; i < status.Count; i++)
{
if (i % 8 ==0)
{
temp.Add(0x00);
}
index = temp.Count - 1;
if (status[i])
{
byte tempByte = (byte)(1 << i);
temp[index] |= tempByte;
}
}
// 设置写入字节数
datas.Add((byte)temp.Count);
// 写入字节数组
datas.AddRange(temp);
datas = CRC16(datas);
// 发送报文给串口
SerialPort serialPort = new SerialPort("COM2", 9600, Parity.None, 8, StopBits.One);
serialPort.Open();
serialPort.Write(datas.ToArray(), 0, datas.Count);
}
#endregion
}
static List<byte> CRC16(List<byte> value, ushort poly = 0xA001, ushort crcInit = 0xFFFF)
{
if (value == null || !value.Any())
throw new ArgumentException("");
//运算
ushort crc = crcInit;
for (int i = 0; i < value.Count; i++)
{
crc = (ushort)(crc ^ (value[i]));
for (int j = 0; j < 8; j++)
{
crc = (crc & 1) != 0 ? (ushort)((crc >> 1) ^ poly) : (ushort)(crc >> 1);
}
}
byte hi = (byte)((crc & 0xFF00) >> 8); //高位置
byte lo = (byte)(crc & 0x00FF); //低位置
List<byte> buffer = new List<byte>();
buffer.AddRange(value);
buffer.Add(lo);
buffer.Add(hi);
return buffer;
}
}