Linux内存管理11(基于6.1内核)

Linux内存管理11(基于6.1内核)---内存启动-build_zonelists初始化

一、初始化备用内存域列表zonelists


之前讲了在memblock完成之后, 内存初始化开始进入第二阶段, 第二阶段是一个漫长的过程, 它执行了一系列复杂的操作, 从体系结构相关信息的初始化慢慢向上层展开, 其主要执行了如下操作:

特定于体系结构的设置

在完成了基础的内存结点和内存域的初始化工作以后, 我们必须克服一些硬件的特殊设置

  • 在初始化内存的结点和内存区域之前, 内核先通过pagging_init初始化了内核的分页机制, 这样我们的虚拟运行空间就初步建立, 并可以完成物理地址到虚拟地址空间的映射工作。

在arm64架构下, 内核在start_kernel()->setup_arch()中通过arm64_memblock_init( )完成了memblock的初始化之后, 接着通过setup_arch()->paging_init()开始初始化分页机制。

paging_init负责建立只能用于内核的页表, 用户空间是无法访问的. 这对管理普通应用程序和内核访问内存的方式,有深远的影响。

  • 在分页机制完成后, 内核通过setup_arch()->bootmem_init开始进行内存基本数据结构(内存结点pg_data_t, 内存域zone和页帧)的初始化工作, 就是在这个函数中, 内核开始从体系结构相关的部分逐渐展开到体系结构无关的部分, 在zone_sizes_init->free_area_init_node中开始, 内核开始进行内存基本数据结构的初始化, 也不再依赖于特定体系结构无关的层次。
bootmem_init
始化内存数据结构包括内存节点, 内存域和页帧page
|
|---->arm64_numa_init();
|     支持numa架构
|
|---->zone_sizes_init(min, max);
    来初始化节点和管理区的一些数据项
    |
    |---->free_area_init_node
    |   初始化内存节点
    |
        |---->free_area_init_core
            |   初始化zone
            |
            |---->memmap_init
            |   初始化page页面
|
|---->memblock_dump_all();
|   初始化完成, 显示memblock的保留的所有内存信息

至此,bootmem_init已经完成了节点和管理区的关键数据已完成初始化, 内核在后面为内存管理做得一个准备工作就是将所有节点的管理区都链入到zonelist中,便于后面内存分配工作的进行。

内核在start_kernel()–>mm_core_init()->build_all_zonelist()中完成zonelist的初始化。

二、后备内存域列表zonelists


内核setup_arch的最后通过bootmem_init中完成了内存数据结构的初始化(包括内存结点pg_data_t, 内存管理域zone和页面信息page), 数据结构已经基本准备好了, 在后面为内存管理做得一个准备工作就是将所有节点的管理区都链入到zonelist中, 便于后面内存分配工作的进行。

2.1 回到start_kernel函数


前面分析了start_kernel()->setup_arch()函数, 已经完成了memblock内存分配器的创建和初始化工作, 然后paging_init也完成分页机制的初始化, 然后bootmem_init也完成了内存结点和内存管理域的初始化工作. setup_arch函数已经执行完了,回到start_kernel。

asmlinkage __visible void __init start_kernel(void)
{

    setup_arch(&command_line);

    mm_core_init();

    kmem_cache_init_late();

    kmemleak_init();
    setup_per_cpu_pageset();

    rest_init();
}

下面内核开始通过start_kernel()–>mm_core_init()->build_all_zonelist()来设计内存的组织形式

2.2 后备内存域列表zonelist


内存节点pg_data_t中将内存节点中的内存区域zone按照某种组织层次存储在一个zonelist中, 即pglist_data->node_zonelists成员信息include/linux/mmzone.h

typedef struct pglist_data {
	/*
	 * node_zones contains just the zones for THIS node. Not all of the
	 * zones may be populated, but it is the full list. It is referenced by
	 * this node's node_zonelists as well as other node's node_zonelists.
	 */
	struct zone node_zones[MAX_NR_ZONES];

	/*
	 * node_zonelists contains references to all zones in all nodes.
	 * Generally the first zones will be references to this node's
	 * node_zones.
	 */
	struct zonelist node_zonelists[MAX_ZONELISTS];
...
}

内核定义了内存的一个层次结构关系, 首先试图分配廉价的内存,如果失败,则根据访问速度和容量,逐渐尝试分配更昂贵的内存。

