Linux内存管理:memblock

思考:

        伙伴系统和struct page数组的内存是从哪里来的,不知道大家有没有考虑过这个问题?

        答案:就是memblock;(本章内容参考4.19内核和x86架构)。

简介

       内存管理是操作系统内核中最复杂的部分之一, start_kernel函数在内核启动第一个init进程前初始化了所有的内核特性(包括那些依赖于不同架构的特性),你也许还记得引导时创立的临时页表,但复杂的内存管理部分还没有开始,当start_kernel函数被调用时,我们会看到初期内存管理到更复杂的内存管理数据结构和技术的转变,为了更好的理解内核的初始化过程,我们需要对这些技术有更清晰的理解,这个章节我们会着重讨论这个过程,主要针对初期的内存管理memblock的介绍。

        首先我们知道在内核启动后,对于内存,分成好几块:

  • 内存中的某些部分使永久分配给内核的,例如代码段和数据段、ramdisk和dtb占用的空间、临时页表和设备数中的保留区域等,是系统内存的一部分,不能被侵占,也不参与内存的分配,称之为静态内存;
  •  GPU/camera/多核共享的内存都需要预留大量连续内存,这部分内存平时不使用,但是必须为各个应用场景预留,这样的内存称之为预留内存;
  •   内存其余的部分,是需要内核管理的内存,称之为动态内存;

        那么memblock就是将以上内存按功能划分为若干内存区,使用不同的类型存放在memory和reserved的两个集合中,memory即为动态内存,而resvered包括静态内存等。

1、memblock是什么

1.1 memblock介绍

        memblock即linux启动后kernel管理内存空间抽象出来的结构,此时buddy系统和slab分配器等并没有初始化,当需要执行一些内存管理、内存分配的任务,此时就是有初期的管理模块memblock机制。在mm_init中会建立内核的内存分配器,停用memblock,释放内存给伙伴系统和slab分配器。memblock是bootmem的升级版本,在config中配置:CONFIG_NO_BOOTMEM=Y。 

1.2 需要的结构体

        第一个结构体的名字就叫做 memblock。它的定义如下:

struct memblock {
         bool bottom_up;
         phys_addr_t current_limit;
         struct memblock_type memory;   --> array of memblock_region
         struct memblock_type reserved; --> array of memblock_region
#ifdef CONFIG_HAVE_MEMBLOCK_PHYS_MAP
         struct memblock_type physmem;
#endif
};

        这个结构体包含五个域。第一个 bottom_up 域置为 true 时允许内存以自底向上模式进行分配。下一个域是 current_limit。 这个域描述了内存块的尺寸限制。接下来的三个域描述了内存块的类型。内存块的类型可以是:被保留、内存和物理内存( CONFIG_HAVE_MEMBLOCK_PHYS_MAP 编译配置选项被开启)。

        接下来我们可以看看下一个数据结构memblock_type,定义如下:

struct memblock_type {
	unsigned long cnt;
	unsigned long max;
	phys_addr_t total_size;
	struct memblock_region *regions;
};

       这个结构体提供了关于内存类型的信息。它包含了描述当前内存块中内存区域的数量、所有内存区域的大小、内存区域的已分配数组的尺寸和指向 memblock_region 结构体数据的指针的域。

        接下来看对应memblock_region的定义:

struct memblock_region {
        phys_addr_t base;
        phys_addr_t size;
        unsigned long flags;
#ifdef CONFIG_HAVE_MEMBLOCK_NODE_MAP
        int nid;
#endif
};

        memblock_region 提供了内存区域的基址和大小,如果 CONFIG_HAVE_MEMBLOCK_NODE_MAP 编译配置选项被开启, memblock_region 结构体也提供了整数域 - numa 节点选择器。flags 域可以是:

/* Definition of memblock flags. */
enum memblock_flags {
	MEMBLOCK_NONE		= 0x0,	/* No special request */
	MEMBLOCK_HOTPLUG	= 0x1,	/* hotpluggable region */
	MEMBLOCK_MIRROR		= 0x2,	/* mirrored region */
	MEMBLOCK_NOMAP		= 0x4,	/* don't add to kernel direct mapping */
};

      以上的关系如图所示:  

