C#实现ModbusRTU详解【三】—— 生成写入报文

写入报文

通过前面两篇文章,我们已经了解到ModbusRTU的仿真环境以及读取报文的生成方法了,接下来本文将介绍标准ModbusRTU的写入报文的生成。

传送门:

C#实现ModbusRTU详解【一】—— 简介及仿真配置

C#实现ModbusRTU详解【二】—— 生成读取报文

如果不知道报文是什么,可以参考第二篇文章。

在了解如何生成写入报文之前,我们需要先知道,ModbusRTU用于写入的功能码有什么,以及ModbusRTU可以写入的区域有什么。

本专栏的第二篇文章有提交ModbusRTU最常用的八个功能码,其中四个是读取的,四个是写入的,写入的功能码如下:

05 写单个线圈
06 写单个寄存器
0F 写多个线圈
10 写多个寄存器

可以发现,这里写入的四个功能码并没有像读取那样划分成了四个区域来写入。如果有学习过PLC的ModbusRTU通讯,就能很好地理解这是为什么了。我们需要知道,离散输入和输入寄存器,对应着PLC的ModbusRTU通讯中的2xxxx及3xxxx的地址,是只读不写的,所以我们能写入的,只有普通线圈和保持型寄存器,也就是PLC的ModbusRTU通讯的1xxxx和4xxxx,可读可写。

具体为什么,我们可以理解为,从站设备的输入地址的作用是将自身获取到的直连的外部仪器的数据,通过ModbusRTU转发给主站,而输入的位和寄存器都是为了保存这些外部的数据,它们不是也不应该是主站给的数据。而另外的相当于是输出地址,也是交互地址,可以把从站设备内部处理的数据发送给主站,也可以接收主站需要给从站处理的数据。

所以可写入的地址只有两个区域,而这两个区域都有单个写入和多个写入的方式,接下来将会详细介绍如何生成写入的报文。


单个数据写入

写入单个数据时,从站响应时会把主站的请求报文原文返回,也就是说从站的响应报文和主站的请求报文是一模一样的。

我们将会用到第二篇文章中提到的枚举类型,以及CRC16校验:

/// <summary>
/// 写入模式
/// </summary>
public enum WriteType
{
    //功能码05
    Write01 = 0x05,
    //功能码06
    Write03 = 0x06,
    //功能码0F
    Write01s = 0x0F,
    //功能码10
    Write03s = 0x10
}
 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 };
        }

05 —— 写入单个线圈

报文格式如下:

主站请求报文及从站响应报文
站地址 功能码 线圈地址(高位) 线圈地址(低位) 写入值(高位) 写入值(低位) CRC16校验码
1字节 1字节 1字节 1字节 1字节 1字节 2字节

写入值的低位固定为00H。高位为FFH或者00H,FFH为置位线圈,00H为复位线圈。

也就是说我们需要把某个线圈的值改为True,那么需要写入的值就是FF 00,反过来如果需要写入False,需要写入的值就是00 00。

打开仿真软件可以看到:

置位第一个线圈:

复位第一个线圈:

 01为从站地址,05为功能码,第一个00 00为线圈地址,FF 00和第二个00 00都是写入的值,最后两位为CRC校验码。

 继续在第二篇的MessageGenerationModule类中写生成报文的方法:

/// <summary>
/// 获取写入单个线圈的报文
/// </summary>
/// <param name="slaveStation">从站地址</param>
/// <param name="startAdr">线圈地址</param>
/// <param name="value">写入值</param>
/// <returns>写入单个线圈的报文</returns>
public static byte[] GetSingleBoolWriteMessage(int slaveStation, short startAdr, bool value)
{
    //创建字节列表
    List<byte> temp = new List<byte>();

    //插入站地址及功能码
    temp.Add((byte)slaveStation);
    temp.Add(0x05);

    //获取线圈地址
    byte[] start = BitConverter.GetBytes(startAdr);
    //根据计算机大小端存储方式进行高低字节转换
    if (BitConverter.IsLittleEndian) Array.Reverse(start);
    //插入线圈地址
    temp.Add(start[0]);
    temp.Add(start[1]);

    //插入写入值
    temp.Add((byte)(value ? 0xFF : 0x00));
    temp.Add(0x00);

    //转换为字节数组
    byte[] result = temp.ToArray();

    //计算校验码并拼接,返回最后的报文结果
    return result.Concat(CheckSum.CRC16(result)).ToArray();
}

 使用控制台打印出写入第一个线圈的报文:

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("置位:");
        byte[] setMessage = MessageGenerationModule.GetSingleBoolWriteMessage(1, 0, true);

        for (int i = 0; i < setMessage.Length; i++)
        {
            Console.Write($"{setMessage[i].ToString("X2")} ");
        }

        Console.WriteLine();

        Console.WriteLine("复位:");
        byte[] resetMessage = MessageGenerationModule.GetSingleBoolWriteMessage(1, 0, false);

        for (int i = 0; i < resetMessage.Length; i++)
        {
            Console.Write($"{resetMessage[i].ToString("X2")} ");
        }

        Console.ReadKey();
    }
}

 输出结果如下,可以看到我们生成的报文与上面仿真生成的是一样的:

06 —— 写入单个寄存器

报文格式如下:

主站请求报文及从站响应报文
站地址 功能码 寄存器地址(高位) 寄存器地址(低位) 写入值(高位) 写入值(低位) CRC16校验码
1字节 1字节 1字节 1字节 1字节 1字节 2字节

打开仿真软件,查看报文:

写入值:

报文为:01 06 00 00 01 2C 89 87。
01是从站地址,06是功能码,00 00是寄存器地址,01 2C是写入的值,即1x16²+2x16+12=300,最后两位89 87是CRC16校验码

根据前面的报文,我们能很轻松地写出生成写入单个寄存器的方法:

/// <summary>
/// 获取写入单个寄存器的报文
/// </summary>
/// <param name="slaveStation">从站地址</param>
/// <param name="startAdr">寄存器地址</param>
/// <param name="value">写入值</param>
/// <returns>写入单个寄存器的报文</returns>
public static byte[] GetSingleDataWriteMessage(int slaveStation, short startAdr, short value)
{
    //从站地址
    byte station = (byte)slaveStation;

    //功能码
    byte type = 0x06;

    //寄存器地址
    byte[] start = BitConverter.GetBytes(startAdr);

    //值
    byte[] valueBytes = BitConverter.GetBytes(value);

    //根据计算机大小端存储方式进行高低字节转换
    if (BitConverter.IsLittleEndian)
    {
        Array.Reverse(start);
        Array.Reverse(valueBytes);
    }

    //拼接报文
    byte[] result = new byte[] { station, type };
    result = result.Concat(start.Concat(valueBytes).ToArray()).ToArray();

    //计算校验码并拼接,返回最后的报文结果
    return result.Concat(CheckSum.CRC16(result)).ToArray();
}

在控制台中打印在第一个寄存器中写入300的报文:

class Program
{
    static void Main(string[] args)
    {
        short data = 300;
        Console.WriteLine($"写入值为:{data}");
        byte[] message = MessageGenerationModule.GetSingleDataWriteMessage(1, 0, data);

        Console.Write("报文为:");
        for (int i = 0; i < message.Length; i++)
        {
            Console.Write($"{message[i].ToString("X2")} ");
        }

        Console.ReadKey();
    }
}

 输入结果如下,与仿真软件生成的报文一致:

0F —— 写入多个线圈

报文格式如下:

主站请求报文
站地址 功能码 起始地址(高位) 起始地址(低位) 写入数量(高位) 写入数量(低位) 字节数 写入值 CRC16校验码
1字节 1字节 1字节 1字节 1字节 1字节 1字节 N字节 2字节
从站响应报文
站地址 功能码 起始地址(高位) 起始地址(低位) 写入数量(高位) 写入数量(低位) CRC16校验码
1字节 1字节 1字节 1字节 1字节 1字节 2字节

打开仿真软件,生成报文如下:

 写入的报文为:01 0F 00 00 00 05 01 1D AF 5F。

其中01是站地址,0F是功能码,00 00是起始地址,00 05表示写入5个线圈,01是后面的数据的字节数,1D就是数据位,最后的AF 5F为CRC16校验码。

可以看到1D对应的二进制是0001 1101,反转顺序即为写入的线圈,即1011 1000,所以我们在生成报文的时候也需要注意把需要写入的数据进行反转。

这个报文生成的方法会较其它方法复杂一点点,故以下代码写上了逐行注释。

