程序的编译,连接

编译过程

许多IDE和编译器将编译和链接的过程合并在一起,称为构建(Build),使用起来非常方便。但只有深入理解其中的机制,才能看清许多问题的本质,正确解决问题。
一般的编译过程可以分解为4个步骤,预处理,编译,汇编和链接:

  • 预编译:处理源代码中的以”#”开始的预编译指令,如”#include”、”#define”等。
  • 编译:把预处理完的文件进行一系列的词法分析、语法分析、语义分析及优化后产生相应的汇编代码文件,是程序构建的核心部分,也是最复杂的部分之一。
  • 汇编:将汇编代码根据指令对照表转变成机器可以执行的指令,一个汇编语句一般对应一条机器指令。
  • 链接:将多个目标文件综合起来形成一个可执行文件。

而对于第2步,编译由编译器完成器,编译器是将高级语言翻译成机器语言的一个工具,其具体步骤包括:

  • 词法分析:将源代码程序输入扫描器,将源代码字符序列分割成一系列记号(Token)。
  • 语法分析:对产生的记号使用上下文无关语法进行语法分析,产生语法树。
  • 语义分析:进行静态语义分析,通常包括声明和类型的匹配,类型的转换。
  • 中间语言生成:使用源代码优化器将语法树转换成中间代码并进行源码级的优化。
  • 目标代码生成:使用代码生成器将中间代码转成依赖于具体机器的目标机器代码。
  • 目标代码优化:使用目标代码优化器对目标代码进行优化,比如选择合适的寻址方式、使用位移替代乘法、删除多余指令等。

如果一个源代码文件中有变量或函数等符号定义在其他模块,那么编译后得到的目标代码中,该符号的地址并没有确定下来,因为编译器不知道到哪里去找这些符号,事实上这些变量和函数的最终地址要在链接的时候才能确定。现代的编译器只是将一个源代码编译成一个未链接的目标文件,最终由链接器将这些目标文件链接起来形成可执行文件。

目标文件格式

编译器编译源代码后生成的文件称为目标文件,事实上,目标文件是按照可执行文件的格式存储的,二者结构只是稍有不同。Linux下的目标文件和可执行文件可以看成一种类型的文件,统称为ELF文件,一般有以下几类:

  • 可重定位文件:如.o文件,包含代码和数据,可以被链接成可执行文件或共享目标文件,静态链接库属于这一类。
  • 可执行文件:如/bin/bash文件,包含了可以直接执行的程序,一般没有扩展名。
  • 共享目标文件:如.so文件,包含代码和数据,可以跟其他可重定位文件和共享目标文件链接产生新的目标文件,也可以跟可执行文件结合作为进程映像的一部分。

目标文件由许多段组成,其中主要的段包括:

  • 代码段(.text):保存编译后得到的指令数据。
  • 数据段(.data):保存已经初始化的全局静态变量和局部静态变量。
  • 只读数据段(.rodata):保存只读变量和字符串常量,有些编译器会把字符串常量放到”.data”段。
  • BSS段(.bss):保存未初始化的全局变量和局部静态变量。

除了这几个常用的段之外,ELF可能包含其他的段,保存与程序相关的信息,如:

  • .comment 编译器版本信息
  • .debug 调试信息
  • .dynamic 动态链接信息
  • .hash 符号哈希表
  • .line 调试时的行号表,源代码行号与编译后指令的对应表
  • .note 额外的比编译器信息
  • .strtab String Table,字符串表,存储用到的各种字符串
  • .symtab Symbol Table,符号表
  • .shstrtab Section String Table,段名表
  • .plt 动态链接跳转表
  • .got 动态链接全局入口表
  • .init 程序初始化代码段
  • .fini 程序终结代码段

ELF目标文件的总体结构如下图所示,其中省去了一些繁琐的结果,把最终的提出出来。

ELF Header
.text
.data
.rodata
.comment
.shstrtab
Section Table
.symtab
.rel.text

以下选取较为重要的进行介绍。
ELF文件头(ELF Header):保存描述整个文件的基本属性,如ELF魔数、文件机器字节长度、数据存储格式等。

段表(Section Header Table):保存各个段的基本属性,是除了文件头之最重要的结构。节选样例内容如下:

[Nr]NameTypeAddrOffSizeESFlgLkInfAl
[1].textPROGBITS0000000000003400005b00AX004

其表示的意义为,下标为1的段是.text段,类型是程序段(PROGBITS包括代码段和数据段),加载地址为0,在文件中的偏移量是0×34,长度为0x5b,项的长度为0(表示该段不包含固定大小的项),标志AX表示该段要分配空间及可以被执行,链接信息的两个0没有意义(不是与链接相关的段),最后的4表示段地址对齐为2^4=16字节。

重定位表:链接器在处理目标文件的时候,需要对目标文件中某些部位进行重定位,即代码段和数据段中那些绝对地址的引用位置,这些重定位信息记录在重定位表里。每个需要重定位的代码段或数据段都会有一个相应的重定位表,如.rel.text是针对”.text”段的重定位表,”.rel.data”是针对”.data”段的重定位表。

字符串表:ELF文件中用到很多字符串,如段名、变量名,因为字符串的长度不固定,用固定的结构来表示它比较困难,一般把字符串集中起来存放到一个表,然后使用字符串在表中的偏移来引用字符串。一般字符串表在ELF中以段的形式保存,常见的有.strtab(字符串表,String Table)和.shstrtab(段表字符串表,Section Header String Table),前者保存如符号名字等普通字符串,后者保存如段名等段表中用到的字符串。

符号表:函数和变量统称为符号,其名称称为符号名。链接过程中关键的部分就是符号的管理,每一个目标文件都会有一个相应的符号表,记录了目标文件用到的所有符号,每个符号有一个对应的符号值,一般为符号的地址。一个样例如下:

NumValueSizeTypeBindVisNdxName
130000001b64FUNCGLOBALDEFAULT1main

其意义如下:下标为13的符号的符号值为0x1b,大小为64字节,类型为函数,绑定信息为全局符号,VIS可以忽略,Ndx表示其所在段的下标为1(通过上一个样例可知,该段为.text段),符号名称为main。如果Ndx下标一项为UND(undefine),则表示该符号在其他模块定义,以后需要重定位。

调试信息:目标文件里可能保存有调试信息,如在GCC编译时加上”-g”参数,会生成许多以”.debug”开头的段。

静态链接

几个目标文件进行链接时,每个目标文件都有其自身的代码段、数据段等,链接器需要将它们各个段的合并到输出文件中,具体有两种合并方法:

  • 按序叠加:将输入的目标文件按照次序叠加起来。
  • 相似段合并:将相同性质的段合并到一起,比如将所有输入文件的”.text”合并到输出文件的”.text”段,接着是”.data”段、”.bss”段等。

第一种方法会产生很多零散的段,而且每个段有一定的地址和空间对齐要求,会造成内存空间大量的内部碎片。所以现在的链接器空间分配基本采用第二种方法,而且一般采用一种称为两部链接的方法:

  1. 空间与地址分配。扫描所有输入的目标文件,获得他们各个段的长度、属性和位置,收集它们符号表中所有的符号定义和符号引用,统一放到一个全局符号表中。此时,链接器可以获得所有输入目标文件的段长度,将他们合并,计算出输出文件中各个段合并后的长度与位置并建立映射关系。
  2. 符号解析与重定位。使用上面收集到的信息,读取输入文件中段的数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址等。

经过第一步后,输入文件中的各个段在链接后的虚拟地址已经确定了,链接器开始计算各个符号的虚拟地址。各个符号在段内的相对地址是固定的,链接器只需要给他们加上一个偏移量,调整到正确的虚拟地址即可。

ELF中每个需要重定位的段都有一个对应的重定位表,也称为重定位段。重定位表中每个需要重定位的地方叫一个重定位入口,包含:

  • 重定位入口的偏移:对于可重定位文件来说,偏移指该重定位入口所要修正的位置的第一个字节相对于该段的起始偏移。
  • 重定位入口的类型和符号:低8位表示重定位入口的类型,高24位表示重定位入口的符号在符号表的下标。

不同的处理器指令对于地址的格式和方式都不一样,对于每一个重定位入口,根据其重定位类型使用对应的指令修正方式修改其指令地址,完成重定位过程。

可执行文件的装载

