首先我们得先知道,报文是什么。
以下摘自百度百科:
报文(message)是网络中交换与传输的数据单元,即站点一次性要发送的数据块。报文包含了将要发送的完整的数据信息,其长短很不一致,长度不限且可变。
报文就是在通信过程中交换数据的载体,由指定格式的字节数组组成。
在ModbusRTU中,结构如下:
数据格式 | 站地址 | 功能码 | 数据区 | 校验码 |
数据长度 | 1字节 | 1字节 | N字节 | 2字节 |
其中:
站地址 | 接收消息的从站地址 |
功能码 | 指定需要执行的功能 |
数据区 | 需要发送的数据、需要读取的数据起始位置及长度 |
校验码 | CRC16校验码(循环冗余校验) |
ModbusRTU常用的功能码有八个,如下表所示:
功能码 | 作用 |
01 | 读线圈 |
02 | 读离散输入 |
03 | 读保持型寄存器 |
04 | 读输入寄存器 |
05 | 写单个线圈 |
06 | 写单个寄存器 |
0F | 写多个线圈 |
10 | 写多个寄存器 |
根据此表可以列出两个枚举类型,以便后续代码实现的使用:
///
/// 读取模式
///
public enum ReadType
{
//功能码01
Read01 = 0x01,
//功能码02
Read02 = 0x02,
//功能码03
Read03 = 0x03,
//功能码04
Read04 = 0x04
}
///
/// 写入模式
///
public enum WriteType
{
//功能码05
Write01 = 0x05,
//功能码06
Write03 = 0x06,
//功能码0F
Write01s = 0x0F,
//功能码10
Write03s = 0x10
}
接下来会逐一介绍不同功能码的具体使用格式。
CRC16校验算法网上有很多,此处不作过多的解释,下面是CRC16的校验算法的代码实现:
public static byte[] CRC16(byte[] data)
{
int len = data.Length;
if (len > 0)
{
ushort crc = 0xFFFF;
for (int i = 0; i < len; i++)
{
crc = (ushort)(crc ^ (data[i]));
for (int j = 0; j < 8; j++)
{
crc = (crc & 1) != 0 ? (ushort)((crc >> 1) ^ 0xA001) : (ushort)(crc >> 1);
}
}
byte hi = (byte)((crc & 0xFF00) >> 8); //高位置
byte lo = (byte)(crc & 0x00FF); //低位置
return BitConverter.IsLittleEndian ? new byte[] { lo, hi } : new byte[] { hi, lo };
}
return new byte[] { 0, 0 };
}
线圈可以理解为开关,对应C#中的布尔量,只有开和关两种状态,可读可写。
报文格式如下:
站地址 | 功能码 | 起始地址(高位) | 起始地址(低位) | 读取数量(高位) | 读取数量(低位) | CRC16校验码 |
1字节 | 1字节 | 1字节 | 1字节 | 1字节 | 1字节 | 2字节 |
站地址 | 功能码 | 数据字节数 | 数据 | CRC16校验码 |
1字节 | 1字节 | 1字节 | N字节 | 2字节 |
打开两个仿真软件可以看到:
其中主站的请求报文为:01 01 00 00 00 0A BC 0D,从站的响应报文为:01 01 02 02 00 B8 9C;
首先从请求报文看起:
第一个01是从站的站地址,即向1号从站发送请求;
第二个01为读线圈的功能码;
第三、四个字节,00 00是起始位,即从地址0开始读取;
第五、六个字节,00 0A是读取数量,换算为10进制为10,即读取10个地址;
最后两个字节为CRC16校验码,由前面六个字节计算得出。
接着看响应报文:
第一个01是从站的站地址,即响应的是1号从站;
第二个01为读线圈的功能码;
第三个字节为02,是数据区的长度,即此字节后面的两个字节都是数据;
第四、五个字节,02 00为读取的数据,换算为二进制为0010 0000 0000 0000。由于读取的是线圈,返回的值其实是一个个的布尔量,也就是位,所以需要换算为二进制才能看到确定的读数,上面的例子是从站的第二个地址,即地址1的值为1(true),所以可以看到,换算为二进制的头四个位表示的就是我们读取到的值,即0010,倒过来看就是地址0为0,地址1为1,地址2为0,地址3为0。之所以这里是两个字节,是因为读取的是十个地址,而每个地址的值都是一个位(bit)。由于一个字节只有八个位(bit),所以溢出的两个位就被存储到了新的字节里面。
最后两个字节为CRC16校验码,由前面五个字节计算得出。
了解到原理之后,就可以开始写代码了。
新建一个叫MessageGenerationModule的类,作为校验码计算的工具类,先写01的请求报文的生成方法:
///
/// 获取读取数据请求报文
///
/// 从站地址
/// 读取模式
/// 起始地址
/// 读取长度
///
public static byte[] GetReadMessage(int slaveStation, ReadType readType, short startAdr, short length)
{
//定义临时字节列表
List temp = new List();
//依次放入头两位字节(站地址和读取模式)
temp.Add((byte)slaveStation);
temp.Add((byte)readType);
//获取起始地址及读取长度
byte[] start = BitConverter.GetBytes(startAdr);
byte[] count = BitConverter.GetBytes(length);
//判断系统是否为小端存储
//如果为true,BitConverter.GetBytes方法会返回低字节在前,高字节在后的字节数组,
//而ModbusRTU则需要高字节在前,低字节在后,所以需要做一次反转操作。
if (BitConverter.IsLittleEndian)
{
Array.Reverse(start);
Array.Reverse(count);
}
//依次放入起始地址和读取长度
temp.AddRange(start);
temp.AddRange(count);
//获取校验码并在最后放入
temp.AddRange(CheckSum.CRC16(temp));
return temp.ToArray();
}
然后是解析的方法:
//响应报文
//01 01 02 02 00 B8 9C
byte[] receiveMsg = new byte[] { 0x01, 0x01, 0x02, 0x02, 0x00, 0xB8, 0x9C };
//计算的校验码
byte[] msgchecksum = CheckSum.CRC16(receiveMsg.Skip(0).Take(5).ToArray());
//校验码验证
if (Enumerable.SequenceEqual(receiveMsg.Skip(5).Take(2).ToArray(), msgchecksum))
{
//获取线圈状态
BitArray bitArray = new BitArray(receiveMsg.Skip(3).Take(2).ToArray());
}
使用控制台的主程序打印出最后的结果:
static void Main(string[] args)
{
byte[] data = MessageGenerationModule.GetReadMessage(1, ReadType.Read01, 0, 10);
Console.WriteLine("发送的报文:");
for (int i = 0; i < data.Length; i++)
{
Console.Write(data[i].ToString("X2") + " ");
}
//响应报文
//01 01 02 02 00 B8 9C
byte[] receiveMsg = new byte[] { 0x01, 0x01, 0x02, 0x02, 0x00, 0xB8, 0x9C };
Console.WriteLine();
Console.WriteLine();
Console.WriteLine("接收到的消息:");
for (int i = 0; i < receiveMsg.Length; i++)
{
Console.Write($"{receiveMsg[i].ToString("X2")} ");
}
//计算的校验码
byte[] msgchecksum = CheckSum.CRC16(receiveMsg.Skip(0).Take(5).ToArray());
Console.WriteLine();
//校验码验证
if (Enumerable.SequenceEqual(receiveMsg.Skip(5).Take(2).ToArray(), msgchecksum))
{
char[] chr = Convert.ToString(BitConverter.ToInt16(receiveMsg, 3), 2).ToArray();
Array.Reverse(chr);
//获取线圈状态
BitArray bitArray = new BitArray(receiveMsg.Skip(3).Take(2).ToArray());
ConsoleResult(bitArray,chr);
Console.WriteLine($"接收到消息的二进制:{new string(chr)}");
}
Console.ReadKey();
}
///
/// 打印消息
///
///
///
private static void ConsoleResult(BitArray bitArray,char[] chr)
{
for (int i = 0; i < chr.Length; i++)
{
Console.WriteLine($"{chr[i]} —— {bitArray[i]}");
}
}
离散输入同样是布尔量,只可读不可写。
报文格式如下:
站地址 | 功能码 | 起始地址(高位) | 起始地址(低位) | 读取数量(高位) | 读取数量(低位) | CRC16校验码 |
1字节 | 1字节 | 1字节 | 1字节 | 1字节 | 1字节 | 2字节 |
站地址 | 功能码 | 数据字节数 | 数据 | CRC16校验码 |
1字节 | 1字节 | 1字节 | N字节 | 2字节 |
与01功能码对比,可以发现,它们的报文格式是一模一样的。
再打开仿真软件查看一下读取报文:
可以看到,请求的报文和响应的报文仅仅只有功能码和校验码不一样了。所以我们只需要修改一下刚刚的方法里的读写模式,即可生成正确的报文,并解析出正确的结果:
byte[] data = MessageGenerationModule.GetReadMessage(1, ReadType.Read02, 0, 10);
//02的响应报文
//01 02 02 02 00 B8 D8
byte[] receiveMsg = new byte[] { 0x01, 0x02, 0x02, 0x02, 0x00, 0xB8, 0xD8 };
其余的代码均不修改,最后可以看到输出结果:
保持型寄存器可以存储16位的无符号整数,32位的单精度浮点数,64位的双精度浮点数,所以解析时需要注意数据类型。
由于存储的最小的单位就是16位的整数,所以每个寄存器存储的值最少为两个字节。
报文格式如下:
站地址 | 功能码 | 起始地址(高位) | 起始地址(低位) | 读取数量(高位) | 读取数量(低位) | CRC16校验码 |
1字节 | 1字节 | 1字节 | 1字节 | 1字节 | 1字节 | 2字节 |
站地址 | 功能码 | 数据字节数 | 数据 | CRC16校验码 |
1字节 | 1字节 | 1字节 | N字节 | 2字节 |
报文格式其实与01、02一样,所以生成报文的方法都是一样的,但是由于读取的数据类型不同,需要修改解析的方法。先看看仿真器生成的报文:
主站的请求报文:01 03 00 00 00 0A C5 CD
从站的响应报文:01 03 14 00 00 00 14 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 EC 63
可以看到,主站的请求报文确实只有功能码和校验码变了。不过可以发现从站响应的报文变长了很多,这是因为读取报文读取的是前十个地址的值,一个地址的整数值为两个字节,所以返回的时候的数据区就会有2x10个(即20个)字节,这也就是第三个字节(表示数据区字节数量的字节)为14(十进制为20)的原因。
所以我们也就不难拿到我们有值的地址1的值了,由前面的解释可以得知,数据区的第一、二个字节表示地址1,第三、四个字节表示地址2,也就是00 14,转换为可以得到值为20。
读取报文的方法只需要修改读取模式:
byte[] data = MessageGenerationModule.GetReadMessage(1, ReadType.Read03, 0, 10);
响应的报文:
byte[] receiveMsg = new byte[] { 0x01, 0x03, 0x14,
0x00, 0x00,
0x00, 0x14,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0xEC, 0x63 };
而解析报文则需要修改为以下形式:
//校验码验证
if (Enumerable.SequenceEqual(receiveMsg.Skip(receiveMsg.Length - 2).Take(2).ToArray(), msgchecksum))
{
//char[] chr = Convert.ToString(BitConverter.ToInt16(receiveMsg, 3), 2).ToArray();
//Array.Reverse(chr);
//获取线圈状态
//BitArray bitArray = new BitArray(receiveMsg.Skip(3).Take(2).ToArray());
//ConsoleResult(bitArray, chr);
//Console.WriteLine($"接收到消息的二进制:{new string(chr)}");
//获取字节数
int count = Convert.ToInt32(receiveMsg[2]);
int index = 0;
for (int i = 3; i < count + 3; i += 2)
{
index++;
//每个地址所属的字节数组
byte[] temp = new byte[] { receiveMsg[i + 1], receiveMsg[i] };
//获取整型结果
short result = BitConverter.ToInt16(temp, 0);
Console.Write($"{index:00} : {receiveMsg[i].ToString("X2")} {receiveMsg[i + 1].ToString("X2")} -- {result}");
Console.WriteLine();
}
}
最后运行可以看到生成的请求报文和解析的结果都是正确的:
输入寄存器与保持型寄存器基本相同,但是输入寄存器是只可读不可写的。
报文格式如下:
站地址 | 功能码 | 起始地址(高位) | 起始地址(低位) | 读取数量(高位) | 读取数量(低位) | CRC16校验码 |
1字节 | 1字节 | 1字节 | 1字节 | 1字节 | 1字节 | 2字节 |
站地址 | 功能码 | 数据字节数 | 数据 | CRC16校验码 |
1字节 | 1字节 | 1字节 | N字节 | 2字节 |
与前面也基本相同,再看看仿真器:
主站的请求报文:01 04 00 00 00 0A 70 0D
从站的响应报文:01 04 14 00 00 00 42 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 05 35
按照上面的方法修改一下读取模式和响应报文的数据以检验同样方法是否依然可行:
byte[] data = MessageGenerationModule.GetReadMessage(1, ReadType.Read04, 0, 10);
//04的响应报文
//01 04 14 00 00 00 42 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 05 35
byte[] receiveMsg = new byte[] { 0x01, 0x04, 0x14,
0x00, 0x00,
0x00, 0x42,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x05, 0x35 };
运行可以得到,结果正确:
通常ModbusRTU协议中传输的都是整数形式的数据,但是也有可能是单精度浮点数或者是双精度浮点数,这种时候就需要修改解析方法了。但是实际应用中,也有不少厂家会将浮点数乘到整数再进行传输,以减少寄存器的使用量,最后我们读取到这样的数据后,做一次简单的除法运算则可以得到正确的结果。比如有一个25.3℃的温度数据,从站将它乘以10得到253,然后主站通过ModbusRTU获取到的数据就是253,需要自行除以10才可以得到25.3。
以下以03为例,先看看仿真的报文:
主站请求报文:01 03 00 00 00 0A C5 CD
从站响应报文:01 03 14 41 CA 66 66 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 02 99
可以看到,请求是没有变化的,响应的报文的数据区依然是20个字节,这是因为一个寄存器最多只能存储16位的数据,如果是需要存储32位的单精度浮点数,则需要占用两个寄存器。所以如果设备寄存器数量不足,则需要尽量使用前面提到的乘成整数再存储的方式。
其中41 CA 66 66则为地址0和地址1存储的25.3这个值。
C#中的解析方法可参考下面进行修改:
//浮点数的响应报文
//01 03 14 41 CA 66 66 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 02 99
byte[] receiveMsg = new byte[] { 0x01, 0x03, 0x14,
0x41, 0xCA,
0x66, 0x66,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x02, 0x99 };
//获取浮点数
int count = Convert.ToInt32(receiveMsg[2]);
int index = 0;
for (int i = 3; i < count + 3; i += 4)
{
index++;
//每个地址所属的字节数组
byte[] temp = new byte[] { receiveMsg[i + 3], receiveMsg[i + 2], receiveMsg[i + 1], receiveMsg[i] };
//获取浮点数结果
float result = BitConverter.ToSingle(temp, 0);
string str = $"{index:00} : ";
for (int j = 0; j < temp.Length; j++)
{
str += $"{temp[j].ToString("X2")} ";
}
str += $"-- {result}";
Console.Write(str);
Console.WriteLine();
}
运行结果如下:
需要注意的是,实际float的转换的高低字节和高低位顺序是有四种不同的情况的,这个需要根据实际情况进行调整,而其它的双精度浮点数之类的值,解析方法实现的思路都是一样的。
MessageGenerationModule类:
public class MessageGenerationModule
{
///
/// 获取读取数据请求报文
///
/// 从站地址
/// 读取模式
/// 起始地址
/// 读取长度
///
public static byte[] GetReadMessage(int slaveStation, ReadType readType, short startAdr, short length)
{
//定义临时字节列表
List temp = new List();
//依次放入头两位字节(站地址和读取模式)
temp.Add((byte)slaveStation);
temp.Add((byte)readType);
//获取起始地址及读取长度
byte[] start = BitConverter.GetBytes(startAdr);
byte[] count = BitConverter.GetBytes(length);
//判断系统是否为小端存储
//如果为true,BitConverter.GetBytes方法会返回低字节在前,高字节在后的字节数组,
//而ModbusRTU则需要高字节在前,低字节在后,所以需要做一次反转操作。
if (BitConverter.IsLittleEndian)
{
Array.Reverse(start);
Array.Reverse(count);
}
//依次放入起始地址和读取长度
temp.AddRange(start);
temp.AddRange(count);
//获取校验码并在最后放入
temp.AddRange(CheckSum.CRC16(temp));
return temp.ToArray();
}
}
CheckSum类:
public class CheckSum
{
#region CRC16
public static byte[] CRC16(byte[] data)
{
int len = data.Length;
if (len > 0)
{
ushort crc = 0xFFFF;
for (int i = 0; i < len; i++)
{
crc = (ushort)(crc ^ (data[i]));
for (int j = 0; j < 8; j++)
{
crc = (crc & 1) != 0 ? (ushort)((crc >> 1) ^ 0xA001) : (ushort)(crc >> 1);
}
}
byte hi = (byte)((crc & 0xFF00) >> 8); //高位置
byte lo = (byte)(crc & 0x00FF); //低位置
return BitConverter.IsLittleEndian ? new byte[] { lo, hi } : new byte[] { hi, lo };
}
return new byte[] { 0, 0 };
}
}
主程序:
class Program
{
static void Main(string[] args)
{
//01
//byte[] data = MessageGenerationModule.GetReadMessage(1, ReadType.Read01, 0, 10);
//02
//byte[] data = MessageGenerationModule.GetReadMessage(1, ReadType.Read02, 0, 10);
//03
byte[] data = MessageGenerationModule.GetReadMessage(1, ReadType.Read03, 0, 10);
//04
//byte[] data = MessageGenerationModule.GetReadMessage(1, ReadType.Read04, 0, 10);
Console.WriteLine("发送的报文:");
for (int i = 0; i < data.Length; i++)
{
Console.Write(data[i].ToString("X2") + " ");
}
//响应报文
//01 01 02 02 00 B8 9C
//byte[] receiveMsg = new byte[] { 0x01, 0x01, 0x02, 0x02, 0x00, 0xB8, 0x9C };
//02的响应报文
//01 02 02 02 00 B8 D8
//byte[] receiveMsg = new byte[] { 0x01, 0x02, 0x02, 0x02, 0x00, 0xB8, 0xD8 };
//03的响应报文
//01 03 14 00 00 00 14 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 EC 63
//byte[] receiveMsg = new byte[] { 0x01, 0x03, 0x14,
// 0x00, 0x00,
// 0x00, 0x14,
// 0x00, 0x00,
// 0x00, 0x00,
// 0x00, 0x00,
// 0x00, 0x00,
// 0x00, 0x00,
// 0x00, 0x00,
// 0x00, 0x00,
// 0x00, 0x00,
// 0xEC, 0x63 };
//04的响应报文
//01 04 14 00 00 00 42 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 05 35
//byte[] receiveMsg = new byte[] { 0x01, 0x04, 0x14,
// 0x00, 0x00,
// 0x00, 0x42,
// 0x00, 0x00,
// 0x00, 0x00,
// 0x00, 0x00,
// 0x00, 0x00,
// 0x00, 0x00,
// 0x00, 0x00,
// 0x00, 0x00,
// 0x00, 0x00,
// 0x05, 0x35 };
//浮点数的响应报文
//01 03 14 41 CA 66 66 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 02 99
byte[] receiveMsg = new byte[] { 0x01, 0x03, 0x14,
0x41, 0xCA,
0x66, 0x66,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x00, 0x00,
0x02, 0x99 };
Console.WriteLine();
Console.WriteLine();
Console.WriteLine("接收到的消息:");
for (int i = 0; i < receiveMsg.Length; i++)
{
Console.Write($"{receiveMsg[i].ToString("X2")} ");
}
//01 01 02 02 02 39 5D
//byte[] receiveMsg = new byte[] { 0x01, 0x01, 0x02, 0x02, 0x02, 0x39, 0x5D };
//计算的校验码
byte[] msgchecksum = CheckSum.CRC16(receiveMsg.Skip(0).Take(receiveMsg.Length - 2).ToArray());
Console.WriteLine();
//校验码验证
if (Enumerable.SequenceEqual(receiveMsg.Skip(receiveMsg.Length - 2).Take(2).ToArray(), msgchecksum))
{
#region 线圈解析
//char[] chr = Convert.ToString(BitConverter.ToInt16(receiveMsg, 3), 2).ToArray();
//Array.Reverse(chr);
//获取线圈状态
//BitArray bitArray = new BitArray(receiveMsg.Skip(3).Take(2).ToArray());
//ConsoleResult(bitArray, chr);
//Console.WriteLine($"接收到消息的二进制:{new string(chr)}");
#endregion
#region 整型解析
//获取字节数
//int count = Convert.ToInt32(receiveMsg[2]);
//int index = 0;
//for (int i = 3; i < count + 3; i += 2)
//{
// index++;
// //每个地址所属的字节数组
// byte[] temp = new byte[] { receiveMsg[i + 1], receiveMsg[i] };
// //获取整型结果
// short result = BitConverter.ToInt16(temp, 0);
// Console.Write($"{index:00} : {receiveMsg[i].ToString("X2")} {receiveMsg[i + 1].ToString("X2")} -- {result}");
// Console.WriteLine();
//}
#endregion
#region 浮点数解析
//获取字节数
int count = Convert.ToInt32(receiveMsg[2]);
int index = 0;
for (int i = 3; i < count + 3; i += 4)
{
index++;
//每个地址所属的字节数组
byte[] temp = new byte[] { receiveMsg[i + 3], receiveMsg[i + 2], receiveMsg[i + 1], receiveMsg[i] };
//获取浮点数结果
float result = BitConverter.ToSingle(temp, 0);
string str = $"{index:00} : ";
for (int j = 0; j < temp.Length; j++)
{
str += $"{temp[j].ToString("X2")} ";
}
str += $"-- {result}";
Console.Write(str);
Console.WriteLine();
}
#endregion
}
Console.ReadKey();
}
///
/// 打印消息
///
///
///
private static void ConsoleResult(BitArray bitArray, char[] chr)
{
for (int i = 0; i < chr.Length; i++)
{
Console.WriteLine($"{chr[i]} —— {bitArray[i]}");
}
}
}
本文介绍了ModbusRTU读取报文的格式及生成和解析,但是需要注意的是,前面介绍的都是标准的ModbusRTU格式的报文,实际应用中,依然存在非标的情况,这种时候就要根据实际情况来自行修正了。下一篇将继续介绍写入的报文。