ucore操作系统实验笔记 - Lab2

操作系统课程笔记 - Lab2

Lab2光就实验而言并不难, 但实验外的东西还是很值得研究的。指导书上也说了,Lab1和Lab2对于初次接触这门课的同学来说是一道坎,只要搞懂了这两Lab的代码,接下来的其他Lab就会相对容易很多。所以除了做实验,我还大致地阅读了每一部分的代码。通过阅读代码,对系统内存的探测,内存物理页的管理, 不同阶段的地址映射等有了更进一步的理解,下面就先从系统内存的探测开始。

系统内存的探测

INT 15h中断与E820参数

在我们分配物理内存空间前,我们必须要获取物理内存空间的信息 - 比如哪些地址空间可以使用,哪些地址空间不能使用等。在本实验中, 我们通过向INT 15h中断传入e820h参数来探测物理内存空间的信息(除了这种方法外,我们还可以使用其他的方法,具体如何使用这些方法请自行网上搜索)。
下面我们来看一下ucore中物理内存空间的信息:

e820map:
  memory: 0009fc00, [00000000, 0009fbff], type = 1.
  memory: 00000400, [0009fc00, 0009ffff], type = 2.
  memory: 00010000, [000f0000, 000fffff], type = 2.
  memory: 07ee0000, [00100000, 07fdffff], type = 1.
  memory: 00020000, [07fe0000, 07ffffff], type = 2.
  memory: 00040000, [fffc0000, ffffffff], type = 2.

这里的type是物理内存空间的类型,1是可以使用的物理内存空间, 2是不能使用的物理内存空间。注意, 2中的"不能使用"指的是这些地址不能映射到物理内存上, 但它们可以映射到ROM或者映射到其他设备,比如各种外设等。

除了这两种类型,还有几种其他类型,只是在这个实验中我们并没有使用:

type = 3: ACPI Reclaim Memory (usable by OS after reading ACPI tables)
type = 4: ACPI NVS Memory (OS is required to save this memory between NVS sessions)
type = other: not defined yet -- treat as Reserved

实现过程

要使用这种方法来探测物理内存空间,我们必须将系统置于实模式下。因此, 我们在bootloader中添加了物理内存空间探测的功能。 这种方法获取的物理内存空间的信息是用内存映射地址描述符(Address Range Descriptor)来表示的,一个内存映射地址描述符占20B,其具体描述如下:

00h    8字节   base address            #系统内存块基地址
08h    8字节   length in bytes         #系统内存大小
10h    4字节   type of address range   #内存类型

每探测到一块物理内存空间, 其对应的内存映射地址描述符就会被写入我们指定的内存空间(可以理解为是内存映射地址描述符表)。 当完成物理内存空间的探测后, 我们就可以通过这个表来了解物理内存空间的分布情况了。

下面我们来看看INT 15h中断是如何进行物理内存空间的探测:

/* memlayout.h */
struct e820map {
    int nr_map;
    struct {
        long long addr;
        long long size;
        long type;
   } map[E820MAX];
};

/* bootasm.S */
probe_memory:
    /* 在0x8000处存放struct e820map, 并清除e820map中的nr_map */
    movl $0, 0x8000
    xorl %ebx, %ebx
    /* 0x8004处将用于存放第一个内存映射地址描述符 */
    movw $0x8004, %di
start_probe:
    /* 传入0xe820作为INT 15h中断的参数 */
    movl $0xE820, %eax
    /* 内存映射地址描述符的大小 */
    movl $20, %ecx
    movl $SMAP, %edx
    /* 调用INT 15h中断 */
    int $0x15
    /* 如果eflags的CF位为0,则表示还有内存段需要探测 */
    jnc cont
    movw $12345, 0x8000
    jmp finish_probe
cont:
    /* 设置下一个内存映射地址描述符的起始地址 */
    addw $20, %di
    /* e820map中的nr_map加1 */
    incl 0x8000
    /* 如果还有内存段需要探测则继续探测, 否则结束探测 */
    cmpl $0, %ebx
    jnz start_probe
finish_probe:

