Linux虚拟文件系统(节点路径搜索)

前面对linux虚拟文件系统的架构以及设计到的数据结构有了一个整体的认识,这里看看linux内核怎么根据给定的文件路径名在内存中找到和建立代表着目标文件或目录的dentry结构和inode结构。文件路径的搜索是文件系统中最基本也是最重要的一部分之一,后面我们会看到,文件的打开、关闭等等操作都将涉及到文件路径的搜索。下面我们看看linux内核中时怎么实现的。

一、搜索中所用数据结构

/*这个数据结构是临时的,只在路径搜索的过程中返回搜索的结果。
*/
struct nameidata {
	struct path	path;/*将目录结构和mount结构封装在path结构中*/
	struct qstr	last;
	struct path	root;
	unsigned int	flags;/*对应搜索的标志*/
	int		last_type;
	unsigned	depth;
	char *saved_names[MAX_NESTED_LINKS + 1];

	/* Intent data */
	union {
		struct open_intent open;
	} intent;
};
/*用来存放路径名中当前节点的杂凑值以及节点名的长度*/
struct qstr {
	unsigned int hash;
	unsigned int len;
	const unsigned char *name;
};

二、搜索

/*name指向在用户空间的路径名;
flag为一些标志位,nd为搜索返回值
*/
int path_lookup(const char *name, unsigned int flags,
			struct nameidata *nd)
{
	return do_path_lookup(AT_FDCWD, name, flags, nd);
}

实际工作都是由上面的do_path_lookup()函数实现的,在这里我们就他进行分析。

/* Returns 0 and nd will be valid on success; Retuns error, otherwise. */
static int do_path_lookup(int dfd, const char *name,
				unsigned int flags, struct nameidata *nd)
{	/*找到搜索的起点,保存在nd中*/
	int retval = path_init(dfd, name, flags, nd);
	if (!retval)
        	/*一旦找到了搜索的起点,从起点开始路径的搜索
		其中nd用来返回搜索结果*/
		retval = path_walk(name, nd);
	if (unlikely(!retval && !audit_dummy_context() && nd->path.dentry &&
				nd->path.dentry->d_inode))
		audit_inode(name, nd->path.dentry);
	if (nd->root.mnt) {
		path_put(&nd->root);
		nd->root.mnt = NULL;
	}
	return retval;
}

2.1 初始化阶段

初始化阶段是由函数path_init()函数实现

/*path_init主要是初始化查询,设置nd结构指向查询开始处的文件,这里分两种情况:
    a,绝对路径(以/开始),获得根目录的dentry。它存储在task_struct中fs指向的fs_struct结构中。
    b,相对路径,直接从当前进程task_struct结构中的获得指针fs,它指向的一个fs_struct,
    fs_struct中有一个指向“当前工作目录”的dentry。
*/
static int path_init(int dfd, const char *name, unsigned int flags, struct nameidata *nd)
{
	int retval = 0;
	int fput_needed;
	struct file *file;
	/*在搜索的过程中,这个字段的值会随着路径名当前搜索结果而变;
	例如,如果成功找到目标文件,那么这个字段的值就变成了LAST_NORM
	而如果最后停留在了一个.上,则变成LAST_DOT(*/
	nd->last_type = LAST_ROOT; /* if there are only slashes... */
	nd->flags = flags;
	nd->depth = 0;
	nd->root.mnt = NULL;

	if (*name=='/') {/*路径名以'/'开头*/
		set_root(nd);/*设置nd的root为当前进程fs的root*/
		nd->path = nd->root;/*保存根目录*/
		path_get(&nd->root);/*递增引用计数*/
	} else if (dfd == AT_FDCWD) {/*相对路径*/
		struct fs_struct *fs = current->fs;
		read_lock(&fs->lock);
		nd->path = fs->pwd;/*保存当前路径*/
		path_get(&fs->pwd);/*递增引用计数*/
		read_unlock(&fs->lock);
	} else {/*???*/
		struct dentry *dentry;
		 /*fget_light在当前进程的struct files_struct中根据所谓的用户空间
 		文件描述符fd来获取文件描述符。另外,根据当前fs_struct
 		是否被多各进程共享来判断是否需要对文件描述符进行加
		 锁,并将加锁结果存到一个int中返回
 		*/
		file = fget_light(dfd, &fput_needed);
		retval = -EBADF;
		if (!file)
			goto out_fail;

		dentry = file->f_path.dentry;

		retval = -ENOTDIR;
		if (!S_ISDIR(dentry->d_inode->i_mode))
			goto fput_fail;
		/*权限检查*/
		retval = file_permission(file, MAY_EXEC);
		if (retval)
			goto fput_fail;
		/*获得path*/
		nd->path = file->f_path;
		path_get(&file->f_path);
		/*解锁*/
		fput_light(file, fput_needed);
	}
	return 0;

fput_fail:
	fput_light(file, fput_needed);
out_fail:
	return retval;
}

