Linux: radix tree实现简析

1. 前言

限于作者能力水平,本文可能存在谬误,因此而给读者带来的损失,作者不做任何承诺。

2. 测试环境

本文基于 linux-4.14.132 内核代码分析。

3. 实现

3.1 概念

基数树(radix tree) ,在 Linux 中是将一个 整数键值 ,映射到 数据指针 的字典树。

3.2 数据结构

Linux 基数树用数据结构 struct radix_tree_root 描述,定义在文件 linux/inclue/radix-tree.h 中:

struct radix_tree_root {
	/* 调用 kmem_cache_alloc() 分配 radix_tree_node 节点时使用的掩码 */
	gfp_t			gfp_mask;
	/* 存储 第1个数据 item 指针 或 根节点 radix_tree_node 指针 */
	struct radix_tree_node	__rcu *rnode;
};

基数树中,当数据 item 指针大于1个情形下, 要额外分配 struct radix_tree_node 来存储它们,来看一下 struct radix_tree_node

/*
 * @count is the count of every non-NULL element in the ->slots array
 * whether that is an exceptional entry, a retry entry, a user pointer,
 * a sibling entry or a pointer to the next level of the tree.
 * @exceptional is the count of every element in ->slots which is
 * either radix_tree_exceptional_entry() or is a sibling entry for an
 * exceptional entry.
 */
struct radix_tree_node {
	unsigned char	shift;		/* Bits remaining in each slot(当前节点slots[]存放item的索引移位:即 (index>>RADIX_TREE_MAP_SHIFT) & RADIX_TREE_MAP_MASK) */
	unsigned char	offset;		/* Slot offset in parent(当前节点指针存放在父节点@parent slots[]中的偏移位置) */
	unsigned char	count;		/* Total entry count (节点slots[]中已存放item数目)*/
	unsigned char	exceptional;	/* Exceptional entry count */
	struct radix_tree_node *parent;		/* Used when ascending tree (父节点,根节点为 NULL) */
	struct radix_tree_root *root;		/* The tree we belong to */
	union {
		struct list_head private_list;	/* For tree user */
		struct rcu_head	rcu_head;	/* Used when freeing node */
	};
	/* 
	 * 存放 item 或 子节点 radix_tree_node  指针:
	 * . 叶节点存放 item 
	 * . 非叶节点存放下一级子节点 radix_tree_node 指针
	 */
	void __rcu	*slots[RADIX_TREE_MAP_SIZE];
	/* 
	 * Linux 4.14 内核每个 slot 只支持 {0,1,2} 3 个 tag 值。
	 * 每个 slot 是否设置了对应的 tag ,通过 bit map 标记:1 表示设置了 tag 。
	 * 每 slot 、每 tag 一个 bit ,所以每 slot 占用 1x3 = 3 个 bit 。
	 */
	unsigned long	tags[RADIX_TREE_MAX_TAGS][RADIX_TREE_TAG_LONGS];
};

基树通过索引 key 值,逐层映射,如下图:
在这里插入图片描述
上图是一个3层的基数树,在 RADIX_TREE_MAP_SHIFT 配置为 6 的情形下,映射的步骤如下:

1. key[17:12] 索引根节点的slots[],从根节点的slots[key[17:12]]处找到第二层的对应节点; 
2. key[11:6] 索引步骤1中找到节点的slots[],找到第三层的对应叶节点;
3. key[5:0] 索引步骤2中找到的叶节点的slots[],定位到要操作的目标item。

上述 根节点struct radix_tree_node::rnode 指向的节点 struct radix_tree_node叶节点是指没有子节点的节点。树不一定是3层,根据现存数据的变化,树的高度会变高或变矮。树的节点也不是一次性建立的,也是根据现存数据情况出现增减。一种特殊情形是:当基树只有一个数据项的时候,直接用 struct radix_tree_node::rnode 存放。

3.3 对基数树的操作

3.3.1 初始化

基树的初始化可以通过定义时初始化运行时初始化两种,下面分别进行演示:

/* 定义时初始化 */
RADIX_TREE(my_radix_tree, GFP_KERNEL);
struct radix_tree_root my_radix_tree;

/* 运行时初始化 */
INIT_RADIX_TREE(&my_radix_tree, GFP_KERNEL);

