操作系统ucore lab1

操作系统ucore lab1

Exercise1:理解通过make生成执行文件的过程。

Question1: 操作系统镜像文件ucore.img是如何一步一步生成的?(需要比较详细地解释Makefile中每一条相关命令和命令参数的含义,以及说明命令导致的结果) Answer:

在Makefile中生成ucore.img的代码是:

1    $(UCOREIMG): $(kernel) $(bootblock)
2   $(V)dd if=/dev/zero of=$@ count=10000
3  $(V)dd if=$(bootblock) of=$@ conv=notrunc
4  $(V)dd if=$(kernel) of=$@ seek=1 conv=notrunc

在执行Makefile时对应的输出为:

1 dd if=/dev/zero of=bin/ucore.img count=10000
2 10000+0 records in
3 10000+0 records out
4 5120000 bytes (5.1 MB) copied, 0.0540315 s, 94.8 MB/s
5 dd if=bin/bootblock of=bin/ucore.img conv=notrunc
6 1+0 records in
7 1+0 records out
8 512 bytes (512 B) copied, 0.000110706 s, 4.6 MB/s
9 dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc
10 138+1 records in
11 138+1 records out
12 70775 bytes (71 kB) copied, 0.000472623 s, 150 MB/s

从Makefile中可以看出要生成ucore.img首先要拥有kernel和bootblock两个可执行文件

所以继续在Makefile中找到kernel的相关代码段

1 kernel = $(call totarget,kernel)
2
3 $(kernel): tools/kernel.ld
4
5 $(kernel): $(KOBJS)
6    @echo + ld $@
7   $(V)$(LD) $(LDFLAGS) -T tools/kernel.ld -o $@ $(KOBJS)
8    @$(OBJDUMP) -S $@ > $(call asmfile,kernel)
9    @$(OBJDUMP) -t $@ | $(SED) '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > $(call symfile,kernel)
10
11 $(call create_target,kernel)

再结合前面的对kernel的一些声明:

# kernel

KINCLUDE    += kern/debug/ \
               kern/driver/ \
               kern/trap/ \
               kern/mm/

KSRCDIR     += kern/init \
               kern/libs \
               kern/debug \
               kern/driver \
               kern/trap \
               kern/mm

KCFLAGS     += $(addprefix -I,$(KINCLUDE))

$(call add_files_cc,$(call listf_cc,$(KSRCDIR)),kernel,$(KCFLAGS))

KOBJS   = $(call read_packet,kernel libs)

查看文件中的文件得出,生成kernel需要以下文件:

1 kernel.ld    init.o  readline.o  stdio.o     kdebug.o   kmonitor.o panic.o clock.o console.o intr.o  picirq.o   trap.o  trapentry.o  vectors.o  pmm.o  printfmt.o   string.o

根据已有的文件,kernel.ld已经存在,其他的.o文件则需要.c和.s文件通过gcc编译生成

生成init.o需要的命令:

1 gcc -Ikern/init/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/init/init.c -o obj/kern/init/init.o

生成其他.o文件的命令和init.o的方法类似。

生成这些.o文件后便可以生成kernel可执行文件

而除kernel外还需要生成bootblock

在Makefile中bootblock的相关代码:

# create bootblock
bootfiles = $(call listf_cc,boot)
$(foreach f,$(bootfiles),$(call cc_compile,$(f),$(CC),$(CFLAGS) -Os -nostdinc))

bootblock = $(call totarget,bootblock)