从上面代码可以看出,要实现物理内存空间的探测,大体上只需要3步:

  1. 设置一个存放内存映射地址描述符的物理地址(这里是0x8000)

  2. 将e820作为参数传递给INT 15h中断

  3. 通过检测eflags的CF位来判断探测是否结束。如果没有结束, 设置存放下一个内存映射地址描述符的物理地址,然后跳到步骤2;如果结束,则程序结束

物理内存空间管理的初始化

当我们在bootloader中完成对物理内存空间的探测后, 我们就可以根据得到的信息来对可用的内存空间进行管理。在ucore中, 我们将物理内存空间按照页的大小(4KB)进行管理, 页的信息用Page这个结构体来保存。下面是Page在Lab2中的具体描述:

struct Page {
    int ref;                        // page frame's reference counter
    uint32_t flags;                 // array of flags that describe the status of the page frame
    unsigned int property;          // the num of free block, used in first fit pm manager
    list_entry_t page_link;         // free list link
};

我们下面来看看程序是如何初始化物理内存空间的页信息的。

物理内存空间管理的初始化的过程

物理内存空间的初始化可以分为以下4步:

  1. 根据物理内存空间探测的结果, 找到最后一个可用空间的结束地址(或者Kernel的结束地址,选一个小的) 根据这个结束地址计算出整个可用的物理内存空间一共有多少个页。

  2. 找到Kernel的结束地址(end),这个地址是在kernel.ld中定义的, 我们从这个地址所在的下一个页开始(pages)写入系统页的信息(将所有的Page写入这个地址)

  3. 从pages开始,将所有页的信息的flag都设置为reserved(不可用)

  4. 找到free页的开始地址, 并初始化所有free页的信息(free页就是除了kernel和页信息外的可用空间,初始化的过程会reset flag中的reserved位)

上面这几部中提到了很多地址空间, 下面我用一幅图来说明:
物理内存空间的分布图

end指的就是BSS的结束处;pages指的是BSS结束处 - 空闲内存空间的起始地址;free页是从空闲内存空间的起始地址 - 实际物理内存空间结束地址。

有了这幅图,这些地址就很容易理解了。

部分源码分析

pages的地址
pages = (struct Page *)ROUNDUP((void *)end, PGSIZE);
free页的起始地址
uintptr_t freemem = PADDR((uintptr_t)pages + sizeof(struct Page) * npage);
初始化free页的信息
init_memmap(pa2page(begin), (end - begin) / PGSIZE);

从pages开始保存了所有物理页的信息(严格来讲, 在pages处保存的npage个页的信息并不一定是所有的物理页信息,它还包括各种外设地址,ROM地址等。不过因为它包含了所有可用free页的信息,我们就可以使用pages来找到任何free页的信息)。 那如何将free页的信息和free页联系起来呢?很简单, 我们用地址的物理页号(pa的高20bit)作为index来定位free页的信息。 因为pages处保存了系统中的第一个物理页的页信息,只要我们知道某个页的物理地址, 我们就可以很容易的找到它的页号(pa >> 12)。 有了页号,我们就可以通过pages[页号]来定位其页的信息了。在本lab中, 获取页的信息是由 pa2page() 来完成的。

在初始化free页的信息时, 我们只将连续多个free页中第一个页的信息连入free_list中, 并且只将这个页的property设置为连续多个free页的个数。 其他所有页的信息我们只是简单的设置为0。

内存段页式管理

这个lab中最重要的一个知识点就是内存的段页式管理。 下图是段页式内存管理的示意图:

段页式内存管理

我们可以看到,在这种模式下,逻辑地址先通过段机制转化成线性地址, 然后通过两种页表(页目录和页表)来实现线性地址到物理地址的转换。 有一点需要注意,在页目录和页表中存放的地址都是物理地址。

下面是页目录表表项:

页目录表表项

下面是页表表项:

页表表项

在X86系统中,页目录表的起始物理地址存放在cr3 寄存器中, 这个地址必须是一个页对齐的地址,也就是低 12 位必须为0。在ucore 用boot_cr3(mm/pmm.c)记录这个值。

在ucore中,线性地址的的高10位作为页目录表的索引,之后的10位作为页表的的索引,所以页目录表和页表中各有1024个项,每个项占4B,所以页目录表和页表刚好可以用一个物理的页来存放。

地址映射