高端内存最廉价, 因为内核没有任何部分依赖于从该内存域分配的内存, 如果高端内存用尽, 对内核没有副作用, 所以优先分配高端内存。

普通内存域的情况有所不同, 许多内核数据结构必须保存在该内存域, 而不能放置到高端内存域, 因此如果普通内存域用尽, 那么内核会面临内存紧张的情况。

DMA内存域最昂贵,因为它用于外设和系统之间的数据传输。
举例来讲,如果内核指定想要分配高端内存域。它首先在当前结点的高端内存域寻找适当的空闲内存段,如果失败,则查看该结点的普通内存域,如果还失败,则试图在该结点的DMA内存域分配。如果在3个本地内存域都无法找到空闲内存,则查看其他结点。这种情况下,备选结点应该尽可能靠近主结点,以最小化访问非本地内存引起的性能损失。

2.3 build_all_zonelists初始化zonelists


内核在start_kernel中通过mm_core_init->build_all_zonelists完成了内存结点及其管理内存域的初始化工作, 调用如下

build_all_zonelists(NULL);

 build_all_zonelists(NULL);建立内存管理结点及其内存域的组织形式, 将描述内存的数据结构(结点, 管理域, 页帧)通过一定的算法组织在一起, 方便以后内存管理工作的进行. 该函数定义mm/page_alloc.c

2.4 build_all_zonelists函数


void __ref build_all_zonelists(pg_data_t *pgdat)
{
	unsigned long vm_total_pages;

	if (system_state == SYSTEM_BOOTING) {
		build_all_zonelists_init();
	} else {
		__build_all_zonelists(pgdat);
		/* cpuset refresh routine should be here */
	}
	/* Get the number of free pages beyond high watermark in all zones. */
	vm_total_pages = nr_free_zone_pages(gfp_zone(GFP_HIGHUSER_MOVABLE));
	/*
	 * Disable grouping by mobility if the number of pages in the
	 * system is too low to allow the mechanism to work. It would be
	 * more accurate, but expensive to check per-zone. This check is
	 * made on memory-hotadd so a system can start with mobility
	 * disabled and enable it later
	 */
	if (vm_total_pages < (pageblock_nr_pages * MIGRATE_TYPES))
		page_group_by_mobility_disabled = 1;
	else
		page_group_by_mobility_disabled = 0;

	pr_info("Built %u zonelists, mobility grouping %s.  Total pages: %ld\n",
		nr_online_nodes,
		page_group_by_mobility_disabled ? "off" : "on",
		vm_total_pages);
#ifdef CONFIG_NUMA
	pr_info("Policy zone: %s\n", zone_names[policy_zone]);
#endif
}

三、设置结点初始化顺序


3.1 zone_table 的变化

在较早的内核中,zone_table 是一个指向不同内存区域(zone)数组的指针,它代表了内核中不同类型内存区域的布局。例如,它会列出 ZONE_DMAZONE_NORMALZONE_HIGHMEM 等区域。

在现代的内核版本中,zone_table 已经被更灵活的内存区域管理方式替代。主要的变化有:

  • zone 数组的管理:在现代内核中,内存区域的管理被封装在更复杂的结构体和系统中,不再直接使用 zone_table 来表示内存区域。内存区域和 NUMA 节点的信息通常通过 struct zonestruct pg_data_t 等数据结构来进行管理。

  • build_zonelists():在 Linux 6.1 内核中,zonelist 的构建和管理是通过新的机制进行的,不再直接操作旧的 zone_table。内核通过 build_zonelists()zone_sizes_init() 等函数来初始化内存区域。

3.2 替代 zone_table 的数据结构

在 Linux 6.x 内核中,内存管理不再直接依赖 zone_table,而是使用以下数据结构和函数来进行内存区域和 zonelist 的管理:

pg_data_tstruct zone

  • pg_data_t 结构体:代表一个 NUMA 节点的数据,包括该节点下的内存区域信息。每个 NUMA 节点都有一个 pg_data_t 结构,其中包含了指向不同内存区域(如 ZONE_DMAZONE_NORMAL 等)的指针。

  • struct zone 结构体:表示一个内存区域,负责管理内存页的分配、回收和区域属性。每个 NUMA 节点可能包含多个 struct zone,以支持不同类型的内存区域。

