linkers

* 本文选自:COM集中营


Under The Hood, July 1997

作者Matt Pietrek
翻译lostall
原文Under The Hood: Linkers In-Depth, July 1997
声明严格地讲,这不是一篇翻译,我更愿意把它叫作“意译”。即,我尽力保证文章内容与原文完全一致,但我不保证与原文表达完全一致。特别地,对常用术语、我拿不准的句子,或者我懒得写汉字的话,我会直接引用英文。如果可能,读者还是尽量阅读原文为最佳。

         [摘要]     这篇文章介绍了Linker的实现机制,是一篇不可多得的好文章。文中主要介绍了Linker的两个最重要的工作:(1)combining sections,(2)processing fixup and relocation。另外还介绍了如何从.lib中查找符号,如何创建import table, export table等。

         在这个专栏里,我通常讨论新的或还没有被广泛应用的技术。但是,随着越来越多的开发者加入Win32程序员的行列,有些对于老手来说是过时的主题,对于新手仍然有神秘感。Linker这个主题就属于这一类。Before you Visual Basic programmers head for the exits, be advised that Visual Basic 5.0 uses a linker.事实上,它使用与VC 5.0同样的链接器。VB 5.0很好地隐藏了这个真相,但如果你巡查一番,就会发现它产生了OBJ文件,并把它们发给Microsft Linker。
         什么是链接器?它怎么工作的?这个月我将阐述这些问题。作为这一次专栏研究的一部分,我查找了一些旧的资源。有意思的是,我将在这里阐述的东西或者已经绝版或者已经不在MSDN CD-ROM上,即使链接器技术影响着几乎每一个Windows程序员。
         为了这次的专栏,我将把Microsoft's LINK.EXE作为标准的linker。(其它的linkers,比如Borland's TLINK32,可能会和我这里说的行为有轻微的差别)在以后的专栏里,我会更深入一些,分析Microsft linker里一些更有用更有趣的开关。首先,我会给你一个linker的过于简单的定义,以后再完善它。一个链接器的工作是把一个或多个目标模块(典型的就是OBJ文件)合并成一个执行文件(即,EXE或DLL)。然而,这回避了问题的实质:什么是一个object module?
         一个目标模块是由一个接受可读文本并把它翻译成CPU可理解的机器代码和数码的程序产生的输出。对于C++来说,C++编译器读入一个C++源文件。对于汇编语言来说,一个assembler(比如MASM)读入一个包含CPU使用的代码和数据字节的直接等价物(译注:即汇编指令)的汇编语言(ASM)文件。在VB 5.0里,输入文件是你的工程里的FRM,BAS,和CLS文件。这个概念也适用于其他大多数语言,比如Fortran。
         一个目标模块的主要构成是机器代码和数据。构成代码和数据的raw data被存储在称为section的连续块中。比如,微软的编译器把他们的机器代码放进一个称为.text的节中,把数据放到一个称为.data的节中。这些名字没有特殊的意义,只是作为一种提醒,它们被专门用作特定的节。其它的编译器可以(并且也是)为他们的section使用不同的名字。如果你在做MS-DOS或16位Windows编程,你可以把前面的叙述中的"sections"替换成单词"segment",然后所说的内容仍然适用。如果你的系统里安装了Visual C++,你可以使用DUMPBIN工具看到你自己的OBJ文件中的sections。执行下面的命令:

DUMPBIN <objname>

         这里 是任意一个OBJ的名字。Figure 1列出了最常用到的节的纲要。通过对一个OBJ文件运行DUMPBIN,你可以看到一个C++程序的典型编译所产生的sections,比如Visual C++/Lib目录下的CHKSTK.OBJ文件:

