关于fuse文件系统的基本概念,可以参考https://blog.csdn.net/ty_laurel/article/details/51685193这篇文章。在这篇文章里,较详细了介绍了fuse文件系统的代码结构和原理,以及工作流程。
本文从一次实际的文件系统访问操作出发,分析在fuse文件系统下,ls命令的执行过程。通过这次分析,对系统命令、文件系统、fuse文件系统实现进行一次剖面的微观学习。使用的源码版本为linux-4.17.6,gblic2.28,coreutils8.30。
本文参考了https://cloud.tencent.com/developer/article/1093521,这篇文章分析了ls命令的执行流程。
关于VFS和linux文件管理的主要数据结构和缓存原理,可以参考https://blog.csdn.net/stonesharp/article/details/50315921,这篇文章对VFS的主要原理和数据结构做了详尽的介绍。
https://blog.csdn.net/wh8_2011/article/details/50708829详细分析了VFS中目录查找的过程。
http://blog.chinaunix.net/uid-20522771-id-4419703.html详细分析了VFS中路径查找的具体策略。
https://blog.csdn.net/younger_china/article/details/73743565介绍了fuse的文件操作执行流程和原理。
ls命令是GNU的coreutils软件包的一部分,可以在http://ftp.gnu.org/gnu/coreutils/下载到源码。其中的ls.c就是ls命令的代码。
其中真正访问文件系统的代码是gobble_file函数,其中使用了glibc的int stat (const char *filename, struct stat *buf)接口来获取文件的信息。
stat函数返回文件信息后,根据buf的内容输出最终结果。
stat函数的相关分析也可以参考https://blog.csdn.net/pk_20140716/article/details/77168421这篇文章。
stat函数是glibc中对于系统调用的封装接口。glibc的源码可以在http://ftp.gnu.org/gnu/glibc获取。
在
/* 64-bit libc uses the kernel's 'struct stat', accessed via the
stat() syscall; 32-bit libc uses the kernel's 'struct stat64'
and accesses it via the stat64() syscall. All the various
APIs offered by libc use the kernel shape for their struct stat
structure; the only difference is that 32-bit programs not
using __USE_FILE_OFFSET64 only see the low 32 bits of some
of the fields (specifically st_ino, st_size, and st_blocks). */
注释中说明,在64位系统中,stat函数通过stat系统调用来获取文件信息。32位系统则通过stat64系统调用来获取信息。
在
/* Get information about the file NAME in BUF. */
int
__xstat (int vers, const char *name, struct stat *buf)
{
if (vers == _STAT_VER_KERNEL || vers == _STAT_VER_LINUX)
return INLINE_SYSCALL (stat, 2, name, buf);
__set_errno (EINVAL);
return -1;
}
INLINE_SYSCALL宏在x86_64架构下最终使用syscall指令调用__NR_stat(4)号系统调用。
关于系统调用的详细执行逻辑和实现可以参考笔者另一篇关于系统调用的分析https://blog.csdn.net/dillanzhou/article/details/82733562。
通过内核源码
sys_newstat的实现在
VFS(Virtural File System,虚拟文件系统)是Linux的文件系统抽象层。在Linux系统中,支持各种类型的文件系统,既有ext3、ext4这种存储在物理硬件中的物理文件系统,也有devfs、procfs这种用于支持系统功能的非物理临时文件系统,还有NFS网络文件系统等等。Linux需要对这些差异巨大的文件系统提供统一的访问和操作接口来达到“一切皆文件”的使用效果,选择的解决方案就是VFS。Linux中所有需要支持标准文件访问操作的文件系统,都需要实现VFS定义的标准接口,并注册到VFS的全局文件系统列表中。用户态进程对任何文件的访问操作,都会通过系统调用传递到VFS对外接口,VFS通过文件路径判断文件属于哪个文件系统,并使用对应文件系统实现的访问操作函数来访问或操作文件。
vfs_stat就是VFS层为stat文件访问操作提供的对外接口。vfs_stat调用vfs_statx实现功能,vfs_statx是所有文件状态获取操作的统一实现,除了vfs_stat外,vfs_lstat和vfs_fstatat接口也都使用vfs_statx实现。(vfs_statx是最新的实现函数,笔者看4.8版本的代码时,实现函数还是vfs_fstatat。vfs_statx的实现和4.8以下版本的vfs_fstatat基本是一样的。)vfs_statx的代码如下:
/**
* vfs_statx - Get basic and extra attributes by filename
* @dfd: A file descriptor representing the base dir for a relative filename
* @filename: The name of the file of interest
* @flags: Flags to control the query
* @stat: The result structure to fill in.
* @request_mask: STATX_xxx flags indicating what the caller wants
*
* This function is a wrapper around vfs_getattr(). The main difference is
* that it uses a filename and base directory to determine the file location.
* Additionally, the use of AT_SYMLINK_NOFOLLOW in flags will prevent a symlink
* at the given name from being referenced.
*
* 0 will be returned on success, and a -ve error code if unsuccessful.
*/
int vfs_statx(int dfd, const char __user *filename, int flags,
struct kstat *stat, u32 request_mask)
{
struct path path;
int error = -EINVAL;
unsigned int lookup_flags = LOOKUP_FOLLOW | LOOKUP_AUTOMOUNT;
if ((flags & ~(AT_SYMLINK_NOFOLLOW | AT_NO_AUTOMOUNT |
AT_EMPTY_PATH | KSTAT_QUERY_FLAGS)) != 0)
return -EINVAL;
if (flags & AT_SYMLINK_NOFOLLOW)
lookup_flags &= ~LOOKUP_FOLLOW;
if (flags & AT_NO_AUTOMOUNT)
lookup_flags &= ~LOOKUP_AUTOMOUNT;
if (flags & AT_EMPTY_PATH)
lookup_flags |= LOOKUP_EMPTY;
retry:
error = user_path_at(dfd, filename, lookup_flags, &path);
if (error)
goto out;
error = vfs_getattr(&path, stat, request_mask, flags);
path_put(&path);
if (retry_estale(error, lookup_flags)) {
lookup_flags |= LOOKUP_REVAL;
goto retry;
}
out:
return error;
}
EXPORT_SYMBOL(vfs_statx);
可以看到vfs_statx中主要执行了两个步骤:1. 使用uer_path_at,根据filename获取文件的路径信息path;2. 使用vfs_getattr,根据path获取文件stat信息。
uer_path_at经过几层调用,最终使用filename_lookup函数来查找文件名。filename_lookup首先使用set_nameidata初始化nameidata数据结构,之后调用path_lookupat查找文件信息。值得注意的是set_nameidata中使用了current->nameidata语句,这里的current是一个宏,在不同硬件架构下实现不同,在x86下的实现在
path_lookupat首先使用path_init函数获取查找起始路径(根目录/或者当前路径pwd)的索引dentry,然后通过link_path_walk函数逐层查找每级目录的dentry,直到查找到最后一层路径或查找失败为止。link_path_walk通过一个for循环逐级查找目录,每个循环的逻辑可以分为两个步骤:第一步,解析当前剩余的路径名中第一层路径的长度和内容,判断是普通路径还是“.”或“..”,并计算路径的hash,保存在hash_len中。hash_len是一个u64变量,使用高32bit保存当前层路径长度,低32bit保存路径hash值(内核开发者就是这么任性,用两个u32变量不行么...)。第二步,从OK标签开始,使用walk_component函数查找下一级路径。
walk_component函数首先使用handle_dots函数处理路径为“.”和“..”的情况。之后使用lookup_fast在VFS系统的dentry LRU缓存中根据之前计算的hash值进行快速检索,如果在缓存中没有查到目标路径的信息,则调用lookup_slow。lookup_slow的主要实现在__lookup_slow中,该函数使用d_alloc_parallel创建新的dentry,之后使用上层目录的inode节点的inode->i_op->lookup接口来查找文件信息。这一步的lookup函数就涉及到文件系统的真正实现了,在物理设备的文件系统中,这个函数很有可能会与磁盘驱动交互来操作磁盘,读取磁盘的目录块来获取上层目录中的文件信息,从而查找当前层目录或文件的inode信息,填充到dentry中。
在VFS看来,fuse也是一种正常的文件系统,fuse文件系统的inode操作接口在
fuse_lookup主要调用fuse_lookup_name来查找目标路径的inode。fuse_lookup_name函数主要有两个步骤:1.使用fuse_simple_request函数,与用户态的fuse client进程交互,通过异步方式发送操作请求,并阻塞等待操作响应,响应信息中就包含了目标路径的inode信息。2.使用fuse_iget函数,根据响应信息生成inode节点。
这部分逻辑的关键在于fuse_simple_request中的异步获取路径信息。经过几层调用,最终调用
这里,fuse的内核模块与用户态进程之间事实上是通过fuse_iqueue这个队列来进行交互的,但是这个队列是个内核态的数据结构,用户态进程显然无法直接访问。为了实现内核和用户态进程的交互,fuse文件系统在内核中注册了一个虚拟的字符设备,设备节点为/dev/fuse。用户态进程通过对这个虚拟字符设备的读写操作来获取内核fuse文件系统操作的请求,并发送响应。
用户态进程与内核的交互通过libfuse库实现。libfuse库的入口为fuse_main。fuse_main是一个宏,真正执行的是fuse_main_real函数。在fuse_main_real函数中会进行一系列的初始化工作,其中就包括设置用户态文件系统的各类文件处理操作的响应函数,打开/dev/fuse设备文件等。之后,fuse_main_real调用fuse_loop(单线程模式)或fuse_loop_mt_32(多线程模式)函数,使进程开始循环读取/dev/fuse,获取内核中的文件处理操作请求。真正读取/dev/fuse设备文件的函数为fuse_session_receive_buf_int,在这个函数中通过glibc的read操作从/dev/fuse中读取内核操作请求,保存到fuse_buf数据结构中,其过程与上文中介绍的stat函数的实现过程基本一致。然后调用fuse_session_process_buf_int处理读取到的请求。值得注意的是,对/dev/fuse的操作也是通过VFS系统实现的,内核在/dev目录上挂载了devtmpfs文件系统,对/dev/目录下设备的操作都通过这个文件系统的接口来实现,最终调用设备驱动中的对应操作接口。
在内核的
fuse_session_process_buf_int函数处理从/dev/fuse中读取到的请求。请求中有一个字段opcode表示这个请求的操作类型,在本文当前的场景下,类型为FUSE_LOOKUP。全局操作数组fuse_ll_ops中设置了每个请求操作类型的处理函数,FUSE_LOOKUP对应的函数为do_lookup。do_lookup函数调用fuse_lowlevel_ops中的lookup接口函数,这个接口函数的默认实现是fuse_lib_lookup,也可以被重新实现。fuse_lib_lookup调用lookup_path来查找文件,使用reply_entry返回查找结果。
lookup_path主要有两个步骤:1. 调用fuse_fs_getattr获取目录文件信息。fuse_fs_getattr中,调用了fs->op.getattr来真正获取文件信息。这里的op是fuse_operations类型的结构,其中包含各类文件操作函数指针,这些函数指针接口是要用户态文件系统自己来实现和指定的。如果获取信息成功,则执行下一步,否则返回目录或文件不存在。2. 调用do_lookup获取节点信息。最终调用find_node函数。该函数首先调用lookup_node,基于文件名计算哈希值,在当前的文件节点哈希表中搜索。如果表中没有文件节点,则调用alloc_node分配一个新的节点,初始化后使用hash_name函数插入到文件节点哈希表中。
lookup_path的逻辑看上去有些奇怪,居然是先获取到目录文件信息,再获取节点信息的。这主要是为了灵活性的考虑,因为在一些虚拟文件系统中,文件是否存在不是通过确定性的节点查找来确定的,而是通过一些路径和文件名规则直接判定的。因此将文件是否存在的判定全部交给了getattr接口来实现。需要注意的是getattr接口使用的是文件的完整路径例如/a/b/c(这个路径由get_path_name函数逐层附加文件路径来生成),而lookup_node使用的是文件名c和上级目录节点信息。getattr接口可以通过对整个路径的一些分析来确定文件是否存在以及文件的状态,这些操作可以是完全虚拟抽象的,和物理存储结构没有任何关系;而lookup_node则只对node哈希表进行操作,行为是非常固定的,因此也不提供接口进行重写。所有对文件系统结构的控制就封装在getattr接口中了。当然,用户态进程可以直接覆盖底层的fuse_lowlevel_ops的lookup接口来重新实现文件查找操作,这时候就可以实现更高效或更符合文件系统实际结构的查找逻辑。
reply_entry最终调用fuse_send_msg函数向内核反馈操作结果,该函数使用writev系统调用向内核回写数据。read和write相关系统调用都在
经过上述VFS->fuse文件系统->fuse设备驱动->fuse进程->fuse设备驱动->fuse文件系统的一系列操作,vfs_statx函数终于获取了fuse文件系统中文件的路径信息path,包括dentry和inode。下一步,vfs_statx调用vfs_getattr来获取文件信息。
vfs_getattr调用inode->i_op->getattr来获取信息,fuse文件系统的实现函数为fuse_getattr,该函数主要调用fuse_update_get_attr。fuse_update_get_attr函数会判断fuse_inode节点中的时间信息来判断节点中保存的文件信息是否有效,如果仍然有效,则直接用fuse_inode中的文件信息来填充kstat结构;如果已经失效,则调用fuse_do_getattr重新获取信息。
fuse_do_getattr同样适用fuse_simple_request与用户态进程通讯,这次的opcode为FUSE_GETATTR。用户态libfuse的操作逻辑与上一节中基本一致,这次的对应处理函数为do_getattr。do_getattr调用fuse_lowlevel_ops中的getattr接口,默认实现为fuse_lib_getattr。
fuse_lib_getattr调用fuse_fs_getattr获取文件信息,然后调用fuse_reply_attr向内核反馈。fuse_fs_getattr中,调用了fs->op.getattr来真正获取文件信息。这里的op是fuse_operations类型的结构,其中包含各类文件操作函数指针,这些函数指针接口是要用户态文件系统自己来实现和指定的。
可以发现这个fuse_fs_getattr函数的描述和上面的文件查找lookup_path过程中的是同一个。事实上,在实际执行ls命令的过程中,vfs_getattr的执行过程里,这里是不会真正被调用的。原因在于在lookup阶段已经通过fuse_fs_getattr获取过文件信息了,在上面提到的fuse_update_get_attr函数中会发现文件信息刚刚被获取过,因此不会再真正向用户态进程发起查询请求。在debug模式下运行fuse的样例程序可以验证这一点。
执行到这里,就成功获取到了目标目录或文件的状态信息,通过cp_new_stat64函数将信息复制给用户态的数据指针后本次查询就结束了。
本文分析了使用ls命令查看fuse用户态文件系统中文件状态的整个实现过程,借此对ls工具、glibc封装、系统调用的实现、VFS系统原理、fuse内核文件系统、fuse驱动、fuse用户态库与进程的相关原理进行了关联分析。可以看到,对fuse文件系统的一次stat操作,可能就要经过stat系统调用->VFS->fuse内核文件系统执行到挂起->read系统调用读取/dev/fuse设备->VFS->fuse内核驱动->fuse用户态操作->writev系统调用->VFS->fuse内核驱动->fuse内核文件系统唤醒继续执行->VFS->fuse内核文件系统执行到挂起->read系统调用读取/dev/fuse设备->VFS->fuse内核驱动->fuse用户态操作->writev系统调用->VFS->fuse内核驱动->fuse内核文件系统唤醒继续执行->stat系统调用结束,这样一个极其复杂的流程,其中光系统调用就执行了5次,内核fuse系统和用户态fuse文件系统进程间通过设备文件驱动进行交互,效率显然远低于普通的内核态文件系统访问。
此外,fuse的用户态文件系统只包括文件系统本身的接口,只能实现虚拟的内存文件系统。如果要实现保存于磁盘的物理文件系统,有两个选择:1. 基于已有的物理文件系统,例如ext4等。用户态文件系统的文件信息和数据保存在下层物理文件系统的文件中,使用时先从下层物理文件系统中获取和写入再将结果转发给fuse内核。2. 在用户态文件系统中直接操作磁盘。这需要直接通过系统调用与内核磁盘驱动交互,或者干脆实现或使用用户态磁盘驱动,例如SPDK。
综上所述,可以总结一下fuse的优缺点和应用场景。优点:可在用户态实现自己的文件系统,灵活性高,实现的文件系统可以通过标准的文件访问接口来访问。对文件系统的调整不需要重新编译内核(使用内核模块ko同样可以办到),编译出的文件系统程序理论上不和特定内核版本绑定(这一点ko就办不到了)。缺点:效率低下,如果要实现物理文件系统则效率会更低(除非使用用户态磁盘驱动)。
因此,fuse的应用场景应该是在对性能要求不高的情况下,实现比较灵活、功能需要频繁变化的虚拟或小型物理文件系统。早期的Android操作系统就使用fuse文件系统来访问管理sdcard上的ext4物理文件系统,从而实现较复杂的权限管理功能。但是这种访问方式访问速度明显低于普通的ext4文件系统访问,使得sdcard的访问速度明显低于标称速度。因此在Android8.0中,引入了sdcardfs文件系统替换fuse,避免了大量的状态切换和内存重复访问复制。