# 操作系统实验报告:ucore-lab1


参考链接:
1. GDB 常用命令参考手册
2. 一篇优秀的gdb的总结
3. lab1实验报告
4. 清华大学操作系统实验lab1实验报告
5. elf文件格式总结

练习一: 理解通过 make 生成执行文件的过程

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

1. GCC相关编译选项
GCC
    -g  增加gdb的调试信息
    -Wall   显示告警信息
    -O2     优化处理 (有 0,1,2,3,0是不优化)
    -fno-builtin   只接受以"__"开头的内建函数
    -ggdb   让gcc为gdb生成比较丰富的调试信息
    -m32    编译32位程序
    -gstabs     此选项以stabs格式生成调试信息,但是不包括gdb调试信息
    -nostdinc   不在标准系统目录中搜索头文件,只在-l指定的目录中搜索
    -fstack-protector-all   启用堆栈保护,为所有函数插入保护代码
    -E  仅做预处理,不进行编译,汇编和链接
    -x c  指明使用的语言为C语言

LDD Flags
    -nostdlib   不连接系统标准启动文件和标准库文件,只把指定的文件传递给连接器
    -m elf\_i386    使用elf_i386模拟器
    -N      把text和data节设置为可读写,同时取消数据节的页对齐,取消对共享库的链接
    -e func     以符号func的位置作为程序开始运行的位置
    -Ttext addr  是连接时将初始地址重定向为addr (若不注明此,则程序的起始地址为0)
2. 编译bootloader
用于加载Kernel操作系统

先把bootasm.S,bootmain.c编译成目标文件
再使用连接器链接到一起,使用start符号作为入口,并且指定text段在程序中的绝对位置是0x7C00, 
0x7c00 :这个是操作系统一开始加载的地址
//bootasm.o
+ cc boot/bootasm.S
gcc -Iboot/ -fno-builtin  -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootasm.S -o obj/boot/bootasm.o

//生成bootmain.o
+ cc boot/bootmain.c
gcc -Iboot/ -fno-builtin  -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Os -nostdinc -c boot/bootmain.c -o obj/boot/bootmain.o

//ld bin/bootblock
ld -m    elf_i386 -nostdlib -N -e start -Ttext 0x7C00 obj/boot/bootasm.o obj/boot/bootmain.o -o obj/bootblock.o

'obj/bootblock.out' size: 468 bytes
build 512 bytes boot sector: 'bin/bootblock' success!
3. 编译Kernel
操作系统本身

先把.c文件和.S汇编文件生成目标文件,之后使用链接起生成Kernel  
+ cc kern/init/init.c
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
+ cc kern/libs/readline.c
gcc -Ikern/libs/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/libs/readline.c -o obj/kern/libs/readline.o
+ cc kern/libs/stdio.c
gcc -Ikern/libs/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/libs/stdio.c -o obj/kern/libs/stdio.o
+ cc kern/debug/kdebug.c
gcc -Ikern/debug/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/debug/kdebug.c -o obj/kern/debug/kdebug.o
+ cc kern/debug/kmonitor.c
gcc -Ikern/debug/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/debug/kmonitor.c -o obj/kern/debug/kmonitor.o
+ cc kern/debug/panic.c
gcc -Ikern/debug/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/debug/panic.c -o obj/kern/debug/panic.o
+ cc kern/driver/clock.c
gcc -Ikern/driver/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/driver/clock.c -o obj/kern/driver/clock.o
+ cc kern/driver/console.c
gcc -Ikern/driver/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/driver/console.c -o obj/kern/driver/console.o
+ cc kern/driver/intr.c
gcc -Ikern/driver/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/driver/intr.c -o obj/kern/driver/intr.o
+ cc kern/driver/picirq.c
gcc -Ikern/driver/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/driver/picirq.c -o obj/kern/driver/picirq.o
+ cc kern/trap/trap.c
gcc -Ikern/trap/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/trap/trap.c -o obj/kern/trap/trap.o
+ cc kern/trap/trapentry.S
gcc -Ikern/trap/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/trap/trapentry.S -o obj/kern/trap/trapentry.o
+ cc kern/trap/vectors.S
gcc -Ikern/trap/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/trap/vectors.S -o obj/kern/trap/vectors.o
+ cc kern/mm/pmm.c
gcc -Ikern/mm/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/ -Ikern/debug/ -Ikern/driver/ -Ikern/trap/ -Ikern/mm/ -c kern/mm/pmm.c -o obj/kern/mm/pmm.o
+ cc libs/printfmt.c
gcc -Ilibs/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/  -c libs/printfmt.c -o obj/libs/printfmt.o
+ cc libs/string.c
gcc -Ilibs/ -fno-builtin -Wall -ggdb -m32 -gstabs -nostdinc  -fno-stack-protector -Ilibs/  -c libs/string.c -o obj/libs/string.o

