Linux启动

从磁盘启动Linux内核需要一个引导装入程序,常见的是LILO。LILO被分为部分(否则太大无法装入整个扇区),BIOS将程序的第一部分(在引导扇区)装入从0x00007c00开始位置的RAM中,然后这段程序又把自己移到地质0x00096a00,建立实模式栈(0x00096000 ~ 0x000969ff),并把LILO的第部分装到从地址0x00096c00开始的RAM中。

第二部分从磁盘读取可用操作系统的映射表,并提供给用户一个提示符,供用户选择。然后根据用户选择引导程序就可以把相应分区的引导扇区拷贝到RAM中并执行它,或直接把内核映像拷贝到RAM中。

导入内核映像主要执行以下步骤:

a、调用一个BIOS过程显示“Loading”信息。

b、调用BIOS过程从磁盘装入内核映像的初始部分,即将内核映像的第一个512字节从地址0x00090000开始存入RAM中,而将setup()函数的代码从0x00090200开始存入RAM中。

header.S

...

 .code16
        .section ".bstext", "ax" #注意这个节的名称

        .global bootsect_start
bootsect_start:

 #这是bootsect代码,也就是vmlinuz第一个512字节的源代码,即内核映像的第一个512字节

       # Normalize the start address
        ljmp        $BOOTSEG, $start2

 

# 0x07C0:0000 如果从这里开始执行,那么说明是被BIOS直接加载过来的,这是不允许的,因为现在linux需要一个bootloader,这也就是上面说的bootsect有点特殊的地方,就是说它并没打算用来执行。所以万一它被作为bootsect由BIOS直接执行,那么就直接提示reboot.

Header.s中定义了三个节.bstext,.bsdata,.header,这3个节共同构成了上vmlinuz的第一个512字节。

c、同样的方式装入剩余的内核映像,并把内核映像放入从低地址0x00010000(适用于make zImage编译的小内核映像)或者从高地址0x00100000(适用于 bzImage编译的大内核映像)开始的RAM中。这段代码是保护模式的代码。

d、跳转到setup()代码。

以下是GRUB将内核映像加载至内存空间后的分布图:


图1


实模式下内核的初始化变量——安装头(setup header)



该安装头为从Kernel boot sector中偏移0x1f1处开始的hdr变量,主要存放初始化期间将会使用到的一些数据。我将把该变量中各个字段的含义集中罗列在这里,在后文中讲到内核执行初始化过程中使用到这些数据时不再单独详细描述。另外要注意的是有些字段的存在同样属于历史遗留性问题,对于这些内容我们直接一带而过。下表是这些字段的概要说明:

注:上表中由红色标识的字段已被废弃,由绿色标识的字段不再建议使用,而是统一在命令行中设置

下面单独列出其中某些重要字段更详细的解释,注意所有字段都以小端法存放,并且其中一些字段存放由引导加载程序从内核中读出的信息(即类型为可读),另外一些字段则由引导加载程序填充(类型为可写),其他的字段则由引导加载程序做适当的修改(类型为可修改)。