在这个实验中,我们在4个不同的阶段使用了四种不同的地址映射, 下面我就分别介绍这4种地址映射。

第一阶段

这一阶段是从bootasm.S的start到entry.S的kern_entry前,这个阶段很简单, 和lab1一样(这时的GDT中每个段的起始地址都是0x00000000并且此时kernel还没有载入)。

virt addr = linear addr = phy addr

第二阶段

这个阶段就是从entry.S的kern_entry到pmm.c的enable_paging()。 这个阶段就比较复杂了,我们先来看bootmain.c这个文件:

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

bootmain.c中的函数被调用时候还处于第一阶段, 所以从上面这个宏定义我们可以知道kernel是被放在物理地址为0x10000的内存空间。我们再来看看链接文件kernel.ld,

/* Load the kernel at this address: "." means the current address */
    . = 0xC0100000;

连接文件将kernel链接到了0xC0100000(这是Higher Half Kernel, 具体参考Higher Half Kernel),这个地址是kernel的虚拟地址。 由于此时系统还只是采用段式映射,如果我们还是使用

virt addr = linear addr = phy addr

的话,我们根本不能访问到正确的内存空间,比如要访问虚拟地址0xC0100000, 其物理地址应该在0x00100000,而在这种映射下, 我们却访问了0xC0100000的物理地址。因此, 为了让虚拟地址和物理地址能匹配,我们必须要重新设计GDT。

在entry.S中,我们重新设计了GDT,其形式如下:

#define REALLOC(x) (x - KERNBASE)

lgdt REALLOC(__gdtdesc)

...

__gdt:
    SEG_NULL
    SEG_ASM(STA_X | STA_R, - KERNBASE, 0xFFFFFFFF)      # code segment
    SEG_ASM(STA_W, - KERNBASE, 0xFFFFFFFF)              # data segment
__gdtdesc:
    .word 0x17                                          # sizeof(__gdt) - 1
    .long REALLOC(__gdt)

可以看到,此时段的起始地址由0变成了-KERNBASE。因此,在这个阶段, 地址映射关系为:

virt addr - 0xC0000000 = linear addr = phy addr

这里需要注意两个个地方,第一,lgdt载入的是线性地址,所以用.long REALLOC(__gdt)将GDT的虚拟地址转换成了线性地址;第二,因为在载入GDT前,映射关系还是

virt addr = linear addr = phy addr

所以通过REALLOC(__gdtdesc)来将__gdtdesc的虚拟地址转换为物理地址,这样,lgdt才能真正的找到GDT存储的地方。

第三阶段

这个阶段是从kmm.c的enable_paging()到kmm.c的gdt_init()。 这个阶段是最复杂的阶段,我们开启了页机制, 并且在boot_map_segment()中将线性地址按照如下规则进行映射:

linear addr - 0xC0000000 = phy addr

这就导致此时虚拟地址,线性地址和物理地址之间的关系如下:

virt addr = linear addr + 0xC0000000 = phy addr + 2 * 0xC0000000

这肯定是错误的,因为我们根本不能通过虚拟地址获取正确的物理地址, 我们可以继续用之前例子。我们还是要访问虚拟地址0xC0100000, 则其线性地址就是0x00100000,然后通过页映射后的物理地址是0x80100000。 我们本来是要访问0x00100000,却访问了0x80100000, 因此我们需要想办法来解决这个问题,即要让映射还是:

virt addr - 0xC0000000 = linear addr = phy addr

这个和第一阶段到第二阶段的转变类似,都是需要调整映射关系。为了解决这个问题, ucore使用了一个小技巧:
在boot_map_segment()中, 线性地址0xC0000000-0xC0400000(4MB)对应的物理地址是0x00000000-0x00400000(4MB)。如果我们还想使用虚拟地址0xC0000000来映射物理地址0x00000000, 也就是线性地址0x00000000来映射物理地址0x00000000,我们可以这么做:

在开启页映射机制前, 将页目录表中的第0项和第0b1100_0000_00设置为相同的映射(boot_pgdir[0] = boot_pgdir[PDX(KERNBASE)]),这样,当虚拟地址是0xC0000000时, 其对应的线性地址就是0x00000000, 然后通过页表可以知道其物理地址也是0x00000000。

