Data Access Component (DAC) Notes
Data Access Component (DAC) Notes
魂祭心 发表于6个月前
Data Access Component (DAC) Notes
  • 发表于 6个月前
  • 阅读 10
  • 收藏 0
  • 点赞 0
  • 评论 0
摘要: 这份文档简要描述了clr的debug实现,重点在于在进程外调试时,调试进程读取调试目标的方法。

Data Access Component (DAC) Notes

=================================

 

Date: 2007

 

调试托管代码需要对托管对象及其构造有深入的了解,举个例子,对象除了数据信息之外还需要不同的头信息,在垃圾回收机工作的时候,对象可能在内存中移动(收缩内存)。在“编辑并继续”之后检索正确的函数版本或者反射函数信息,调试器需要知道EnC(edit-and-continue?)和元数据信息。调试器必须能够区分应用程序域和程序集。VM文件夹下的代码体现了这些托管构造的必要知识。检索托管代码新信息和数据的API和CLR引擎运行的算法需保持一致。

 

调试器可以在进程运行时也可以工作在进行未运行时工作,In-process中的调试器需要调试对象程序的实时数据对象,这种情况下,运行时已经加载,目标程序正在运行,在调试对象中有一个辅助线程在运行一些可以获取当前调试所需信息的代码,因为辅助线程与调试目标在同一个进程中,它可以获取到对象的地址空间和运行时代码,所有的运算都是在目标进程中完成,这是一种简单的方式来获取调试器所需要的托管代码解构信息,然而进程内调试有一些缺点,栗子,如果调试对象没在运行(一种场景是调试对象是一个转储文件),运行时并没有加载(可能在当前机器上根本就没有),此时,调试器无法执行运行时代码来获取调试所需的信息。

 

CLR调试器已经可以运行在进程中,一个调试器扩展SOS(Son of Strike)和 Strike(CLR早期)能够用来检索托管代码。从.NET Framework 4开始,调试器能运行在进程外。CLR调试器API提供了提供了很多SOS组件之前不支持的功能。SOS和CLR调试器使用 Data Access Component (DAC)来实现进程外调试,DAC原则上可以视做CLR执行引擎的子集。它能用在转储文件上,甚至是在CLR未安装的机器上面工作,其实现主要包括一组宏和模板,结合执行引擎代码的条件编译。当编译runtime时,clr.dll和mscordacwks.dll同时生成。编译CoreClr时有些细微的区别:它生成的时coreclr.dll和msdaccore.dll。在其他的操作系统上名字也有所区别。为了检索对象,DAC可以读取其内存,获取mscordacwks中VM代码的输入。 然后,它可以在宿主机中运行相应的函数来计算有关托管结构所需的信息,并将最终结果返回给调试器。

 

请注意。DAC需要读取对象进程的内存。调试进程和调试对象进程是独立的,地址空间也是独立的。因此需要清楚的区分对象内存和宿主(调试器)内存。在宿主进程中使用目标地址结果无法预料,通常情况下是错误的结果。当使用DAC检索目标内存时,在正确的地址空间中使用目标地址时十分重要的,此外,有时目标地址严格用作数据,在这种情况下,使用主机地址同样不正确,比如,要显示一个托管函数的信息,可能需要列出开始的地址,地址大小。当在VM文件夹下编辑DAC可能运行的代码时,需要正确的选择宿主地址或者目标进程地址。

 

DAC底层(使用宏和模板控制访问主机或者目标内存)提供了一些约定用来区分指针是主机地址还是目标内存地址。如果一个函数是_DACized_(使用DAC基础结构使函数在进程之外工作),主机中类型T指针定义成_T*,目标指针定义成PTR_T,不过请记住,主机和目标的概念只对DAC有意义,在一个non_DAC编译中,只有一个地址空间,宿主地址和目标地址是相同的:在VM函数中定义一个类型T*或者是PTR_T类型的局部变量,当在coreclr.dll中执行是这是个主机指针,_T*的局部变量和PTR_T类型的局部变量并没有绝对的区别。如果在由同一个源编译成的mscordacwks.dll (msdaccore.dll)中执行该函数,那么定义的类型_T*会是一个真的主机指针(debugger作为宿主机),然而当我们把这个指针传递到VM其他函数时会变的混乱。如果DACizing一个函数(_T*转换PTR_T),我们有时需要追踪一个指针的源头来确定指针是宿主机指针还是目标指针。

 