setup_sects——占用1个字节,类型为可读,表示Kernel setup所占用的物理内存大小,且以一个512字节的扇区为单位。为了保持后向兼容,如果该字段被赋值为0,那么实际的值是4。实模式代码由boot sector(总是占用一个512字节的扇区大小)加上Kernel setup代码组成。
syssize——占用4个字节,类型为可读,该字段表示保护模式代码的尺寸,大小以16字节的小段为单元。但对于现在的Linux内核来说,在进行引导配置时仅使用其中的两个字节——两个高位字节不再可用,因此如果LOAD_HIGH标志被置位那么该字段不能被认为是内核的大小。
jump——占用2个字节,类型为可读。该字段包含x86架构下的跳转指令,0xEB(跳转指令的字节码)后跟一个相对于地址0x202的有符号偏移,这个字段能被用来决定安装头的大小。
header——占用4个字节,类型为可读,包含魔数"Hdrs"(0x53726448)。如果该魔数没有在偏移0x202处设置,那么启动协议的版本被认为是旧的,因此装载一个老的内核。但我们在源文件header.S中清晰地看到了该标签,因此总是加载bzImage内核映像。并且header字段之后的version字段包含协议的版本,例如若version字段被设置为0x0204则代表使用2.04版本的协议,在源文件中该字段被设置为0x020a,因此表示使用最新的2.10版本的协议。
kernel_version——占用2个字节,类型为可读,如果该字段被设置为非零值,则表示一个指向以NULL结尾的含有内核版本号的字符串的指针。这能够被用来向用户展示内核版本。字段值应小于0x200*setup_sects。
type_of_loader——占用1个字节,类型为可写,该字段与ext_loader_type以及ext_loader_ver字段联合起来表示所使用的bootloader的类型及其版本号,由于x86架构下始终使用的是GRUB,因此这里我们不再继续深究,具体细节可参考Documentation\x86目录下的boot.txt文件。
loadflags——占用1个字节,类型为可修改,这个字段是一个位掩码(bitmask)。第0位(只读):LOADED_HIGH,如果该位置0,那么保护模式下的代码被加载至0x1 0000处,若复位则加载至0x10 0000。第5位(可写):QUIET_FLAG,如果置0则打印早期信息,如果复位则禁止早期信息。第6位(可写):KEEP_SEGMENTS,如果该位置0那么在32位入口点处重新加载段寄存器,如果复位那么不会重新加载。第7位(可写):CAN_USE_HEAP,将该位置1指示字段heap_end_ptr中的值有效,如果这个位被清除,那么一些Kernel setup代码将无法执行。其中最重要的是第0位与第7位。
code32_start——占用4个字节,类型为可修改。表示在保护模式中的跳转地址,其值默认为内核的加载地址,同时能够用来被bootloader决定合适的加载地址。修改这个字段是出于以下两个目的:①作为引导加载程序的钩子(a boot loader hook),②如果没有安装钩子的bootloader将一个可重定位的内核加载至非标准的地址,那么bootloader将会修改这个字段以指向加载地址。
ramdisk_image/ramdisk_size——均占用4个字节且类型为可写。这两个字段主要指示initrd的32位线性地址及其尺寸,若不使用initrd则这两位均为0。此外还有一个名为ramdisk_max的字段,同样占用4个字节但类型为可读,表示initrd可用物理内存的最大地址。我们在前文中提到过,initrd主要是由GRUB的次引导程序载入内存,实现一些模块的加载及文件系统的安装。
heap_end_ptr——占用2个字节,类型为可写。将这个字段设置为Kernel setup中堆栈结尾处距离实模式代码起始部分的偏移减去0x200后的值。
cmd_line_ptr——占用4个字节,类型为可写。将这个字段设置为内核命令行的线性地址。内核命令行能够被定位至Kernel setup中堆的结尾处至物理地址0xA 0000之间的任何位置,正如实模式代码自身一样,内核命令行同样不一定需要被放置在同一个64KB段中。即使引导加载程序不支持命令行,也需要填充这个字段,在这种情形下可以将其指向一个空字符串。但如果这个字段被置为0,那么内核将假设引导加载程序不支持2.02以上版本的协议(当前所使用的是2.10版本的协议)。
kernel_alignment——占用4个字节,类型为可读可修改。若relocatable_kernel字段被设置为真,那么这个字段是由内核要求的对齐单元。一个可重定位内核当被加载至对齐方式与当前字段不兼容的地址处,那么在内核的初始化过程中将会被重新对齐。在允许更小对齐的情况下,这个字段可以由引导加载程序修改。
relocatable_kernel——占用1个字节,类型为可读。如果这个字段非零,内核的保护模式部分能够被加载至满足kernel_alignment字段的任意地址处。完成加载之后bootloader将会设置code32_start字段以指向被加载的代码,或是bootloader钩子。
min_alignment——占用1个字节,类型为可读。这个字段如果非零那么作为2的幂指示最小对齐要求。如果引导加载程序使用了这个字段,它也应该更新kernel_alignment字段,更新方式为:kernel_alignment=1<<min_alignment。
cmdline_size——占用4个字节,类型为可读。这个字段表示不考虑结束符0在内的命令行的最大尺寸。这移位这命令行最多能够包含cmdline_size个字符。
hardware_subarch/hardware_subarch_data——分别占用4个及8个字节,且类型均为可写,这个字段允许bootloader通知内核现在所处的硬件环境。
payload_offset——占用4个字节,类型为可写。如果非零那么这个字段包含从保护模式代码的起始地址到负载(payload)的偏移量。负载应该被压缩,压缩和非压缩的数据都应该使用标准的魔数来决定。当前所支持的压缩格式分别是:gzip(魔数为1F 88或1F 9E),bzip2(魔数为42 5A),LZMA(魔数为5D 00)以及XZ(魔数为FD 37)。非压缩负载的格式至今总是ELF(魔数为7F 45 4C 46)。下一个字段payload_length指示负载的长度。
setup_data——占用8个字节,类型为可写。这个字段是一个指向节点为setup_data结构体且以NULL结尾的链表的64位物理指针。它被用来定义可扩展的启动参数传递机制。
pref_address——占用8个字节,类型为可读。这个字段如果非零,则其值为内核首选的加载地址。一个可重定位的bootloader应该尽可能试图将内核加载至此处。一个不可重定位的内核则无条件移动其自身并从该地址处开始运行。
init_size——占用4个字节,类型为可读。这个字段指示了在内核能够检测内存映射之前它所需要的线性连续内存的总量,这段连续内存起始于内核运行时的开始地址。它能够被用来帮助可重定位的引导加载程序为内核选择一个安全的加载地址。

 