+ ld bin/kernel
ld -m    elf_i386 -nostdlib -T tools/kernel.ld -o bin/kernel  obj/kern/init/init.o obj/kern/libs/readline.o obj/kern/libs/stdio.o obj/kern/debug/kdebug.o obj/kern/debug/kmonitor.o obj/kern/debug/panic.o obj/kern/driver/clock.o obj/kern/driver/console.o obj/kern/driver/intr.o obj/kern/driver/picirq.o obj/kern/trap/trap.o obj/kern/trap/trapentry.o obj/kern/trap/vectors.o obj/kern/mm/pmm.o  obj/libs/printfmt.o obj/libs/string.o
编译sign
用于生成一个符合规范的硬盘主引导扇区。
+ cc tools/sign.c
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
  • dd - 转换和拷贝文件

    if  代表输入文件。如果不指定if,默认就会从stdin中读取输入。 
    of  代表输出文件。如果不指定of,默认就会将stdout作为默认输出。 
    bs 代表字节为单位的块大小。 
    count 代表被复制的块数。 
    /dev/zero 是一个字符设备,会不断返回0值字节(\0)
    conv=notrunc    输入文件的时候,源文件不会被截断
    seek=blocks     从输出文件开头跳过 blocks(512字节) 个块后再开始复制
    
  • 过程
    生成一个空的软盘镜像,然后把bootloader以不截断的方式填充到开始的块中,然后kernel跳过bootloader所在的块,再填充

dd if=/dev/zero of=bin/ucore.img count=10000
dd if=bin/bootblock of=bin/ucore.img conv=notrunc
dd if=bin/kernel of=bin/ucore.img seek=1 conv=notrunc

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

从sign.c的代码来看,一个磁盘主引导扇区只有512字节。且 第510个(倒数第二个)字节是0x55,第511个(倒数第一个)字节是0xAA。

练习二: 使用 qemu 执行并调试 lab1 中的软件。

1. 从 CPU 加电后执行的第一条指令开始, 单步跟踪 BIOS 的执行。
2. 在初始化位置 0x7c00 设置实地址断点,测试断点正常。
3. 在调用 qemu 时增加-d in_asm -D q.log 参数,便可以将运行的汇编指令保存在 q.log 中。
将执行的汇编代码与 bootasm.S 和 bootblock.asm 进行比较, 看看二者是否一致。


qemu-system-i386
    -hda file  硬盘选项
    -parallel dev   重定向虚拟并口到主机设备。最多可虚拟3个并口
    -serial dev     重定向虚拟串口到主机设备
         vc:虚拟控制台
         pty:仅仅linux有效,虚拟tty(一个虚拟伪终端会被立刻分配)
         none:没有设备被分配
         null:无效设备
    -S   启动的时候不直接从CPU启动,需要在窗口中按c来继续
    -s   shorthand for -gdb tcp::1234,打开端口1234,供gdb来调试