include/linux/mmzone.h 

struct pg_data_t {
    struct zone *node_zones[MAX_NR_ZONES];
    struct zone *zones[MAX_NUMNODES][MAX_NR_ZONES];
    ...
};

struct zone {
    // 管理内存页的信息
};

zonelist 和内存区域的初始化

内存区域的初始化和构建 zonelist(内存区域链表)通常是通过如下函数来完成:

  • build_zonelists():根据 NUMA 配置和系统的内存布局来构建 zonelist。它会根据物理内存的分布和 NUMA 节点来设置适当的内存分配优先级。
void build_zonelists(void) {
    for_each_possible_node(node) {
        build_zonelist_for_node(node);
    }
}
  • zone_sizes_init():用于初始化内存区域的大小信息。这个函数会设置每个内存区域的大小和属性,以确保内存管理系统可以根据不同区域的特点进行内存分配。
void zone_sizes_init(void) {
    // 设置每个内存区域的大小和其他信息
}

3.3 内存区域与 NUMA 节点的关系

在多 NUMA 节点系统中,pg_data_t 结构会存储每个节点的 zone 信息,确保内存区域正确地划分和管理。每个 NUMA 节点的 pg_data_t 会指向一个 zonelist,表示该节点的内存区域链表。

四、build_all_zonelists_init完成内存域zonelists的初始化


build_all_zonelists函数在通过set_zonelist_order设置了zonelists中结点的组织顺序后, 首先检查了ssytem_state标识. 如果当前系统处于boot阶段(SYSTEM_BOOTING), 就开始通过build_all_zonelists_init函数初始化zonelist