建立堆栈——准备C语言的运行环境



在前文中提到过,GRUB在完成一系列工作之后通过执行一个长跳转指令进入内核的入口点,该入口点位于从实模式内核起始的偏移量0x200处。这意味着如果实模式内核代码在地址0x9 0000处,内核的入口点则为0000:9020。在起始处,ds/es/ss寄存器应该指向实模式内核代码的开始处,即如果代码被加载至0x9 0000处时这些寄存器的值被置为0x9000(注意这一点很重要!),栈指针寄存器sp一般指向堆的顶部,并且中断被禁用。此外为了防范错误,在有些引导加载程序中将把fs/gs/ds/es/ss寄存器均设为相同的值。通常引导加载程序的典型设置方式如下所示:

/*段基址由特定的引导加载程序而定,在x86架构下始终使用GRUB*/
/*因此seg被设置为0x9000*/
seg = base_ptr >> 4;  
 
/*禁用中断*/
cli();
 
/*设置实模式内核栈 */
_SS = seg;
_SP = heap_end;
 
/*将DS/ES/FS/GS寄存器设为段基址值*/
_DS = _ES = _FS = _GS = seg;
 
/*执行长跳转将控制权转交内核*/
/*从header.S的_start全局标号处开始执行*/
jmp_far(seg+0x20, 0);
以下是紧跟全局标号_start的头两个字节的内容:

    .globl    _start
_start:
        # Explicitly enter this as bytes, or the assembler
        # tries to generate a 3-byte jump here, which causes
        # everything else to push off to the wrong offset.
 
        /*跳转指令,对应于安装头变量中的jump字段*/
        .byte    0xeb        # short (2-byte) jump
        .byte    start_of_setup-1f
 
1:  /*标号1*/
 
    # Part 2 of the header, from the old setup.S
这里.byte 0xeb与.byte start_of_setup-1f是汇编指令jmp start_of_setup-1f的硬编码形式,其中的跳转为短转移,因此start_of_setup-1f所在的字节表示偏移量。因为汇编指令经过汇编器的“翻译”之后所形成的均为如上形式的字节码,而CPU本质上并不对数据和指令严格区分,因此可以通过对数据进行精心构造,使其表面上看起来是被处理的数据,但本质上却是可以被用来执行的指令。说到这里还想扯句题外话,我们经常说一个可执行文件被感染了,通常就是指该文件的内部构造被修改了,因为对任何文件都可以进行写操作,所以该可执行文件自然可以由某个不怀好意的进程增加一些新的数据,而这些数据正是被精心构造好的指令,在该文件此后的执行过程中发生的原本不可能存在的一系列操作都是这些数据的“功劳”,而这种方式正是通常所说的“区段注入”,这些被感染的文件自然也就成了所谓的病毒或是木马,另外在某些形式的缓冲区溢出攻击中也利用了这一点。其实这一特点还会催生很多有意思的话题,比如可执行文件对自身进行修改,在执行过程中进行自我进化——有些《黑客帝国》的味道 :-)。