gdb     
    -x 从文件中执行gdb命令
    -q 不要打印介绍和版权信息。
    -tui    可以将终端屏幕分成原文本窗口和控制台的多个子窗口,可以一边看源码一边调试
  • -S –s是使得qemu在执行第一条指令之前停下来,。然后sleep 两秒应该是给qemu充分的时间准备等待连接。接下来使用GDB调试工具, -tui提供了代码与命令行分屏查看的界面,tools/gdbinit中存放的是gdb调试
qemu-system-i386 -S -s -parallel stdio -hda bin/ucore.img -serial null &
sleep 2
gnome-terminal -e "gdb -q -tui -x tool/gdbinit"

#tools/gdbinit
target remote :1234 #链接远端端口1234
使用make debug,调用debug建立qemu和gdb的链接
x/10i $pc:显示程序当前位置开始往后的10条汇编指令。
(gdb) x/10i $pc
    => 0xfff0:  add    %al,(%eax)
       0xfff2:  add    %al,(%eax)
       0xfff4:  add    %al,(%eax)
       0xfff6:  add    %al,(%eax)
       0xfff8:  add    %al,(%eax)
       0xfffa:  add    %al,(%eax)
       0xfffc:  add    %al,(%eax)
       0xfffe:  add    %al,(%eax)
       0x10000: add    %al,(%eax)
       0x10002: add    %al,(%eax)
stepi 执行下一条汇编/CPU指令。
    (gdb) stepi
    0x0000e05b in ?? ()
    (gdb) x/5i $pc
    => 0xe05b:  add    %al,(%eax)
       0xe05d:  add    %al,(%eax)
       0xe05f:  add    %al,(%eax)
       0xe061:  add    %al,(%eax)
       0xe063:  add    %al,(%eax)
break *address  在指定的地址处设置断点。一般在没有源代码时使用。
    (gdb) b *0x7c00
    Breakpoint 1 at 0x7c00
continue(c) 继续执行直到下一个断点或观察点。
    (gdb) c
    Continuing.
    Breakpoint 1, 0x00007c00 in ?? ()

    (gdb) x/5i $pc
    => 0x7c00:  cli    
       0x7c01:  cld    
       0x7c02:  xor    %eax,%eax
       0x7c04:  mov    %eax,%ds
       0x7c06:  mov    %eax,%es
//tools/gdbinit"  
file obj/bootblock.o 
target remote :1234 
set architecture i8086 
b *0x7c00 
continue 
x /10i $pc

练习 三:分析 bootloader 进入保护模式的过程。

BIOS 将通过读取硬盘主引导扇区到内存,并转跳到对应内存中的位置执行 bootloader。 请分析bootloader 是如何完成从实模式进入保护模式的。
//在开启A20之前,BIOS还做了很多事:关中断、清除方向标志,给各个数据段清零。
.globl start
start:
.code16                 # Assemble for 16-bit mode
    cli                 # Disable interrupts
    cld                 # String operations increment

    xorw %ax, %ax       # Segment number zero
    movw %ax, %ds       # -> Data Segment
    movw %ax, %es       # -> Extra Segment
    movw %ax, %ss       # -> Stack Segment

seta20.1:
    inb $0x64, %al               # 等待8042键盘控制器不忙
    testb $0x2, %al
    jnz seta20.1

    movb $0xd1, %al             # 发送写8042输出端口的指令 
    outb %al, $0x64             

seta20.2:
    inb $0x64, %al              # 等待8042键盘控制器不忙
    testb $0x2, %al
    jnz seta20.2

    movb $0xdf, %al              # 打开A20
    outb %al, $0x60 
如何初始化GDT表? 
#把gdt表的起始位置和界限装入GDTR寄存器 

lgdt gdtdesc            #把gdt表的起始位置和界限装入GDTR寄存器 movl %cr0, %eax          orl $CR0_PE_ON, %eax    
movl %eax, %cr0         #把保护模式位开启


工作在保护模式下。复位PE将返回到实模式工作。
此外,gdtdesc指出了全局描述符表在符号gdt处,