2.2 实际搜索操作

static int path_walk(const char *name, struct nameidata *nd)
{
	current->total_link_count = 0;
	return link_path_walk(name, nd);
}
/*
 * Wrapper to retry pathname resolution whenever the underlying
 * file system returns an ESTALE.
 *
 * Retry the whole path once, forcing real lookup requests
 * instead of relying on the dcache.
 */
static __always_inline int link_path_walk(const char *name, struct nameidata *nd)
{
	struct path save = nd->path;
	int result;

	/* make sure the stuff we saved doesn't go away */
	path_get(&save);/*递增path的引用计数*/
	/*实际的工作*/
	result = __link_path_walk(name, nd);
	if (result == -ESTALE) {
		/* nd->path had been dropped */
		nd->path = save;
		path_get(&nd->path);
		nd->flags |= LOOKUP_REVAL;
		result = __link_path_walk(name, nd);
	}

	path_put(&save);

	return result;
}
/*
 * Name resolution.
 * This is the basic name resolution function, turning a pathname into
 * the final dentry. We expect 'base' to be positive and a directory.
 *
 * Returns 0 and nd will have valid dentry and mnt on success.
 * Returns error and drops reference to input namei data on failure.
 */
static int __link_path_walk(const char *name, struct nameidata *nd)
{
	struct path next;
	struct inode *inode;
	int err;
	unsigned int lookup_flags = nd->flags;
	/*如果路径名以'/'开头,就把他跳过去,因为在这种情况下nd中
	path已经指向本进程的根目录了,注意,这里多个连续的'/'与一个
	‘/’是等价的,如果路径名中仅仅包含有'/'字符的话,那么其
	目标就是根目录,所以任务完成,不然需要继续搜索*/
	while (*name=='/')
		name++;
	if (!*name)
		goto return_reval;
	/*作为path_walk起点的节点必定是一个目录,一定有相应的索引节点
	存在,所以指针inode一定是有效的,而不可能是空指针*/
	inode = nd->path.dentry->d_inode;
    /*进程的task_struct结构中有个计数器link_count.在搜索过程中有可能
    碰到一个节点(目录项)只是指向另一个节点的链接,此时就用这个计数器来对
    链的长度进行计数,这样,当链的长度达到某一个值时就可以终止搜索而失败
    返回,以防陷入循环。另一方面,当顺着符号链接进入另一个设备上的文件系统
    时,有可能会递归地调用path_walk。所以,进入path_walk后,如果发现这个
    计数器值非0,就表示正在顺着符号链接递归调用path_walk往前搜索过程中,
    此时不管怎样都把LOOKUP_FOLLOW标志位设成1.*/
	if (nd->depth)
		lookup_flags = LOOKUP_FOLLOW | (nd->flags & LOOKUP_CONTINUE);

	/* At this point we know we have a real path component. */
	for(;;) {
		unsigned long hash;
        
		struct qstr this;
		unsigned int c;

		nd->flags |= LOOKUP_CONTINUE;
		/*检查当前进程对当前节点的访问权限,这里所检查的是相对路径中
		的各层目录(而不是目标文件)的访问权限。注意,对于中间节点所需
		的权限为执行权,即MAY_EXEC*/
      		 err = exec_permission_lite(inode);
 		if (err)
			break;

		this.name = name;
		c = *(const unsigned char *)name;

		hash = init_name_hash();
		do {
			name++;
			hash = partial_name_hash(c, hash);
			c = *(const unsigned char *)name;
		} while (c && (c != '/'));/*路径名中的节点定以‘/’字符分开的,*/
		this.len = name - (const char *) this.name;
		this.hash = end_name_hash(hash);

		/* remove trailing slashes? */
		if (!c)/*最后一个字符为'\0',就是说当前节点已经是路径名中的最后一节*/
			goto last_component;/*跳转*/
		/*循环跳过'/'*/
		while (*++name == '/');
		/*当前节点实际上已经是路径名的最后一个节点,只不过在此后面又多添加了
		若干个'/'字符,这种情况常常发生在用户界面上,特别是在shell的命令中
		当然这种情况要求最后的节点必须是个目录*/
		if (!*name)
			goto last_with_slashes;/*跳转*/

        	/*运行到这里,表示当前节点为中间节点,所以'/'字符后面还有其他字符*/
		/*
		 * "." and ".." are special - ".." especially so because it has
		 * to be able to know about the current root directory and
		 * parent relationships.
		 */
		 /*以'.'开头表示这是个隐藏的文件,而对于代表着目录的节点则只有在两种
		 情况下才是允许的。一种是节点名为'.',表示当前目录,另一种是'..',表示
		 当前目录的父目录*/
		if (this.name[0] == '.') switch (this.len) {
			default:
				break;
			case 2:	
				if (this.name[1] != '.')
					break;
				follow_dotdot(nd);/*为'..',到父目录中去*/
				inode = nd->path.dentry->d_inode;
				/* fallthrough */
			/*2中没有break语句,也就是所继续执行1中的语句,
			将会跳到for语句的开头处理路径中的下一个节点*/
			case 1:
				continue;
		}
		/*
		 * See if the low-level filesystem might want
		 * to use its own hash..
		 */
		 /*特定文件系统提供他自己专用的杂凑函数,所以在这种情况下就通过这个
		 函数再计算一遍当前节点的杂凑值*/
		if (nd->path.dentry->d_op && nd->path.dentry->d_op->d_hash) {
			err = nd->path.dentry->d_op->d_hash(nd->path.dentry,
							    &this);
			if (err < 0)
				break;
		}
		/* This does the actual lookups.. */
		/*实际的搜索工作*/
       		 err = do_lookup(nd, &this, &next);
		if (err)
			break;

		err = -ENOENT;
		inode = next.dentry->d_inode;
		if (!inode)
			goto out_dput;
		/*涉及到具体文件系统的相关操作*/
		if (inode->i_op->follow_link) {
			err = do_follow_link(&next, nd);
			if (err)
				goto return_err;
			err = -ENOENT;
			inode = nd->path.dentry->d_inode;
			if (!inode)
				break;
		} else/*将path中的相关内容转化到nd中*/
			path_to_nameidata(&next, nd);
		err = -ENOTDIR; 
		if (!inode->i_op->lookup)
			break;
		continue;
		/* here ends the main loop */

last_with_slashes:
		lookup_flags |= LOOKUP_FOLLOW | LOOKUP_DIRECTORY;
last_component:
		/* Clear LOOKUP_CONTINUE iff it was previously unset */
		nd->flags &= lookup_flags | ~LOOKUP_CONTINUE;
		if (lookup_flags & LOOKUP_PARENT)/*要寻找的不是路径终点,而是他的上一层*/
			goto lookup_parent;
		if (this.name[0] == '.') switch (this.len) {
			default:
				break;
			case 2:	
				if (this.name[1] != '.')
					break;
				follow_dotdot(nd);/*向上层移动*/
				inode = nd->path.dentry->d_inode;
				/* fallthrough */
			case 1:
				goto return_reval;
		}
		/*具体文件系统的操作*/
		if (nd->path.dentry->d_op && nd->path.dentry->d_op->d_hash) {
			err = nd->path.dentry->d_op->d_hash(nd->path.dentry,
							    &this);
			if (err < 0)
				break;
		}/*顺次查找路径节点,下一个存放在next中*/
		err = do_lookup(nd, &this, &next);
		if (err)
			break;
		inode = next.dentry->d_inode;
		if ((lookup_flags & LOOKUP_FOLLOW)/*当终点为符号链接时*/
		    && inode && inode->i_op->follow_link) {
			err = do_follow_link(&next, nd);
			if (err)
				goto return_err;
			inode = nd->path.dentry->d_inode;
		} else
			/*path转化为nd*/
			path_to_nameidata(&next, nd);
		err = -ENOENT;
		if (!inode)
			break;
		if (lookup_flags & LOOKUP_DIRECTORY) {
			err = -ENOTDIR; 
			if (!inode->i_op->lookup)
				break;
		}
		goto return_base;
lookup_parent:
		nd->last = this;
		nd->last_type = LAST_NORM;/*根据终点节点名设置*/
		if (this.name[0] != '.')
			goto return_base;
		if (this.len == 1)
			nd->last_type = LAST_DOT;
		else if (this.len == 2 && this.name[1] == '.')
			nd->last_type = LAST_DOTDOT;
		else
			goto return_base;
return_reval:
		/*
		 * We bypassed the ordinary revalidation routines.
		 * We may need to check the cached dentry for staleness.
		 */
		if (nd->path.dentry && nd->path.dentry->d_sb &&
		    (nd->path.dentry->d_sb->s_type->fs_flags & FS_REVAL_DOT)) {
			err = -ESTALE;
			/* Note: we do not d_invalidate() */
			if (!nd->path.dentry->d_op->d_revalidate(
					nd->path.dentry, nd))
				break;
		}
return_base:
		return 0;
out_dput:
		path_put_conditional(&next, nd);
		break;
	}
	path_put(&nd->path);
return_err:
	return err;
}