$(bootblock): $(call toobj,$(bootfiles)) | $(call totarget,sign)
    @echo + ld $@
    $(V)$(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 $^ -o $(call toobj,bootblock)
    @$(OBJDUMP) -S $(call objfile,bootblock) > $(call asmfile,bootblock)
    @$(OBJCOPY) -S -O binary $(call objfile,bootblock) $(call outfile,bootblock)
    @$(call totarget,sign) $(call outfile,bootblock) $(bootblock)

$(call create_target,bootblock)

结合文件夹中的文件可以看出要生成bootblock需要bootasm.o、bootmain.o、sign三个文件

生成bootasm.o的相关代码为:

gcc -Iboot/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootasm.S -o obj/boot/bootasm.o

其中相关参数的含义为:

ggdb 生成可供gdb使用的调试信息
-m32生成适用于32位环境的代码
-gstabs 生成stabs格式的调试信息
-nostdinc 不使用标准库
-fno-stack-protector 不生成用于检测缓冲区溢出的代码
-0s 位减小代码长度进行优化

生成sign的相关代码为:

gcc -Itools/ -g -Wall -O2 -c tools/sign.c -o obj/sign/tools/sign.o
gcc -g -Wall -O2 obj/sign/tools/sign.o -o bin/sign

这些都生成完毕之后就可以开始生成ucore.img

生成ucore.img过程中产生的信息:

dd if=/dev/zero of=bin/ucore.img count=10000
10000+0 records in
10000+0 records out
5120000 bytes (5.1 MB) copied, 0.0540315 s, 94.8 MB/s
dd if=bin/bootblock of=bin/ucore.img conv=notrunc
1+0 records in
1+0 records out
512 bytes (512 B) copied, 0.000110706 s, 4.6 MB/s
dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc
138+1 records in
138+1 records out
70775 bytes (71 kB) copied, 0.000472623 s, 150 MB/s

生成一个有10000个块的文件,每个块默认512字节,用0填充

dd if=/dev/zero of=bin/ucore.img count=10000

把bootblock中的内容写到第一个块

dd if=bin/bootblock of=bin/ucore.img conv=notrunc

从第二个块开始写kernel中的内容

dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc

Question2:
一个被系统认为是符合规范的硬盘主引导扇区的特征是什么?
Answer:

从sign.c中的:

if (size != 512) {
        fprintf(stderr, "write '%s' error, size is %d.\n", argv[2], size);
        return -1;
    }

可以看出,要求硬盘主引导扇区的大小时512字节

buf[510] = 0x55;
buf[511] = 0xAA;

可以看出,还需要第510个字节是0x55,第511个字节为0xAA,也就是说扇区的最后两个字节内容是0x55AA
Exercise2:使用qemu执行并调试lab1中的软件。
Question1:
从CPU加电后执行的第一条指令开始,单步跟踪BIOS的执行。
Answer:

根据附录中的说明, 修改 lab1/tools/gdbinit,

set architecture i8086
target remote :1234

在 lab1目录下,执行

make debug

这时GDB停在BIOS的第一条指令出,可能是由于不兼容的问题,无法正常显示出指令,只看得到地址为0xe05b,而指令码却显示为??

然后输入si单步执行,也是只能看到地址,但指令显示为??

所以使用

x/5i 0xffff0

Question2:
在初始化位置0x7c00设置实地址断点,测试断点正常。
Answer:

在gdbinit 中添加指令:

set architecture i8086
b *0x7c00
c
x/5i $pc
set architecture i386

然后运行Make debug

断点正常

Question3:
从0x7c00开始跟踪代码运行,将单步跟踪反汇编得到的代码与bootasm.S和 bootblock.asm进行比较。
Answer:

​ 在0x7c00处break,然后使用si和 x/i $pc 指令一行一行的跟踪,将得到的反汇编代码为:

0x00007c01 in ?? ()
(gdb) x/i $pc
=> 0x7c01:      cld    
(gdb) si
0x00007c02 in ?? ()
(gdb) x/i $pc
=> 0x7c02:      xor    %eax,%eax
(gdb) si
0x00007c04 in ?? ()
(gdb) x/i $pc
=> 0x7c04:      mov    %eax,%ds
(gdb) 

bootblock.asm 中的代码为:

.code16                                             # Assemble for 16-bit mode
    cli                                             # Disable interrupts
    7c00:   fa                      cli    
    cld                                             # String operations increment
    7c01:   fc                      cld    

    # Set up the important data segment registers (DS, ES, SS).
    xorw %ax, %ax                                   # Segment number zero
    7c02:   31 c0                   xor    %eax,%eax
    movw %ax, %ds                                   # -> Data Segment
    7c04:   8e d8                   mov    %eax,%ds
    movw %ax, %es                                   # -> Extra Segment
    7c06:   8e c0                   mov    %eax,%es
    movw %ax, %ss                                   # -> Stack Segment
    7c08:   8e d0                   mov    %eax,%ss

bootasm.s中的代码为:

.globl start
start:
.code16                                             # Assemble for 16-bit mode
    cli                                             # Disable interrupts
    cld                                             # String operations increment

    # Set up the important data segment registers (DS, ES, SS).
    xorw %ax, %ax                                   # Segment number zero
    movw %ax, %ds                                   # -> Data Segment
    movw %ax, %es                                   # -> Extra Segment
    movw %ax, %ss                                   # -> Stack Segment

以上列举了一小段,其他的部分类似。

根据比较可知,这三部分内容是一样的。

Question4:
自己找一个bootloader或内核中的代码位置,设置断点并进行测试
Answer:

在init.c中会对gdt进行初始化,在初始化过程中,调用了gdt_init()这个函数,我选择此处进行测试。

可见已经成功在gdt_init处停止,

然后使用x/i $pc指令查询正在执行的汇编代码,与原文件中的c代码对照,测试结束。

Exercise3:分析bootloader进入保护模式的过程。
Answer:

从bootasm.s查看代码,并分析过程

cli 
cld
首先禁止中断,并且修改控制方向标志寄存器DF=0,使得内存地址从低到高增加

_注:_stl为允许中断发生,std使DF置位即令DF=1

然后对实模式下的段寄存器进行初始化
xorw %ax, %ax
movw %ax, %ds
movw %ax, %es
movw %ax, %ss
   

xorw %ax, %ax 将ax寄存器置为0,然后,对ds(数据段寄存器),es(额外寄存器),ss(栈寄存器)

1 然后将A20置为高位,这样32位的地址线就都可以用了,也就可以访问4G的内存空间

相关代码:
   seta20.1:
       inb $0x64, %al                                 
       testb $0x2, %al
       jnz seta20.1

       movb $0xd1, %al                                 # 0xd1 -> port 0x64
       outb %al, $0x64                                 

    seta20.2:
       inb $0x64, %al                                  
       testb $0x2, %al
       jnz seta20.2

       movb $0xdf, %al                                 # 0xdf -> port 0x60
       outb %al, $0x60          

2 接下来初始化GDT表:

  lgdt gdtdesc
GDT表已经在数据区声明:
# Bootstrap GDT

.p2align 2                                          # force 4 byte alignment
gdt:
   SEG_NULLASM                                     # null seg
   SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff)           # code seg for bootloader and kernel
   SEG_ASM(STA_W, 0x0, 0xffffffff)                 # data seg for bootloader and kernel