上面四句话实现了打开保护模式位。 3、如何使能进入保护模式? 通过长跳转指令
    ljmp $PROT_MODE_CSEG, $protcseg 进入了保护模式。
进入保护模式之后还有一个步骤:把所有的数据段寄存器指向上面的GDT描述符表中的数据段(0x10)    

练习 四:分析 bootloader 加载 ELF 格式的 OS 的过程。(要求在报告中写出分析)

1. bootloader如何读取硬盘扇区的?

//首先是读取一个扇区(512字节),参数是扇区号和写入地址
static void
readsect(void *dst, uint32_t secno) {
    waitdisk();     // /* wait for disk to be ready */
    outb(0x1F2, 1);     // 设置读取扇区数的数目为1

    /*  下面连续四句是说把secno的32位写入设备ID寄存器,
        0~27 位是偏移量,
        第28位的是主副通道,剩下的29~31被强制置1*/
    outb(0x1F3, secno&0xFF);    
    outb(0x1F4, (secno>>8)&0xFF);
    outb(0x1F5, (secno>>16)&0xFF);
    outb(0x1F6, ((secno>>24)&0xF)|0xE0);    //设置第28位,29~31强制置1

    outb(0x1F7, 0x20);  //cmd 0x20 -读取磁盘 

    waitdisk(); //等待磁盘准备好

    /* 读到dst地址, 因为这里是以DW(双字)为单位,所以要/4*/
    /*    1个字节   1byte=8bit,   
          1个字 == 2个字节
          1个双字 == 2个字 == 4个字节 */
    insl(0x1F0, dst, SECTSIZE/4);
}
//从设备中读count个字节到va这个地址,offset是指想读的位置距离开始的偏移
static void
readseg(uintptr_t va, uint32_t count, uint32_t offset) {
    uintptr_t end_va = va + count;

    //计算当前偏移在那个扇区的位置,并且让va地址向前偏移这些字节,
    //然后读完之后,用户开始传入的地址va的内容就是偏移所在内容
    va -= offset % SECTSIZE;    

    // translate from btyes to sectors; kernel starts at sector 1
    uint32_t secno = (offset/ SECTSIZE) +1 ;    //计算偏于所在的扇区,kernel是在起始就是第一个扇区


    for (; va < end_va; va += SECTSIZE, secno++) (
    readsect((void *)va, secno);
    )
}

2. bootloader 是如何加载 ELF 格式的 OS?


/* file header */                                                                         
struct elfhdr {
    uint32_t e_magic;     // must equal ELF_MAGIC                                         
    uint8_t e_elf[12];    //描述信息
    uint16_t e_type;      //文件类型 1=relocatable, 2=executable, 3=shared object, 4=core image   
    uint16_t e_machine;   //文件体系结构类型: 3=x86, 4=68K, etc.
    uint32_t e_version;   // file version, always 1
    uint32_t e_entry;     //程序入口的虚拟地址 entry point if executable
    uint32_t e_phoff;     // file position of program header or 0                         
    uint32_t e_shoff;     // file position of section header or 0
    uint32_t e_flags;     // architecture-specific flags, usually 0                       
    uint16_t e_ehsize;    // size of this elf header
    uint16_t e_phentsize; // size of an entry in program header
    uint16_t e_phnum;     // number of entries in program header or 0                     
    uint16_t e_shentsize; // size of an entry in section header
    uint16_t e_shnum;     // number of entries in section header or 0
    uint16_t e_shstrndx;  // section number that contains section name strings            
};                                                                                        

/* program section header */                                                              
struct proghdr {
    uint32_t p_type;   // loadable code or data, dynamic linking info,etc.                
    uint32_t p_offset; // file offset of segment
    uint32_t p_va;     // virtual address to map segment                                  
    uint32_t p_pa;     // physical address, not used                                      
    uint32_t p_filesz; // size of segment in file
    uint32_t p_memsz;  // size of segment in memory (bigger if contains bss)             
    uint32_t p_flags;  // read/write/execute bits
    uint32_t p_align;  // required alignment, invariably hardware page size               
};