Dump of file CHKSTK.OBJ
 File Type: COFF OBJECT
      Summary
            0 .data
           2F .text

         为compiler或assembler产生的输出起的特殊名字是一个compilation unit(编译单元)。但是我们中的大多数人就把它看作OBJ文件。linker最重要的工作就是收集所有的compilation units,然后合并来自不同compilation units的所有section。当然,如果事情真是这么简单,那链接器就只不过是一个连接数据块的一般程序。链接器的复杂性来自于处理fixups。稍后再详述。
         你可能想知道链接器决定在最终的执行文件中怎样安排来自不同OBJ的code和data节。事实是链接器有一组精心设计的规则必须要遵守。实际上,链接器的任务非常复杂以至于它对它的输入文件做了两次扫描(two passes)。第一次pass使链接器了解要处理的东西。第二次pass,链接器应用所有的规则生成执行文件。
         尽管我不打算描述每个规则的所有细节,有两个规则就已经覆盖了链接器的大多数行为。最主要的规则就是链接器必须把每一个指定的OBJ文件的所有code和data放到执行文件中。如果你给了链接器三个OBJ文件,那么所有这三个OBJ文件的代码和数据必须以某种方式合并到执行文件中。然而,链接器不是简单的提取OBJ文件的raw section,再把它们一个接一个地串在一起放进执行文件中。相反,链接器用相同的名字合并所有的节。例如,如果三个OBJ文件都有一个.text节,那么生成的执行文件将有一个单独的.text节,由三个各自的.text节组成,它们按照链接器遇到的顺序连接到一起。


Figure 2 A.OBJ, B.OBJ, and C.OBJ

         链接器遵守的另一条规则是在执行文件中的节的顺序由链接器处理节的顺序决定。通过在命令行指定的OBJ文件列表的顺序,链接器完成这个工作。但是,相同名字的节合并在一起的主要原则更为优先。
         Figure 2显示了三个OBJ文件:A.OBJ, B.OBJ, and C.OBJ。每一个文件有三个节:都有_text和_data节,但在各自文件的不同位置。他们也都各自有一个唯一的节(即a_asm, b_asm, and c_asm)。假设你调用LINK,传递下面的命令行:

A.OBJ B.OBJ C.OBJ

         段的顺序(以及相同名字的节是怎样被合并的)显示在Figure 2的右侧。你可以从这篇文章顶部的链接中下载源文件和OBJ文件。这样,你可以试验不同的链接器命令行,比如"Link B.OBJ A.OBJ C.OBJ" — 即使你没有MASM或一个兼容的assembler。
         记住这两个规则,你就处在正确的方向上,知道链接器怎么在MS-DOS和16位Windows上完成它的工作的。尽管Win32链接器对我所描述的东西加了些手法。对新手而言,有一个$ section name rule。如果一个节的名字中包含一个$符号(比如,.idata$4),那么这个$符号以及其后所有的字符在执行文件中被丢掉。然而,在链接器丢掉这个名字之前,它合并名字匹配$符号的节。在排列OBJ sections到执行文件时,$符号后面部分的名字被用到。这些节根据$符号后面的名字的字母顺序排序。比如,有三个节分别叫作foo$c, foo$a, and foo$b,准备在执行文件中合并成一个节叫做foo。这个节的数据将从foo$a的数据开始,接着是foo$b,最后以foo$c结束。这种自动合并名字中带$符号的节的方式被链接器以各种各样的方式使用。在后面我讨论imported functions时你会看到一个例子。它也被用来创建C++构造和析构函数静态初始化需要的data tables。
         除了$合并规则外,Win32链接器有几个特殊的情况。具有代码属性的节被赋予了特殊的优先级,被放在了执行文件的前面。在代码节后面,链接器放置了uninitialized data sections—由没有在编译时指定初始值的全局数据组成(例如,int i;在C++中被声明为一个全局变量)。再后面是initialized data(包括.data节),以及链接器生成的数据节,比如.reloc。
         未初始化数据通常被编译器放到一个叫.bss的节中。但是现在已经很少在一个执行文件中看到.bss节了。微软的链接器把.bss节合并到了.data节中,它被编译器用作主要的初始化数据节。但是等一下,这其中另有蹊跷!这种情况只会发生在除了Posix以外的子系统中,而且这个子系统的版本要大于3.5。其它包含未初始化数据的节被链接器单独留下了(即,它们没有被合并)。
         现在从执行文件的后面往回看,如果OBJ文件中有一个.debug节,它被放在执行文件的最后面。如果没有.debug节,链接器将试图把.reloc节放在最后,因为在大多数情况下Win32 loader不需要读重定位信息。减少需要读取的执行程序的大小就等于减少了加载的时间。我将在后面介绍relocation。
         在两个基本规则之外还有一个例外,就是在Win32下存在removable sections。这些节存在于OBJ文件中,但是链接器不把它们拷贝到执行文件中。这些节典型地都有LINK_ REMOVE和LINK_INFO属性(看winnt.h),并且被命名为.drectve。微软的编译器生成这些节是为了传递信息给链接器。如果你查看一个Visual C++编译生成的OBJ文件,你可以看到.drectve节中的数据可能象下面这样:

-defaultlib:LIBC -defaultlib:OLDNAMES

         如果它看上去象是传给链接器的命令行参数,那就对了。当你使用C++的修饰符__declspec(dllexport)时可以看到另一个证据。例如:

void __declspec(dllexport) ExportMe( void ){...}

         将导致.drectve节也包含如下数据:

-export:_ExportMe

         的确是如此,如果你看看LINK的命令行选项表,-export也是其中之一。

Fixups and Relocations

         为什么编译器不能直接从源文件生成执行文件呢,那样不就不需要链接器了吗?主要原因是大多数程序不只包含一个源文件。编译器擅长针对单个的源文件生成原始的机器码的等价物。因为一个源文件可能包含对外部文件的代码或数据的引用,所以编译器不能确切地生成正确的代码去调用那个函数或访问那个变量。作为替代,编译器唯一的选择的在输出文件中包含描述外部代码或数据的额外信息。用于这种描述代码和数据的外部引用的术语是fixup。直接了当地说,编译器生成的访问外部函数和变量的代码是不正确的,必须在以后被修补
         考虑在C++中调用一个叫做Foo的函数:

 //...
 Foo();
 //...

         32位C++编译器生成的确切字节如下所示:

E8 00 00 00 00

         0xE8是CALL指令的操作码。随后的DWORD应该包含距离Foo函数的偏移量(相对CALL指令)。很明显Foo不可能距离CALL指令0个字节。如果你要执行这段代码的话绝对不会如你所愿。这段代码是坏的,需要被fixed up。
         在上面的例子中,链接器需要把CALL操作码后面的DWORD替换成Foo的正确地址。在执行文件中,链接器会把Foo的相对地址写到这个DWORD。然而链接器又是怎么知道需要做这个工作的呢?一个fixup record告诉它要这么做的。它怎么知道Foo函数在哪呢?链接器知道执行文件中的所有符号,因为是由它负责排列和合并执行文件的各个组成部分的。
         现在该讨论那些fixup records了。对于基于Intel的OBJ文件,正常情况下只会遇到三种类型的fixup record。第一种是32位的relative fixup,被称作REL32 fixup(它对应于定义在WINNT.H中的IMAGE_REL_I386_REL32)。在上面调用Foo函数的例子中,将会有一个REL32 fixup record,以及链接器需要用适当的值重写的那个DWORD的偏移量。如果你运行:

DUMPBIN /RELOCATIONS

         对于上面的代码生成的OBJ,你将看到类似下面的输出:

                                Symbol    Symbol
  Offset    Type    Applied To  Index     Name
  ————————  ————    ——————————  ——————    ——————
  00000004  REL32    00000000      7      _Foo

         用英语说,这个fixup record的意思是链接器需要计算到Foo函数的相对偏移量,并且把这个值写到这个节的偏移量4字节处。因为这个fixup record只是链接器在创建执行文件之前需要用到,所以它会被丢掉,不会出现在执行文件中。那为什么大多数执行文件都包含一个叫.reloc的节呢?这是第二种类型的fixup开始起作用了。考虑下面的程序:

 int i;
 int main()
 {
     i = 0x12345678;
 }

         Visual C++在执行文件中为赋值语句生成的指令是:

MOV DWORD PTR [00406280],12345678

         有趣的是指令中的[00406280]部分。它引用了内存中的一个固定位置,并假定包含变量i的DWORD是在执行文件的加载地址(默认是0x400000)上面0x6280字节处。现在考虑如果执行文件没有加载在缺省的加载地址会发生什么?比如说,Win32 loader把它加载到内存的地址高了2MB(即,加载在0x600000处)。那么指令中的[00406280]部分应该被调整为0x00606280。
         DIR32 (Direct 32) fixups就是为了这种情况而被用在OBJ文件中的。它们表示某个东西的实际的(直接的)地址需要被修正的位置。它们也隐含的表示了执行文件的加载地址是有意义的。当创建执行文件时,loader从OBJ文件中提出DIR32 fixups,并创建.reloc节。不过在这发生之前,先对这个OBJ运行一下DUMPBIN /RELOCATIONS,显示如下:

                                Symbol   Symbol
  Offset    Type    Applied To  Index    Name
  ————————  —————   ——————————  ——————   ——————
  00000005  DIR32    00000000      4     _i

         这个fixup record意思是说链接器需要计算变量_i的直接32位地址,并把这个值写到这个节的偏移量5字节处。
         执行文件中的.reloc节基本上是一系列的地址,在这个地址处需要考虑缺省的和实际的加载地址的不同。默认地,链接器创建的执行文件,其.reloc节不需要被Win32 loader使用。但是,当Win32 loader需要加载一个执行文件到某个不同于它的preferred load address的地方时,这个.reloc节允许所有对代码和数据的直接引用被更新。
         第三种通常存在于Intel OBJ文件中的fixup类型,DIR32NB(Direct 32, No Base),被用作调试信息。链接器的一个次要工作是创建调试信息,包括函数和变量的名字,以及它们的地址。因为只有链接器知道所有的函数和变量最终在哪,the DIR32NB fixup is used to indicate spots in the debug information where the address of a function or variable is needed. DIR32和DIR32NB fixup的主要差异在于DIR32NB fixup中修补的值不包含执行文件的缺省加载地址。

Libraries

         在某些情况下,需要把两个或更多的OBJ文件合并到一个单独的文件,这个文件以后可以再传给链接器。一个典型的例子就是C++运行时库(RTL)。这个C++ RTL由许多源文件组成,这些文件编译后生成的OBJ被合并在一起成为一个library。对Visual C++而言,标准的,单线程的,静态版本的运行时库被称作LIBC.LIB。还有其他一些版本,用于调试的(比如,LIBCD.LIB),用于多线程的(LIBCMT.LIB)。
         库文件通常都有一个.LIB的扩展名。他们包含一个library header,紧接着的是被包含的OBJ文件的raw data。这个library header告诉链接器哪些符号(函数和变量)可以在随后的OBJ中找到,以及一个指定的符号存在于哪个OBJ当中。你可以通过DUMPBIN /LINKERMEMBER开关看到一个library的内容。如果在后面指定:1或:2,你会发现DUMPBIN的输出可读性更好,原因就不细说了。例如,对Visual C++ 5.0中的PENTER.LIB使用下面的命令行:

"DUMPBIN /LINKERMEMBER:1 PENTER.LIB"

         产生的部分输出:

     6 public symbols
       180 _DumpCAP@0
       180 _StartCAP@0
       180 _StopCAP@0
       180 _VERSION
       180 __mcount
       180 __penter

         每一个符号名字前面的180表明这个符号(比如,_DumpCAP@0)可以在位于库文件起始处0x180字节的地方的一个OBJ文件中找到。正如你看到的,PENTER.LIB只包含了一个OBJ。更复杂的LIB将包含多个OBJ,那样符号名前面的偏移量就会不同了。
         与命令行里输入的OBJ不同,链接器不是一定要把库文件里的每一个OBJ包含到最终的执行文件中。事实上,完全相反。链接器不会包含一个库文件中的某个OBJ的任何代码或数据,除非至少引用了那个OBJ里的一个符号(译注:在VC6里如果引用了LIB中的某个全局变量,似乎该LIB中的所有其它全局变量也都被放到了执行文件中,即使它们不在一个OBJ中,即使它们没有被引用)。换句话说,在链接器的命令行上显示指定的OBJ属于第一级,总是被包含到执行文件中。而LIB中的OBJ文件是备用的,只有在被引用的时侯才被包含到执行文件中。
         一个library中的符号可以有三种方式被引用(因此,它所在的OBJ也会被包含)。第一种,从一个显式的OBJ文件中直接引用这个符号。比如,如果我打算从我写的一个源文件中调用C++的printf函数,那么在我的OBJ文件中将会为它产生一个引用(以及一个fixup)。当创建执行文件时,链接器将搜索它的LIB文件,哪一个OBJ包含了printf代码,并且包含它找到的这个OBJ。(译注:不是包含整个LIB,只包含printf所在的那个OBJ)
         第二种,可以是一个间接引用。间接的意思是一个通过第一种方法被包含的OBJ文件引用了库文件中另一个OBJ的符号。这个第二个OBJ可能又引用了库文件中的第三个OBJ。链接器的一个艰巨的任务就是跟踪和包含每一个有符号被引用的OBJ,即使这个符号间隔了49级。
         当查找一个符号时,链接器按照它在命令行上遇到的顺序搜索LIB文件。然而,一旦一个符号在一个library中被找到,这个library就成了优先的library,以后再查找所有的符号时都会从它开始。一旦一个符号在它那没找到,这个library就失去了这个特性。这种情况下,查找链接器列表中的下一个library。(关于更详细的技术描述,参阅Microsoft Knowledge Base article Q31998)
         我们现在来说说import library的问题。在结构上,import library和一般的library没有区别。当解析符号时,链接器不知道一个import library和一个一般的library之间的差异。主要的不同是import library中没有和每一个OBJ对应的compilation unit(例如,源文件)。相反,链接器根据执行文件输出的符号自己生成一个import library。换句话说,当链接器创建一个执行文件中的exports table时,它也创建相应的import library以引用那些符号。这一点很好地过渡到我的下一个主题,imports table。