gdtdesc:
   .word 0x17                                      # sizeof(gdt) - 1
   .long gdt                                       # address gdt
然后将cr0置为1开启保护模式
 movl %cr0, %eax
       orl $CR0_PE_ON, %eax
       movl %eax, %cr0
CR0_PE_ON已经在前面声明为0x1
   .set CR0_PE_ON,             0x1 
通过段间跳转指令ljmp改变cs的基址
   ljmp $PROT_MODE_CSEG, $protcseg
PORT_MODE_CSGE已在数据段定义:
 .set PROT_MODE_CSEG,        0x8  
设置保护模式下的段寄存器
   movw $PROT_MODE_DSEG, %ax                       # Our data segment selector
   movw %ax, %ds                                   # -> DS: Data Segment
   movw %ax, %es                                   # -> ES: Extra Segment
   movw %ax, %fs                                   # -> FS
   movw %ax, %gs                                   # -> GS
   movw %ax, %ss                                   # -> SS: Stack Segment
PROT_MODE_DSEG为0x10

建立堆栈,并且调用bootmain.c
  movl $0x0, %ebp
       movl $start, %esp
       call bootmain
栈的地址是从0到0x7c00

Exercise4:分析bootloader加载ELF格式的OS的过程
Answer:

bootloader首先读取ELF头表:

  // read the 1st page off disk
    readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);