32位硬件平台上进程的虚拟地址空间的地址为0到2^32-1:0×00000000~0xFFFFFFFF,即通常说的4GB虚拟空间大小。在Linux操作系统下,4GB被划分成两部分,操作系统本身占用了0xC00000000到0xFFFFFFFF共1GB的空间,剩下的从0×00000000到0xBFFFFFFFF共3GB的空间留给进程使用。
可执行文件只有被装载到内存以后才能运行,最简单的办法是把所有的指令和数据全部装入内存,但这可能需要大量的内存,为了更有效地利用内存,根据程序运行的局部性原理,我们可以把程序中最常用的部分驻留内存,将不太常用的数据放在磁盘中,即动态装入。

现在大部分操作系统采用的是页映射的方法进行程序装载。页映射并不是一下把程序的所有数据和指令都装入内存,而是将内存和所有磁盘中的数据和指令按照”页(Page)”为单位划分成若干个页,以后所有的装载和操作的单位就是页。目前一般的页大小为4K=4096字节。装载管理器负责控制程序的装载问题,当运行到的某条指令不在内存的时候,会将该指令所在的页装载到内存中的一个地方,然后继续程序的运行。如果内存中已经没有位置,装载管理器会根据一定的算法放弃某个正在使用的页,并用新的页来替代,然后程序可以继续运行。

可执行文件中包含代码段、数据段、BSS段等一系列的段,其中很多段都要映射进进程的虚拟地址空间。当段的数量增加时,会产生空间浪费问题。因为ELF文件被映射时是以系统的页长度为单位进行的,一个段映射的长度应为页长度的整数倍,如果不是,那么多余部分也将占用一个页,从而产生内存浪费。
实际上操作系统并不关心可执行文件各个段所包含的实际内容,它只关心一些跟装载有关的问题,最主要的是段的权限(可读、可写、可执行)。ELF中,段的权限组合可以分成三类:

  • 以代码段为代表的权限为可读可执行的段。
  • 以数据段和BSS段为代表的权限为可读可写的段。
  • 以只读数据段为代表的权限为只读的段。

于是,对于相同权限的段,可以把它们合并到一起当做一个段进行映射,这样可以把原先的多个段当做一个整体进行映射,明显地减少页面内部碎片,节省内存空间。这个称为”Segment”,表示一个或多个属性类似的”Section”,可以认为”Section”是链接时的概念,”Segment”是装载时的概念。链接器会把属性相似的”Section”放在一起,然后系统会按照这些”Section”组成的”Segment”来映射并装载可执行文件。

进程的虚拟地址空间中除了被用来映射可执行文件的各个”Segment”之外,还有包括栈(Stack)和堆(Heap)的空间,一个进程中的栈和堆在也是以虚拟内存区域(VMA, Virtual Memrory Area)的形式存在。操作系统通过给进程空间划分出一个个的VMA来管理进程的虚拟空间,基本原则是将相同权限属性的、有相同映像文件的映射成一个VMA,一个进程基本可以分为如下几种VMA区域:

  • 代码VMA,权限只读,可执行,有映像文件。
  • 数据VMA,权限可读写,可执行,有映像文件。
  • 堆VMA,权限可读写,可执行,无映像文件,匿名,可向上扩展。
  • 栈VMA,权限可读写,不可执行,无映像文件,匿名,可向下扩展。

其常见的分布情况如下图所示:

OS
STACK VMA
HEAP VMA
DATA VMA
CODE VMA

动态链接

静态链接允许不同程序开发者相对独立地开发和测试自己的程序模块,促进程序开发的效率,但其也有相应的缺点:

  • 浪费内存和磁盘空间。在多进程操作系统下,每个程序内部都保留了公用的库函数及其他数量可观的库函数及辅助数据结构,浪费大量空间。
  • 程序开发和发布困难。一个程序如果使用了很多第三方的静态库,那么程序中一旦有任何库的更新,整个程序就要重新链接并重新发布给客户,非常不方便。

