ns-3-tracing

随手在CSDN上发的,文章原链接可以查看(图片在本地存储并渲染,图片在CSDN上加载不出来)https://tang-mouren.github.io/2023/07/14/ns-3-tracing/。本文章只是我的学习笔记,肯定有很多错误,如有大佬烦请批评指正。

NS-3 Tracing (草稿)


本文章只是笔者学习的笔记,只起记录作用,内容的正确性和严谨性不作保证。

Config subsytem

前言概述

ns-3 tracing system(跟踪系统)可以让使用者在无需对源代码进行重新编译的前提下进行自定义化的程序输出从而对仿真程序的运行状态进行追踪检测。

ns-3 tracing system分为两个部分,一个是跟踪源(trace sources),一个是追踪接收器(trace sinks),而ns-3提供了连接两者的函数。如下所示:

std::ostringstream oss;
  oss <<"/NodeList/" << wifiStaNodes.Get (nWifi - 1)->GetId () <<"/$ns3::MobilityModel/CourseChange";
  Config::ConnectWithoutContext (oss.str (), MakeCallback (&CourseChange));// The connection function.
//The function CourseChange will be called when the tracing source changes.

ns-3往往使用Config子系统来实现追踪。追踪系统使用config path来选择追踪源。

比如说,对于一个nodelist中id为7的node,想要追踪,path可能是如下这一行:“/NodeList/7/$ns3::MobilityModel/CourseChange”。其中,config path的最后的段必须是对象的属性

Config::ConnectWithoutContext和Config::Connext实际上寻找一个Ptr<Object>的一个封装指针,然后再最底层次上调用合适的TraceConnect方法函数。

“/“字符指代一个伪命名域。”/NodeList/7"指向了Nodecontainer中的一个明确的node,表现形式为Ptr<Node>,它同时也是ns3::Object的子类。接下来的路径段由” " 字符打头。 " "字符打头。" "字符打头。""表明申请另一个已经聚合的对象。注意,ns3中支持面向对象中的类聚合设计思路,从而形成不同对象之间的关联。比如说上面例子中的“$ns3::MobilityModel”,就表明了调用node->GetObject<MobilityModel>()。GetObject<class T>很巧妙的实现了类型的转换,返回一个Ptr<class T>的指针。而MobilityModel有一个mCourseChange的属性。

在mobility-model.cc中可以看到有如下代码:

.AddTraceSource ("CourseChange", "The value of the position and/or velocity vector changed", MakeTraceSourceAccessor (&MobilityModel::m_courseChangeTrace))

在mobility-model.h中可以看到如下代码:

private:
/*
Used to alert subscibers that a change in direction, velocity, or position has occurred.
*/
  TracedCallback<Ptr<const MobilityModel> > m_courseChangeTrace;

protected:
  /*
   * Must be invoked by subclasses when the course of the
   * position changes to notify course change listeners.
   */
  void NotifyCourseChange (void) const;
void
MobilityModel::NotifyCourseChange (void) const
{
m_courseChangeTrace(this);
}
//It can be seen that the NotifyCourseChange function calls the 
//attribute function pointer m_courseChangeTrace

可见,当course发生更改的时候,会有一个NotifyCoursechange的函数调用,在MobilityModel base class层面上进行调用。之后,会调用m_courseChangeTrace,调用任意注册的追踪接收器。

这里,m_courseChangeTrace就是一个特殊的callbacks的列表,当使用Config functions的时候可以被用于连接查询。调用m_courseChangeTrace就会调用所有注册的callback function(目前理解是这样,存疑)

话说到这里,那么MobilityModel class具体是一个什么样的类呢》它被设计为一个base class来给具体的子类提供一个普适化的接口。

NS-3学习笔记(七):NS-3的对象框架 之 聚合 | Rain’s Blog (rainsia.github.io)NS-3中框架的聚合可以参考这篇文章。

  1. 调试的核心在于必须有回调的源,但是如何找到可用的回调源?
  2. 找到了源,如何找出config path?
  3. 如何确定callback function的类型?
  4. 搞定了上述的一切,这究竟是什么?

有哪些可用的跟踪源?