2.2.1 处理double dot

所谓double dot为访问上层目录

static __always_inline void follow_dotdot(struct nameidata *nd)
{
	set_root(nd);

	while(1) {
		struct vfsmount *parent;
		struct dentry *old = nd->path.dentry;
		/*如果已经达到本进程的根节点,这时不能再往上跑了
		所以保持不变*/
		if (nd->path.dentry == nd->root.dentry &&
		    nd->path.mnt == nd->root.mnt) {
			break;
		}
		spin_lock(&dcache_lock);
	        /*已经到达节点与其父节点在同一个设备上。在这种情况下
	        既然已经到达的这个节点的dentry结构已经建立,则其父节点的
	        dentry结构也必然已经建立在内存中,而且dentry结构中的指针
	        d_parent就指向其父节点,所以往上跑一层是很简单的事情*/
		if (nd->path.dentry != nd->path.mnt->mnt_root) {
			nd->path.dentry = dget(nd->path.dentry->d_parent);/*往上走一层,并且对应用计数加一*/
			spin_unlock(&dcache_lock);
			dput(old);/*释放就得目录的引用*/
			break;
		}
		spin_unlock(&dcache_lock);
		spin_lock(&vfsmount_lock);
	        /*运行到这里,表示已经到达节点就是其所在设备上的根节点
	        往上跑一层就要跑到另一个设备上去了,当将一个存储设备安装到
	        另一个设备上的某个节点时,内核会分配和设置一个vfsmount
	        结构,通过这个结构将两个设备以及两个节点连接起来。
	        所以,每个已经安装的存储设备都有一个vfsmount结构,结构
	        中有个指针mnt_parent指向其父设备,另一个指针mnt_mountpoint
	        指向代表这安装点的dentry结构*/
		/*保存nd指定的mnt的父mnt*/
		parent = nd->path.mnt->mnt_parent;
       		 /*当前的vfsmount结构代表这跟设备*/
		if (parent == nd->path.mnt) {
			spin_unlock(&vfsmount_lock);
			break;
		}
      	  /*当前设备不是跟设备*/
        
		mntget(parent);
		/*指向该设备上的安装点的上一层目录*/        
		nd->path.dentry = dget(nd->path.mnt->mnt_mountpoint);
		spin_unlock(&vfsmount_lock);
		dput(old);
		mntput(nd->path.mnt);
		nd->path.mnt = parent;/*指向上层设备上的vfsmount 结构*/
	}
	follow_mount(&nd->path);/*mnt向子节点移动一个*/
}
static void follow_mount(struct path *path)
{
	while (d_mountpoint(path->dentry)) {/*如果该文件系统已经安装*/
		/*找到指定的孩子mnt*/ 
		struct vfsmount *mounted = lookup_mnt(path);
		if (!mounted)/*如果没有孩子mnt了*/
			break;
        /*递减引用计数*/
		dput(path->dentry);
		mntput(path->mnt);
		path->mnt = mounted;/*找到的mnt作为path的mnt*/
		path->dentry = dget(mounted->mnt_root);/*递增引用计数,将找到的mnt文件系统的根目录赋给path*/
	}
}

