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通讯,如果进制转换方面仍存在问题,后面我将会再写一篇简单的进制转换的方法介绍。

  • 33
    点赞
  • 86
    收藏
    觉得还不错? 一键收藏
  • 16
    评论
课程背景Modbus 协议是工业自动化控制系统中常见的通信协议,协议的全面理解是个痛点。本课程主讲老师集10多年在Modbus协议学习、使用中的经验心得,结合当前物联网浪潮下Modbus协议开发的痛点,推出这套面向Modbus 协议初学者的课程。本课程不同于以往市面课程只是协议讲解无实现代码,而是采用讲解与实践并重的方式,结合STM32F103ZET6开发板进行手把手编程实践,十分有利于初学者学习。涵盖了学习者在Modbus协议方面会遇到的方方面面的问题,是目前全网首个对Modbus协议进行全面总结的课程。课程名称   协议讲解及实现>>课程内容1、Modbus 协议的基础。2、Modbus协议栈函数编程实现。3、Modbus协议在串行链路编程实现。4、Modbus协议在以太网链路编程实现。5、常见问题的解决方法。带给您的价值通过学习本课程,您可以做到如下:1、全面彻底的理解Modbus协议。2、理解在串行链路,以太网链路的实现。3、掌握Modbus协议解析的函数编程方法,调试工具的使用。4、掌握多个串口,网口同时运行同一个Modbus协议栈的方法。5、掌握Modbus协议下,负数,浮点数等处理方法。讲师简介许孝刚,山东大学工程硕士,副高职称,技术总监。10多年丰富嵌入式系统开发经验,国家软考“嵌入式系统设计师”。2017年获得“华为开发者社区杰出贡献者”奖励。
要使用C#组建Modbus RTU协议的报文,你可以按照以下步骤进行操作: 1. 首先,你需要使用C#中的串口通信功能来与Modbus设备进行通信。你可以使用SerialPort类来实现这一功能。在你的代码中,需要引入System.IO.Ports命名空间。 2. 创建一个SerialPort对象,并设置串口通信的相关参数,例如波特率、数据位、停止位和校验位等。例如: ```csharp SerialPort serialPort = new SerialPort("COM1", 9600, Parity.None, 8, StopBits.One); serialPort.Open(); ``` 请根据你的实际情况修改串口号、波特率等参数。 3. 组建Modbus RTU协议的报文Modbus RTU协议的报文由多个字节组成,包括设备地址、功能码、数据和校验等。根据Modbus协议规范,你可以使用字节数组来表示报文。 4. 通过串口发送报文。使用SerialPort对象的Write方法将报文发送到Modbus设备。例如: ```csharp byte[] message = new byte[]{ 0x01, 0x03, 0x00, 0x00, 0x00, 0x0A, 0xC5, 0xCD }; serialPort.Write(message, 0, message.Length); ``` 在这个例子中,我们发送了一个读取保持寄存器的功能码为3的请求。 5. 接收Modbus设备的响应。使用SerialPort对象的Read方法从串口接收Modbus设备的响应。你可以根据Modbus协议规范解析接收到的字节数据。 ```csharp byte[] buffer = new byte[serialPort.BytesToRead]; serialPort.Read(buffer, 0, buffer.Length); ``` 在这个例子中,我们将接收到的字节数据存储在名为buffer的字节数组中。 6. 最后,记得关闭串口连接。 ```csharp serialPort.Close(); ``` 这就是使用C#组建Modbus RTU协议报文的基本步骤。当然,具体的实现还取决于你需要实现Modbus功能和设备。你可能需要参考Modbus协议规范来了解更多细节,并根据具体情况进行适当的调整和处理。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 16
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值