如果一个人对DAC不了解,很容易发现使用DAC基础结构不好用,TADDRs , PTR_this , dac,_casts等代码很难理解,然而只要多用点心,就会发现也没有难么难,主机地址和目标地址的不同是一种强类型形式,这里多做点工作,易于确保代码的正确性。

 

因为DAC可能工作在转储文件上,因而VM源文件中的部分clr.dll代码必须是非侵入式的,具体来书,不要直接写入到目标地址空间,也不要强制垃圾回收(如果推迟垃圾回收的时间,就还有可能进行分配),请注意, 主机状态始终是突变的 (临时、堆栈或本地堆值);突变的目标空间是有问题的(the _host_ state is always mutated (temporaries, stack or local heap values); it is only mutating the _target_ space that is problematic.??)。为了确保这个问题,需要做到两点:代码分解和条件编译。理想情况下, 我们将会分离vm代码,严格区分侵入式功能和非侵入式功能。

 

不幸的式,代码太多,之前写的大部分根本没有考虑过DAC,我们有大量的带有"find or create"的功能。一些功能做了检查,其他的一些直接写入目标空间,有时,我们通过传递一个标志位控制这个行为。这在代码加载器中很常见。举例子,为了避免在使用DAC之前重构代码的艰巨任务,有了第二种方法防止些侵入式代码。我们定义了一个预处理常量DACCESS_COMPILE来控制部分代码编译成DAC,但是需要尽享少的使用该常量,当我们写新的DACize代码时,更愿意尽可能的进行重构(分离原则),因此,一个具有"find or create"语义的功能应该是两个函数,一个函数查找信息,一个函数用于在查找失败是调用的包装器。这样,DAC代码可以直接调用查找函数,避免创建。

 

DAC如何工作

======================

 

DAC在 mscordacwks.dll中封送所需数据,它通过读取目标地址空间来获取封送数据,然后保存在宿主机地址空间,这样mscordacwks才能操作这些数据。这只会在需要的时候才做,如果mscordacwks函数不需要目标的值,DAC不会封送数据。

 

封送原则

---------------------

 

DAC维护一个数据缓存,这避免了反复调用相同值产生的开销,当前,如果调试对象还在运行,值可能会改变,我们假定如果调试器在调试点停止的时候不会改变。继续执行时,必须Flush(数据写回目标对象,同时清空自身)缓存,DAC将在调试器下次进入断点时再次读取对象内容。DAC缓存实体是DAC_INSTANCE类型,这包括了(还有其他的数据)目标地址,数据大小和封送数据空间。当DAC封送数据时,它返回缓存对象上的封送数据的地址作为宿主机地址。

 

当DAC从调试目标读取值时,他会把值整理成一个给定长度(取决于对象的类型)的字节块。通过把调试目标的地址保存在缓存实体的一个字段上,调试器建立了在目标地址和宿主机地址(缓存地址)之间的映射关系。调试会话的断点停止和继续,访问相同类型DAC只会进行一次封送。(如果调试器使用不同的类型来引用目标地址,那么长度也可能是不同的,DAC会为这个新类型创建一个新的缓存实体)。如果值已经在缓存中,DAC会通过目标地址来查找。因此只要使用相同的类型引用两个调试对象地址,那么我们就可以正确的比较两个宿主机地址。指针不支持类型转换,我们也不保证缓存对象和调试目标对象之间的空间关系,因此比较两个类型的大小是不正确的。对象布局必须保证完全相等,这样在调试对象和在缓存对象上可以采用相同的方式访问字段。封送对象中的每个字段都是调试对象地址的指针(通常生命为PTR类型的成员)。如果要使用这些指针的值,DAC必须在使用之前封送到宿主机中。

 

因为使用相同的源代码编译mscorwks.dll和mscordacwks.dll,因而他们肯定是完全匹配的,想像一下,如果在不同的build之间添加或者移除了字段,对象的布局也不会相同,那么DAC就无法正确的封送对象,这是个很明显,又很容易忽略的问题,不能存在仅在DAC或者仅在non-DAC中的字段,因此,下面的示例定义会导致错误的行为。

 

    class Foo

    {

        ...

        int nCount;

 

        // DON'T DO THIS!! Object layout must match in DAC builds

        #ifndef DACCESS_COMPILE

 

            DWORD dwFlags;

 

        #endif

 

        PTR_Bar pBar;

        ...

    };

 