2.2.2 实际的路径搜索工作

/*
 *  It's more convoluted than I'd like it to be, but... it's still fairly
 *  small and for now I'd prefer to have fast path as straight as possible.
 *  It _is_ time-critical.
 */
static int do_lookup(struct nameidata *nd, struct qstr *name,
		     struct path *path)
{
	struct vfsmount *mnt = nd->path.mnt;
    /*在内存中寻找该节点已经建立的dentry结构。内核中有个hash表dentry_hashtable
    是一个list_head指针数组,一旦在内存中建立起一个目录节点的dentry结构
    就根据其节点名的hash值挂入hash表中的某个队列,需要寻找时则还是根据hash值从
    hash表着手*/
	struct dentry *dentry = __d_lookup(nd->path.dentry, name);

	if (!dentry)/*如果没有找到,转向下面*/
		goto need_lookup;
	if (dentry->d_op && dentry->d_op->d_revalidate)
		goto need_revalidate;
done:/*内存中找到了dentry*/
	path->mnt = mnt;
	path->dentry = dentry;
    /*访问下一个mnt,其实就是现在mnt的子mnt*/
	__follow_mount(path);
	return 0;

need_lookup:/*到这里是在内存中没有找到dentry结构*/
   	 /*到磁盘上通过其所在的目录寻找,找到后在内存中为其建立起
   	 dentry结构并将之挂入hash表中某个队列中*/
	dentry = real_lookup(nd->path.dentry, name, nd);
	if (IS_ERR(dentry))
		goto fail;
	goto done;

need_revalidate:
	dentry = do_revalidate(dentry, nd);
	if (!dentry)
		goto need_lookup;
	if (IS_ERR(dentry))
		goto fail;
	goto done;

fail:
	return PTR_ERR(dentry);
}
/*
 * This is called when everything else fails, and we actually have
 * to go to the low-level filesystem to find out what we should do..
 *
 * We get the directory semaphore, and after getting that we also
 * make sure that nobody added the entry to the dcache in the meantime..
 * SMP-safe
 */