3.3.2 插入

int __radix_tree_insert(struct radix_tree_root *, unsigned long index,
			unsigned order, void *);
static inline int radix_tree_insert(struct radix_tree_root *root,
			unsigned long index, void *entry)
{
	return __radix_tree_insert(root, index, 0, entry);
}

root 参数传递基树对象指针 &my_radix_treeindex 参数为 item 的索引,entry 参数为 item 指针。看下插入操作的代码实现:

int __radix_tree_insert(struct radix_tree_root *root, unsigned long index,
			unsigned order, void *item)
{
	...
	
	error = __radix_tree_create(root, index, order, &node, &slot);
	if (error)
		return error;

	/* 插入 @item 到节点 @node 的 @slot */
	error = insert_entries(node, slot, item, order, false);
	if (error < 0)
		return error;
	...
	
	return 0;
}

int __radix_tree_create(struct radix_tree_root *root, unsigned long index,
			unsigned order, struct radix_tree_node **nodep,
			void __rcu ***slotp)
{
	struct radix_tree_node *node = NULL, *child;
	void __rcu **slot = (void __rcu **)&root->rnode;
	unsigned long maxindex;
	unsigned int shift, offset = 0;
	unsigned long max = index | ((1UL << order) - 1);
	...

	/*
	 * 加载【根节点】信息:
	 * child: 根节点指针;
	 * maxindex: 根节点最大索引值;
	 * shift: 【 根节点索引移位 + RADIX_TREE_MAP_SHIFT:从该值计算根节点索引区间范围 】。
	 */
	shift = radix_tree_load_root(root, &child, &maxindex);
	
	...
	
	if (max > maxindex) { /* 请求插入 item 的 index ,大于基树当前索引区间,需要扩展基树 */
		/* 按 {max,shift} 扩展基树,以能容纳新 item 的 index */
		int error = radix_tree_extend(root, gfp, max, shift);
		if (error < 0)
			return error;
		shift = error;
		child = rcu_dereference_raw(root->rnode); /* 扩展后基树的新根节点 */
	}

	/*
	 * 寻找插入的目标位置: {@node, @slot}.
	 *
	 * 循环直到基树叶节点为止:
	 * 所有上层节点的 slots 不用来存放 item,而是存放更下一层 
	 * radix_tree_node 的指针,不存放 item ;
	 * 只有叶节点才能插入 item .
	 */
	while (shift > order) {
		shift -= RADIX_TREE_MAP_SHIFT;
		if (child == NULL) { /* 子节点 radix_tree_node 尚未创建,创建它 */
			/* Have to add a child node.  */
			child = radix_tree_node_alloc(gfp, node, root, shift,
							offset, 0, 0);
			if (!child)
				return -ENOMEM;
			/* 新分配的子节点 @child 插入上层父节点 @node offset 位置的 slot */
			rcu_assign_pointer(*slot, node_to_entry(child));
			if (node) /* 父节点 @node 不为空 */
				node->count++; /* 添加子节点 @child 到了 上层父节点 @node ,父节点 slot 计数加1 */
		} else if (!radix_tree_is_internal_node(child)) // ???
			break;

		/* Go a level down */
		node = entry_to_node(child);
		/* 
		 * 计算 @index 在 @node 中的插入位置: 
		 * offset: 插入位置在 @node 中 slot 的偏移位置;
		 * child: 插入位置 offset 处 slot 当前值: 
		 *        如果 @node 是叶节点,可能返回已插入 item 地址 或 NULL (空闲情形);
		 *        如果 @node 是非叶节点,可能返回已创建的 radix_tree_node 指针 或 NULL (尚未分配)。
		 */
		offset = radix_tree_descend(node, &child, index);
		slot = &node->slots[offset];
	}
	
	if (nodep)
		*nodep = node; /* 返回要插入的 node */
	if (slotp)
		*slotp = slot; /* 返回要插入的 node 中 slot */
	return 0;
}

static unsigned radix_tree_load_root(const struct radix_tree_root *root,
		struct radix_tree_node **nodep, unsigned long *maxindex)
{
	struct radix_tree_node *node = rcu_dereference_raw(root->rnode);

