C# 串口发送ModbusRTU、ModbusASCII的8条主要功能代码并获取返回的响应信息

1.概述

在我的文章“C# ModbusRTU命令功能码”和"C# Modbus ASCII命令功能码"中给出了Modbus两种传输方式的主要8条功能代码,它们都是byte[]数组,都是独立的指令代码,并没有集成到串口发送中来,这样做是为了扩大它的应用范围,这些独立的功能代码即可以应用于RS232/485/422转网络传输的环境中,也可以应用于Modbus串行总线部署,本文就给出它们应用于Modbus串口通信发送和接收数据的程序代码。

2.程序代码

由于本文的代码是要使用前面说过两篇文章的功能代码,所以首先要把两篇文章中的ModbusRTU类和ModbusASCII类放入本文代码的同一命名空间中(或者引用所在的命名空间),其次这两个类的8条主要功能代码方法名和参数都一样,因此采用了反射来调用这两个类的方法(当然大家也可以声明定义这两个类去使用,但是会增加代码量)。
我们需要使用下面四个枚举,Errors枚举相比我原来两篇文章的Errors枚举有了几个新增项。

    #region 枚举

    /// <summary>
    ///  ModbusRTU类/ModbusASCII类方法名
    /// </summary>
    public enum ModbusSerialCMD
    {
        ReadCoilStatus_0x01 = 0x01,
        ReadInputStatus_0x02 = 0x02,
        ReadHoldingRegister_0x03 = 0x03,
        ReadInputRegister_0x04 = 0x04,
        WriteSingleCoil_0x05 = 0x05,
        WriteSingleRegister_0x06 = 0x06,
        WriteMultipleCoil_0x0F = 0x0F,
        WriteMultipleRegister_0x10 = 0x10
    }

    /// <summary>
    /// Modbus的类型
    /// </summary>
    public enum ModbusType
    {
        /// <summary>
        /// Modbus serial RTU
        /// </summary>
        SERIAL_RTU = 0,

        /// <summary>
        /// Modbus serial ASCII
        /// </summary>
        SERIAL_ASCII = 1,

        /// <summary>
        /// Modbus TCP/IP
        /// </summary>
        TCP_IP = 2,
    }

    /// <summary>
    /// Modbus指令代码
    /// </summary>
    public enum ModbusCodes
    {
        READ_COILS = 0x01,                      //读取线圈
        READ_DISCRETE_INPUTS = 0x02,            //读取离散量输入
        READ_HOLDING_REGISTERS = 0x03,          //读取保持寄存器
        READ_INPUT_REGISTERS = 0x04,            //读取输入寄存器
        WRITE_SINGLE_COIL = 0x05,               //写单个线圈
        WRITE_SINGLE_REGISTER = 0x06,           //写单个保持寄存器
        READ_EXCEPTION_STATUS = 0x07,           //读取异常状态
        DIAGNOSTIC = 0x08,                      //回送诊断校验
        GET_COM_EVENT_COUNTER = 0x0B,           //读取事件计数
        GET_COM_EVENT_LOG = 0x0C,               //读取通信事件记录
        WRITE_MULTIPLE_COILS = 0x0F,            //写多个线圈
        WRITE_MULTIPLE_REGISTERS = 0x10,        //写多个保持寄存器
        REPORT_SLAVE_ID = 0x11,                 //报告从机标识(ID)
        READ_FILE_RECORD = 0x14,                //读文件记录
        WRITE_FILE_RECORD = 0x15,               //写文件记录
        MASK_WRITE_REGISTER = 0x16,             //屏蔽写寄存器
        READ_WRITE_MULTIPLE_REGISTERS = 0x17,   //读写多个寄存器
        READ_FIFO_QUEUE = 0x18,                 //读取队列
        READ_DEVICE_IDENTIFICATION = 0x2B       //读取设备标识
    }

    /// <summary>
    /// 错误代码
    /// </summary>
    public enum Errors
    {
        NO_ERROR = 0,//无错误
        EXCEPTION_UNKNOWN = 1,//未知异常
        EXCEEDING_MODBUSCODE_RANGE = 2,//超出ModbusCode范围
        UNPROCESSED_MODBUSCODE = 3,//没有处理的ModbusCode
        WRONG_RESPONSE_ADDRESS = 4,//响应地址错误
        WRONG_RESPONSE_REGISTERS = 5,//响应寄存器错误
        WRONG_RESPONSE_VALUE = 6,//响应值错误
        WRONG_CRC = 7,//CRC16校验错误
        WRONG_LRC = 8,//LRC校验错误
        TOO_MANY_REGISTERS_REQUESTED = 9,//请求的寄存器数量太多
        ZERO_REGISTERS_REQUESTED = 10,//零寄存器请求
        EXCEPTION_ILLEGAL_FUNCTION = 20,//非法的功能码
        EXCEPTION_ILLEGAL_DATA_ADDRESS = 21,//非法的数据地址
        EXCEPTION_ILLEGAL_DATA_VALUE = 22,//非法的数据值
        EXCEPTION_SLAVE_DEVICE_FAILURE = 23,//从站(服务器)故障

        EXCEPTION_SYSTEM = 30,//系统异常
        SERIALPORT_NOT_OPEN = 31,//串口没有打开
        EXCEPTION_SERIALPORT = 32,//串口异常
        INVALID_CHAR_PACK = 33,//无效字符数据包
        RX_TIMEOUT = 34,//接收超时
        EXCEPTION_SERIAL_SEND_CMD = 35//串口发送指令异常
    }

    #endregion