static struct dentry * real_lookup(struct dentry * parent, struct qstr * name, struct nameidata *nd)
{
	struct dentry * result;
	struct inode *dir = parent->d_inode;

	mutex_lock(&dir->i_mutex);
	/*
	 * First re-do the cached lookup just in case it was created
	 * while we waited for the directory semaphore..
	 *
	 * FIXME! This could use version numbering or similar to
	 * avoid unnecessary cache lookups.
	 *
	 * The "dcache_lock" is purely to protect the RCU list walker
	 * from concurrent renames at this point (we mustn't get false
	 * negatives from the RCU list walk here, unlike the optimistic
	 * fast walk).
	 *
	 * so doing d_lookup() (with seqlock), instead of lockfree __d_lookup
	 */
	result = d_lookup(parent, name);
	if (!result) {
		struct dentry *dentry;

		/* Don't create child dentry for a dead directory. */
		result = ERR_PTR(-ENOENT);
		if (IS_DEADDIR(dir))
			goto out_unlock;
		/*从slab中分配dentry并且初始化*/
		dentry = d_alloc(parent, name);
		result = ERR_PTR(-ENOMEM);
		if (dentry) {
            		/*调用具体文件系统的loopup函数*/
			result = dir->i_op->lookup(dir, dentry, nd);
			if (result)
				dput(dentry);
			else
				result = dentry;
		}
out_unlock:
		mutex_unlock(&dir->i_mutex);
		return result;
	}

	/*
	 * Uhhuh! Nasty case: the cache was re-populated while
	 * we waited on the semaphore. Need to revalidate.
	 */
	mutex_unlock(&dir->i_mutex);
	if (result->d_op && result->d_op->d_revalidate) {
		result = do_revalidate(result, nd);
		if (!result)
			result = ERR_PTR(-ENOENT);
	}
	return result;
}

2.2.2.1 分配dentry并且初始化

struct dentry *d_alloc(struct dentry * parent, const struct qstr *name)
{
	struct dentry *dentry;
	char *dname;
	/*从slab中非配dentry*/
	dentry = kmem_cache_alloc(dentry_cache, GFP_KERNEL);
	if (!dentry)
		return NULL;