封送处理细节

--------------------

 

DAC通过一个类型,宏和模板集合来工作,通常下在DAC的不同builds之间含义相同,在NON-DAC的不同builds之间含义不同。这些定义在[src\inc\daccess.h][daccess.h],你在这个文件头会看到一个很长的注释了写DAC的必要细节。

[daccess.h]: https://github.com/dotnet/coreclr/blob/master/src/inc/daccess.h

一个示例可能有助于了解封送处理的工作方式。公共调试方案在下面的框图中表示:

图中的调试器可能是vs,mdbg,windbg等等,调试器通过CLR调试器接口(dbi)来获取所需信息,来自目标的信息必须经过DAC,调试器实现了DBI,它负责实现实现ReadVirtual函数读取目标内容,图中的虚线表示线程边界。

 

如果调试器需要显示托管应用程序中某个函数的堆栈起始地址,需假定调试器已经获取从DBI中获取了ICorDebugFunction接口的示例,这个示例首先调用了DBI API ICorDebugFunction::GetNativeCode,这会通过GetNativeCodeInfo方法(传递参数传递domain文件和元数据信息。)调用进DAC。下面的代码是实际函数的一个简化,大致说明了封送处理的过程。

 

    void DacDbiInterfaceImpl::GetNativeCodeInfo(TADDR taddrDomainFile,

    mdToken functionToken,

    NativeCodeFunctionData \* pCodeInfo)

    {

        ...

 

        DomainFile \* pDomainFile = dac\_cast<PTR\_DomainFile>(taddrDomainFile);

        Module \* pModule = pDomainFile->GetCurrentModule();

 

        MethodDesc\* pMethodDesc = pModule->LookupMethodDef (functionToken);

        pCodeInfo->pNativeCodeMethodDescToken = pMethodDesc;

 

        // if we are loading a module and trying to bind a previously set breakpoint, we may not have

        // a method desc yet, so check for that situation

        if(pMethodDesc != NULL)

        {

            pCodeInfo->startAddress = pMethodDesc->GetNativeCode();

            ...

        }

    }

 

第一步要获取函数所在的模块。传进的taddrDomainFile参数代表一个目标地址,并在这个函数里进行解析。这需要DAC封送值,dac_cast操作符会构造一个新的PRT_DomainFile,其目标地址和domainFileTaddr的地址相同。我们把它指定给pDomainFile(隐式转换成主机指针类型)。这个转换操作符是PTR类型的成员,这也是封送发生的地方,如果DAC第一次在缓存中查找目标地址,他会读取目标地址读取已经封送DomainFile示例对象的数据,然后写入到缓存上,最后,返回封送之后的宿主机地址。

 

在DomainFile示例对象上调用GetCurrentModule方法,这个函数是个简单的字段访问,返回 DomainFile::m_pModule,注意,这里返回的是个Module*。而非宿主机地址,m_pModule的值是个目标地址(DAC可以赋值DomainFile对象作为原始字节)的成员,这个成员的类型是PTY_MODULE,因此,如果函数返回它,DAC会自动的封送Module*的PTR_Module成员。转换后返回的是个主机地址。这样就获取了正确的模块和元数据,就有足够的信息获取MethodDesc。

 

    Module * DomainFile::GetCurrentModule()

    {

        LEAF_CONTRACT;

        SUPPORTS_DAC;

        return m_pModule;

    }

在这段简化代码中,可以看到方法元数据是个方法的描述,下一步,在模块实体上调用LookupMethodDef方法

    inline MethodDesc \*Module::LookupMethodDef(mdMethodDef token)

    {

        WRAPPER\_CONTRACT;

        SUPPORTS\_DAC;

        ...

        return dac\_cast<PTR\_MethodDesc>(GetFromRidMap(&m\_MethodDefToDescMap,

        RidFromToken(token)));

    }

使用RidMap来查找MethodDesc,函数返回一个TADDR;

    TADDR GetFromRidMap(LookupMap \*pMap, DWORD rid)

    {

        ...

 

        TADDR result = pMap->pTable[rid];

        ...

        return result;

    }

 