继续回到内核的剖析上来,我们在之前说过上面这两个字节的硬编码执行的是jmp跳转指令的功能,而如果直接写jmp start_of_setup-1f形式的汇编指令,汇编器最终也能生成执行相同功能的字节码,那为什么不直接写汇编指令而偏要费那么大劲,去精心构造这两个字节的数据呢?其实上面的注释已经给出了详细的解答,因为汇编器生成的字节码最终将会占用3个字节,这使得后续的字段都被“推移”到错误的偏移处,所以我们只能通过硬编码的形式来实现跳转。另外在GAS汇编中还有一个重要的知识点,那就是在start_of_setup-1f中的1f并不表示十六进制的数据0x1f,其中的1表示一个标号,紧跟着的f表示向前的(forward),而如果要向后面的标号1跳转,则应该写成1b(b—backward)。.byte start_of_setup-1f这个字节的值表示两个标号——即start_of_setup与1之间的偏移量,由汇编器在汇编过程中自动填充。因为在汇编中的分支及循环语句只能通过jmp及其各种不同的变体来实现,因而跳转指令所跳转到的位置必须赋予一个标号,如果每个标号都取一个具有特定意义的名称将会very painful,因而GNU assembler中的这一特性对于汇编爱好者来说无疑very absorbing。我们在源文件中可以看到其中的大部分标号都仅仅只是一些没有含义的数字,足以说明维护Linux内核代码的这些hackers是十足的懒人 :-)。


接着跳转到start_of_setup标号处:

    .section ".entrytext", "ax"
start_of_setup:
#ifdef SAFE_RESET_DISK_CONTROLLER
# Reset the disk controller.
    movw    $0x0000, %ax        # Reset disk controller
    movb    $0x80, %dl        # All disks
    int    $0x13
#endif
 
# Force %es = %ds  /*强制将ds寄存器的内容赋值给es寄存器*/
    movw    %ds, %ax
    movw    %ax, %es  /*注意此时ax寄存器的内容与ds寄存器相同*/
    cld  /*清除方向标志,使用在串传送指令中,表示在完成传送后将di寄存器自动增加*/
在start_of_setup标号之后紧跟着的又是一个历史遗留的产物。在上述代码中我们看到如果预定义了宏SAFE_RESET_DISK_CONTROLLER,那么将调用BIOS中断例程0x13重置磁盘控制器。而这仅仅只是针对老式硬盘的代码,目前的硬盘并不需要执行这些指令,留着它仅仅是为了兼容老式硬盘,因此内核文件中并未预定义这个宏。

# Apparently some ancient versions of LILO invoked the kernel with %ss != %ds,
# which happened to work by accident for the old code.  Recalculate the stack
# pointer if %ss is invalid.  Otherwise leave it alone, LOADLIN sets up the
# stack behind its own code, so we can't blindly put it directly past the heap.
        /*注意之前已将ds寄存器中的值赋予ax寄存器*/
    movw    %ss, %dx  /*将ss寄存器赋予dx寄存器*/
    cmpw    %ax, %dx    # %ds == %ss?  /*比较ds寄存器与ss寄存器是否相等*/
    movw    %sp, %dx  /*将栈指针寄存器sp的值赋给dx寄存器*/
        
        /*若ds寄存器的值与ss相等则跳转至标号2处,即说明sp寄存器已被合理设置*/
        je    2f        # -> assume %sp is reasonably set
 
        /*反之则说明ss寄存器无效,建立一个新的栈*/
    # Invalid %ss, make up a new stack
    movw    $_end, %dx  /*将Kernel setup的结束地址装入dx寄存器*/
    testb    $CAN_USE_HEAP, loadflags  /*位测试操作的结果为真,不发生跳转*/
    jz    1f
    movw    heap_end_ptr, %dx  /*heap_end_ptr = _end+STACK_SIZE-512*/
1:    addw    $STACK_SIZE, %dx  /*将heap_end_ptr的值加上STACK_SIZE,即栈的大小*/
    jnc    2f
    xorw    %dx, %dx    # Prevent wraparound