ns-3: All TraceSources (nsnam.org)可以查看。

如何确定config path?

首先,在上面这个网址中,点开具体的class后可以在界面中搜索到Detailed Description.一个例子如下,有三个重要的内容,config paths, attributes, tracesources.

image-20230709144902486

一种方式可以找出有人已经确认的ns-3 codebase

$ find . -name ’*.cc’ | xargs grep CourseChange | grep Connect

有了这个路径之后,就可以使用调用Config subsystem中的Config::Connect, Config::Set函数。其中,Config::Connect会匹配与路径相符合的跟踪源,将输入回调和它们进行连接,同时callback函数在被调用的时候会额外接收一个基于跟踪时间通知的上下文字符串。如果不想接收额外的字符串则可以使用ConnectWithoutContext。此外,Config::Connect失败会产生致命错误。如果在失败的时候产生致命的错误,则可以使用ConnectFailSafe函数。

callback will receive an extra context string upon trace event notification.

For example, in this case,
src/mobility/examples/main-random-topology.cchas something just waiting for you to use:

Config::Connect ("/NodeList/*/$ns3::MobilityModel/CourseChange",
MakeCallback (&CourseChangeCallback));

如何找出回调函数的返回值以及参数类型?

最简单的方式就是在之前提到Detailed information中的TraceSources。

image-20230709210145967

先找到上面的source名称。Config Path + 名称就是完整的trace path。所以就有了最上面例子中这样一段使用ostringstream的代码:

std::ostringstream oss;
  oss <<"/NodeList/" << wifiStaNodes.Get (nWifi - 1)->GetId () <<"/$ns3::MobilityModel/CourseChange";
  Config::ConnectWithoutContext (oss.str (), MakeCallback (&CourseChange));// The connection function.
//The function CourseChange will be called when the tracing source changes.

再找到callback signature,它展示了callback function应有的形式。其中callback function的返回值其实必定都是void。

找到回调函数应有的类型之后,就可以自己创建回调函数并且使用Config::Connect函数来进行关联,然后查看追踪信息了。当然也可以采用config::ConnectwithoutText函数。这两个连接形式要使用不同格式的callback function。Connect 的Path string的生成可以考虑使用 std::ostringstream来进行装载。其中Connect函数的形式可以把trace path传入callback function中,这样在打印追踪信息的时候可以更加明确是哪一个追踪源的信息。

void
CourseChange(Ptr<const MobilityModel> model)
{
  ...
}// for Config::ConnectiwithoutText
void
CourseChange(std::string context, Ptr<const MobilityModel> model)
{
  ...
}// for Config::Connect

TraceValue又是什么?

参考src/core/model/traced-value.h(注:此处参考的教程为ns-3.18,不同版本的路径可能不一样)

template <typename T>
class TracedValue
{
    public:
    ...
    void Set (const T &v) {
        if (m_v != v)
            {
            m_cb (m_v, v);
            m_v = v;
        }
    }
    ...
    private:
        T m_v;
        TracedCallback<T,T> m_cb;
};

可见,TracedValue是一个模板类的封装。而TracedCallback<T, T>中的T于此有所关联。上面的那个总结的例子中,IntTrace(int32_t, int32_t),正是对这个TracedCallback的实例化。

一个例子进行回顾

下面是ns-3 tutorial fourth.cc的代码。它没有直接利用Config system,而是对自己的object手动添加了trace source path,并且利用trace connect对添加的trace source path添加了回调函数。这一个例子可以对上文的所有内容进行一个梳理回顾。下面代码这种低层次的实现方式显得繁琐,这也就告知了为什么ns-3需要一个Config subsytem对庞大的trace source进行一个科学的管理,方便编程者实现对程序内部各种信息的追踪。

tutorial原话: The internal code for Config::ConnectWithoutContext and Config::Connect actually do find a Ptr and call the appropriate TraceConnect method at the lowest level.具体寻找和解析的过程在上文中的Path路径的解析已经有所提及

/* -*- Mode:C++; c-file-style:"gnu"; indent-tabs-mode:nil; -*- */
/*
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License version 2 as
 * published by the Free Software Foundation;
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 */