	/*
	 * 返回【根节点】指针:
	 * . 没有 item 时返回 NULL
	 * . 只有 1个 item 时返回该 item
	 * . 其它情形返回【根节点】指针
	 */
	*nodep = node;

	if (likely(radix_tree_is_internal_node(node))) {
		node = entry_to_node(node);
		*maxindex = node_maxindex(node); /* 返回【根节点】的最大索引值 */
		return node->shift + RADIX_TREE_MAP_SHIFT; /* 返回: 【根节点】的索引移位 + RADIX_TREE_MAP_SHIFT */
	}

	*maxindex = 0; /* 当前只有 @root ,它只能存一个 item ,所以最大索引为0 */
	return 0; /* 同理 shift 返回 0 */
}

static int radix_tree_extend(struct radix_tree_root *root, gfp_t gfp,
				unsigned long index, unsigned int shift)
{
	void *entry;
	unsigned int maxshift;
	int tag;

	/* Figure out what the shift should be.  */
	maxshift = shift;
	while (index > shift_maxindex(maxshift)) /* 可能需要将基树增高多级,才能容纳 @index */
		maxshift += RADIX_TREE_MAP_SHIFT;

	/*
	 * 保存【当前根节点】:
	 * 增高基树后,新增的、最顶层的 radix_tree_node 会成为新的【根节点】,
	 * 而【当前根节点及其子树】会成为比其高一层的、新增节点的子节点(子树)。
	 */
	entry = rcu_dereference_raw(root->rnode);
	if (!entry && (!is_idr(root) || root_tag_get(root, IDR_FREE)))
		goto out;

	do {
		/* 创建一个节点。后创建的节点,比先创建的节点位于基树更上层 */
		struct radix_tree_node *node = radix_tree_node_alloc(gfp, NULL,
							root, shift, 0, 1, 0);
		if (!node)
			return -ENOMEM;

		...

		if (radix_tree_is_internal_node(entry)) {
			/* 旧的根节点在基树中下移一层:设置新节点 @node 为 旧根节点 @entry 的父节点 */
			entry_to_node(entry)->parent = node;
		} else if (radix_tree_exceptional_entry(entry)) {
			...
		}
		/*
		 * entry was already in the radix tree, so we do not need
		 * rcu_assign_pointer here
		 */
		node->slots[0] = (void __rcu *)entry;  /* 【新的根节点】的第1个slot指向【旧的根节点】 */
		entry = node_to_entry(node);
		rcu_assign_pointer(root->rnode, entry); /* 设置新 @node 成为基树根节点 */
		shift += RADIX_TREE_MAP_SHIFT; /* 计算更高一层节点的索引移位 */
	} while (shift <= maxshift); /* 是否还要增加一层,索引范围才能容纳 @index ? */
out:
	return maxshift + RADIX_TREE_MAP_SHIFT; /* 返回比新基数根节点更高一层节点的 shift */
}

static struct radix_tree_node *
radix_tree_node_alloc(gfp_t gfp_mask, struct radix_tree_node *parent,
			struct radix_tree_root *root,
			unsigned int shift, unsigned int offset,
			unsigned int count, unsigned int exceptional)
{
	struct radix_tree_node *ret = NULL;
	...
	ret = kmem_cache_alloc(radix_tree_node_cachep, gfp_mask);
out:
	BUG_ON(radix_tree_is_internal_node(ret));
	if (ret) {
		ret->shift = shift;
		ret->offset = offset;
		ret->count = count;
		ret->exceptional = exceptional;
		ret->parent = parent; /* 设置父节点 */
		ret->root = root;
	}
	return ret;
}

static unsigned int radix_tree_descend(const struct radix_tree_node *parent,
			struct radix_tree_node **nodep, unsigned long index)
{
	unsigned int offset = (index >> parent->shift) & RADIX_TREE_MAP_MASK; /* 计算 node / item 在节点内 slots[] 内偏移 */
	void __rcu **entry = rcu_dereference_raw(parent->slots[offset]);
	
	...
	*nodep = (void *)entry;
	return offset;
}

小结一下基数树的插入过程:

如章节 3.2 末尾处描述,通过将 index 按每位域 RADIX_TREE_MAP_SHIFT 位进行拆分,通过这些
位域,在基树中逐级定位每层级的 struct radix_tree_node ,直到找到一个叶节点为止,然后用 
index 的最低 RADIX_TREE_MAP_SHIFT 位来索引该叶节点的 slots[] ,最后将 item 存入到目标 
slot 。
在插入过程中:如果某层级所有节点的 slots[] 存满,则会引起基数树增高一层;如果某个要插入
的目标节点不存在,则会创建新的一个或多个节点。
正如前面所述,基数树只有叶节点的 slots[] 才存储 item ,其它层级节点的 slots[] 存储下一
层级节点的指针。

3.3.3 查找

有了插入过程的铺垫,查找过程就不难理解了。这里只描述 radix_tree_lookup() ,其它接口读者可自行分析。看一下 radix_tree_lookup() 的实现:

void *radix_tree_lookup(const struct radix_tree_root *root, unsigned long index)
{
	return __radix_tree_lookup(root, index, NULL, NULL);
}

void *__radix_tree_lookup(const struct radix_tree_root *root,
			  unsigned long index, struct radix_tree_node **nodep,
			  void __rcu ***slotp)
{
	struct radix_tree_node *node, *parent;
	unsigned long maxindex;
	void __rcu **slot;

 restart:
	parent = NULL;
	slot = (void __rcu **)&root->rnode;
	radix_tree_load_root(root, &node, &maxindex);
	if (index > maxindex) /* 索引指向的 item 不存在 */
		return NULL;

	while (radix_tree_is_internal_node(node)) {
		unsigned offset;

		if (node == RADIX_TREE_RETRY)
			goto restart;
		parent = entry_to_node(node);
		offset = radix_tree_descend(parent, &node, index);  /* 基树层级下沉:逐级向下查找 */
		slot = parent->slots + offset;
	}

	if (nodep)
		*nodep = parent;
	if (slotp)
		*slotp = slot;
	return node;
}

/* radix_tree_descend() 的细节见 3.3.2 插入 */

像插入操作一样,查找也是通过将 index 按每位域 RADIX_TREE_MAP_SHIFT 位进行拆分,通过这些位域,在基树中逐级定位每层级的 struct radix_tree_node ,直到找到一个叶节点为止,然后用 index 的最低 RADIX_TREE_MAP_SHIFT 位来索引该叶节点的 slots[] ,最后找到目标 item 。

3.3.4 删除

删除操作是以查找操作为基础,同样有多个接口,这里只分析 radix_tree_delete() 的实现,对其它接口感兴趣的读者,可自行阅读代码分析。看一下 radix_tree_delete() 的具体实现:

void *radix_tree_delete(struct radix_tree_root *root, unsigned long index)
{
	return radix_tree_delete_item(root, index, NULL);
}

void *radix_tree_delete_item(struct radix_tree_root *root,
			     unsigned long index, void *item)
{
	struct radix_tree_node *node = NULL;
	void __rcu **slot = NULL;
	void *entry;

	entry = __radix_tree_lookup(root, index, &node, &slot);
	if (!slot) /* @index 指向的 item 不存在,无法删除 */
		return NULL;
	
	...
	if (item && entry != item) /* 删除指定 item: @index 和 @item 指代不一致 */
		return NULL;

	__radix_tree_delete(root, node, slot); /* 删除 item {node,slot} */

	return entry;
}

static bool __radix_tree_delete(struct radix_tree_root *root,
				struct radix_tree_node *node, void __rcu **slot)
{
	void *old = rcu_dereference_raw(*slot);
	int exceptional = radix_tree_exceptional_entry(old) ? -1 : 0;
	unsigned offset = get_slot_offset(node, slot);
	int tag;

	if (is_idr(root))
		node_tag_set(root, node, IDR_FREE, offset);
	else
		for (tag = 0; tag < RADIX_TREE_MAX_TAGS; tag++) /* 删除的同时清空 tag */
			node_tag_clear(root, node, tag, offset);

	/* 
	 * . @node slot 计数减1: node->count += -1
	 * . 清空 @slot: *slot = NULL
	 */
	replace_slot(slot, NULL, node, -1, exceptional);
	return node && delete_node(root, node, NULL, NULL); /* 删除 item 可能引发节点删除 */
}