动态链接可以解决空间浪费和更新困难的问题,其不对那些组成程序的目标文件进行链接,而是等到程序运行时才进行链接。使用了动态链接之后,当我们运行一个程序时,系统会首先加载该程序依赖的其他的目标文件,如果其他目标文件还有依赖,系统会按照同样方法将它们全部加载到内存。当所需要的所有目标文件加载完毕之后,如果依赖关系满足,系统开始进行链接工作,包括符号解析及地址重定位等。完成之后,系统把控制权交回给原程序,程序开始运行。此时如果运行第二个程序,它依赖于一个已经加载过的目标文件,则系统不需要重新加载目标文件,而只要将它们连接起来即可。

动态链接可以解决共享的目标文件存在多个副本浪费磁盘和内存空间的问题,因为同一个目标文件在内存中只保存一份。另外,当一个程序所依赖的库升级之后,只需要将简单地用新的库将旧的覆盖掉,无需将所有的程序再重新链接一遍,当程序下次运行时,新版本的库会被自动加载到内存并链接起来,程序仍然可以正常运行,并且完成了升级过程。

对于静态链接的可执行文件来说,整个进程只有一个文件要被映射,那就是可执行文件本身。但是对于动态链接来说,除了可执行文件本身,还有它所依赖的共享目标文件,此时,它们都是被操作系统用同样的方法映射进进程的虚拟地址空间,只是它们占用的虚拟地址和长度不同。另外,动态链接器也和普通共享对象一样被映射到进程的地址空间。系统开始运行程序之前,会把控制权交给动态链接器,由它完成所有的动态链接工作,然后再把控制权交回给程序,程序就开始执行。

装载时重定位

动态链接的共享对象在被装载时,其在进程虚拟地址空间的位置是不确定的,为了使共享对象能够在任意地址装载,可以参考静态链接时的重定位(Link Time Relocation)思想,在链接时对所有的绝对地址的引用不做重定位,把这一步推迟到装载时再完成。一旦模块装载完毕,其地址就确定了,即目标地址确定,系统就对程序中所有的绝对地址引用进行重定位。这种装载时重定位(Load Time Relocation)又称为基址重置(Rebasing)。

但是动态链接模块被装载映射至虚拟空间后,指令部分是在多个进程之间共享的,由于装载时重定位的方法需要修改指令,所以没有办法做到同一份指令被多个进程共享,因为指令被重定位之后对于每个进程来讲是不同的。当然,动态链接库中的可修改的数据部分对于不同的进程来说有多个副本,所以它们可以采用装载时重定位的方法来解决。

地址无关代码

装载时重定位导致指令部分无法在多个进程之间共享,失去了动态链接节省内存的一大优势。为了程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,可以把指令中那些需要改变的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变了,而数据部分可以在每个进程中拥有一个副本。这种方案称为地址无关代码(PIC, Position-independent Code)技术。
我们把共享对象模块中的地址引用按照是否扩模块分成模块内部引用和模块外部引用,按照不用的引用方式分成指令引用和数据引用,然后把得到的4种情况分别进行处理:

  • 模块内部调用或跳转。因为被调用的函数和调用者处于同一个模块,相对位置固定,而现代的系统对于模块内部的跳转、函数调用可以采用相对地址调用或者给予寄存器的相对调用,所以这种指令不需要重定位,其是地址无关的。
  • 模块内部数据访问。显然指令不能包含数据的绝对地址,那么只有进行相对寻址。因为一个模块前面一半是若干个页的代码,然后是若干个也的数据,这些页之间的相对位置是固定的,即任何一条指令与它所需要访问的模块颞部数据之间的相对位置是固定的,那么只需要相对当前指令加上固定的偏移量就可以访问模块内部数据了。现代的体系结构中,数据的相对寻址往往没有相对当前指令地址(PC)的寻址方式,ELF中使用了巧妙的办法获取当前的PC值,然后再加上一个偏移量达到访问相应变量的目的。
  • 模块间数据访问。模块间的数据访问目标地址要等到装载时才能确定,这些变量的地址跟模块的装载地址相关。ELF在数据段里建立一个指向这些变量的指针数组,称为全局偏移表(GOT, Global Offset Table),当代码需要引用该全局变量时,可以通过GOT中相对应的项间接引用。当指令需要一个其他模块的变量时,程序会先找到GOT,然后根据GOT中变量对应的项找到该变量的目标地址。每个变量对应一个4字节的地址,链接器在装载模块的时候会查找每个变量所在的地址,然后填充GOT的各个项,以确保每个指针所指向的地址都正确。由于GOT本身放在数据段,它可以在被模块装载时修改,并且每个进程都可以有独立的副本,相互不受影响。
  • 模块间调用、跳转。采用上述类似的方法,不同的是,GOT中相应保存的是目标函数的地址,当模块需要调用目标函数时,可以通过GOT中的项进行间接跳转。调用一个函数时,先得到当前指令地址PC,然后加上一个偏移得到函数地址在GOT中的偏移,然后进行间接调用。