	if (name->len > DNAME_INLINE_LEN-1) {
		dname = kmalloc(name->len + 1, GFP_KERNEL);
		if (!dname) {
			kmem_cache_free(dentry_cache, dentry); 
			return NULL;
		}
	} else  {
		dname = dentry->d_iname;
	}
    	/*初始化非配的dentry结构*/
	dentry->d_name.name = dname;

	dentry->d_name.len = name->len;
	dentry->d_name.hash = name->hash;
	memcpy(dname, name->name, name->len);
	dname[name->len] = 0;

	atomic_set(&dentry->d_count, 1);
	dentry->d_flags = DCACHE_UNHASHED;
	spin_lock_init(&dentry->d_lock);
	dentry->d_inode = NULL;
	dentry->d_parent = NULL;
	dentry->d_sb = NULL;
	dentry->d_op = NULL;
	dentry->d_fsdata = NULL;
	dentry->d_mounted = 0;
	INIT_HLIST_NODE(&dentry->d_hash);
	INIT_LIST_HEAD(&dentry->d_lru);
	INIT_LIST_HEAD(&dentry->d_subdirs);
	INIT_LIST_HEAD(&dentry->d_alias);

	if (parent) {
		dentry->d_parent = dget(parent);
		dentry->d_sb = parent->d_sb;
	} else {
		INIT_LIST_HEAD(&dentry->d_u.d_child);
	}

	spin_lock(&dcache_lock);
	if (parent)
		list_add(&dentry->d_u.d_child, &parent->d_subdirs);
	dentry_stat.nr_dentry++;
	spin_unlock(&dcache_lock);

	return dentry;
}

从上面的代码中可以看到,linux内核中的路径搜索大体工作如下:

1,初始化查询,设置nd结构指向查询开始处的文件;

2,从起点开始路径的搜索,其中nd用来返回搜索结果,在搜索过程中需要根据路径名称一步一步的访问,包括字符‘/‘的处理、访问上层目录的处理(需要考虑超出本文件系统)以及访问的dentry在内存中不存在需要从新分配的情况等;

程序返回后,参数中的nd结构保存了当前的搜索结果信息,包括目标文件或目录的dentry结构和inode结构。


  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
Linux系统中,虚拟文件系统(Virtual File System,VFS)是一个抽象层,用于统一管理不同文件系统的接口和操作。它提供了一种统一的文件系统访问方式,使得应用程序可以透明地处理不同类型的文件系统,而无需关心底层文件系统的差异。 虚拟文件系统的管理机制主要包括以下几个方面: 1. VFS数据结构:VFS维护了一些重要的数据结构,如超级块(superblock)、索引节点(inode)、文件描述符(file descriptor)等。这些数据结构用于表示文件系统的状态和属性,并提供了对文件和目录的访问接口。 2. 文件系统注册:在Linux系统启动时,各个文件系统需要向VFS注册自己的操作函数和属性。这样,当用户程序请求访问某个文件系统时,VFS可以根据文件路径的前缀判断应该使用哪个文件系统的操作函数。 3. 文件路径解析:当用户程序提供一个文件路径时,VFS负责将路径解析为具体的文件系统和索引节点。它会逐级搜索路径中的目录,并调用相应文件系统的操作函数进行路径解析和权限检查。 4. 文件缓存管理:为了提高文件访问效率,VFS采用了文件缓存(Page Cache)机制。它将最近访问的文件块缓存在内存中,避免频繁的磁盘访问。文件缓存使用了LRU(Least Recently Used)算法来管理缓存中的文件块。 5. 文件系统挂载:在Linux系统中,可以通过挂载操作将一个文件系统关联到指定的挂载点上。VFS负责管理已挂载的文件系统,并提供了挂载、卸载和重新挂载等操作接口。挂载点形成了一个文件系统的树状结构,称为虚拟文件系统表(Virtual File System Table)。 通过这些机制,VFS实现了对不同文件系统的抽象和统一管理。它使得用户程序可以通过统一的文件系统接口进行文件操作,而无需关心底层文件系统的具体细节。这为Linux系统的灵活性和可扩展性提供了基础。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值