Creating the Imports Table

         Win32依赖的最基本的特性之一就是能够从其它执行文件中导入函数。关于导入的DLL和函数的所有信息都放在执行文件中的一个被称为imports table的表中。当它被放在一个只有它自己的节时,这个节被叫作.idata。
         因为imports对Win32执行程序非常重要,所以看上去很奇怪链接器对于import table没有任何的认识。换句话说,链接器不知道或者不关心你调用的一个函数是否放在另一个DLL中,或者在同一个执行文件中。它完成这个工作的方法非常巧妙。通过简单的遵循上面说过的节合并和符号分解规则,链接器创建了import table,而看上去并不知道这个表的特别意义。
         让我们看看一个import library的部分片断,来了解一下链接器是怎么完成这个壮举的。Figure 3显示了对USER32.LIB运行DUMPBIN输出的部分结果。假设你已经调用了ActivateKeyboardLayout函数。在你的OBJ文件中可以找到一个用于_ActivateKeyboardLayout@8的fixup record。从USER32.LIB header中,链接器确定这个函数可以在文件偏移量0xEA14的OBJ文件中找到。此时,链拉器确认要包含这个OBJ的内容到最终生成的执行文件中(看Figure 3)。
         从Figure 3中,你可以看到将要包含的OBJ中的各种不同的节,包括.text, .idata$5, .idata$4, and .idata$6。在.text节中是一条JMP指令(操作码是0xFF 0x25)。从Figure 3最后的COFF Symbol table中,可以看到那个_ActivateKeyboardLayout@8符号被解析成.text节中的这个JMP指令。因此,链接器把你对ActivateKeyboardLayout的调用挂钩成import library的OBJ的.text节内的JMP指令。
         链接器把一组.idata$XXX节合并成执行文件中的一个单独的.idata节。现在回想起链接器必须遵守合并名字中带$符号的节的规则。如果从USER32.LIB中引用了其他的导入函数,那他们的.idata$4, .idata$5 and .idata$6节也将被混合在一起。最终的结果就是所有的.idata$4节生成了一个数组,所有的.idata$6节生成了另一个数组。如果你对术语"import address table"熟悉的话,这就是这个表被创建的过程。
         最后,要注意的是.idata$6节的raw data包含了字符串ActivateKeyboardLayout。imported function的名字就是这样被放到import address table中的。问题的重点是,创建import table对于链接器来说不是一件大事。它只是根据前面我讲过的规则完成它的工作而已。