#include "ns3/object.h"
#include "ns3/uinteger.h"
#include "ns3/traced-value.h"
#include "ns3/trace-source-accessor.h"

#include <iostream>

using namespace ns3;

class MyObject : public Object
{
public:
  static TypeId GetTypeId (void)
  {
    static TypeId tid = TypeId ("MyObject")
      .SetParent (Object::GetTypeId ())
      .AddConstructor<MyObject> ()
      .AddTraceSource ("MyInteger",
                       "An integer value to trace.",
                       MakeTraceSourceAccessor (&MyObject::m_myInt))
    ;
    return tid;
  }

  MyObject () {}
  TracedValue<int32_t> m_myInt;
};

void
IntTrace (int32_t oldValue, int32_t newValue)
{
  std::cout << "Traced1231 " << oldValue << " to " << newValue << std::endl;
}
int
main (int argc, char *argv[])
{
  Ptr<MyObject> myObject = CreateObject<MyObject> ();
  myObject->TraceConnectWithoutContext ("MyInteger", MakeCallback (IntTrace));
  myObject->m_myInt = 1234;
  myObject->m_myInt = 114514;
}

实现原理

暂略,具体参考ns-3 tutorial 7.2.6中的一个章节。

实际用例:TCP-cwnd实现

TCP通过控制发送端cwnd(congestion windows)的大小来规避发送过程中端到端途中网络情况带来的拥塞状况。通过对丢包情况进行侦测,来判断当前是否发生拥塞。如果发生拥塞则减小一次性发送的窗口大小,否则增大窗口大小来提升发送速率。

三个时间片段

  1. Setup Time: It is the period when the main function is running, but before the Simulator::Run is called.
  2. Simulation Time: The period when Simulator::Run is executing.
  3. Teardown Time: The Simulator::Run return control back to the main function, and the allocation of resources get released.

一个常见的错误是,在configuration time的时候访问某些在simulation time创建的entities。举个例子,一个sn-3 Socket(套接字,可以查阅传输层相关资料)往往作为动态对象被创建,用于应用程序之间的沟通。一般来说,ns-3应用不会试图创建一个动态的对象指导StartApplication method被调用。这是为了确保在应用程序试图做某些操作之前,调试的各个方面能够得到充分且完全的确认。

应对上述问题的方法有二:1. 创建一个调试事件,在动态对象创建之后再运行,并且给这个事件挂载上追踪器。2.在configuration time就创建这个动态对象,挂载好,并且把这个对象交给系统以便在simulation time进行使用。

在fifith.cc中,我们创建了一个自己的Myapp class。其中必须要对虚函数StartApplication和StopApplication方法进行重写。它们会在simulation的过程中按照需要发送消息的时间阶段被自动调用

有关ns-3中的应用程序的启动和停止:app.start, app.stop会分别调用app.SetStartTime app.SetStopTime。从\src/network/model/appliaction.cc中可以看到SetStartTime 方法的全部执行内容仅仅是设置了成员变量m_startTime的值(外加一条NS_LOG_FUNCTION(start_time))。(SetStopTime则是m_stopTime)

在ns-3系统中,有一个全局的列表,它记录了所有系统中的节点,当你在simulation的时候创建了一个节点的时候,就会有一个指向该节点的指针被添加到这个全局的列表,而这个列表的名称就是NodeList,所以你会看到Config Path以/NodeList打头。

打开src/network/model/node-list.cc:

uint32_t NodeList::Add (Ptr<Node> node){
  NS_LOG_FUNCTION (node);
  return NodeListPriv::Get ()->Add (node);
}
static Ptr<NodeListPriv> NodeListPriv::Get (void){
  NS_LOG_FUNCTION_NOARGS ();
  return *DoGet ();
}

static Ptr<NodeListPriv> * NodeListPriv::DoGet (void){
  NS_LOG_FUNCTION_NOARGS ();
  static Ptr<NodeListPriv> ptr = 0;
  if (ptr == 0)
    {
      ptr = CreateObject<NodeListPriv> ();
      Config::RegisterRootNamespaceObject (ptr);
      Simulator::ScheduleDestroy (&NodeListPriv::Delete);
    }
  return &ptr;
}