举个例子,比如enable_paging()后应该运行gdt_init(),gdt_init()的虚拟地址是0xC01033CF,那么其对应的线性地址就是0x001033CF,它将映射到页目录表的第一项。并且这个线性地址和0xC01033CF最终是指向同一个物理页,它们对应的物理地址是0x001033CF。而根据gdt_init()的虚拟地址和链接地址可知,gdt_init()的物理地址就是0x001033CF,因此通过这种地址变换后,我们可以正确的取到之后的指令。

因为ucore在当前lab下的大小是小于4MB的,因此这么做之后, 我们依然可以按照阶段二的映射方式运行ucore。如果ucore的大小大于了4MB, 我们只需按同样的方法设置页目录表的第1,2,3...项。

第四阶段

这一阶段开始于kmm.c的gdt_init()。gdt_init()重新设置GDT, 新的GDT又将段的起始地址变为了0. 调整后, 地址的映射关系终于由

virt addr = linear addr + 0xC0000000 = phy addr + 2 * 0xC0000000

变回了

virt addr = linear addr = phy addr + 0xC0000000

同时,我们把目录表中的第0项设置为0,这样就把之前的这种映射关系解除了。通过这几个步骤的转换, 我们终于在开启页映射机制后将映射关系设置正确了。

自映射

在这个实验中,为了方便快速地访问页目录项和页表项,用了一个小技巧 - 也就是自映射。如果用常规方法的话,要访问一个页表项,必须先使用虚拟地址访问到对应的页目录项,然后通过其中的页表地址找到页表,最后再通过虚拟地址的页表项部分找到我们需要的页表项。这个过程是比较繁琐的,为了方便的访问这些表项,我们使用了自映射。我们下面通过代码来看看什么是自映射以及如何使用自映射来快速查找表项的内容。

自映射的实现

首先,我们将页一个目录项的值设置为页目录表的物理地址,这么做的作用就是当我们使用的虚拟地址,高10位为1111 1010 11b时,它对应的页表就是页目录表,这也就实现了自映射:

/* VPT = 0xFAC00000 = 1111 1010 11 | 00 0000 0000 | 0000 0000 0000b 
注意,这个地址是在ucore有效地址之外的地址(有效地址从0xC0000000到0xF8000000) */
boot_pgdir[PDX(VPT)] = PADDR(boot_pgdir) | PTE_P | PTE_W;

这样之后,我们来看看如何使用这种自映射。我们还定义了一个地址VPD:

VPD = 0xFAFEB000 = 1111 1010 11 | 11 1110 1011 | 0000 0000 0000b

通过VPD和VPT,我们就能方便的访问页目录项和页表项了。下面给一个例子说明如何获取0xF8000000所在的页目录项和页表项:
首先,我们思考下直接通过VPD = 1111 1010 11 | 11 1110 1011 | 0000 0000 0000b这个地址我们能访问到什么?在页目录表中通过1111 1010 11可以找到boot_pgdir[PDX(VPT)]这项,这项又直接指回了boot_pgdir,此时我们将页目录表当成了页表。我们此时再用第二个11 1110 1011,还是找到boot_pgdir[PDX(VPT)]这项,它还是指回boot_pgdir。也就是说,VPD这个地址最终就是指回了页目录表,并且我们可以通过它的最后12bit来访问页目录表。0xF8000000地址对应的页目录项是1111 1000 00b,我们只要将这个值放在VPD的后12bit就行了(因为12bit是大于10bit的,因此我们一定能找到需要访问的页目录项),也就是说我们通过

VPD + 0xF8000000 >> 22

就可以获得0xF8000000对应的页目录项。如果懂了如何获取页目录项,再来看如何获取页表项就很简单了。首先,我们根据VPT可以访问到页目录表,这个页目录表同样也是VPT对应的页表,通过VPT的低22位我们就可以像访问页表一样的访问页目录表。0xF8000000的高20位是1111 1000 0000 0000 0000b,用这个地址我们就可以通过页目录表找到它对应的页表项了。这里我觉得指导书上说的不对,如果要能访问一个非4MB对齐的地址,不能直接使用

VPT + addr >> 12

而要用

VPT + [addr_31_22 : 00 : addr_21_12]