首先是用以生成反转的二进制数的方法,比如我们需要修改从零地址开始的五个线圈状态,如上面仿真软件中的情况,则需要定义一个List或bool数组,用以存储位信息,然后再根据这个位集合,来生成对应的反转顺序的二进制数。代码如下:

/// <summary>
/// 反转顺序并生成字节
/// </summary>
/// <param name="data">位数据</param>
/// <returns></returns>
public static byte GetBitArray(IEnumerable<bool> data)
{
    //把位数据集合反转
    var reverseData = data.Reverse();

    //定义初始字节,值为0000 0000
    byte temp = 0x00;

    //循环计数
    int index = 0;

    //循环位集合
    foreach (bool item in reverseData)
    {
        //判断每一位的数据,为true则左移一个1到对应的位置
        if (item) temp = (byte)(temp | (0x01 << index));

        //计数+1
        index++;
    }

    //返回最后使用位数据集合生成的二进制字节
    return temp;
}

使用这个方法可以生成指定的二进制数据,如下所示:

有了上面的生成值字节的方法,就可以写出以下生成报文的方法了:

/// <summary>
/// 获取写入多个线圈的报文
/// </summary>
/// <param name="slaveStation">从站地址</param>
/// <param name="startAdr">起始地址</param>
/// <param name="value">写入值</param>
/// <returns>写入多个线圈的报文</returns>
public static byte[] GetArrayBoolWriteMessage(int slaveStation, short startAdr, IEnumerable<bool> value)
{
    //定义报文临时存储字节集合
    List<byte> tempList = new List<byte>();

    //插入从站地址
    tempList.Add((byte)slaveStation);

    //插入功能码
    tempList.Add(0x0F);

    //获取起始地址
    byte[] start = BitConverter.GetBytes(startAdr);

    //获取写入线圈数量
    byte[] length = BitConverter.GetBytes(Convert.ToInt16(value.Count()));

    //根据计算机大小端存储方式进行高低字节转换
    if (BitConverter.IsLittleEndian)
    {
        Array.Reverse(start);
        Array.Reverse(length);
    }

    //插入起始地址
    tempList.Add(start[0]);
    tempList.Add(start[1]);

    //插入写入线圈数量
    tempList.Add(length[0]);
    tempList.Add(length[1]);

    //定义写入值字节集合
    List<byte> valueTemp = new List<byte>();

    //由于一个字节只有八个位,所以如果需要写入的值超过了八个,
    //则需要生成一个新的字节用以存储,
    //所以循环截取输入的值,然后生成对应的写入值字节
    for (int i = 0; i < value.Count(); i += 8)
    {
        //写入值字节临时字节集合
        List<bool> temp = value.Skip(i).Take(8).ToList();

        //剩余位不足八个,则把剩下的所有位都放到同一个字节里
        if (temp.Count != 8)
        {
            //取余获取剩余的位的数量
            int m = value.Count() % 8;
            //截取位放入临时字节集合中
            temp = value.Skip(i).Take(m).ToList();
        }

        //获取位生成的写入值字节
        byte tempByte = GetBitArray(temp);

        //将生成的写入值字节拼接到写入值字节集合中
        valueTemp.Add(tempByte);
    }

    //获取写入值的字节数
    byte bytecount = (byte)valueTemp.Count;

    //插入写入值的字节数
    tempList.Add(bytecount);

    //插入值字节集合
    tempList.AddRange(valueTemp);

    //根据报文字节集合计算CRC16校验码,并拼接到最后,然后转换为字节数组并返回
    return tempList.Concat(CheckSum.CRC16(tempList)).ToArray();
}

 在控制台中打印出生成的报文,可以看到与仿真软件中的是一致的:

class Program
{
    static void Main(string[] args)
    {
        List<bool> list = new List<bool>() { true, false, true, true, true };

        byte data = MessageGenerationModule.GetBitArray(list);
        byte[] message = MessageGenerationModule.GetArrayBoolWriteMessage(1, 0, list);

        Console.WriteLine("值字节为:");
        Console.WriteLine($"十六进制:{data.ToString("X2")}");
        Console.WriteLine($"二进制:{Convert.ToString(data, 2)}");

        Console.WriteLine();

        Console.WriteLine("报文为:");
        for (int i = 0; i < message.Length; i++)
        {
            Console.Write($"{message[i].ToString("X2")} ");
        }

        Console.ReadKey();
    }
}

 