下面为串口通信发送Modbus功能码指令和接收Modbus返回信息的代码。

#region ModbusRTU/ModbusASCII串口通信类

    public class ModbusSerial
    {
        #region 内部变量
        //串口
        private SerialPort serialPort;

        //串口默认(一般波特率为9600)请求延时时间(毫秒)
        private int rxTimeout = 5000;

        //字符之间的时间间隔
        private int charDelay = 0;

        //信息帧之间的时间间隔
        private int frameDelay = 0;

        //从站连接状态
        private bool _connect = false;

        //错误提示
        private Errors _error;

        //Modbus串口类型
        private Type ModbusSerialClass = null;

        //字符间超时计时器
        private Stopwatch stopWatch = new Stopwatch();

        // 接收字符时间
        private long charReadTime = 0L;

        #endregion

        #region 属性

        /// <summary>
        /// 从站连接状态(只读)
        /// </summary>
        public bool Connected { get { return _connect; } }

        /// <summary>
        /// Modbus错误(只读)
        /// </summary>
        public Errors Error { get { return _error; } }

        #endregion

        #region 内部方法
        /// <summary>
        /// 两个字符之间的时间间隔,以1.5字符为基准(1个字符为:1起位位+7/8数据位+1位奇偶校验位+1/2停止位),单位为毫秒
        /// ModbusRTU一个字符数据位是8,有奇偶校验共11位,无奇偶校验是10位
        /// ModbusASCII一个字符数据位是7,有奇偶校验共10位,无奇偶校验是9位
        /// </summary>
        /// <param name="sp">串口数据</param>
        /// <returns>1个字节 + 1.5字符时间间隔数值(从读取一个字节开始到下个字节开始)</returns>
        private int CharDelay(SerialPort sp)
        {
            int charTime;

            if (sp.BaudRate > 19200)
                charTime = 1;   // 固定值,0.75ms向上取整
            else
            {
                int charBits = 1 + sp.DataBits;
                charBits += sp.Parity == Parity.None ? 0 : 1;
                switch (sp.StopBits)
                {
                    case StopBits.One:
                        charBits += 1;
                        break;
                    // 停止位1.5按2位计算
                    case StopBits.OnePointFive:
                    case StopBits.Two:
                        charBits += 2;
                        break;
                }
                charTime = Convert.ToInt32(Math.Ceiling(((double)charBits * (1d + 1.5d) * 1000) / (double)sp.BaudRate));
            }

            return charTime;
        }

        /// <summary>
        /// Modbus两个信息帧之间的时间间隔,3.5个字符传输时间为基准,单位为毫秒
        /// </summary>
        /// <param name="sp">串口数据</param>
        /// <returns>3.5个字符的时间间隔</returns>
        private int FrameDelay(SerialPort sp)
        {
            int frameTime;

            if (sp.BaudRate > 19200)
                frameTime = 2;   // 固定值,1.75ms向上取整
            else
            {
                int frameBits = 1 + sp.DataBits;
                frameBits += sp.Parity == Parity.None ? 0 : 1;
                switch (sp.StopBits)
                {
                    case StopBits.One:
                        frameBits += 1;
                        break;

                    case StopBits.OnePointFive: // Ceiling
                    case StopBits.Two:
                        frameBits += 2;
                        break;
                }
                frameTime = Convert.ToInt32(Math.Ceiling(((double)frameBits * 3.5d * 1000) / (double)sp.BaudRate));
            }

            return frameTime;
        }
        #endregion

        #region 对外事务处理

        //向串口正确发送完数据后调用
        public Action<Type, byte[]> SendFinished { get; set; }

        //串口正确返回数据后调用
        public Action<byte[]> ReceiveFinished { get; set; }

        #endregion

        #region 构造函数
        /// <summary>
        /// 构造函数,初始化串口
        /// </summary>
        /// <param name="portName">串口名称(如COM1)</param>
        /// <param name="baudRate">波特率</param>
        /// <param name="dataBits">数据位数</param>
        /// <param name="parity">奇偶校验</param>
        /// <param name="stopbits">停止位</param>
        /// <param name="handshake">握手协议(XOnXOff等)</param>
        public ModbusSerial(ModbusType modbusType, string portName, int baudRate, int dataBits, System.IO.Ports.Parity parity, System.IO.Ports.StopBits stopbits, System.IO.Ports.Handshake handshake)
        {
            try
            {
                //使用的Modbus类型
                if (modbusType == ModbusType.SERIAL_RTU)
                    ModbusSerialClass = Type.GetType("FYS.ModbusRTU");//FYS是命名空间名称,ModbusRTU是该命名空间下的ModbusRTU类
                else if (modbusType == ModbusType.SERIAL_ASCII)
                    ModbusSerialClass = Type.GetType("FYS.ModbusASCII");//FYS是命名空间名称,ModbusASCII是该命名空间下的ModbusASCII类
                else
                    ModbusSerialClass = Type.GetType("FYS.ModbusRTU");//默认为ModbusRTU类
            }
            catch
            {
                ModbusSerialClass = null;
                _error = Errors.EXCEPTION_SYSTEM;//系统异常
            }


            //初始化串口参数
            serialPort = new SerialPort();
            serialPort.PortName = portName;
            serialPort.BaudRate = baudRate;
            serialPort.DataBits = dataBits;
            serialPort.Parity = parity;
            serialPort.StopBits = stopbits;
            serialPort.Handshake = handshake;

            //初始化字符传输时间最小间隔
            charDelay = CharDelay(serialPort);
            //初始化信息帧传输时间最小间隔
            frameDelay = FrameDelay(serialPort);

        }
        #endregion

        #region 连接/断开串口
        public bool Connect()
        {
            try
            {
                serialPort.Open();
                if (serialPort.IsOpen)
                {
                    serialPort.DiscardInBuffer();
                    serialPort.DiscardOutBuffer();
                    _connect = true;
                }
                else
                    _connect = false;
            }
            catch
            {
                _connect = serialPort.IsOpen;
            }

            return _connect;
        }

        public void DisConnect()
        {
            if (serialPort.IsOpen)
            {
                try
                {
                    serialPort.Close();
                    _connect = false;
                }
                catch (System.IO.IOException)
                {
                    _connect = serialPort.IsOpen;
                }
            }

        }

        #endregion

        #region 发送Modbus指令函数
        /// <summary>
        /// 向串口发送8条主要的ModbusRTU/ModbusASCII功能代码,并采集ModbusSlave(从站)返回的结果
        /// </summary>
        /// <param name="mdCode">ModbusCodes枚举功能码</param>
        /// <param name="SlaveId">从站号</param>
        /// <param name="SlaveAddress">从站起始地址</param>
        /// <param name="Values">对应功能的数值</param>
        /// <returns>串口返回的ModbusSlave结果</returns>
        public byte[] Send(ModbusCodes mdCode, byte SlaveId, ushort SlaveAddress, object Values)
        {
            _error = Errors.NO_ERROR;

            //获取ModbusRTU/ModbusASCII类的功能码名称,即方法名称
            string ModbusCMD = Enum.GetName(typeof(ModbusSerialCMD), mdCode);
            //功能码方法的参数组
            object[] param = null;

            //接收发送指令返回的数据
            List<byte> ReadBuffer = new List<byte>();

            try
            {
                //生成功能码方法的参数组
                switch (mdCode)
                {
                    case ModbusCodes.READ_COILS://功能码0x01

                    case ModbusCodes.READ_DISCRETE_INPUTS://功能码0x02

                    case ModbusCodes.READ_HOLDING_REGISTERS://功能码0x03

                    case ModbusCodes.READ_INPUT_REGISTERS://功能码0x04

                    case ModbusCodes.WRITE_SINGLE_REGISTER://功能码0x06

                        //(ushort)object这样显式/强制转换会报异常,所以改成Convert.ToUint16
                        param = new object[] { SlaveId, SlaveAddress, Convert.ToUInt16(Values) };

                        break;
                    case ModbusCodes.WRITE_SINGLE_COIL://功能码0x05
                        param = new object[] { SlaveId, SlaveAddress, (bool)Values };
                        break;

                    case ModbusCodes.WRITE_MULTIPLE_COILS://功能码0x0F
                        param = new object[] { SlaveId, SlaveAddress, (bool[])Values };
                        break;

                    case ModbusCodes.WRITE_MULTIPLE_REGISTERS://功能码0x10

                        //(ushort)object这样转换会报异常。
                        //转换方法1:ushort[]用IEnumerabler.Cast()方法序列化object,
                        //然后再通过Select方法将每个元素Convert.ToUInt16,如下面语句
                        //ushort[] values = ((IEnumerable)Values).Cast<object>().Select(u => Convert.ToUInt16(u)).ToArray();
                        //转换方法2:用 as 操作符
                        param = new object[] { SlaveId, SlaveAddress, Values as ushort[] };

                        break;

                    default: //其他Modbus指令不做处理

                        _error = Errors.UNPROCESSED_MODBUSCODE;
                        return null;
                }

                //通过反射得到对应的功能码方法
                MethodInfo Method = ModbusSerialClass.GetMethod(ModbusCMD);
                //通过反射调用该方法,得到功能码的byte[]数组
                byte[] buffer = (byte[])Method.Invoke(Activator.CreateInstance(ModbusSerialClass), param);
                if (buffer != null)
                {
                    if (!Connected)
                    {
                        _error = Errors.SERIALPORT_NOT_OPEN;
                        return null;
                    }

                    //ModbusRTU每帧数据需要有3.5字符时间间隔
                    if (ModbusSerialClass.Name.Contains("ModbusRTU"))
                    {
                        System.Threading.Thread.Sleep(frameDelay);
                    }
                    //向串口写入信息帧
                    serialPort.Write(buffer, 0, buffer.Length);

                    //复位接收字符时间
                    charReadTime = 0;

                    //发送完后的回调
                    SendFinished?.BeginInvoke(ModbusSerialClass, buffer, null, null);

                    //接收发送指令返回的数据
                    byte[] backBuffer = ReceiveBytes2();

                    if (backBuffer == null)
                    {
                        return null;
                    }

                    //返回串口采集到的数据
                    return backBuffer;
                }
                else//没有生成发送指令
                {
                    _error = Errors.EXCEPTION_SERIAL_SEND_CMD;
                    return null;
                }

            }
            catch
            {
                _error = Errors.EXCEPTION_SYSTEM;
                return null;
            }

        }

        #region 忽略字符间的1.5字符时间间隔,采集串口返回数据
        /// <summary>
        /// 读取ModbusSlave(从站)返回数据,忽略字符间1.5个字符的时间间隔
        /// </summary>
        /// <returns>从站返回的byte[]数据</returns>
        public byte[] ReceiveBytes2()
        {
            _error = Errors.NO_ERROR;

            //数据接收成功
            bool isFinished = false;

            //读取的数据
            List<byte> readBuffer = new List<byte>();

            //开始计时
            if (!stopWatch.IsRunning)
                stopWatch.Start();

            //在读取数据的超时时间内及没有读完数据时循环进行取回返回数据
            while (rxTimeout - stopWatch.ElapsedMilliseconds > 0 && !isFinished)
            {
                //串口有返回数据
                if (serialPort.BytesToRead > 0)
                {
                    int reByte = -1;
                    try
                    {
                        //读取一个字节
                        reByte = serialPort.ReadByte();
                        readBuffer.Add((byte)reByte);
                    }
                    catch (InvalidOperationException)
                    {
                        _error = Errors.EXCEPTION_SERIALPORT;
                        stopWatch.Stop();
                        return null;
                    }

                }
                else//串口没有数据
                {
                    //没有收到过任何一个字符,就继续循环等待
                    if (readBuffer.Count == 0)
                    {
                        continue;
                    }

                    //收到过字符,已经没有字符接收了
                    if (readBuffer.Count != 0)
                    {
                        isFinished = true;
                    }
                }
            }

            if (stopWatch.IsRunning)
                stopWatch.Stop();

            //接收超时
            if (!isFinished)
            {
                _error = Errors.RX_TIMEOUT;
                return null;
            }

            //正确采集完串口数据
            ReceiveFinished?.BeginInvoke(readBuffer.ToArray(), null, null);

            return readBuffer.ToArray();
        }
        #endregion

        #region 考虑字符间有1.5字符时间间隔的方式采集串口返回数据
        /// <summary>
        /// 读取ModbusSlave(从站)返回数据,采用字符间1.5个字符的时间间隔
        /// </summary>
        /// <returns>从站返回的byte[]数据</returns>
        public byte[] ReceiveBytes()
        {
            _error = Errors.NO_ERROR;

            //数据接收成功
            bool isFinished = false;

            //读取的数据
            List<byte> readBuffer = new List<byte>();

            //开始计时
            if (!stopWatch.IsRunning)
                stopWatch.Start();

            //在读取数据的超时时间内及没有读完数据时循环进行取回返回数据
            while (rxTimeout - stopWatch.ElapsedMilliseconds > 0 && !isFinished)
            {
                //串口有数据
                if (serialPort.BytesToRead > 0)
                {
                    int reByte = -1;
                    try
                    {
                        //读取一个字节
                        reByte = serialPort.ReadByte();
                    }
                    catch (InvalidOperationException)
                    {
                        _error = Errors.EXCEPTION_SERIALPORT;
                        stopWatch.Stop();
                        return null;
                    }

                    //从循环开始到读取完一个字节所用时间小于读取1个字节+1.5个字符间隔时间
                    if (stopWatch.ElapsedMilliseconds - charReadTime < charDelay)
                    {
                        charReadTime = stopWatch.ElapsedMilliseconds;
                        //增加到收到数据序列中
                        readBuffer.Add((byte)reByte);

                        continue;
                    }
                    else//大于1+1.5个字符时间
                    {
                        _error = Errors.INVALID_CHAR_PACK;
                        stopWatch.Stop();
                        return null;
                    }

                }
                else//串口没有数据
                {
                    //没有收到过任何一个字符,就继续循环等待
                    if (readBuffer.Count == 0)
                    {
                        charReadTime = stopWatch.ElapsedMilliseconds;
                        continue;
                    }


                    //收到过字符,已经没有字符接收了
                    if (readBuffer.Count != 0)
                    {
                        charReadTime = stopWatch.ElapsedMilliseconds;
                        isFinished = true;
                    }
                }
            }

            if (stopWatch.IsRunning)
                stopWatch.Stop();

            //接收超时
            if (!isFinished)
            {
                _error = Errors.RX_TIMEOUT;
                return null;
            }

            //正确采集完串口数据
            ReceiveFinished?.BeginInvoke(readBuffer.ToArray(), null, null);

            return readBuffer.ToArray();

        }
        #endregion

        #endregion
    }

    #endregion