ELFHDR是前面定义的一个临时空间:

#define ELFHDR          ((struct elfhdr *)0x10000)      // scratch space

而uintptr_t是在defs.h中定义的:

typedef uint32_t uintptr_t;

这行代码的作用是,可以通过uintptr_t来代替uint32_t,而uint32_t是前文中,unsigned in的替替代品

typedef unsigned int uint32_t;

所以一层层推导下来,uintptr_t就是unsigned int。

这里是为了把ELF的头表读进去,传入readseg的三个参数分别表示,起始位置,大小,和偏移。

大小是SECTSIZE*8,SETCSIZE的大小是512:

#define SECTSIZE        512

这里读入了512*8个位,然而实际并不需要这么大,亲测只要一个SECTSIZE也就是512就足够,所以猜测可能是为了以后的扩展,所以在这里定义了这么大。

当头表读入完成后,就通过e_magic检测是否是合法的ELF文件:

   // is this a valid ELF?
    if (ELFHDR->e_magic != ELF_MAGIC) {
        goto bad;
    }

如不是就会跳入到bad执行指令,如果是就继续往下执行。

根据ELF头表中的描述,将ELF文件加载到内存中的相应位置保存到ph中;

  ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff);

将结束位置加载到eph中:

eph = ph + ELFHDR->e_phnum;(e_phnum是程序中的条目数)

然后就将ELF文件中的数据通过循环加载到内存中:

  for (; ph < eph; ph ++) {
        readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
    }

加载完成后根据ELF头表中存储的入口信息,找到内核的入口:

((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();

Exercise5:实现函数调用堆栈跟踪函数
Answer:

代码如下:

 uint32_t ebp = read_ebp();
    uint32_t eip = read_eip();
    int i,j;
    for(i = 0; ebp!=0 && i < STACKFRAME_DEPTH; i++) {
        cprintf("ebp:0x%08x eip:0x%08x ",ebp, eip);
        cprintf("args");
        uint32_t *args = (uint32_t *)ebp + 2;
        for(j = 0; j < 4; j++)
            cprintf("0x%08x ",args[j]); 
        cprintf("\n");
        print_debuginfo(eip-1);
        eip = *((uint32_t *)ebp+1);
            ebp = *((uint32_t *)ebp+0);
    }

详解:

调用read_ebp()和read_eip()函数得到ebp和eip当前的值:

   uint32_t ebp = read_ebp();
    uint32_t eip = read_eip();

然后在循环中输出所有的ebp和eip的值,由于ebp指向的位置存储着caller的ebp,所以可以不断地向上找到所有函数的ebp,eip也同理,eip总是存储在caller的ebp+4的位置,直到到达栈底或者长度超过STACKFRAME_DEPTH。

一开始由于注释中并没有提示到达栈底的情况,所以我是这样写的:

for(i = 0;  i < STACKFRAME_DEPTH; i++) 

这样写的结果就是没有及时的停止,会把栈外的信息也输出出来:

ebp:0x00007b08 eip:0x001009a6 args0x00010094 0x00000000 0x00007b38 0x00100092 
    kern/debug/kdebug.c:306: print_stackframe+21
ebp:0x00007b18 eip:0x00100c9b args0x00000000 0x00000000 0x00000000 0x00007b88 
    kern/debug/kmonitor.c:125: mon_backtrace+10
ebp:0x00007b38 eip:0x00100092 args0x00000000 0x00007b60 0xffff0000 0x00007b64 
    kern/init/init.c:48: grade_backtrace2+33
ebp:0x00007b58 eip:0x001000bb args0x00000000 0xffff0000 0x00007b84 0x00000029 
    kern/init/init.c:53: grade_backtrace1+38
ebp:0x00007b78 eip:0x001000d9 args0x00000000 0x00100000 0xffff0000 0x0000001d 
    kern/init/init.c:58: grade_backtrace0+23
ebp:0x00007b98 eip:0x001000fe args0x001032fc 0x001032e0 0x0000130a 0x00000000 
    kern/init/init.c:63: grade_backtrace+34
ebp:0x00007bc8 eip:0x00100055 args0x00000000 0x00000000 0x00000000 0x00010094 
    kern/init/init.c:28: kern_init+84
ebp:0x00007bf8 eip:0x00007d68 args0xc031fcfa 0xc08ed88e 0x64e4d08e 0xfa7502a8 
    <unknow>: -- 0x00007d67 --
ebp:0x00000000 eip:0x00007c4f args0xf000e2c3 0xf000ff53 0xf000ff53 0xf000ff53 
    <unknow>: -- 0x00007c4e --
ebp:0xf000ff53 eip:0xf000ff53 args0x00000000 0x00000000 0x00000000 0x00000000 
    <unknow>: -- 0xf000ff52 --
ebp:0x00000000 eip:0x00000000 args0xf000e2c3 0xf000ff53 0xf000ff53 0xf000ff53 
    <unknow>: -- 0xffffffff --
ebp:0xf000ff53 eip:0xf000ff53 args0x00000000 0x00000000 0x00000000 0x00000000 
    <unknow>: -- 0xf000ff52 --
ebp:0x00000000 eip:0x00000000 args0xf000e2c3 0xf000ff53 0xf000ff53 0xf000ff53 
    <unknow>: -- 0xffffffff --
ebp:0xf000ff53 eip:0xf000ff53 args0x00000000 0x00000000 0x00000000 0x00000000 
    <unknow>: -- 0xf000ff52 --
ebp:0x00000000 eip:0x00000000 args0xf000e2c3 0xf000ff53 0xf000ff53 0xf000ff53 
    <unknow>: -- 0xffffffff --
ebp:0xf000ff53 eip:0xf000ff53 args0x00000000 0x00000000 0x00000000 0x00000000 
    <unknow>: -- 0xf000ff52 --
ebp:0x00000000 eip:0x00000000 args0xf000e2c3 0xf000ff53 0xf000ff53 0xf000ff53 
    <unknow>: -- 0xffffffff --
ebp:0xf000ff53 eip:0xf000ff53 args0x00000000 0x00000000 0x00000000 0x00000000 
    <unknow>: -- 0xf000ff52 --
ebp:0x00000000 eip:0x00000000 args0xf000e2c3 0xf000ff53 0xf000ff53 0xf000ff53 
    <unknow>: -- 0xffffffff --
ebp:0xf000ff53 eip:0xf000ff53 args0x00000000 0x00000000 0x00000000 0x00000000 
    <unknow>: -- 0xf000ff52 --

发现问题后把代码改成了这样:

for(i = 0; ebp!=0 && i < STACKFRAME_DEPTH; i++)

这样就可以及时的停住:

ebp:0x00007b08 eip:0x001009a6 args0x00010094 0x00000000 0x00007b38 0x00100092 
    kern/debug/kdebug.c:306: print_stackframe+21
ebp:0x00007b18 eip:0x00100ca1 args0x00000000 0x00000000 0x00000000 0x00007b88 
    kern/debug/kmonitor.c:125: mon_backtrace+10
ebp:0x00007b38 eip:0x00100092 args0x00000000 0x00007b60 0xffff0000 0x00007b64 
    kern/init/init.c:48: grade_backtrace2+33
ebp:0x00007b58 eip:0x001000bb args0x00000000 0xffff0000 0x00007b84 0x00000029 
    kern/init/init.c:53: grade_backtrace1+38
ebp:0x00007b78 eip:0x001000d9 args0x00000000 0x00100000 0xffff0000 0x0000001d 
    kern/init/init.c:58: grade_backtrace0+23
ebp:0x00007b98 eip:0x001000fe args0x001032fc 0x001032e0 0x0000130a 0x00000000 
    kern/init/init.c:63: grade_backtrace+34
ebp:0x00007bc8 eip:0x00100055 args0x00000000 0x00000000 0x00000000 0x00010094 
    kern/init/init.c:28: kern_init+84
ebp:0x00007bf8 eip:0x00007d68 args0xc031fcfa 0xc08ed88e 0x64e4d08e 0xfa7502a8 
    <unknow>: -- 0x00007d67 --

最后一行,位于堆栈的底部,所以对应着第一个调用的函数,也就是bootmain.c中的bootmain。同时由于BootLoader设置的堆栈从0x7c00开始,当 call bootmain时,进入bootmain函数,将call指令压栈,bootmain中的ebp是0x7bf8。

Exercise6:完善中断初始化和处理
Question1:
中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几位代表中断处理代码的入口?
Answer:

中断描述符表中一个表项占8个字节,其中每个位的作用如图:
中断描述符表
其中015和4863分别为offset的低16位和高16位,16~31位是段选择子,通过段选择子得到段基址,再加上段内偏移量就可以得到中断处理代码的入口。
Question2:
请编程完善kern/trap/trap.c中对中断向量表进行初始化的函数idt_init。在idt_init函数中,依次对所有中断入口进行初始化。使用mmu.h中的SETGATE宏,填充idt数组内容。每个中断的入口由tools/vectors.c生成,使用trap.c中声明的vectors数组即可。
Answer:

代码如下:

  extern uintptr_t __vectors[];
    int i = 0;
    for(i = 0; i<sizeof(idt) / sizeof(struct gatedesc);i++) {
    SETGATE(idt[i], 0,KERNEL_CS, __vectors[i], DPL_KERNEL);
    }
     SETGATE(idt[T_SYSCALL], 0, KERNEL_CS, __vectors[T_SYSCALL], DPL_USER);
    lidt(&idt_pd);

代码详解:

extern uintptr_t __vectors[];   

这里将在此文件外的kern/trap/vector.S中定义的数组__vectors[]导入进来。那个数组的定义很长,在此不再细细描述。

   for(i = 0; i<sizeof(idt) / sizeof(struct gatedesc);i++) {
        SETGATE(idt[i], 0,KERNEL_CS, __vectors[i], DPL_KERNEL);
    }

在这里对整个idt数组进行初始化。对SETGATE的定义在mmu.h中:

#define SETGATE(gate, istrap, sel, off, dpl) {            \
    (gate).gd_off_15_0 = (uint32_t)(off) & 0xffff;        \
    (gate).gd_ss = (sel);                                \
    (gate).gd_args = 0;                                    \
    (gate).gd_rsv1 = 0;                                    \
    (gate).gd_type = (istrap) ? STS_TG32 : STS_IG32;    \
    (gate).gd_s = 0;                                    \
    (gate).gd_dpl = (dpl);                                \
    (gate).gd_p = 1;                                    \
    (gate).gd_off_31_16 = (uint32_t)(off) >> 16;        \
}
传入的第一个参数gate是中断的描述符表
传入的第二个参数istrap用来判断是中断还是trap
传入的第三个参数sel的作用是进行段的选择
传入的第四个参数off表示偏移
传入的第五个参数dpl表示这个中断的优先级

在这里先把所有的中断都初始化为内核级的中断

SETGATE(idt[T_SYSCALL], 0, KERNEL_CS, __vectors[T_SYSCALL], DPL_USER);

然后再把系统调用的中断初始化为用户级的中断。

lidt(&idt_pd)

最后把idt的位置告诉CPU

Question3:
1.请编程完善trap.c中的中断处理函数trap,在对时钟中断进行处理的部分填写trap函数中处理时钟中断的部分,使操作系统每遇到100次时钟中断后,调用print_ticks子程序,向屏幕上打印一行文字”100 ticks”。
Answer:

代码如下:

  ticks++;
    if(ticks%TICK_NUM == 0)
        print_ticks();
    break;

此处代码的意思就是在,每次时钟中断后ticks就加一,每次达到TICK_NUM就会调用print_ticks()语句,向屏幕上输出一句话。

运行结果截图:
在这里插入图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值