10 —— 写入多个寄存器

报文格式如下:

主站请求报文
站地址 功能码 起始地址(高位) 起始地址(低位) 写入数量(高位) 写入数量(低位) 字节数 写入值 CRC16校验码
1字节 1字节 1字节 1字节 1字节 1字节 1字节 N字节 2字节
从站响应报文
站地址 功能码 起始地址(高位) 起始地址(低位) 写入数量(高位) 写入数量(低位) CRC16校验码
1字节 1字节 1字节 1字节 1字节 1字节 2字节

打开仿真软件查看报文:

其中写入的报文为:01 10 00 00 00 05 0A 00 0A 00 14 00 1E 00 28 00 32 82 86。

其中:

01为从站地址;

10为功能码;

00 00为起始地址;

00 05为写入的寄存器数量;

0A为后面数据字节数量,即10个;

后面的十个字节每两个为一个寄存器的值,由于写入的是16位的整型,所以一个数据占用两个字节,数值依次为:

        00 0A — 10

        00 14 — 20

        00 1E — 30

        00 28 — 40

        00 32 — 50;

最后两个字节为CRC16校验码。

明白了每个字节所代表的含义后,我们就可以根据它写出对应的报文生成方法:

/// <summary>
/// 获取写入多个寄存器的报文
/// </summary>
/// <param name="slaveStation">从站地址</param>
/// <param name="startAdr">起始地址</param>
/// <param name="value">写入值</param>
/// <returns>写入多个寄存器的报文</returns>
public static byte[] GetArrayDataWriteMessage(int slaveStation, short startAdr, IEnumerable<short> value)
{
    //定义报文临时存储字节集合
    List<byte> tempList = new List<byte>();

    //插入从站地址
    tempList.Add((byte)slaveStation);

    //插入功能码
    tempList.Add(0x10);

    //获取起始地址
    byte[] start = BitConverter.GetBytes(startAdr);

    //获取写入值的数量
    byte[] length = BitConverter.GetBytes(Convert.ToInt16(value.Count()));

    //根据计算机大小端存储方式进行高低字节转换
    if (BitConverter.IsLittleEndian)
    {
        Array.Reverse(start);
        Array.Reverse(length);
    }

    //插入起始地址
    tempList.AddRange(start);

    //插入写入值数量
    tempList.AddRange(length);

    //创建写入值字节集合
    List<byte> valueBytes = new List<byte>();

    //将需要插入的每个值转换为字节数组,
    //并根据计算机大小端存储方式进行高低字节转换
    //然后插入到值的字节集合中
    foreach (var item in value)
    {
        byte[] temp = BitConverter.GetBytes(item);
        if (BitConverter.IsLittleEndian)
        {
            Array.Reverse(temp);
        }
        valueBytes.AddRange(temp);
    }

    //获取写入值的字节数
    byte count = Convert.ToByte(valueBytes.Count);

    //插入写入值的字节数
    tempList.Add(count);

    //插入写入值字节集合
    tempList.AddRange(valueBytes);

    //根据报文字节集合计算CRC16校验码,并拼接到最后,然后转换为字节数组并返回
    return tempList.Concat(CheckSum.CRC16(tempList)).ToArray();
}

最后在控制台中打印出来,查看报文,可以看到与仿真生成的报文一致:

class Program
{
    static void Main(string[] args)
    {
        List<short> shortList = new List<short>() { 10, 20, 30, 40, 50 };

        byte[] message = MessageGenerationModule.GetArrayDataWriteMessage(1, 0, shortList);

        Console.WriteLine("报文为:");
        for (int i = 0; i < message.Length; i++)
        {
            Console.Write($"{message[i].ToString("X2")} ");
        }

        Console.ReadKey();
    }
}


结尾

至此,我们已经成功完成了八个最常用的标准ModbusRTU报文的生成方法了,下一篇将详细介绍如何使用我们的报文生成方法去实现标准的ModbusRTU通讯,如果进制转换方面仍存在问题,后面我将会再写一篇简单的进制转换的方法介绍。

猜你喜欢

转载自blog.csdn.net/XUMENGCAS/article/details/122185571