于是,四种地址引用方式在理论上都实现了地址无关性。

数据段地址无关性

以上的方法能够保证共享对象中代码部分地址无关,但数据部分并不是地址无关的,比如:

static int a;
static int* p = &a;

指针p的地址是绝对地址,指向变量a,但a的地址会随着共享对象的装载地址改变而变。
数据段在每个进程都有一份独立的副本,并不担心被进程改变,于是可以选择装载时重定位的方法来解决数据段中绝对地址引用的问题。对于共享对象来说,如果数据段中有绝对地址的引用,那么编译器和链接器会产生一个重定位表,这个表中包含了”R_386_RELATIVE”类型的重定位入口来解决上述问题。当动态链接器装载共享对象时,如果发现共享对象上有这样的重定位入口,就会对该共享对象进行重定位。
其实对代码段也可以使用装载时重定位而不是地址无关代码的方法,它有以下特点:
代码段不是地址无关,不能被多个进程共享,失去了节省内存的有点。
运行速度比地址无关代码的共享对象块,因为它省去了地址无关代码中每次访问全局数据和函数时都要做一次计算当前地址以及间接地址寻址的过程。

动态链接相关结构

动态链接下可执行文件的装载与静态链接下基本一样,首先操作系统会读取可执行文件的头部,检查文件的合法性,然后从头部中的”Program Header”中读取每个”Segment”的虚拟地址、文件地址和属性,并将它们映射到进程虚拟空间的相应位置,这些步骤跟前面的静态链接情况下的装载基本无异。在静态链接情况下,操作系统接着就可以把控制权交给可执行文件的入口地址,然后程序开始执行。但在动态链接情况下,操作系统会先启动一个动态链接器,动态链接器得到控制权后,开始执行一系列自身的初始化操作,然后根据当前的环境参数,开始对可执行文件进行动态链接工作。当所有动态链接工作完成以后,动态链接器会将控制权转交到可执行文件的入口地址,程序开始正式执行。

动态链接涉及到的段主要如下:

  • “.interp”段。在Linux中,操作系统在对可执行文件进行加载时,会寻找装载该可执行文件需要的相应的动态链接器,即”.interp”段指定的路径的共享对象。
  • “.dynamic”段。动态链接ELF中最重要的结构,保存了动态链接器需要的基本信息,比如依赖于哪些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等。”.dynamic”段保存的信息类似于ELF文件头,只是ELF文件头保存的是静态链接相关的内容,这里换成动态链接所使用的相应信息。
  • 动态符号表。ELF中专门保存符号信息的段为”.dynsym”。类似于”.symtab”,但”.dynsym”只保存与动态链接相关的符号,而”.symtab”则保存了所有的符号,包括”.synsyms”中的符号。同样地,动态符号表也需要一些辅助的表,如保存符号名的字符串表,静态链接时叫符号字符串表”.strtab”,在这里就是动态符号字符串表”.dynstr”(Dynamic String Table)。为了加快动态链接下程序符号查找的过程,往往还有扶着的符号哈希表”.hash”。动态链接符号表的结构与静态链接的符号表几乎一样,可以简单地将导入函数看做是对其他目标文件函数的引用,把导出函数看做是在本目标文件定义的函数即可。
  • 动态链接重定位表。动态链接下,可执行文件一旦依赖于其他共享对象,它的代码或数据中就会有对于导入符号的引用,这些导入符号的地址在运行时才确定,所以需要在运行时将这些导入符号的引用修正,即需要重定位。如果共享对象不是以PIC编译的,那么它需要在装载是被重定位;如果它是PIC编译的,虽然代码段不需要重定位,但是数据段还包含了绝对地址的引用,其绝对地址被分离出来成了GOT,而GOT是数据段的一部分,需要重定位。
    装载时重定位跟静态链接中的目标文件重定位十分相似。静态链接中,目标文件里包含专门用于重定位信息的重定位表,如”.rel.txt”表示代码段的重定位表,”.rel.data”表示数据段的重定位表。类似地,动态链接中,重定位表分别为”.rel.dyn”和”.rel.plt”,前者是对数据引用的修正,修正的位置位于”.got”以及数据段,后者是对于函数引用的修正,修正的位置位于”.got.plt”。