3.代码说明

3.1、上述代码考虑了ModbusRTU串口通信规范中的要求,一是每个字节(1位起始位 + 8位数据位 + 1位奇偶校验位【无奇偶校验则为0位】 + 1位停止位)之间要小于等于1.5字节的时间间隔,二是传输的信息帧之间要有大于等于3.5字节的时间间隔,程序中用了C#的精确测量计时类Stopwatch来计算字节和信息帧的传输间隔时间。经过测试,发送ModbusRTU指令时可以保证正确,但是在接收返回结果时,程序ReceiveBytes()会有大半概率发生字节之间超过1.5字节时间间隔的现象,导致返回数据不完整。比如按常用的波特率9600来计算,传输一个字节为1.042毫秒,1.5个字节为1.563毫秒,从接收一个字节开始到接收下一个字节开始为2.5个字节时间,即2.605毫秒,向上取整为3毫秒,但实际测试时,我发现常有超过3毫秒情况,甚至有30多毫秒的,不知道是我测试的设备有问题,还是Windows系统计时器与Modbus设备计时存在差异,或者是其他什么情况,这我也不清楚了,用C语言或汇编语言编写单片机的人肯定会知道原因,而我只是用C#高级语言编写串口通信,这方面我知识欠缺,因此就不再探讨了,总之,我又写了一个不考虑字符时间间隔的ReceiveBytes2()方法,只要串口有数据(BytesToRead > 0)就去读取,直到串口没有数据返回。如果串口一直没有返回数据,会按照默认的5秒延时后返回空数据。
3.2、程序如果返回空值,可以查看错误状态Error的值来确定产生原因。
3.3、程序增加了两个外暴回调SendFinished和ReceiveFinished,可以在发送完指令和接收成功数据后,根据自己需求增加相应操作。
3.4、程序通过构造函数先初始化串口,然后再连接串口,连接成功后再发送相应指令。
3.5、程序的接收方法ReceiveBytes()或ReceiveBytes2()可以单独使用,自己根据实际环境需求按条件进行监听查询。
3.6、程序有些细节已经在注释中写明,这里不再赘述。