build_all_zonelists(pg_data_t *pgdat, struct zone *zone)
{

    if (system_state == SYSTEM_BOOTING) {
        build_all_zonelists_init();

4.1 system_state系统状态标识


其中system_state变量是一个系统全局定义的用来表示系统当前运行状态的枚举变量。

 include/linux/kernel.h

/* Values used for system_state */
extern enum system_states
{
    SYSTEM_BOOTING,
    SYSTEM_RUNNING,
    SYSTEM_HALT,
    SYSTEM_POWER_OFF,
    SYSTEM_RESTART,
} system_state;
  • 如果系统system_state是SYSTEM_BOOTING, 则调用build_all_zonelists_init初始化所有的内存结点。

  • 否则的话如果定义了冷热页CONFIG_MEMORY_HOTPLUG且参数zone(待初始化的内存管理域zone)不为NULL, 则调用setup_zone_pageset设置冷热页。

if (system_state == SYSTEM_BOOTING)
{
    build_all_zonelists_init();
}
else
{
#ifdef CONFIG_MEMORY_HOTPLUG
    if (zone)
        setup_zone_pageset(zone);
#endif

4.2 build_all_zonelists_init函数

build_all_zonelists函数在如果当前系统处于boot阶段(system_state == SYSTEM_BOOTING), 就开始通过build_all_zonelists_init函数初始化zonelist。

build_all_zonelists_init函数定义mm/page_alloc.c

static noinline void __init
build_all_zonelists_init(void)
{
	int cpu;

	__build_all_zonelists(NULL);

	/*
	 * Initialize the boot_pagesets that are going to be used
	 * for bootstrapping processors. The real pagesets for
	 * each zone will be allocated later when the per cpu
	 * allocator is available.
	 *
	 * boot_pagesets are used also for bootstrapping offline
	 * cpus if the system is already booted because the pagesets
	 * are needed to initialize allocators on a specific cpu too.
	 * F.e. the percpu allocator needs the page allocator which
	 * needs the percpu allocator in order to allocate its pagesets
	 * (a chicken-egg dilemma).
	 */
	for_each_possible_cpu(cpu)
		per_cpu_pages_init(&per_cpu(boot_pageset, cpu), &per_cpu(boot_zonestats, cpu));

	mminit_verify_zonelist();
	cpuset_init_current_mems_allowed();
}

build_all_zonelists_init将将所有工作都委托给__build_all_zonelists完成了zonelists的初始化工作, 后者又对系统中的各个NUMA结点分别调用build_zonelists。

函数__build_all_zonelists定义mm/page_alloc.c

static void __build_all_zonelists(void *data)
{
	int nid;
	int __maybe_unused cpu;
	pg_data_t *self = data;
	unsigned long flags;

	/*
	 * The zonelist_update_seq must be acquired with irqsave because the
	 * reader can be invoked from IRQ with GFP_ATOMIC.
	 */
	write_seqlock_irqsave(&zonelist_update_seq, flags);
	/*
	 * Also disable synchronous printk() to prevent any printk() from
	 * trying to hold port->lock, for
	 * tty_insert_flip_string_and_push_buffer() on other CPU might be
	 * calling kmalloc(GFP_ATOMIC | __GFP_NOWARN) with port->lock held.
	 */
	printk_deferred_enter();

#ifdef CONFIG_NUMA
	memset(node_load, 0, sizeof(node_load));
#endif

	/*
	 * This node is hotadded and no memory is yet present.   So just
	 * building zonelists is fine - no need to touch other nodes.
	 */
	if (self && !node_online(self->node_id)) {
		build_zonelists(self);
	} else {
		/*
		 * All possible nodes have pgdat preallocated
		 * in free_area_init
		 */
		for_each_node(nid) {
			pg_data_t *pgdat = NODE_DATA(nid);

			build_zonelists(pgdat);
		}

#ifdef CONFIG_HAVE_MEMORYLESS_NODES
		/*
		 * We now know the "local memory node" for each node--
		 * i.e., the node of the first zone in the generic zonelist.
		 * Set up numa_mem percpu variable for on-line cpus.  During
		 * boot, only the boot cpu should be on-line;  we'll init the
		 * secondary cpus' numa_mem as they come on-line.  During
		 * node/memory hotplug, we'll fixup all on-line cpus.
		 */
		for_each_online_cpu(cpu)
			set_cpu_numa_mem(cpu, local_memory_node(cpu_to_node(cpu)));
#endif
	}

	printk_deferred_exit();
	write_sequnlock_irqrestore(&zonelist_update_seq, flags);
}

for_each_online_node遍历了系统中所有的活动结点.

由于UMA系统只有一个结点,build_zonelists只调用了一次, 就对所有的内存创建了内存域列表.

NUMA系统调用该函数的次数等同于结点的数目. 每次调用对一个不同结点生成内存域数据。

4.3 build_zonelists初始化每个内存结点的zonelists


build_zonelists(pg_data_t *pgdat)完成了节点pgdat上zonelists的初始化工作, 它建立了备用层次结构zonelists. 由于UMA和NUMA架构下结点的层次结构有很大的区别, 因此内核分别提供了两套不同的接口:mm/page_alloc.c

#ifdef CONFIG_NUMA
/*
 * Build zonelists ordered by zone and nodes within zones.
 * This results in conserving DMA zone[s] until all Normal memory is
 * exhausted, but results in overflowing to remote node while memory
 * may still exist in local DMA zone.
 */

static void build_zonelists(pg_data_t *pgdat)
{
	static int node_order[MAX_NUMNODES];
	int node, nr_nodes = 0;
	nodemask_t used_mask = NODE_MASK_NONE;
	int local_node, prev_node;

	/* NUMA-aware ordering of nodes */
	local_node = pgdat->node_id;
	prev_node = local_node;

	memset(node_order, 0, sizeof(node_order));
	while ((node = find_next_best_node(local_node, &used_mask)) >= 0) {
		/*
		 * We don't want to pressure a particular node.
		 * So adding penalty to the first node in same
		 * distance group to make it round-robin.
		 */
		if (node_distance(local_node, node) !=
		    node_distance(local_node, prev_node))
			node_load[node] += 1;

		node_order[nr_nodes++] = node;
		prev_node = node;
	}

	build_zonelists_in_node_order(pgdat, node_order, nr_nodes);
	build_thisnode_zonelists(pgdat);
	pr_info("Fallback order for Node %d: ", local_node);
	for (node = 0; node < nr_nodes; node++)
		pr_cont("%d ", node_order[node]);
	pr_cont("\n");
}


#else   /* CONFIG_NUMA */

static void build_zonelists(pg_data_t *pgdat)
{
	int node, local_node;
	struct zoneref *zonerefs;
	int nr_zones;

	local_node = pgdat->node_id;

	zonerefs = pgdat->node_zonelists[ZONELIST_FALLBACK]._zonerefs;
	nr_zones = build_zonerefs_node(pgdat, zonerefs);
	zonerefs += nr_zones;

	/*
	 * Now we build the zonelist so that it contains the zones
	 * of all the other nodes.
	 * We don't want to pressure a particular node, so when
	 * building the zones for node N, we make sure that the
	 * zones coming right after the local ones are those from
	 * node N+1 (modulo N)
	 */
	for (node = local_node + 1; node < MAX_NUMNODES; node++) {
		if (!node_online(node))
			continue;
		nr_zones = build_zonerefs_node(NODE_DATA(node), zonerefs);
		zonerefs += nr_zones;
	}
	for (node = 0; node < local_node; node++) {
		if (!node_online(node))
			continue;
		nr_zones = build_zonerefs_node(NODE_DATA(node), zonerefs);
		zonerefs += nr_zones;
	}

	zonerefs->zone = NULL;
	zonerefs->zone_idx = 0;
}

#endif  /* CONFIG_NUMA */

我们以UMA结构下的build_zonelists为例, 来讲讲内核是怎么初始化备用内存域层次结构的, UMA结构下的build_zonelists函数定义mm/page_alloc.c

node_zonelists的数组元素通过指针操作寻址, 这在C语言中是完全合法的惯例。实际工作则委托给build_zonerefs_node。在调用时,它首先生成本地结点内分配内存时的备用次。

内核在build_zonelists中按分配代价从昂贵到低廉的次序, 迭代了结点中所有的内存域. 而在build_zonerefs_node中, 则按照分配代价从低廉到昂贵的次序, 迭代了分配代价不低于当前内存域的内存域。

首先我们来看看build_zonerefs_node函数, mm/page_alloc.c

/*
 * Builds allocation fallback zone lists.
 *
 * Add all populated zones of a node to the zonelist.
 */
static int build_zonerefs_node(pg_data_t *pgdat, struct zoneref *zonerefs)
{
	struct zone *zone;
	enum zone_type zone_type = MAX_NR_ZONES;
	int nr_zones = 0;

	do {
		zone_type--;
		zone = pgdat->node_zones + zone_type;
		if (populated_zone(zone)) {
			zoneref_set_zone(zone, &zonerefs[nr_zones++]);
			check_highest_zone(zone_type);
		}
	} while (zone_type);

	return nr_zones;
}

备用列表zonelists的各项是借助于zone_type参数排序的, 该参数指定了最优先选择哪个内存域, 该参数的初始值是外层循环的控制变量。其值可能是ZONE_HIGHMEM、ZONE_NORMAL、ZONE_DMA或ZONE_DMA32之一。

nr_zones表示从备用列表中的哪个位置开始填充新项. 由于列表中尚没有项, 因此调用者传递了0。

内核在build_zonelists中按分配代价从昂贵到低廉的次序, 迭代了结点中所有的内存域. 而在build_zonelists_node中, 则按照分配代价从低廉到昂贵的次序, 迭代了分配代价不低于当前内存域的内存域。

在build_zonelists_node的每一步中, 都对所选的内存域调用populated_zone, 确认zone->present_pages大于0, 即确认内存域中确实有页存在. 倘若如此, 则将指向zone实例的指针添加到zonelist->zones中的当前位置. 后备列表的当前位置保存在nr_zones。

在每一步结束时, 都将内存域类型zone_type减1.换句话说, 设置为一个更昂贵的内存域类型. 例如, 如果开始的内存域是ZONE_HIGHMEM, 减1后下一个内存域类型是ZONE_NORMAL。

考虑一个系统, 有内存域ZONE_HIGHMEM、ZONE_NORMAL、ZONE_DMA。在第一次运行build_zonelists_node时, 实际上会执行下列赋值。

zonelist->zones[0] = ZONE_HIGHMEM;
zonelist->zones[1] = ZONE_NORMAL;
zonelist->zones[2] = ZONE_DMA;

以某个系统为例, 图中示范了一个备用列表在多次循环中不断填充的过程,系统中共有四个结点:

其中
A=(NUMA)结点0 0=DMA内存域
B=(NUMA)结点1 1=普通内存域
C=(NUMA)结点2 2=高端内存域
D=(NUMA)结点3

第一步之后, 列表中的分配目标是高端内存,接下来是第二个结点的普通和DMA内存域。

内核接下来必须确立次序,以便将系统中其他结点的内存域按照次序加入到备用列表。

回到build_zonelists函数, UMA架构下该函数定义:mm/page_alloc.c

static void build_zonelists(pg_data_t *pgdat)
{
	int node, local_node;
	struct zoneref *zonerefs;
	int nr_zones;

	local_node = pgdat->node_id;

	zonerefs = pgdat->node_zonelists[ZONELIST_FALLBACK]._zonerefs;
	nr_zones = build_zonerefs_node(pgdat, zonerefs);
	zonerefs += nr_zones;

	/*
	 * Now we build the zonelist so that it contains the zones
	 * of all the other nodes.
	 * We don't want to pressure a particular node, so when
	 * building the zones for node N, we make sure that the
	 * zones coming right after the local ones are those from
	 * node N+1 (modulo N)
	 */
	for (node = local_node + 1; node < MAX_NUMNODES; node++) {
		if (!node_online(node))
			continue;
		nr_zones = build_zonerefs_node(NODE_DATA(node), zonerefs);
		zonerefs += nr_zones;
	}
	for (node = 0; node < local_node; node++) {
		if (!node_online(node))
			continue;
		nr_zones = build_zonerefs_node(NODE_DATA(node), zonerefs);
		zonerefs += nr_zones;
	}

	zonerefs->zone = NULL;
	zonerefs->zone_idx = 0;
}

        第一个for循环依次迭代大于当前结点编号的所有结点。在我们的例子中,有4个结点编号副本为0、1、2、3,此时只剩下结点3。新的项通过build_zonelists_node被加到备用列表。此时j的作用就体现出来了。在本地结点的备用目标找到之后,该变量的值是3。该值用作新项的起始位置。

        第二个for循环接下来对所有编号小于当前结点的结点生成备用列表项。

        备用列表中项的数目一般无法准确知道,因为系统中不同结点的内存域配置可能并不相同。因此列表的最后一项赋值为空指针,显式标记列表结束。
        对总数N个结点中的结点m来说,内核生成备用列表时,选择备用结点的顺序总是:m、m+1、m+2、…、N1、0、1、…、m1。这确保了不过度使用任何结点。例如,对照情况是:使用一个独立于m、不变的备用列表。

4.4 setup_pageset初始化per_cpu缓存


前面讲解内存管理域zone的时候, 提到了per-CPU缓存, 即冷热页。在组织每个节点的zonelist的过程中, per_cpu_pages_init初始化了per-CPU缓存(冷热页面)。

static void per_cpu_pages_init(struct per_cpu_pages *pcp, struct per_cpu_zonestat *pzstats)
{
	int pindex;

	memset(pcp, 0, sizeof(*pcp));
	memset(pzstats, 0, sizeof(*pzstats));

	spin_lock_init(&pcp->lock);
	for (pindex = 0; pindex < NR_PCP_LISTS; pindex++)
		INIT_LIST_HEAD(&pcp->lists[pindex]);

	/*
	 * Set batch and high values safe for a boot pageset. A true percpu
	 * pageset's initialization will update them subsequently. Here we don't
	 * need to be as careful as pageset_update() as nobody can access the
	 * pageset yet.
	 */
	pcp->high = BOOT_PAGESET_HIGH;
	pcp->batch = BOOT_PAGESET_BATCH;
	pcp->free_factor = 0;
}

在此之前free_area_init_node初始化内存结点的时候, 内核就输出了冷热页的一些信息, 该工作由zone_pcp_init完成, 该函数定义:mm/page_alloc.c

__meminit void zone_pcp_init(struct zone *zone)
{
	/*
	 * per cpu subsystem is not up at this point. The following code
	 * relies on the ability of the linker to provide the
	 * offset of a (static) per cpu variable into the per cpu area.
	 */
	zone->per_cpu_pageset = &boot_pageset;
	zone->per_cpu_zonestats = &boot_zonestats;
	zone->pageset_high = BOOT_PAGESET_HIGH;
	zone->pageset_batch = BOOT_PAGESET_BATCH;

	if (populated_zone(zone))
		pr_debug("  %s zone: %lu pages, LIFO batch:%u\n", zone->name,
			 zone->present_pages, zone_batchsize(zone));
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值