在这里插入图片描述

 1.3 初始化静态变量

        在编译时会做好memblock结构体的初始化,代码如下:

static struct memblock_region memblock_memory_init_regions[INIT_MEMBLOCK_REGIONS] __initdata_memblock;
static struct memblock_region memblock_reserved_init_regions[INIT_MEMBLOCK_REGIONS] __initdata_memblock;
#ifdef CONFIG_HAVE_MEMBLOCK_PHYS_MAP
static struct memblock_region memblock_physmem_init_regions[INIT_PHYSMEM_REGIONS] __initdata_memblock;
#endif

struct memblock memblock __initdata_memblock = {
	.memory.regions		= memblock_memory_init_regions,
	.memory.cnt		= 1,	/* empty dummy entry */
	.memory.max		= INIT_MEMBLOCK_REGIONS,
	.memory.name		= "memory",

	.reserved.regions	= memblock_reserved_init_regions,
	.reserved.cnt		= 1,	/* empty dummy entry */
	.reserved.max		= INIT_MEMBLOCK_REGIONS,
	.reserved.name		= "reserved",

#ifdef CONFIG_HAVE_MEMBLOCK_PHYS_MAP
	.physmem.regions	= memblock_physmem_init_regions,
	.physmem.cnt		= 1,	/* empty dummy entry */
	.physmem.max		= INIT_PHYSMEM_REGIONS,
	.physmem.name		= "physmem",
#endif

	.bottom_up		= false,
	.current_limit		= MEMBLOCK_ALLOC_ANYWHERE,
};

        可以看到和memblock结构体同名的变量的初始化,首先应该注意__initdata_memblock,这个宏的定义如下:   

#ifdef CONFIG_ARCH_DISCARD_MEMBLOCK
#define __init_memblock __meminit
#define __initdata_memblock __meminitdata
#else
#define __init_memblock
#define __initdata_memblock
#endif

        你会发现这个宏依赖于 CONFIG_ARCH_DISCARD_MEMBLOCK 。如果这个编译配置选项开启,内存块的代码会被放置在 .init 段,这样它就会在内核引导完毕后被释放掉。

2、memblock-API接口

2.1 各种API

        我们已经结束了memblock结构体初始化讲解,现在我们要看memblock-API和它的实现,所有的memblock-API实现都在mm/block.c,为了理解memblock是怎样实现和工作的,让我们先看看它的用法。内核中很多地方使用到了内存块,举个例子,arch/x86/kernel/e820.c中的e820__memblock_setup函数,这个函数使用了e820提供的内存映射并使用memblock_add和memblock_reserve添加到memblock的region中,接下来我们介绍这几个常用的API。


//   基本接口
// 向memory区中添加内存区域.
memblock_add(phys_addr_t base, phys_addr_t size)

//  向memory区中删除区域.
memblock_remove(phys_addr_t base, phys_addr_t size)

//  申请内存
memblock_alloc(phys_addr_t size, phys_addr_t align)


// 释放内存
memblock_free(phys_addr_t base, phys_addr_t size)


//   查找 & 遍历
//  在给定的范围内找到未使用的内存
phys_addr_t memblock_find_in_range(phys_addr_t start, phys_addr_t end, phys_addr_t size, phys_addr_t align)

//  反复迭代 memblock
for_each_mem_range(i, type_a, type_b, nid, flags, p_start, p_end, p_nid)


//   获取信息
//  获取内存区域信息
phys_addr_t get_allocated_memblock_memory_regions_info(phys_addr_t *addr);
//  获取预留内存区域信息
phys_addr_t get_allocated_memblock_reserved_regions_info(phys_addr_t *addr);