static bool delete_node(struct radix_tree_root *root,
			struct radix_tree_node *node,
			radix_tree_update_node_t update_node, void *private)
{
	bool deleted = false;

	do {
		struct radix_tree_node *parent;

		if (node->count) { /* 节点 slot 尚不为空,但可能存在只有一个子节点的情形,这时候压缩基树 */
			if (node_to_entry(node) ==
					rcu_dereference_raw(root->rnode)) /* @node 为根节点 */
				deleted |= radix_tree_shrink(root, update_node,
								private); /* 压缩基树 */
			return deleted;
		}

		/* 节点 @node  当前包含的 slot 数目为0,则该节点可以删除了 */
		parent = node->parent;
		if (parent) { /* 如果节点 @node 有父节点,删除节点,父节点对应 slot 位置应清空,slot 计数也应减1 */
			parent->slots[node->offset] = NULL; /* 父节点对应 slot 位置应清空 */
			parent->count--; /* 父节点 slot 计数也应减1 */
		} else { /* @node 没有父节点,则其为根节点 */
			/*
			 * Shouldn't the tags already have all been cleared
			 * by the caller?
			 */
			if (!is_idr(root))
				root_tag_clear_all(root);
			root->rnode = NULL; /* 根节点被删除了 */
		}

		WARN_ON_ONCE(!list_empty(&node->private_list));
		radix_tree_node_free(node); /* 释放节点 @node */
		deleted = true; /* 标记有节点被删除 */

		node = parent; /* 继续,子节点被删除,可能导致父节点的 slot 全空,也即父节点也应被删除 */
	} while (node);

	return deleted;
}

static inline bool radix_tree_shrink(struct radix_tree_root *root,
				     radix_tree_update_node_t update_node,
				     void *private)
{
	bool shrunk = false;

	/* 层层压缩基树:将那些只有一个最左子节点的根节点删除:它们的索引域为0,没有存在的必要了 */
	for (;;) {
		struct radix_tree_node *node = rcu_dereference_raw(root->rnode); /* 取当前根节点 */
		struct radix_tree_node *child;

		if (!radix_tree_is_internal_node(node))
			break;
		node = entry_to_node(node);

		/*
		 * The candidate node has more than one child, or its child
		 * is not at the leftmost slot, or the child is a multiorder
		 * entry, we cannot shrink.
		 */
		if (node->count != 1)
			break;
		/*
		 * 根节点只有1个子节点的情形,存在压缩基树的可能性:
		 * 因为此时根节点没有存在的必要了,只需把 root->rnode 指向其子树根节点,
		 * 就可以删除当前根节点了。
		 */
		child = rcu_dereference_raw(node->slots[0]); /* 取根节点的最左边子节点 */
		/*
		 * 虽然根节点只有1个子节点,但不在最左边,意味着索引的根节点域不为0,
		 * 不能删除根节点,否则子树的节点没法索引到对应的 item 。
		 */
		if (!child)
			break;
		if (!radix_tree_is_internal_node(child) && node->shift) // ???
			break;
		/* 已满足删除根节点的所有条件,将其位于最左边、唯一的子节点的父节点清空: 根节点不存在父节点 */
		if (radix_tree_is_internal_node(child))
			entry_to_node(child)->parent = NULL;

		/*
		 * We don't need rcu_assign_pointer(), since we are simply
		 * moving the node from one part of the tree to another: if it
		 * was safe to dereference the old pointer to it
		 * (node->slots[0]), it will be safe to dereference the new
		 * one (root->rnode) as far as dependent read barriers go.
		 */
		root->rnode = (void __rcu *)child; /* 根节点的最左边、唯一的子节点,成为新的根节点 */
		if (is_idr(root) && !tag_get(node, IDR_FREE, 0))
			root_tag_clear(root, IDR_FREE);

		/*
		 * We have a dilemma here. The node's slot[0] must not be
		 * NULLed in case there are concurrent lookups expecting to
		 * find the item. However if this was a bottom-level node,
		 * then it may be subject to the slot pointer being visible
		 * to callers dereferencing it. If item corresponding to
		 * slot[0] is subsequently deleted, these callers would expect
		 * their slot to become empty sooner or later.
		 *
		 * For example, lockless pagecache will look up a slot, deref
		 * the page pointer, and if the page has 0 refcount it means it
		 * was concurrently deleted from pagecache so try the deref
		 * again. Fortunately there is already a requirement for logic
		 * to retry the entire slot lookup -- the indirect pointer
		 * problem (replacing direct root node with an indirect pointer
		 * also results in a stale slot). So tag the slot as indirect
		 * to force callers to retry.
		 */
		node->count = 0; /* 旧的根节点 @node 子节点数清0 */
		if (!radix_tree_is_internal_node(child)) { /* 基树只有一层的情形,回归 */
			node->slots[0] = (void __rcu *)RADIX_TREE_RETRY;
			...
		}

		...
		radix_tree_node_free(node); /* 释放旧的根节点的 radix_tree_node */
		shrunk = true; /* 标记压缩过基树 */
	}

	return shrunk;
}