uint32_t NodeListPriv::Add (Ptr<Node> node){
  NS_LOG_FUNCTION (this << node);
  uint32_t index = m_nodes.size ();
  m_nodes.push_back (node);
  Simulator::ScheduleWithContext (index, TimeStep (0), &Node::Initialize, node);
  return index;
}

其中,NodeListPriv这个类是NodeList中部分private属性的api的实现。从NodeListPriv的Doget函数可以看出,只有一个全局的指向一个NodeListPriv的指针,也就是说只有一个NodeListPriv,而这个可以认为就是NodeList的具体载体。NodeListPriv可以提供大量的api,而NodeList中的public方法都是通过NodeListPriv::Get()获取到这个全局的NodeListPriv指针后调用相应的方法来实现的。因此,NodeListPriv可以认为是真正的定义NodeList的类,并且具体实例化对象只有一个全局的NodeList,而NodeList类则是对NodeList类Public方法的一个封装聚合。看上去比较类似于C语言中用struct结构体包含一堆相同类型的函数指针一样。

打开src/network/model/application.cc,可以看到如下代码。

void Application::DoInitialize (void)
{
  NS_LOG_FUNCTION (this);
  m_startEvent = Simulator::Schedule (m_startTime, &Application::StartApplication, this);
  if (m_stopTime != TimeStep (0))
    {
      m_stopEvent = Simulator::Schedule (m_stopTime, &Application::StopApplication, this);
    }
  Object::DoInitialize ();
}

接下来是ns3-tutorial的原文,这一段的内容信息量集中且非常关键。

Here, we finally come to the end of the trail. If you have kept it all straight, when you implement an ns-3 Application, your new application inherits from class Application. You override the StartApplication and StopApplication methods and provide mechanisms for starting and stopping the flow of data out of your new Application. When a Node is created in the simulation, it is added to a global NodeList. The act of adding a node to this NodeList causes a simulator event to be scheduled for time zero which calls the Node::Initialize method of the newly added Node to be called when the simulation starts. Since a Node inherits from Object, this calls the Object::Initialize method on the Node which, in turn, calls the DoInitialize methods on all of the Objects aggregated to the Node (think mobility models). Since the Node Object has overridden DoInitialize, that method is called when the simulation starts. The Node::DoInitialize method calls the Initialize methods of all of the Applications on the node. Since Applications are also Objects, this causes Application::DoInitialize to be called. When Application::DoInitialize is called, it schedules events for the StartApplication and StopApplication calls on the Application. These calls are designed to start and stop the flow of data from the Application

node创建的时候会被添加到全局列表。当调试开始的时候,node的initialize方法会被调用。Object::initialize与其他很多函数如startapplication这种可以在子类重载实现多态的虚函数不一样,它不是virutal的,而且node中也没有对initialize进行重写,因此调用node::initialize的方法其实调用的就是Object::initialize。读源码可知,它会调用所有node节点中聚合对象的DoInitialize的方法(有一个遍历的函数),而application对象就聚合在node中,因此application的DoInitialize也会被调用,而application的DoInitialize会为Start stopapplication安排事件调用,这样就实现了application中信息流的控制。

下面画一个我个人理解的流程图。

Create the node -> the node get registered in the NodeList(注:node不属于我们之前提到的在simulation进行过程中创建的动态对象。node属于网络拓扑图的一部分,这是调试的前提,在调试开始前要先分配好内存空间)

Simulation starts -> node(registered)::initialize -> node.application.Doinitialize -> schedules events for Start/Stop application -> the flow of data gets controlled.

sixth.cc

这个是讲解tracing的一个比较具体的文件。fifth.cc只是个过渡的。ns-3 tutorial有一些我个人不太能接受的点,就是前面提到重要的东西没有在接下来的讲解中马上使用。甚至tutorial自己都承认了(笑):

It may have occurred to you that we just spent a lot of time implementing an example that exhibits all of the problems we purport to fix with the ns-3 tracing system! You would be correct. But, bear with us. We’ve not done yet.(看到这句话真的脸上笑嘻嘻,心里mmp)