这表明是个调试目标地址,但并非一个真正的指针,仅仅是个数字(尽管它代表一个地址)。问题在于LookupMethodDef需要返回一个可以引用的MethodDesc地址,为了达到这个目的,函数使用dac_cast<PTR_MethodDesc函数来讲TADDR转换成一个PTR_MethodDesc。你可以把这个过程视作void*形式转换成MethodDesc*的地址转换。事实上,如果GetFromRidMap直接返回一个PTR_VOID(带有指针语义)而不是TADDR(数字语义),那么代码会简单一些。这里再次说明了之前的问题,返回的隐式转换语句确保DAC封送了对象,并且返回了DAC缓存中的宿主机器地址。

 

GetFromRidMap的赋值语句是通过数组索引来获取一个特定的值。pMap参数是MethodDesc的一个结构体字段.DAC封送MethodDesc时会拷贝整个字段,pMap是个结构体地址,是个宿主进程指针,引用不会调用DAC,pTable字段是PTR_TADDR类型,也就是说是个目标地址数组。类型也表明这是一个封送类型。pTable也是一个调试进程地址。可以通过一个PTR的重载索引操作符引用。获取数组地址,然后计算所需要对象的目标地址,最后封送一个数组元素到调试进程的DAC缓存中并返回他的值(数组元素赋值给局部变量,在返回出来)。

 

最后,DAC/DBI接口函数调用MethodDesc::GetNativeCode函数获取代码路径,这个函数返回PCODE的值,这是个调试目标地址,但是不能直接引用(这只是TADDR的别名),专门指定代码的位置。我们在ICorDebugFunction示例中保存这个值,并且将这个值返回给调试器。

 

### PTR类型

 

DAC从调试目标空间封送值到调试地址空间,弄明白DAC如何处理对象指针时必要的。我们把这些基础类型统称位"PTR types".在 [daccess.h][daccess.h]中定义了两个类。 __TPtrBase很多派生类型的基类和一个不会直接使用的类型__GlobalPtr,而是间接的在宏中使用,每个都有一个成员保存目标地址的值。对于__TPtrBase,这是个全地址,对于__GlobalPtr保存的是个相对对DAC全局基地址的相对地址.__TPtrBase中的字母“T”代表“target”。使用__TPtrBase的派生类作为数据成员或者局部变量,使用__GlobalPtr作为全局变量或者静态变量

 

实际上,我们在宏中使用这些类型。[daccess.h][daccess.h]的介绍注释中提供了一些使用案例。在DAC编译中,宏会通过这些封送模板扩大生成的实例化类型。在non-DAC编译中则不会,举例子。下面的代码定义了类型PTR_MethodTable用来标识方法表指针(约定使用PTR_前缀):

 

    typedef DPTR(class MethodTable) PTR\_MethodTable;

 

在DAC生成中,DPTR宏将展开以声明一个名为PTR_MethodTable的__DPtr 类型。在non-DAC生成中, 宏只声明PTR_MethodTable为MethodTable*。DAC功能不会导致non-DAC生成中的任何行为更改或性能降级。

 

在DAC编译中。DAC会自动封送变量,数据成员,和返回的PTR_MethodTable类型。正如上节案例中看到的。封送处理是完全透明的 __DPtr类型重载操作符重新定义了间接引用指针和数组索引,还有一个转换操作符用于转换成主机地址类型。这些操作决定了值是否从缓存中读取并返回,还是需要从调试对象读取,载入缓存在返回,如果你对这里的实现细节有兴趣,负责缓存操作的代码是DacInstantiateTypeByAddressHelper函数。

 

DPTR重定义成PTR很常见,也会是在全局变量,局部变量,限制使用的数组,指向可变对象的指针,在mscordacwks.dll调用的类型虚函数指针中使用PTR类型。这些情况比较少,可以在 [daccess.h][daccess.h]查询相关内容。

 

GPTR和VPTR宏十分场景,值得特别说明。用法和他们的外在行为和DPTR十分相似。自动透明封送。VPTR宏为带虚函数的类生命了一个封送指针类型。这个特别的宏是必要的。因为虚函数表本质上是个隐式的额外字段。函数地址必须由DAC转换成主机地址,DAC必须独立的封送。以这种方式对这些类进行处理意味着 dac 自动实例化正确的实现类, 使得不用强制转换基类和派生类型。当你定义了一个VPTR类型,你必须在vptr_list.h添加。__GlobalPtr类型提供了一些封送通过GPTR,GVAL,SPTR和SVAL宏封送全局变量和静态变量的基础功能。全局变量和静态变量的实现几乎相同(包括使用__GloablPtr类)并且需要在[dacvars.h][dacvars.h]添加一个实体。 daccess.h 和 dacvars.h的注释提供有关这些类型定义的更多细节。

 