首先解释一下注释——有些旧版本的引导加载程序LILO在装载完内核并将控制权转交给内核后,寄存器ss与ds并不相等。并且此时ss寄存器无效,因而需要重新计算栈指针,建立新栈的过程是由上述代码中跳转指令je  2f与标号2之间所执行的指令完成的。建立新栈时首先执行movw  $_end, %dx指令将_end的值赋给dx寄存器,这里_end是在汇编时由汇编器自动填充的,它的值正是Kernel setup与实模式内核代码起始地址的偏移量,由图1中我们可以看出其值最大为0x8000,在arch\x86\boot目录中的链接脚本setup.ld也验证了这一点:

    . = ASSERT(_end <= 0x8000, "Setup too big!");  /*第59行*/
上述语句断言当_end的值大于0x8000时,执行链接时将会报错,提示“Kernel setup太大”。接着执行testb $CAN_USE_HEAP, loadflags指令,测试在loadflags字段中是否已经置位CAN_USE_HEAP所指示的位,这两个操作数在源文件中的定义如下:

    
loadflags:
LOADED_HIGH    = 1            # If set, the kernel is loaded high
CAN_USE_HEAP    = 0x80        # If set, the loader also has set
                    # heap_end_ptr to tell how much
                    # space behind setup.S can be used for
                    # heap purposes.
                    # Only the loader knows what is free
        .byte    LOADED_HIGH  /*被设置为LOADED_HIGH*/
我们发现loadflags字段的值被设置为LOADED_HIGH,因此由注释看出保护模式下的内核将被加载至起始地址0x10 0000处。然而根据1=0000 0001b可知这个字段的第7位并未被置1,但是其注释同样指出只有在第7位置1时才表示内核会使用堆,那么loadflags字段中的这一位究竟是否会被置1?答案是肯定的,因为Linux内核从启动协议版本号2.01开始,至今一直都支持实模式下的堆,所以根据注释可以猜测正是bootloader在加载内核时将这一位设置成1。于是testb $CAN_USE_HEAP, loadflags指令最后的运算结果为1,从而不发生跳转,随后紧接着执行movw heap_end_ptr, %dx指令,这条指令将heap_end_ptr的值填充dx寄存器,其中heap_end_ptr的值为:

heap_end_ptr:    .word    _end+STACK_SIZE-512
                    # (Header version 0x0201 or later)
                    # space from here (exclusive) down to
                    # end of setup code can be used by setup
                    # for local heap purposes.
heap_end_ptr被设置为_end+STACK_SIZE+512,其中STACK_SIZE的值在arch\x86\boot\Boot.h文件中被定义如下:

#define STACK_SIZE    512    /* Minimum number of bytes for stack */
可以发现heap_end_ptr的值其实就等价于_end,而_end的值为Kernel setup的结束地址。接着执行标号1后的指令addw $STACK_SIZE, %dx,其中STACK_SIZE表示整个栈的大小,在实模式下,512字节的内存被分配给堆和栈同时使用完全足够。接着执行jnc  2f指令,由于标志位没有发生进位,因此直接跳转至标号2处,代码如下:

2:    # Now %dx should point to the end of our stack space
    andw    $~3, %dx    # dword align (might as well...)
    jnz    3f  /*测试条件为真,执行跳转*/
    movw    $0xfffc, %dx    # Make sure we're not zero
 
3:    movw    %ax, %ss  /* 实际执行 movw %ds, %ss */
    movzwl    %dx, %esp    # Clear upper half of %esp
        
        /*此时允许中断,该指令与GRUB中的cli对应*/
        sti            # Now we should have a working stack
首先执行andw $~3, %dx指令将dx寄存器中的最低两位清零,即将栈底地址执行双字对齐操作,使得加载数据的效率更高。之后执行jnz  3f指令,由于上一条指令执行后的结果非零,因此跳转。在标号3后首先执行movw %ax, %ss指令,将ax寄存器赋值给ss,这里注意我们在一开始跳转至start_of_setup标号处执行时,曾将ax寄存器用来暂存ds寄存器的值,而此后的执行过程中该寄存器的值一直都未发生改变,因此这条指令实际是将ds寄存器的值赋值给了ss寄存器。紧接着执行movzwl  %dx, %esp将栈底地址赋值给esp寄存器从而完成堆栈的建立,将栈指针寄存器esp赋值为栈底地址表明初始时刻栈为空。其后的sti指令则打开中断,执行该指令是因为在将控制权转交给实模式下的内核代码之前,GRUB执行了cli()禁用中断的操作,因此完成堆栈的建立之后需要再次打开中断。上图所执行的一系列指令最终可由下图形象的显示:


图3
完成堆栈的建立操作后,还需对cs:eip执行相关的修正操作,具体指令如下所示:

# We will have entered with %cs = %ds+0x20, normalize %cs so
# it is on par with the other segments.
    pushw    %ds
    pushw    $6f
    lretw
6:
前两条pushw指令分别将ds寄存器的值以及标号6处的偏移量进行压栈,因为我们已经正确建立了堆栈,因此上述两条指令可以正常工作。此后执行lretw长跳转指令,它将先前压入堆栈的操作数——即ds寄存器以及标号6对应的偏移量分别弹出至cs寄存器和eip寄存器,此后从标号6处继续开始执行。之所以要执行这三条指令,是因为GRUB将内核装载至从地址0x9 0000开始的物理内存后,执行一条长跳转指令jmp_far(seg+0x20, 0)跳过了512字节大小的bootsect,该指令将cs寄存器的值设置为0x9020,而其他的一系列段寄存器ds/es/ss/fs/gs的值均指向起始地址0x9 0000处,因此在执行上述三条指令后将所有的段寄存器的值均设置为0x9000——即执行了前文所说的修正操作。


接着执行标号6之后的指令:

# Check signature at end of setup
    cmpl    $0x5a5aaa55, setup_sig
    jne    setup_bad  /*测试条件为假,不执行跳转*/
 
# Zero the bss
    movw    $__bss_start, %di  /*将bss段的起始地址加载至di寄存器中*/
    movw    $_end+3, %cx  /*将地址_end+3加载至cx寄存器中*/
    xorl    %eax, %eax  /*将eax寄存器清零*/
    subw    %di, %cx  /*将cx寄存器的值减去di寄存器的值,并将结果放入cx寄存器*/
    shrw    $2, %cx  /*将cx寄存器中的值右移两位,即将cx的值除以4,并将结果存入cx寄存器*/
    rep; stosl  /*执行串指令操作stosl,将eax中的值保存到es:edi指向的内存中,并且将edi自增4*/
 
# Jump to C code (should not return)
    calll    main  /*跳转到main函数中*/
首先比较setup_sig的值是否与0x5a5aaa55相等,若不等则跳转至setup_bad标号处,同样setup_sig的值是在链接的时候由链接器填充的,该值同样定义在arch\x86\boot\setup.ld链接脚本文件中:

    .signature    : {
        setup_sig = .;
        LONG(0x5a5aaa55)
    }
可以发现该值确实被定义为0x5a5aaa55,因此jne  setup_bad指令将不会发生跳转。接下来就是清空实模式下内核代码的bss段——该段是Kernel setup的最后一段内存空间,需要注意bss段与数据段的区别:bss段存放的是未初始化的全局变量和静态变量,而数据段存放的则是已初始化的全局变量和静态变量。首先将bss段的起始地址装载至di寄存器中并将eax清零,随后设置带前缀的串指令rep; stosl的循环次数,该循环次数存放在cx寄存器中,这里需要注意的是由于执行一次串指令将清除4个字节的内存空间,因此cx寄存器存放的应该是整个bss段占用的内存空间大小除以4之后的值,由于bss段的大小可能并非4的倍数,而除4之后会将余数舍去,因此为了保证将整个bss段所占内存全部清零,需要将Kernel setup的结束地址_end加3之后再存入寄存器cx中,即执行movw  $_end+3, %cx指令才能正确清空整个bss段,而非movw  $end, %cx。在将整个bss段全部清零之后,即执行call main指令跳转至C语言的main函数中,该函数主要执行一系列硬件检测及初始化操作,至于详细内容放在后文剖析。
————————————————
版权声明:本文为CSDN博主「随心随意随缘」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/jn1158359135/article/details/7436211

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值