我也是挺哭笑不得的。作为新手,而且英语非母语,读完的东西如果不能立即得到讲解对阅读的连贯性的影响应该还是蛮大的。前面提到的Config subsystem用于连接追踪源和追踪调度函数的知识点也没在接下来的sixth.cc使用,还用的是objectbase->TraceConnect(哭,我都怀疑自己对文档的理解了)

言归正传,咱们可以直接跳过fifth.cc这个官方都承认过渡的文件,把精力放在sixth.cc文件上。

咱们关注CwndChange和RxDrop中,Ptr<OutputStreamWrapper>,Ptr<PcapFileWrapper>。这个stream参数的拷贝函数是private的,这说明了根本就不希望用户来进行对这个对象的拷贝操作。这个对象解决了C++流中很麻烦的问题,相当于是一个安全的黑盒封装。作为初级学习者咱们只需要知道并且调用它就好了。

stream不能被copy,但我们依旧可以创建它。如下sixth.cc片段代码我们可以了解如何创建流并且将流传递给追踪调度函数。

#define mian main
int mian(){
    ...
  AsciiTraceHelper asciiTraceHelper;
  Ptr<OutputStreamWrapper> stream = asciiTraceHelper.CreateFileStream ("sixth.cwnd");
  ns3TcpSocket->TraceConnectWithoutContext ("CongestionWindow", MakeBoundCallback (&CwndChange, stream));
    
  PcapHelper pcapHelper;
  Ptr<PcapFileWrapper> file = pcapHelper.CreateFile ("sixth.pcap", std::ios::out, PcapHelper::DLT_PPP);
  devices.Get (1)->TraceConnectWithoutContext("PhyRxDrop", MakeBoundCallback (&RxDrop, file));
}

PcapFileWrapper是一个复杂的封装,它的父类是ns3::Object。而OutputStreamWrapper本身根本就不是ns-3 Object。

对于PacapFileWrapper构造函数的第三个参数,DLT_PPP用于记录带有point to point headers的包,DLT_EN10MB用于记录csma 设备的包而DLT_IEEE802_11则适用于记录WIFI设备的包。

MakeBoundCallback其中,makeboundcallback可以给callback函数带上必然使用的参数(传入第一个callback指针后,后面是可变长的参数,可以按照需要放入多个)。在这里就可以将创建的Ptr stream, file指针传入callback函数中的第一个参数。

ns-3中主要包含有两个helper: Device helper和protocol helper,device helpers主要聚焦于追踪应当发生于某一节点内部的问题,创建的文件遵循<prefix>-<node>-<device>命名惯例,而device helpers主要聚焦于协议或者配对的接口,关注的是协议栈模型,创建的跟踪文件遵循<prefix>-<protocol>-<interface>的命名惯例。因此,跟踪的方式也存在一下两个维度的分类。

image-20230712152040073

PcapHelperForDevice提供了高层的功能。它会调用低层次的enablepcap,enablepcapinternal。

The class PcapHelperForDevice is a mixin provides the high level functionality for using pcap tracing in an ns-3 device. Every device must implement a single virtual method inherited from this class.