比如一个高20位地址是1111 1000 11|00 0000 0011b,那么要用在VPT中,1111 1000 11要放在VPT的21_12位,用于找到页目录表项,从而找到页表,剩下的00 0000 0003就要用来在页表中找页表项。因为VPT中的低22位为0,如果直接使用addr >> 22的话,那么1111 1000 11|00 0000 0011b就变成了0011 1110 00| 1100 0000 0011b,这样的话,用于查找页目录项和页表项的索引就不对了,所以我觉得应该是我说的那种转换方法。也即是1111 1000 11|00 0000 0011b变成了1111 1000 11|0000 0000 0011b,这样才和之前的是对应的。如果要访问的地址是4MB对齐的,那么就可以直接用VPT + addr >> 12了。

Task1

这个练习是实现first-fit连续物理内存分配算法。难度不大,主要通过实现两个函数 default_alloc_pages(size_t n)default_free_pages(struct Page *base, size_t n) 。 下面是这两个函数的代码:

/* default_pmm.c */

static struct Page *
default_alloc_pages(size_t n) {
    assert(n > 0);
    if (n > nr_free) {
        return NULL;
    }
    struct Page *page = NULL;
    list_entry_t *le = &free_list;
    /* find the fist memory block that is larger or equal to n pages */  
    while ((le = list_next(le)) != &free_list) {
        struct Page *p = le2page(le, page_link);
        if (p->property >= n) {
            page = p;
            break;
        }
    }
    if (page != NULL) {
        /* if the memory block is larger than n pages, we need to divide this 
         * memory block to two pieces and add the second piece to the free_list.
         * Item in free list should be sorted by address */ 
        if (page->property > n) {
            struct Page *p = page + n;
            p->property = page->property - n;
            list_add_after(&(page->page_link), &(p->page_link));
        }
        /* cleanup Page information and remove it from free_list */
        for (int i = 0; i < n; i++)
            ClearPageProperty(page + i);
        page->property = 0;
        list_del(&(page->page_link));
        nr_free -= n;
    }
    return page;
}

default_alloc_pages(size_t n) 会返回分配的物理页对应的页信息,根据页信息,我们可以通过计算它的index(page - pages)来获取物理页的物理页号。之后根据各种转换,我们就能知道物理页的物理地址和虚拟地址。

/* default_pmm.c */

static void
default_free_pages(struct Page *base, size_t n) {
    struct Page *prev, *next;
    assert(n > 0);
    struct Page *p = base;
    for (; p != base + n; p ++) {
        /* !PageProperty(p) checks two things: 
         * 1. whether base belongs to free or allocated pages. If page is allocated, Property flag 
         * is set to 0; If page is free, Property flag is set to 1. 
         * 2. whether base + n across the boundary of base memory block. 
         * The Property flag of allocated page is set to 0, is one page's Property flag is set to 1, 
         * base + n must across the boundary. */ 
        assert(!PageReserved(p) && !PageProperty(p));
        p->flags = 0;
        set_page_ref(p, 0);
    }
    base->property = n;
    list_entry_t *le = list_next(&free_list);
    /* find the first Page in free_list that address is larger than base */ 
    while (le != &free_list) {
        struct Page *p = le2page(le, page_link); 
        if (p > base)
           break; 
        le = list_next(le);
    }
    /* there are two cases here: 
     * 1. free_list is not empty 
     * 2. we can find a Page in free_list that address is larger than base */
    if (le != &free_list) {
        next = le2page(le, page_link);
        /* if we can combine base and next memory spaces, just do it. But we should not insert base to free_list here. 
         * We will deal with this later */
        if (base + n == next) {
            base->property += next->property;
            next->property = 0; 
            list_del(&(next->page_link));
        } 
        /* if base's address is smaller than the first Page's address, we just insert base to free_list */
        if (le->prev == &free_list) {
            list_add_after(&(free_list), &(base->page_link));
        } 
        else {
            prev = le2page(le->prev, page_link);
            /* if we can combine base and previous memory spaces, just do it. In this case, we do not need 
             * to insert base to free_list */
            if (prev + prev->property == base) {
                prev->property += base->property;
                base->property = 0;
            } 
            /* if we can not combine base and previous memory spaces, no matter base can combine next memory space or not, 
             * we just insert base to free_list */
            else {
                list_add_after(&(prev->page_link), &(base->page_link));
            }
        } 
    }
    /* there are two cases here: 
     * 1. free_list is empty 
     * 2. we can not find a Page in free_list that address is larger than base 
     * In these two cases, we only need to set base page's Property flag to 1 and insert
     * it to free_list */
    else {
        list_add_before(&(free_list), &(base->page_link));
    }
    for (int i = 0; i < n; i++)
        SetPageProperty(base + i);
    nr_free += n;
}