全局变量和静态变量有些特殊之处,他们构成了调试目标地址空间的入口点(所有其他的DAC用法都要求有个目标地址)。很多globals本身就是DACized。在DAC中很少产生没有预先DACized的全局变量。通过使用恰当的宏和[dacvars.h][dacvars.h]定义的实体。能够启用生成后步骤(ndp\clr\src\dacupdatedll调用的DacTableGen.exe)将全局变量保存成一张表并嵌入到mscordacwks.dll中。DAC在读取一个全局变量时使用这张表来确定到哪里查找目标地址空间。

 

###值类型

 

除了指针类型,DAC也要封送一些静态或全局值类型(和静态,全局指针相反)。定义了一个?VAL_*个格式的宏集合.使用GVAL_*代表全局值类型,SVAL代表静态值类型。文件 [daccess.h][daccess.h]注释做了个表来说明如何使用这些不同形式的宏。还有会在DACized代码中使用的静态,全局值类型(包括全局,静态指针)定义的说明。

 

### 纯地址

 

TADDR和PCODE类型时纯粹的调试目标地址。是个整形而不是一个指针。这防止在调试程序中不正确的引用。DAC也不会把他们当作指针,因为没有类型和大小信息,所以不能引用和封送。主要在这两种情况下使用:把地址当作纯粹的数据和计算目标指针地址的算法中(也能在PTR类型中使用指针算法)。因TADDRs没有指定目标位置的类型信息, 所以当我们执行地址运算时,需要显式地指定类型大小。

 

有个不参与封送的特殊类型PTRS:PTR_VOID和PTR——CVOID。他们分别是void*和void*常量。TADDR只是个数字,而不是一个指针。因此无法通过常用的将void*置换成TADDR的方式DACize.还需要额外的转换。即使在non_dac的代码中也是如此。使用PTR_VOID 可以使 DACize 的代码更清晰, 因为它保留了 void 所需的语义.如果DACize使用了PTR_VOID和PTR_CVOID类型的函数。不能直接从这个地址上封送数据,因为无法确定有多少数据要读取。也就不能直接引用(甚至做指针运算),但这和void*的语义是相同的,与void*的情况一样,当我们使用的时候,转换成一个更加具体的PTR类型。有一个 PTR_BYTE 类型, 它是一个标准封送的目标指针 (支持指针运算等)。通常,当DACize代码时,void*转换成PTR_VOID,BYTE*转换成PTR_BYTE, [daccess.h][daccess.h]注释有对PTR_VOID的详细解释。

 

有时,遗留代码在一个宿主指针类型中保存调试目标地址,比如void*。这多数是bug。使得代码难以理解。在做跨平台时也会因为指针类型不同导致错误。再DAC编译中,void*类型是个不包括调试目标地址的宿主指针。我们努力的消除这些用法。但有些是相当普遍的代码, 需要一段时间才能完全消除。

 

### 转换

 