void EnablePcap (std::string prefix, Ptr<NetDevice> nd, bool promiscuous = false, bool explicitFilename void EnablePcap (std::string prefix, std::string ndName, bool promiscuous = false, bool explicitFilename void EnablePcap (std::string prefix, NetDeviceContainer d, bool promiscuous = false);
void EnablePcap (std::string prefix, NodeContainer n, bool promiscuous = false);
void EnablePcap (std::string prefix, uint32_t nodeid, uint32_t deviceid, bool promiscuous = false);
void EnablePcapAll (std::string prefix, bool promiscuous = false);

对于EnablePcap函数,有一个promiscuous默认为false的参数,promiscuous mode为混杂模式,也就是接收所有经过它的数据包,而不仅仅只接收发给它的数据包。EnablePcap有多种形式,prefix之后用Ptr<device>,netdevicecontainer,nodecontainer,nodeID等等。当然,也可以直接使用helper.EnablePcapAll(“prefix”)直接打开全部的追踪。

注意,每一个node具有唯一的node id,每一个interface有一个为一个device id。

AsciiTraceHelperForDevice之所以EnableAscii有两倍的重载数量,是因为ns-3支持多个tracing源向同一个文件中写入追踪记录。因此EnableAscii,可以使用Ptr stream来代替prefix。

注意,如果想使用Name进行追踪,可以使用Names::Add(“client”, …),只是要注意,每一个名称必须只能绑定一个Node。注意,ascii tracing如何要自定义文件名,要带上.tr后缀。

Ptr<OutputStreamWrapper> stream = asciiTraceHelper.CreateFileStream (“trace-file-name.tr”);比如说,如果你想用stream来使用EnableAscii的话,在创建streamwrapper对象的时候需要给文件后缀名加上.tr。这个是Ascii tracing才有的,Enablepcap的封装中并不支持直接使用pcapstream直接进行enable。

TraceHelper总结概述

我们来总结一下traceHelper的结构和具体实现,首先Helper有两大类,Ascii和Pcap,下面只讲解Pcap,Ascii同理(各种类中把TraceHelper中的Pacp换成Ascii就好了,两者小小的不同(是否可通过stream创建在前面也提及了)

首先,helper中简略认为有,PcapHelper,PcapHelperForIpv4,PcapHelperForIpv6,PcapHelperForDevice。

第一个PcapHelper(AsciiTraceHelper),它可以创建实例对象,这个实例对象可以通过CreateFile,CreateFileStream等函数帮助我们创建记录文件,流等辅助工具。

PcapHelperForDevice(AsciiTraceHelperForDevice),则是一个提供了用户层面的,提供pcap操作的封装。它是一个虚基类,因此不可以直接被创建为实例对象,它只负责为子类提供像EnablePcap(最终调用EnablePcapInternal),EnablePcapInternal这些通用层面的函数。这些函数的调用应当在子类对象中

**在point-to-point-helper.h文件中,这个helper类就多继承了PcapHelperForDevice,AsciiTraceHelperForDevice类,因此在代码中就可以通过PointToPointHelper实例化对象调用PcapHelperForDevice提供的EnablePcap函数。**详细调用例子可以参考third.cc文件。

class PointToPointHelper : public PcapHelperForDevice, public AsciiTraceHelperForDevice{
    ...
};
<file>.cc:
void PointToPointHelper::EnablePcapInternal (std::string prefix, Ptr<NetDevice> nd, bool promiscuous, bool explicitFilename)// override the virtual function.
{
  //
  // All of the Pcap enable functions vector through here including the ones
  // that are wandering through all of devices on perhaps all of the nodes in
  // the system.  We can only deal with devices of type PointToPointNetDevice.
  //
  ...
}

同时,在InternetStackHelper头文件中,我们也可以看到有如下继承关系:

class InternetStackHelper : public PcapHelperForIpv4, public PcapHelperForIpv6, 
                            public AsciiTraceHelperForIpv4, public AsciiTraceHelperForIpv6

这个也就是作为Helper在protocol上封装的一个典范例子。前面PointToPointHelper则是在设备层面上进行封装的一个典范例子。

总结tutorial中提及的tracing的三种层面的方法

  1. object::TraceConnect,最底层,直接对单个对象进行追踪源和追踪器的绑定。
  2. PcapHelper, PcapHelperForIpv4, PcapHelperForDevice,从device和protocol两个层面,封装了文件记录追踪的函数。
  3. Config::Connect,通过Config Path来定位,从更高一层次来对tracing进行实现。

可以说,object::TraceConnect是对单个object的层面上进行底层的traceconnect,而DeviceHelper,protocolHelper中的enableascii,enablepcap是从设备,协议层面对trace进行了一层封装,使得通过文件.tr .pcap记录的方式更加的方便。而Config subsystem则从更高层面的系统级对追踪源和追踪器的连接进行了更加高层次的封装。在tutorial中的讲述中,Config subsystem会通过Config path最终找到对应的node object并且使用底层的traceconnect进行连接。不过实际使用中如果不需要高级的功能使用Config::Connect还是会有些麻烦。

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值