default_free_pages(struct Page *base, size_t n) 将根据传入的Page address来释放n page大小的内存空间。该函数会判断Page address是否是allocated的,也会判断是否base + n会跨界(由allocated到free的内存空间)。如果输入的Page address合法,则会将新的Page插入到free_list中的合适位置(free_list是按照Page地址由低向高排序的)。

有一点需要注意,在本first-fit连续物理内存分配算法中,对于任何allocated后的Page,Property flag都为0;任何free的Page,Property flag都为1。

对于allocated后的Pages,第一个Page的property在这里是被清零了的,如果ucore要求只能用第一个Page来free Pages,那么allocate时,第一个Page的property就不应该清零。我们在free Page时要用property来判断Page是不是第一个Page。

如果ucore规定free需要free掉整个Page块,那么我们还需要检测第一个Page的property是否和要free的page数相等。

上面这几点在Lab2中并不能确定,如果之后Lab有说明,或者出现错误,我们需要重新修改这些地方。

Task2

这个练习是实现寻找虚拟地址对应的页表项。

/* pmm.c */

pte_t *
get_pte(pde_t *pgdir, uintptr_t la, bool create) {
    pte_t *pt_addr;
    struct Page *p;
    uintptr_t *page_la; 
    if (pgdir[(PDX(la))] & PTE_P) {
        pt_addr = (pte_t *)(KADDR(pgdir[(PDX(la))] & 0XFFFFF000)); 
        return &pt_addr[(PTX(la))]; 
    }
    else {
        if (create) {
            p = alloc_page();
            if (p == NULL) {
                cprintf("boot_alloc_page failed.\n");
                return NULL;
            }
            p->ref = 1;
            page_la = KADDR(page2pa(p));
            memset(page_la, 0x0, PGSIZE); 
            pgdir[(PDX(la))] = ((page2pa(p)) & 0xFFFFF000) | (pgdir[(PDX(la))] & 0x00000FFF); 
            pgdir[(PDX(la))] = pgdir[(PDX(la))] | PTE_P | PTE_W | PTE_U;
            return &page_la[PTX(la)]; 
        }
        else {
            return NULL;
        }
    }
}

这个代码很简单, 但有几个地方还是需要注意下。
首先,最重要的一点就是要明白页目录和页表中存储的都是物理地址。所以当我们从页目录中获取页表的物理地址后,我们需要使用KADDR()将其转换成虚拟地址。之后就可以用这个虚拟地址直接访问对应的页表了。

第二, *, &, memset() 等操作的都是虚拟地址。注意不要将物理或者线性地址用于这些操作(假设线性地址和虚拟地址不一样)。

第三,alloc_page()获取的是物理page对应的Page结构体,而不是我们需要的物理page。通过一系列变化(page2pa()),我们可以根据获取的Page结构体得到与之对应的物理page的物理地址,之后我们就能获得它的虚拟地址。

Task3

这个练习是实现释放某虚地址所在的页并取消对应二级页表项的映射。这个练习比Task2还要简单,我就直接贴出代码了。

/* pmm.c */

static inline void
page_remove_pte(pde_t *pgdir, uintptr_t la, pte_t *ptep) {
    pte_t *pt_addr;
    struct Page *p;
    uintptr_t *page_la; 
    if ((pgdir[(PDX(la))] & PTE_P) && (*ptep & PTE_P)) {
        p = pte2page(*ptep);   
        page_ref_dec(p); 
        if (p->ref == 0) 
            free_page(p); 
        *ptep = 0;
        tlb_invalidate(pgdir, la);
    }
    else {
        cprintf("This pte is empty!\n"); 
    }
}
  • 5
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值