第1章:文件系统原理与 FUSE3 环境搭建

张开发
2026/4/17 17:51:05 15 分钟阅读

分享文章

第1章:文件系统原理与 FUSE3 环境搭建
本章实例HelloFS —— 一个硬编码的只读文件系统在这个示例中当我们挂载文件系统后可以在挂载点看到一个文件也可以读取该文件的内容。文件系统是操作系统中最基础、最重要的子系统之一。我们每天使用电脑时无论是打开文档、保存照片还是运行程序都离不开文件系统的支撑。然而大多数开发者对文件系统的内部工作原理知之甚少——它像一个黑盒默默地将我们的数据组织在磁盘上。笔者在《文件系统技术内幕》中介绍了很多概念并结合产品级的代码揭示了其实现原理。但是由于企业级文件系统代码量巨大学习起来不太容易。本章及接下来的章节我们将从零实现一个文件系统代码量尽量小让大家更加彻底的理解文件系统的方方面面。由于在《文件系统技术内幕》中已经对文件系统的概念和原理进行了深入的介绍因此接下来的内容将不再重复相关的内容如果有不熟悉的概念可以翻阅《文件系统技术内幕》这本书。以Linux中的文件系统为例通常实现在内核空间。但是在内核空间实现一个文件系统的门槛是比较高的本系列文章将借助FUSE从而在用户空间实现我们的文件系统。首先我们介绍一下 FUSEFilesystem in Userspace的相关内容然后介绍如何搭建开发环境。最后我们实现一个最简单的文件系统——HelloFS。文件系统非常简单这是一个只读文件系统在该文件系统中有一个文件我们可以查看该文件的内容和属性。在实现成名这个文件系统中的文件是一个硬编码的文件。虽然是一个只包含一个文件的文件系统但它包含了 FUSE 文件系统开发的所有核心要素。1.1 Linux文件系统的整体架构Linux操作系统支持多大几十个文件系统这些文件系统包括如Ext4、XFS、Btrfs和ZFS等。为了支持多种文件系统Linux实现了一个抽象层来屏蔽底层的差异接下来我们先简单介绍一下这个抽象层。Linux 内核中存在一个精巧的抽象层叫做VFSVirtual File System虚拟文件系统。VFS 的设计目的是为用户空间程序提供统一的文件操作接口而不需要关心底层使用的是哪种具体文件系统。我们可以通过下面这个图展示VFS与应用程序和具体文件系统之间关系。我们可以举一个简单的例子比如你在终端中执行cat /home/user/hello.txt时以下是简化的调用流程从实现层面理解VFS 定义了一组标准的操作接口如read、write、open、close等每种具体的文件系统只需要实现这些接口就可以被内核统一管理。当请求到达VFS时VFS就可以根据具体文件系统注册的函数调用具体文件系统的功能。这就是为什么你可以在同一个 Linux 系统上同时使用 ext4、XFS、NFS 等不同文件系统而用户程序完全不需要修改。1.2 FUSE 架构剖析通过VFS的架构图可以看出FUSE也是位于VFS直线的一个文件系统不同之处在于到这个内核模块的数据并不会被持久化而是转发给了用户态的一个成为libfuse的模块。换而言之FUSE包含两个模块内核态的FUSE文件系统和用户态的开发库libfuse。libfuse类似VFS也提供了一套接口。如果基于libfuse开发用户态文件系统只需要实现定义的接口就行。基于FUSE开发的文件系统的整个生态包含如下几个组件。FUSE 内核模块fuse.ko注册为一个文件系统类型负责接收 VFS 的请求并转发到用户空间libfuse 用户态库提供 API 供开发者实现文件系统回调函数处理与内核的通信细节用户态文件系统程序这就是我们要开发的程序需要在这里实现具体的文件系统逻辑如创建文件读写文件和删除文件等操作。下面这张图是应用程序VFSFUSE和我们开发的文件系统之间的关系。在上图中有个非常重要的内容是/dev/fuse它是 FUSE 内核模块创建的一个字符设备。正是通过/dev/fuse实现了内核与用户态 FUSE 进程之间的通信。每次文件操作都会通过这个设备传递请求和响应。当用户态 FUSE 进程启动并挂载文件系统时它会打开/dev/fuse然后进入一个事件循环不断从该设备读取请求、处理请求、将响应写回。libfuse 库封装了这些底层通信细节让开发者只需关注文件系统逻辑。需要知道的是FUSE有两个版本也即FUSE2和FUSE3本书使用 FUSE3libfuse 3.x它相比 FUSE2 有以下重要改进API 简化去除了许多已废弃的接口头文件从fuse.h改为fuse3/fuse.hreaddirplus支持可以在目录遍历时同时返回文件属性减少额外的getattr调用改进的多线程模型支持clone_fd选项每个线程使用独立的/dev/fuse文件描述符更好的挂载 APIfuse_session_mount()替代了旧的fuse_mount()writeback cache内核端的回写缓存支持显著提升写入性能在编写代码时需要注意包含正确的头文件fuse3/fuse.h并在编译时链接fuse3库。基于FUSE整个IO的流程相对于传统文件系统会有比较大的变化。以open(/mnt/fuse_test/hello.txt, O_RDONLY)为例一次完整的 FUSE 文件操作的流程如下用户程序调用open()系统调用内核 VFS识别出/mnt/fuse_test/是一个 FUSE 挂载点因此将调用FUSE内核模块的接口FUSE 内核模块将请求打包写入/dev/fuse设备libfuse 库在用户态 FUSE 进程中从/dev/fuse读取请求用户态 FUSE 程序处理请求例如调用我们实现的open回调函数处理结果通过 libfuse 写回/dev/fuseFUSE 内核模块将结果返回给 VFSVFS将结果返回给用户程序这个过程涉及两次用户态/内核态切换比内核文件系统多这就是 FUSE 性能损失的来源。但对于我们的学习目的来说这个开销完全可以忽略。我们可以对比一下传统文件系统和基于FUSE的文件系统的异同点。传统的文件系统如 ext4、XFS、Btrfs都是作为内核模块运行的。基于内核开发的文件系统有如下特定性能极高直接在内核空间操作没有用户态/内核态切换的开销开发难度大需要精通内核编程一个 bug 可能导致整个系统崩溃调试困难不能使用常规的用户态调试工具迭代缓慢每次修改都需要重新编译内核模块、卸载/加载基于FUSE开发的文件系统为用户态文件系统其与内核文件系统存在诸多的差异主要包括开发简单使用普通的 C/C 程序开发可以使用任何用户态库调试方便可以使用 GDB、Valgrind、AddressSanitizer 等常规工具安全性好文件系统崩溃不会影响内核最多只是挂载点不可用性能损失每次文件操作都需要经过内核-用户态-内核的切换有一定开销无论是在内核开发文件系统也好还是基于FUSE开发文件系统也罢文件系统的实现原理是一样的。对于学习文件系统原理、快速原型开发、以及许多实际应用场景如网络文件系统、加密文件系统、归档文件系统FUSE 是一个极好的选择。1.3 开发环境搭建为了能够进行后续的开发我们需要搭建开发环境。本书所有内容基于Ubuntu20.04开发但在其他Linux环境应该也是可以运行的。在 Ubuntu/Debian 系统上执行以下命令安装所需的开发工具和库# 安装 FUSE3 开发库sudoapt-getinstalllibfuse3-dev fuse3# 安装编译工具链sudoapt-getinstallbuild-essential cmake pkg-config# 验证安装pkg-config--modversionfuse3# 应输出 3.x.xfusermount3--version# 应输出版本信息在 CentOS/RHEL/Fedora 上sudodnfinstallfuse3-devel fuse3sudodnfinstallgcc-c cmake pkg-config我们通过CMake工具来构建可执行文件如下是CMake的一个模板文件。在我们的配套代码中都有这样一个CMake文件大家在这里了解一下这个文件即可暂时不用深究。### CMakeLists.txt 模板 每个章节的项目都使用类似的 CMake 配置 cmake cmake_minimum_required(VERSION 3.10) project(hellofs) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(PkgConfig REQUIRED) pkg_check_modules(FUSE3 REQUIRED fuse3) add_executable(hellofs hellofs.cpp) target_include_directories(hellofs PRIVATE ${FUSE3_INCLUDE_DIRS}) target_compile_options(hellofs PRIVATE ${FUSE3_CFLAGS_OTHER}) target_link_libraries(hellofs ${FUSE3_LIBRARIES})准备好环境后我们可以先编译运行一下本章开发的程序非常简单只需要执行如下几个命令就行。# 编译cdcode/ch01_hellofsmkdirbuildcdbuild cmake..make# 创建挂载点mkdir-p/tmp/hellofs# 前台运行推荐调试时使用 -f 选项此时程序将阻塞该终端./hellofs-f/tmp/hellofs上述-f选项让 FUSE 进程在前台运行这样我们可以直接看到日志输出方便调试。不加-f的话进程会以守护进程方式运行在后台。至此我们开发的文件系统已经运行起来接下来我们可以访问挂载点目录/tmp/hellofs了。我们可以在另一个终端中进行测试比如执行如下命令ls/tmp/hellofscat/tmp/hellofs/hello.txt上述命令分布是查看文件的属性及文件的内容。好了有了初步的了解接下来我们看看这个文件系统时怎么实现的。1.4 实现 HelloFS有了前面的准备工作接下来让我们实现本书的第一个文件系统。如前文所述我们实现的文件系统非常简单它的根目录下只有一个文件hello.txt内容是固定的 “Hello, FUSE3!\n”。一个 FUSE3 程序的包含两个核心的内容定义一个fuse_operations结构体在这个结构体中填入我们实现的回调函数指针调用fuse_main()启动事件循环也就是等待内核消息接下来我们详细介绍一下这个最简单的文件系统是如何实现的先从程序的入口开始介绍。程序入口与回调注册C程序都有一个名称为main的入口函数我们开发的文件系统也不例外。以下是hellofs.cpp中的实际入口代码#defineFUSE_USE_VERSION31#includefuse3/fuse.hstaticstructfuse_operationshello_oper{};intmain(intargc,char*argv[]){hello_oper.getattrhello_getattr;hello_oper.readdirhello_readdir;hello_oper.openhello_open;hello_oper.readhello_read;returnfuse_main(argc,argv,hello_oper,NULL);}在上述代码中核心的地方是定义了结构体fuse_operations的实例并调用fuse_main函数开始监听内核的消息。核心内容包括如下几点FUSE_USE_VERSION 31必须在#include fuse3/fuse.h之前定义告诉 libfuse 使用 FUSE 3.1 API。如果不定义编译时会报版本警告fuse_operations结构体这里用 {}零初始化然后在main中逐个赋值回调函数指针。未赋值的回调保持为NULL对应操作会返回-ENOSYSFunction not implementedfuse_main()这是 FUSE 的高级 API 入口它会解析命令行参数如-f前台运行、-d调试模式、挂载点路径然后进入事件循环不断从/dev/fuse读取请求并分发到对应的回调函数为了能够实现上述最小功能的文件系统也即获取文件列表虽然只有一个文件和文件内容我们需要实现获取目录内容、打开文件、读取文件和获取文件属性的接口。也就是getattr获取属性、readdir列目录、open打开文件、read读取数据四个接口。getattr() — 文件属性查询getattr()是最重要的回调函数——几乎所有文件操作都会先调用它来获取文件/目录的属性。它的作用类似于stat()系统调用。以下是hellofs.cpp中的完整实现staticconstchar*hello_path/hello.txt;staticconstchar*hello_contentHello, FUSE3!\n;staticinthello_getattr(constchar*path,structstat*stbuf,structfuse_file_info*fi){(void)fi;// 未使用的参数显式标记避免编译警告memset(stbuf,0,sizeof(structstat));// 清零确保所有字段有确定值if(strcmp(path,/)0){stbuf-st_modeS_IFDIR|0755;// 目录 rwxr-xr-xstbuf-st_nlink2;// . 和父目录条目stbuf-st_uidgetuid();// 当前用户的 UIDstbuf-st_gidgetgid();// 当前用户的 GIDreturn0;}if(strcmp(path,hello_path)0){stbuf-st_modeS_IFREG|0444;// 普通文件 r--r--r--stbuf-st_nlink1;stbuf-st_sizestrlen(hello_content);// 14 字节stbuf-st_uidgetuid();stbuf-st_gidgetgid();return0;}return-ENOENT;// 其他路径文件不存在}要点解读(void)fiFUSE3 的getattr增加了第三个参数fuse_file_infoFUSE2 没有未使用时用(void)避免编译器警告memset清零必须先清零struct stat否则未设置的字段如st_blocks、st_blksize可能包含栈上的垃圾值导致ls -l显示异常getuid()/getgid()让文件的所有者显示为运行 FUSE 程序的用户而不是 rootst_size的重要性内核根据st_size决定读取多少数据。如果st_size 0cat命令不会调用read()文件看起来就是空的返回值-ENOENTFUSE 回调通过返回负的 errno 值报告错误-ENOENT对应 “No such file or directory”readdir() — 目录内容列举readdir()负责列出目录的内容。当用户执行ls时会触发这个回调。以下是hellofs.cpp中的实现staticinthello_readdir(constchar*path,void*buf,fuse_fill_dir_t filler,off_t offset,structfuse_file_info*fi,enumfuse_readdir_flagsflags){(void)offset;(void)fi;(void)flags;// 未使用的参数if(strcmp(path,/)!0)return-ENOENT;// 只有根目录其他路径返回不存在filler(buf,.,NULL,0,(enumfuse_fill_dir_flags)0);// 当前目录filler(buf,..,NULL,0,(enumfuse_fill_dir_flags)0);// 父目录filler(buf,hello.txt,NULL,0,(enumfuse_fill_dir_flags)0);// 我们的文件return0;}要点解读filler函数libfuse 提供的回调用于向目录列表中逐个添加条目。每次调用添加一个目录项.和..UNIX 文件系统的标准目录项分别指向当前目录和父目录。虽然 FUSE 可以不添加它们内核会自动补上但显式添加是好习惯第三个参数NULL表示不提供struct stat信息。内核会对每个条目单独调用getattr获取属性。如果提供了stat可以减少一次getattr调用readdirplus 优化第四个参数0偏移量设为 0 表示一次性返回所有条目模式。大目录数万文件应使用非零偏移量支持分批返回open() 与 read() — 文件打开和数据读取open()检查文件是否可以被打开read()返回文件内容。以下是hellofs.cpp中的实现staticinthello_open(constchar*path,structfuse_file_info*fi){if(strcmp(path,hello_path)!0)return-ENOENT;if((fi-flagsO_ACCMODE)!O_RDONLY)return-EACCES;// 只读文件系统拒绝写入打开return0;}staticinthello_read(constchar*path,char*buf,size_t size,off_t offset,structfuse_file_info*fi){(void)fi;if(strcmp(path,hello_path)!0)return-ENOENT;size_t lenstrlen(hello_content);if(offset(off_t)len)return0;// 偏移量超过文件末尾返回 0 EOFif(offsetsizelen)sizelen-offset;// 截断到实际可用数据长度memcpy(buf,hello_contentoffset,size);returnsize;// 返回实际读取的字节数}要点解读fi-flags O_ACCMODEO_ACCMODE是掩码值为 3提取打开模式的低两位。O_RDONLY0、O_WRONLY1、O_RDWR2。HelloFS 是只读的只允许O_RDONLYread的偏移量处理内核可能分多次读取文件。第一次read(buf, 4096, 0)读取前 4096 字节第二次read(buf, 4096, 14)从偏移 14 开始——此时offset len返回 0 表示 EOF内核就知道文件读完了返回值语义read返回实际读取的字节数正数返回 0 表示 EOF返回负数表示错误挂载测试前面我们已经进行了基本的测试编译并运行 HelloFS 后我们可以进行更多的测试验证。比如查看目录列表或者查看文件属性# 列出目录$ls-la/tmp/hellofs/ total0drwxr-xr-x2root root0Jan11970.drwxrwxrwt8root root160Jan100:00..-r--r--r--1root root14Jan11970hello.txt# 查看文件属性$stat/tmp/hellofs/hello.txt File: /tmp/hellofs/hello.txt Size:14Blocks:0IO Block:4096regularfile...恭喜你已经实现了自己的第一个文件系统。虽然它只有一个硬编码的文件但它展示了 FUSE3 文件系统的完整工作流程通过实现getattr、readdir、open、read四个回调函数我们就能让 Linux 内核把我们的程序当作一个真正的文件系统来对待。可以通过在 FUSE 的调试模式下-d选项运行来观察这些请求具体命令格式如下./hellofs-d/tmp/hellofs-d选项会启用 FUSE 的调试输出打印每一个进入的请求和返回的响应这对理解 FUSE 的工作机制非常有帮助。完整代码见code/ch01_hellofs/hellofs.cpp和code/ch01_hellofs/CMakeLists.txt。

更多文章