Creating the Exports Table

         除了为执行文件创建一个import table以外,链接器还要负责创建相反的东西:exports table。这里,链接器的工作是既艰巨也容易。在第一遍扫描的时侯,链接器有收集所有导出符号的信息和创建一个导出函数表的任务。在这个过程中,链接器创建export table并且把它写到一个OBJ文件中的.edata节中。这个OBJ文件各方面都是标准的,除了它使用.EXP的扩展名而不是.OBJ。That's right,你可以使用DUMPBIN检查那些EXP文件的内容,它们在你build DLL时被生成。
         在第二遍扫描的过程中,链接器的工作就非常少了。它简单地把EXP当作一个普通的OBJ文件。这又意味着在这个OBJ中的.edata将被包含到最终的执行文件中。毫无疑问,如果你在一个执行文件中看到了一个.edata节,那它就是export table。然而,现在.edata节是越来越少见了。如果执行程序使用了Win32 console或者GUI子系统,链接器似乎自动把.edata节与.rdata节合并到了一起,如果.rdata存在的话。

Wrap Up

         很明显,一个链接器有比我在这里说的更多的工作。例如,生成特定类型的调试信息(比如CodeView)是链接器所有工作中主要的一项。然而,创建调试信息对于链接器来说不是绝对必须的工作,所以我没有花时间来描述它。同样地,一个链接器也应该能够创建一个MAP文件,列出执行文件中包含的所有公有符号,但这又不是链接器必须的功能。
         尽管我已经涉及了很多复杂的话题,链接器在本质上只是一个简单的工具,用于把多个编译单元合并成一个可运行的执行文件。第一个要素是合并节;第二个是解析被合并的节之间的引用(fixup)。额外奉送一点与系统相关的数据结构的知识,比如exports table,并且你已经涉及到了这个强大而又必要的工具的基础。


* 本文选自:COM集中营

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
### 回答1: Linkers and Loaders是指链接器和装载器,是计算机领域中的一种重要工具,主要用于将程序的各个部分合并在一起并最终加载到内存中运行。 链接器的主要工作是将多个目标文件合并成一个可执行文件。目标文件包含机器语言指令和符号表信息,链接器将各个目标文件的符号表信息进行合并,并解决模块之间的引用问题,使得程序能正确地执行。 装载器的主要工作是将可执行文件加载到计算机的内存中运行。装载器将可执行文件中的各个段按照一定的地址空间进行装载,并进行重定位等操作,确保程序能够在内存中正确执行。 总之,链接器和装载器是构建可执行程序需要用到的重要工具,对于程序的可靠性、效率和安全性都具有重要作用。 ### 回答2: linkers and loaders是计算机科学中用于程序编译的工具。Linkers在编译过程中将多个目标文件合并成一个可执行文件,同时也会解决外部和内部符号的引用问题。Loaders则是将可执行文件加载到内存中,并把符号解析成运行时地址,最终使程序在计算机上运行。 linkers在编译过程中实现了模块化编程,将单独编写的多个目标文件链接在一起。linkers将程序中各个部分之间的符号和引用关系整合在一起,实现一个完整的可执行程序。linkers主要有静态链接和动态链接两种形式,静态链接将库文件直接合并到可执行文件中,而动态链接则是在程序运行时动态加载库文件。 loaders则负责将可执行文件从磁盘加载到内存中去。借助加载器,操作系统可以根据可执行文件文件头和段表信息将程序映射到内存中的相应地址,方便操作系统进行命令的调用和运行。 总之,linkers和loaders是编译过程中非常重要的组成部分。它们不仅使编译工作更加高效和灵活,同时也是计算机开发中程序正确性和性能的关键。 ### 回答3: linker是编译后的程序中用来处理符号链接的工具。它的作用是将不同程序模块(source file, object file)之间的符号联系起来。在链接过程中,linker会分析程序中使用到的符号,并将这些符号与其对应的地址进行关联,以便程序中的不同模块能够相互调用。同时,linker也会将所有的模块组合成一个可执行文件,方便程序的执行。 loader是将程序加载入内存并实现分页与映射的软件。loader的主要作用是将程序文件从磁盘上读入内存,并根据程序的逻辑要求,将其按照合适的方式映射到内存中的合适位置。同时,loader还负责分配与管理程序所需要的内存空间,维护程序在内存中的状态,以及实现程序的执行。 在计算机科学领域中,linkers和loaders是非常重要的工具。它们帮助编程人员将不同的程序模块链接起来,并在不同的系统中进行适当的加载。对于任何一个开发者而言,了解和掌握linkers和loaders都是非常重要的基本技能。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值