4.程序使用举例

假如我们使用ModbusASCII串口通讯方式向起始地址为4000(0x0FA0)的保持寄存器写入两个数据10(0x0A)和258(0x0102),语句如下:

ModbusSerial MS = new ModbusSerial(ModbusType.SERIAL_ASCII, "COM5", 9600, 7, System.IO.Ports.Parity.None, System.IO.Ports.StopBits.One, System.IO.Ports.Handshake.None);
            if (MS.Connect())
            {
                MS.Send(FYS.ModbusCodes.WRITE_MULTIPLE_REGISTERS, 0x01, 0x0FA0, new ushort[] { 0x000A, 0x0102 });
                
            }
             MS.DisConnect();

我用的USB转串口线连接到电脑上,电脑就出现了COM5串口,所以在上面语句中端口名称是“COM5”。我们采用是ModbusASCII,这里数据位我选择了7位(也可以是8位),波特率是9600,停止位1位,无奇偶校验和流控制。
初始化完毕串口后,判断连接成功,发送了写多个保持寄存器的指令WRITE_MULTIPLE_REGISTERS,从站号是1,起始地址0x0FA0,即4000,数据为两个数值0x000A和0x0102。用Modbus虚拟助手调试,看出结果正确,如下图:
在这里插入图片描述

我们再用ModbusRTU方式来读取上面保持寄存器的值(Modbus虚拟助手也要由ModbusASCII改为ModbusRTU,数据位由7改成8,要和电脑端的串口配置改成一样),语句如下:

List<byte> results = new List<byte>();
            ModbusSerial MS = new ModbusSerial(ModbusType.SERIAL_RTU, "COM5", 9600, 8, System.IO.Ports.Parity.None, System.IO.Ports.StopBits.One, System.IO.Ports.Handshake.None);
            if (MS.Connect())
            {
                byte[] backValues = MS.Send(FYS.ModbusCodes.READ_HOLDING_REGISTERS, 0x01, 0x0FA0, 2);
                results.AddRange(backValues);
            }
            
            MS.DisConnect();

结果运行如下:
在这里插入图片描述
从图可以看出,返回数据第1位是站号,第二位是功能号0x03,第三位后面数值的字节长度为4,第4位与第5位是寄存器4000的值0x000A,第6位和第7位是寄存器4001的值0x0102,最后两位是CRC16校验值。因此结果也完全正确。

对于Modbus返回的数据如何转换为具体的应用数值,在PLC或其他支持Modbus的设备一般都会有具体格式说明,比如Modicon PLC返回int和float的数据格式都为3412。如果大家有兴趣进一步了解如何转换,请参考我另一篇文章"C# ModbusRTU、ModbusASCII及ModbusTCP返回数据解析为数值,功能码0x01-0x04使用1234/2143/3412/4321等字节顺序解析值"。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值