static inline unsigned long
get_slot_offset(const struct radix_tree_node *parent, void __rcu **slot)
{
	return slot - parent->slots;
}

3.3.5 tag 操作

3.3.5.1 设置 tag
void *radix_tree_tag_set(struct radix_tree_root *root,
			unsigned long index, unsigned int tag)
{
	struct radix_tree_node *node, *parent;
	unsigned long maxindex;

	radix_tree_load_root(root, &node, &maxindex);
	BUG_ON(index > maxindex);

	/* tag 设置是一个链式操作,index 关联的各级节点 tag 都会被设置 */
	while (radix_tree_is_internal_node(node)) {
		unsigned offset;

		parent = entry_to_node(node);
		offset = radix_tree_descend(parent, &node, index);
		BUG_ON(!node);

		if (!tag_get(parent, tag, offset))
			tag_set(parent, tag, offset);
	}

	/* set the root's tag bit */
	/* 基树对象 tag 设置 (ROOT_TAG_SHIFT) : 复用 gfp_mask 中用不到的 bit 空间 */
	if (!root_tag_get(root, tag))
		root_tag_set(root, tag);

	return node;
}
3.3.5.2 清除 tag
void *radix_tree_tag_clear(struct radix_tree_root *root,
			unsigned long index, unsigned int tag)
{
	struct radix_tree_node *node, *parent;
	unsigned long maxindex;
	int uninitialized_var(offset);

	radix_tree_load_root(root, &node, &maxindex);
	if (index > maxindex) /* @index 指向的 item 不存在 */
		return NULL;

	parent = NULL;

	while (radix_tree_is_internal_node(node)) { /* 按 @index 在基树逐级下层,直到找到一个 item 为止 */
		parent = entry_to_node(node);
		offset = radix_tree_descend(parent, &node, index); /* node <== 下一级 node 或 目标item */
	}

	if (node)
		node_tag_clear(root, parent, tag, offset);  /* 清除 tag */

	return node;
}
3.3.5.3 获取 tag 状态
int radix_tree_tag_get(const struct radix_tree_root *root,
			unsigned long index, unsigned int tag)
{
	struct radix_tree_node *node, *parent;
	unsigned long maxindex;

	if (!root_tag_get(root, tag))
		return 0;

	radix_tree_load_root(root, &node, &maxindex);
	if (index > maxindex)
		return 0;

	while (radix_tree_is_internal_node(node)) {
		unsigned offset;

		parent = entry_to_node(node);
		offset = radix_tree_descend(parent, &node, index);

		if (!tag_get(parent, tag, offset))
			return 0;
		if (node == RADIX_TREE_RETRY)
			break;
	}

	return 1;
}

4. 使用范例

我将 Linux 基数树内核代码剥离出来,和测试代码一同放在 此处,这样可以方便我们再用户空间用 VS 或 GDB 进行调试,感兴趣的读者可以自取。

5. Linux 基数树的应用和发展

在 Linux 中,使用基数树来管理中断描述符;在内存管理中,基数树有广泛的应用,如用来快速发现脏页或回写中的页面等。
在较新版的 Linux 内核中,基数树使用 xarray 实现。

6. 参考资料

https://lwn.net/Articles/175432/
https://0xax.gitbooks.io/linux-insides/content/DataStructures/linux-datastructures-2.html

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值