//   获取信息
#define memblock_dbg(fmt, ...) \
    if (memblock_debug) printk(KERN_INFO pr_fmt(fmt), ##__VA_ARGS__)

        大致翻看了一下内核代码, 发现很少使用memblock_free(),因为很多地方都是申请了内存做永久使用的. 再者,其实在内核中通过memblock_alloc来分配内存其实比较少,一般都是在调用memblock底层的一些函数来简单粗暴的分配的。

2.2 memblock_add将内存区域加入到memory中

2.3 memblock_add函数   

2.3.1 memblock_add函数  

        memblock_add函数负责向memory区中添加内存区域, 有两个参数:物理基址和内存区域大小,并且把该内存区域添加到memblock。memblock_add函数本身并没有什么, 它只是调用了memblock_add_range函数来完成工作, 定义在mm/memblock.c。

int __init_memblock memblock_add(phys_addr_t base, phys_addr_t size)
{
    memblock_dbg("memblock_add: [%#016llx-%#016llx] flags %#02lx %pF\n",
             (unsigned long long)base,
             (unsigned long long)base + size - 1,
             0UL, (void *)_RET_IP_);
    return memblock_add_range(&memblock.memory, base, size, MAX_NUMNODES, 0);
}

        memblock_add传递的参数依次是 : 内存块类型(memory), 物理基址, 内存区域大小, 最大节点数(1如果CONFIG_NODES_SHIFT没有在配置文件中设置,不然就是1 << CONFIG_NODES_SHIFT)和标志。

2.3.2 memblock_add_range函数代码

        memblock_add_range函数添加新的内存区域到内存块,主要有以下流程:

  • 首先,该函数检查给定的内存区域大小,如果是0就放回。
  • 在这之后,memblock_add_range用给定的memblock_type检查memblock结构体中是否存在内存区域。
  • 如果没有,我们就用给定的值填充新的memory_region然后返回。
  • 如果memblock_type不为空,我们就把新的内存区域添加到memblock_type类型的memblock中。
    int __init_memblock memblock_add_range(struct memblock_type *type,
    				phys_addr_t base, phys_addr_t size,
    				int nid, enum memblock_flags flags)
    {
    	bool insert = false;
    	phys_addr_t obase = base;
    	phys_addr_t end = base + memblock_cap_size(base, &size);
    	int idx, nr_new;
    	struct memblock_region *rgn;
    
    	if (!size)
    		return 0;
    
    	/* special case for empty array */
    	if (type->regions[0].size == 0) {
    		WARN_ON(type->cnt != 1 || type->total_size);
    		type->regions[0].base = base;
    		type->regions[0].size = size;
    		type->regions[0].flags = flags;
    		memblock_set_region_node(&type->regions[0], nid);
    		type->total_size = size;
    		return 0;
    	}
    repeat:
    	/*
    	 * The following is executed twice.  Once with %false @insert and
    	 * then with %true.  The first counts the number of regions needed
    	 * to accommodate the new area.  The second actually inserts them.
    	 */
    	base = obase;
    	nr_new = 0;
    
    	for_each_memblock_type(idx, type, rgn) {
    		phys_addr_t rbase = rgn->base;
    		phys_addr_t rend = rbase + rgn->size;
    
    		if (rbase >= end)
    			break;
    		if (rend <= base)
    			continue;
    		/*
    		 * @rgn overlaps.  If it separates the lower part of new
    		 * area, insert that portion.
    		 */
    		if (rbase > base) {
    #ifdef CONFIG_HAVE_MEMBLOCK_NODE_MAP
    			WARN_ON(nid != memblock_get_region_node(rgn));
    #endif
    			WARN_ON(flags != rgn->flags);
    			nr_new++;
    			if (insert)
    				memblock_insert_region(type, idx++, base,
    						       rbase - base, nid,
    						       flags);
    		}
    		/* area below @rend is dealt with, forget about it */
    		base = min(rend, end);
    	}
    
    	/* insert the remaining portion */
    	if (base < end) {
    		nr_new++;
    		if (insert)
    			memblock_insert_region(type, idx, base, end - base,
    					       nid, flags);
    	}
    
    	if (!nr_new)
    		return 0;
    
    	/*
    	 * If this was the first round, resize array and repeat for actual
    	 * insertions; otherwise, merge and return.
    	 */
    	if (!insert) {
    		while (type->cnt + nr_new > type->max)
    			if (memblock_double_array(type, obase, size) < 0)
    				return -ENOMEM;
    		insert = true;
    		goto repeat;
    	} else {
    		memblock_merge_regions(type);
    		return 0;
    	}
    }

2.3.3 memblock_add_range函数流程解析

        首先,我们用如下代码获得内存区域的结束位置:

phys_addr_t end = base + memblock_cap_size(base, &size);

         memblock_cap_size函数会设置size大小确保base + size不会溢出,会返回size和ULLONG_MAX - base中的最小值,在那之后我们得到了新的内存区域的结束地址, 然后

  • 查内存区域是否重叠。

  • 将新的添加到memblock, 并且看是否能和已经添加到memblock中的内存区域进行合并。

        首先遍历所有已经存储的内存区域并检查有没有和新的内存区域重叠,如果新内存区域没有和已经存储在memblock的内存区域重叠, 把该新内存区域插入到memblock中. 如果有重叠通通过一个小巧的来完成冲突处理。

base = min(rend, end);

      重叠检查完毕后, 新的内存区域已经是一块干净的不包含重叠区域的内存, 把新的内存区域插入到memblock中包含两步:

  • 把新的内存区域中非重叠的部分作为独立的区域加入到memblock。

  • 合并所有相邻的内存区域。

        这个过程分为两次循环来完成, 由一个标识变量insert和report代码跳转标签控制。

        第一次循环的时候, 检查新内存区域是否可以放入内存块中并调用memblock_double_array, 而由于insert = false, 则执行!insert条件语句标记的代码块, 并设置insert = true, 然后goto 跳转到report标签继续开始第二次循环。

        第二次循环中, insert = true, 则执行相应的insert == true的代码块, 并且执行memblock_insert_region将新内存区域插入, 最后执行memblock_merge_regions(type)合并内存区域。

2.4 memblock_remove删除内存区域  

int __init_memblock memblock_remove(phys_addr_t base, phys_addr_t size)
{
    return memblock_remove_range(&memblock.memory, base, size);
}

2.5 memblock_alloc申请内存

2.5.1 memblock_alloc函数代码

phys_addr_t __init memblock_alloc(phys_addr_t size, phys_addr_t align)
{
	return memblock_alloc_base(size, align, MEMBLOCK_ALLOC_ACCESSIBLE);
}

        memblock_alloc()很粗暴的从能用的内存里分配, 而有些情况下需要从特定的内存范围内分配内存. 解决方法就是通过memblock_alloc_range_nid函数或者实现类似机制的函数。

        memblock_alloc的也是通过memblock_alloc_range_nid函数来完成内存分配的。

2.5.2 memblock_alloc_range_nid函数

        代码如下:

static phys_addr_t __init memblock_alloc_range_nid(phys_addr_t size,
					phys_addr_t align, phys_addr_t start,
					phys_addr_t end, int nid,
					enum memblock_flags flags)
{
	phys_addr_t found;

	if (!align)
		align = SMP_CACHE_BYTES;

	found = memblock_find_in_range_node(size, align, start, end, nid,
					    flags);
	if (found && !memblock_reserve(found, size)) {
		/*
		 * The min_count is set to 0 so that memblock allocations are
		 * never reported as leaks.
		 */
		kmemleak_alloc_phys(found, size, 0, 0);
		return found;
	}
	return 0;
}

        memblock_alloc_range_nid函数的主要工作如下

  • 首先使用memblock_find_in_range_node指定内存区域和大小查找内存区域。

  • memblock_reserve后将其标为已经分配。

2.5.3 memblock_find_in_range_node函数

        代码如下:

phys_addr_t __init_memblock memblock_find_in_range_node(phys_addr_t size,
					phys_addr_t align, phys_addr_t start,
					phys_addr_t end, int nid,
					enum memblock_flags flags)
{
	phys_addr_t kernel_end, ret;

	/* pump up @end */
	if (end == MEMBLOCK_ALLOC_ACCESSIBLE ||
	    end == MEMBLOCK_ALLOC_KASAN)
		end = memblock.current_limit;

	/* avoid allocating the first page */
	start = max_t(phys_addr_t, start, PAGE_SIZE);
	end = max(start, end);
	kernel_end = __pa_symbol(_end);

	/*
	 * try bottom-up allocation only when bottom-up mode
	 * is set and @end is above the kernel image.
	 */
	if (memblock_bottom_up() && end > kernel_end) {
		phys_addr_t bottom_up_start;

		/* make sure we will allocate above the kernel */
		bottom_up_start = max(start, kernel_end);

		/* ok, try bottom-up allocation first */
		ret = __memblock_find_range_bottom_up(bottom_up_start, end,
						      size, align, nid, flags);
		if (ret)
			return ret;

		/*
		 * we always limit bottom-up allocation above the kernel,
		 * but top-down allocation doesn't have the limit, so
		 * retrying top-down allocation may succeed when bottom-up
		 * allocation failed.
		 *
		 * bottom-up allocation is expected to be fail very rarely,
		 * so we use WARN_ONCE() here to see the stack trace if
		 * fail happens.
		 */
		WARN_ONCE(IS_ENABLED(CONFIG_MEMORY_HOTREMOVE),
			  "memblock: bottom-up allocation failed, memory hotremove may be affected\n");
	}

	return __memblock_find_range_top_down(start, end, size, align, nid,
					      flags);
}

         如果从memblock_alloc过来, end就是MEMBLOCK_ALLOC_ACCESSIBLE,这个时候会设置为current_limit,如果不通过memblock_alloc分配, 内存范围就是指定的范围. 紧接着对start做调整,为的是避免申请到第一个页面,memblock_bottom_up返回的是memblock.bottom_up,前面初始化的时候也知道这个值是false(在numa初始化时会设置为true),所以初始化前期应该调用的是__memblock_find_range_top_down函数去查找内存。

2.5.4 __memblock_find_range_top_down查找内存区域        

        函数通过使用for_each_free_mem_range_reverse宏封装调用__next_free_mem_range_rev()函数,此函数逐一将memblock.memory里面的内存块信息提取出来与memblock.reserved的各项信息进行检验,确保返回的this_start和this_end不会是分配过的内存块。

        然后通过clamp取中间值,判断大小是否满足,满足的情况下,将自末端向前(因为这是top-down申请方式)的size大小的空间的起始地址(前提该地址不会超出this_start)返回回去。

2.5.5 memblock_reserve标记申请的内存

        其实和memblock_add的实现方法一样,但传参数给memblock_add_range不一样,这边使用的是全局变量memblock的reserve区域。

2.6 memblock_free释放内存区域

        其实和memblock_remove实现方法一样,但传参数给memblock_remove_range不一样,这边使用全局变量memblock的reserved区域。

3、memblock初始化

3.1 x86架构下memblock的初始化

        在内核初始化初期,物理内存会通过Int 0x15来被探测和整理, 存放到e820中.而初始化就发生在这个以后,参加arch/x86/kernel/setup.c,只截取了部分。

void __init setup_arch(char **cmdline_p)
{
	/*
	 * Need to conclude brk, before e820__memblock_setup()
	 *  it could use memblock_find_in_range, could overlap with
	 *  brk area.
	 */
	reserve_brk();

	cleanup_highmap();

	memblock_set_current_limit(ISA_END_ADDRESS);
	e820__memblock_setup();
}

        首先内核建立内核页表需要扩展__brk, 而扩展后的brk就立即被声明为已分配. 这项工作是由reserve_brk通过调用memblock_reserve完成的, 而其实并不是正真通过memblock分配的, 因为此时memblock还没有完成初始化。

       reserve_brk函数定义在arch/x86/kernel/setup.c, 此时memblock还没有初始化, 只能通过memblock_reserve来完成内存的分配。

static void __init reserve_brk(void)
{
    if (_brk_end > _brk_start)
        memblock_reserve(__pa_symbol(_brk_start),
                 _brk_end - _brk_start);

    /* Mark brk area as locked down and no longer taking any
       new allocations */
    _brk_start = 0;
}

        设置完__brk后, 可以看到,setup_arch()函数通过e820__memblock_setup,依据e820中的信息来初始化memblock.

void __init e820__memblock_setup(void)
{
    int i;
    u64 end;

    /*
     * EFI may have more than 128 entries
     * We are safe to enable resizing, beause memblock_x86_fill()
     * is rather later for x86
     */
    memblock_allow_resize();

    for (i = 0; i < e820.nr_map; i++) {
        struct e820entry *ei = &e820.map[i];

        end = ei->addr + ei->size;
        if (end != (resource_size_t)end)
            continue;

        if (ei->type != E820_RAM && ei->type != E820_RESERVED_KERN)
            continue;

        memblock_add(ei->addr, ei->size);
    }

    /* throw away partial pages */
    memblock_trim_memory(PAGE_SIZE);

    memblock_dump_all();
}

         比较简单,通过e820中的信息memblock_add(),将内存添加到memblock中的memory中,当做可分配内存.后两个函数主要是修剪内存使之对齐和输出信息。至此, 我们的memblock就初始化好了, 简单而且粗暴。

3.2 arm架构下的memblock的初始化

void __init setup_arch(char **cmdline_p)
{
    arm_memblock_init(mdesc);
}

 arm_memblock_init定义在arch/arm/mm/init.c, 如下所示

void __init arm_memblock_init(const struct machine_desc *mdesc)
{
    /* Register the kernel text, kernel data and initrd with memblock. */
#ifdef CONFIG_XIP_KERNEL
    memblock_reserve(__pa(_sdata), _end - _sdata);
#else
    memblock_reserve(__pa(_stext), _end - _stext);
#endif
#ifdef CONFIG_BLK_DEV_INITRD
    /* FDT scan will populate initrd_start */
    if (initrd_start && !phys_initrd_size) {
        phys_initrd_start = __virt_to_phys(initrd_start);
        phys_initrd_size = initrd_end - initrd_start;
    }
    initrd_start = initrd_end = 0;
    if (phys_initrd_size &&
        !memblock_is_region_memory(phys_initrd_start, phys_initrd_size)) {
        pr_err("INITRD: 0x%08llx+0x%08lx is not a memory region - disabling initrd\n",
               (u64)phys_initrd_start, phys_initrd_size);
        phys_initrd_start = phys_initrd_size = 0;
    }
    if (phys_initrd_size &&
        memblock_is_region_reserved(phys_initrd_start, phys_initrd_size)) {
        pr_err("INITRD: 0x%08llx+0x%08lx overlaps in-use memory region - disabling initrd\n",
               (u64)phys_initrd_start, phys_initrd_size);
        phys_initrd_start = phys_initrd_size = 0;
    }
    if (phys_initrd_size) {
        memblock_reserve(phys_initrd_start, phys_initrd_size);

        /* Now convert initrd to virtual addresses */
        initrd_start = __phys_to_virt(phys_initrd_start);
        initrd_end = initrd_start + phys_initrd_size;
    }
#endif

    arm_mm_memblock_reserve();

    /* reserve any platform specific memblock areas */
    if (mdesc->reserve)
        mdesc->reserve();

    early_init_fdt_reserve_self();
    early_init_fdt_scan_reserved_mem();

    /* reserve memory for DMA contiguous allocations */
    dma_contiguous_reserve(arm_dma_limit);

    arm_memblock_steal_permitted = false;
    memblock_dump_all();
}

3.3 arm64 架构下memblock的初始化

void __init setup_arch(char **cmdline_p)
{
    /*  初始化memblock  */
    arm64_memblock_init( );

    /*  分页机制初始化  */
    paging_init();

    bootmem_init();
}

4、调试

        如果需要了解memblock的详细分配流程,可以通过在bootargs中加入“memblock=debug”。

在内核启动后,通过/proc/kmsg查看调试信息。

内存块技术也支持 debugfs 。如果你不是在 X86 架构下运行内核,你可以访问:

/sys/kernel/debug/memblock/memory

/sys/kernel/debug/memblock/reserved

来获取 memblock 内容的核心转储信息。

5、总结

        memblock内存管理是将所有的物理内存放到memblock.memory中作为可用内存来管理, 分配过的内存只加入到memblock.reserved中, 并不从memory中移出。

        同理释放内存也会加入到memory中. 也就是说, memory在fill过后基本就是不动的了. 申请和分配内存仅仅修改reserved就达到目的. 在初始化阶段没有那么多复杂的内存操作场景, 甚至很多地方都是申请了内存做永久使用的, 所以这样的内存管理方式已经足够凑合着用了, 毕竟内核也不指望用它一辈子. 在系统完成初始化之后所有的工作会移交给强大的buddy系统来进行内存管理。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值