void
bootmain(void) {
    // read the 1st page off disk    //512*8 = 4k
    readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0); 

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

    struct proghdr *ph, *eph;

    // load each program segment (ignores ph flags)
    ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff);   //获取程序头部表格首地址
    eph = ph + ELFHDR->e_phnum; 

    //把程序从硬盘中加载到内存,忽略p_flags
    for (; ph < eph; ph ++) {
        readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
    }   

    // call the entry point from the ELF header 
    // note: does not return
    ((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();

bad:
    outw(0x8A00, 0x8A00);
    outw(0x8A00, 0x8E00);

    /* do nothing */
    while (1);
}

练习 五:实现函数调用堆栈跟踪函数

  • 前提知识

    栈相关的寄存器两个,ebp(基址寄存器)和esp(栈指针寄存器),栈的增长方向是由高到低
    eip是程序指令指针,当前程序运行的指令
    
    举例,main函数在调用sum(int a, int b)的时候
    高地址-—|--------------------------|--
            |          参数b           |
          -—|--------------------------|--
            |          参数a           |
          -—|--------------------------|--
            |   sum函数下一条命令的地址  |        
          -—|--------------------------|--
            |     main函数栈的基址     |
    低地址-—|--------------------------|--     ebp
    
    此时ebp是sum函数栈的基址,然后eip里面是sum函数中的第一条指令
    sum函数执行完之后,sum函数栈的内容全部出栈,
    然后ebp重新变成main函数的函数栈基址,ebp=*((uint_t*)ebp)
    eip=*((uint_t*)ebp+1),就是sum函数之后的指令的地址,然后函数参数出栈
    
///kern/debug/kdebug.c
void print_stackframe(void) {
    uint32_t t_ebp = read_ebp();
    uint32_t t_eip = read_eip();

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

练习 六:完善中断初始化和处理 (需要编程)

中断向量表调用关系
系统发生中断--->中断号(0~255)-->找到IDT,拿到段选择子和偏移-----> 拿着段选择子找GDT,找到段基址,再使用偏移跳转到程序入口地址

1. 中断向量表中一个表项占多少字节?其中哪几位代表中断处理代码的入口?

中断向量表一个表项占用8字节,其中2-3字节是段选择子,0-1字节和6-7字节拼成位移, 两者联合便是中断处理程序的入口地址。

2. 请编程完善 kern/trap/trap.c 中对中断向量表进行初始化的函数 idt_init。

在 idt_init 函数中,依次对所有中断入口进行初始化。使用 mmu.h 中的 SETGATE 宏
#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:陷阱门设为1,中断门设为0. 
sel:段选择子,全局描述符表的代码段段选择子 //memlayout.h里面有宏定义GD_KTEXT
off:处理函数的入口地址,即__vectors[]中的内容。
dpl:特权级.从实验指导书中可知,ucore中的应用程序处于特权级3,内核态特权级为0.
注意除了系统调用中断(T_SYSCALL)以外,其它中断均使用中断门描述符,权限为内核态权限;而系统调用中断使用异常,权限为陷阱门描述符
void
idt_init(void) {
    extern uintptr_t __vectors[];
    for(int i = 0; i < sizeof(idt)/sizeof(struct gatedesc); i++)
    {
        SETGATE(idt[i], 0, GD_KTEXT, __vectors[i]; 0);
    }
    SETGATE(idt[T_SYSCALL], 0, GD_KTEXT, __vectors[T_SYSCALL]; 3);
    lidt(&idt_pd);
}

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

case IRQ_OFFSET+IRQ_TIMER:
    ticks++;
    if (ticks % TICK_NUM == 0) {
        print_ticks();
    }
    break;
/*
    make
    make qemu
*/
  • 2
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值