动态链接的步骤

动态链接的步骤基本上分为3步:启动动态链接器本身,然后是装载所有需要的共享对象,最后是重定位和初始化。

  1. 动态链接器自举。普通共享对象文件的重定位工作由动态链接器完成,动态链接器本身本身不可以依赖于其他共享对象,其重定位工作由其自身完成,这需要动态链接器在启动时有一段非常精巧的代码可以完成这项艰巨的工作而同时不能用到全局和静态变量,甚至不能调用函数,这种具有一定限制的启动代码称为自举(Bootstrap)。
    动态链接器获得控制权后,自举代码开始执行。自举代码首先找到自己的GOT,而GOT的第一个入口即是”.dynamic”段的偏移地址,由此找到了动态链接器本身的”.dynamic”段。通过”.dynamic”的信息,自举代码可以获得动态链接器本身的重定位表和符号表,从而得到动态链接器本身的重定位入口,先将他们全部重定位,然后动态链接器代码可以使用自己的全局变量和静态变量。
  2. 装载共享对象。自举完成后,动态链接器将可执行文件盒链接器本身的符号表合并到一个全局符号表中,然后开始寻找可执行文件依赖的共享对象。通过”.dynamic”段中类型的入口是DT_NEEDED的项,链接器可以列出可执行文件所依赖的所有共享对象,将他们的名字放入一个装载集合中。然后从集合中取出一个共享对象的名字,找到相应的文件后打开,读取相应的ELF文件头”.dynamic”段,然后将它相应的代码段和数据段映射到进程空间。如果这个ELF共享对象还依赖其他共享对象,则将所依赖的共享对象的名字放入装载集合中。如此循环把所有依赖对象都装载进内存为止。如果把依赖关系看做一个图的话,装载过程就是图的遍历过程,可以使用广度优先或深度优先搜索的顺序进行编译。
  3. 重定位和初始化。上述步骤完成后,链接器开始重新遍历可执行文件和每个共享对象的重定位表,将他们的GOT/PLT中的每个需要重定位的位置进行修正。因为此时动态链接器已经拥有了进程的全局符号表,所以这个修正过程比较容易,和前面的地址重定位原理基本相同。
    重定位完成后,如果共享对象有”.init”段,那么动态链接器会执行”.init”段的代码,用来实现共享对象特有的初始化过程,比如共享对象中C++的全局/静态对象的构造。相应地,如果有”.finit”段,当进程退出时会执行”.finit”段中的代码,比如类似的C++全局对象的析构。而进程的可执行文件本身的的”.init”和”.finit”段不是由动态链接器执行,而是有运行库的初始化部分代码负责执行。

重定位和初始化后,准备工作宣告完成,所需要的共享对象也都已经装载并且链接完成,这是动态链接器就如释重负,将进程的控制权交给程序的入口并开始执行。

显式运行时链接

动态链接还有一种更加灵活的模块加载方式,称为显式运行时链接(Explicit Run-time Linking),也叫运行时加载。就是让程序自己在运行时控制加载指定的模块,并且可以在不需要该模块时将其卸载。一般的共享对象不需要进行任何修改就可以进行运行时加载,称为动态装载库(Dynamic Loading Library)。动态库的装载通过以下一系列的动态链接器API完成:

  • dlopen:打开一个动态库,加载到进程的地址空间,完成初始化过程。
  • dysm:通过指定的动态库句柄找到制定的符号的地址。
  • dlerror:每次调用dlopen()、dlsym()或dlclose()以后,可以调用dlerror()来判断上一次调用是否成功。
  • dlclose:将一个已经加载的模块卸载。系统会维持一个加载引用计数器,每次使用dlopen()加载时,计数器加一;每次使用dlclose()卸载时,计数器减一。当计数器减到0时,模块才真正地卸载
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值