在CLR的早期实现中,我们使用c风格的转换,宏和构造方法来做类型转换。例子。方法MethodIterator::Next

    if (methodCold)

    {

        PTR_CORCOMPILE_METHOD_COLD_HEADER methodColdHeader

        = PTR_CORCOMPILE_METHOD_COLD_HEADER((TADDR)methodCold);

 

        if (((TADDR)methodCode) == PTR_TO_TADDR(methodColdHeader->hotHeader))

        {

            // Matched the cold code

            m_pCMH = PTR_CORCOMPILE_METHOD_COLD_HEADER((TADDR)methodCold);

            ...

methodCold和methodCode都是BYTE*类型,本质上是个目标地址。4行,methodCold转换成TADDR并用作PTR_CORCOMPILE_METHOD_COLD_HEADER的构造参数,methodColdHeader是个显示的调试目标地址,6行,是另一个methodCode的c风格转换。methodColdHeader的hotHeader字段声明为PTR_CORCOMPILE_METHOD_HEADER类型。宏PTR_TO_TADDR从PTR类型实例中提取原始调试目标地址,并赋值给methodCode。最后9行,又构造了一个PTR_CORCOMPILE_METHOD_COLD_HEADER类型实例,methodCold转换为TADDR作为参数。

 

这段代码过于复杂且难以理解,更糟的是,没有对宿主地址和目标地址进行分离保护,methodCold和methodCode的声明开始,没有特别的理由将它们解释为目标地址。如果这些指针在 dac 生成中引用, 就好像它们确实是主机指针一样。此代码段表明任何任意指针类型 (与PTR类型相对) 都可以被强制 TADDR。鉴于这两个变量始终保持目标地址, 它们应该是PTR_BYTE, 而不是BYTE*。

 

在不同的 PTR 类型之间做转换有约定的方法: dac_cast。dac_cast 运算符是 c++ static_cast 运算符的dac识别的DAC-aware版本 (clr约定在强制转换指针类型时规定而不是c风格的转换)。dac_cast运算符将执行以下任何操作:

1. 从TADDR创建一个PTR

2. 转换不同的PTR类型

3. 从以前封送到dac缓存的宿主实例上创建PTR

4. 从PTR类型中提取TADDR

5. 从以前封送到dac缓存的宿主实例上创建获取TADDR

 

假定methodCold和methodCode是PTR_BYTEbeijing,那么上面的代码可以重写成如下形式

    if (methodCold)

    {

        PTR_CORCOMPILE_METHOD_COLD_HEADER methodColdHeader

        = dac_cast<PTR_CORCOMPILE_METHOD_COLD_HEADER>(methodCold);

 

        if (methodCode == methodColdHeader->hotHeader)

        {

            // Matched the cold code

            m_pCMH = methodColdHeader;

可能仍旧显得复杂,但至少明显的减少了转换的次数。使用构造分离宿主进程和调试目标指针的分离,因此,代码更加的安全。特别是如果我们尝试做错误的事情(通常, dac_cast 应用于转换。), dac_cast 通常会生成编译器或运行时错误。

 

DACizing

========

 

何时需要DACize?

---------------------------

无论何时添加新功能, 都需要考虑可调试需要, 并 DACize 代码以支持您的功能。还必须确保任何其他更改(如bug修复和代码清理)必须符合dac规则。否则,更改会破坏调试器和SOS。如果您只是修改现有的代码 (而不是实现一个新的功能), 通常。在确定修改的函数包括SUPPORTS_DAC约定时,需要注意DAC。此约定还有其他的变化形式比如SUPPORTS_DAC_WRAPPER 和LEAF_DAC_CONTRACT。[contract.h][contract.h]的注释解释了他们的差异。如果在函数中看到多个 DAC-specific类型, 则应假定代码将在 dac 生成中运行。

[contract.h]: https://github.com/dotnet/coreclr/blob/master/src/inc/contract.h

 

DACizing 确保引擎中的代码能够与DAC正常工作。使用DAC正确地将值从目标封送到主机是很重要的。从主机错误地使用的目标地址 (反之亦然)会导致未映射的地址。如果映射了错误地址, 则无法得到预期的值。因此, DACizing 主要确保对DAC需要封送的所有值使用PTR类型。另一个主要任务是确保我们不在DAC生成中执行侵入代码。实际上, 这意味着我们有时必须重构代码或添加 DACCESS_COMPILE 预处理器指令。我们还要确保我们加入适当的 SUPPORTS_DAC约定。使用此约定告诉该功能与 dac 一起工作。, 表明该功能与 dac 一起工作。这一点很重要, 原因有两个:

1. 如果我们以后从其他SUPPORTS_DAC函数调用它, 我们知道它是DAC安全的, 我们不需要担心DACizing它。

2. 如果我们对函数进行了修改, 我们需要确保它们是DAC安全的。如果我们从这一个函数中添加一个调用, 我们还需要确保它是DAC安全的, 或者我们只在non-DAC生成中进行调用。

标签: dac core debugger
共有 人打赏支持
粉丝 4
博文 37
码字总数 46353
×
魂祭心
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
* 金额(元)
¥1 ¥5 ¥10 ¥20 其他金额
打赏人
留言
* 支付类型
微信扫码支付
打赏金额:
已支付成功
打赏金额: