本章实例:HelloFS —— 一个硬编码的只读文件系统 在这个示例中,当我们挂载文件系统后,可以在挂载点看到一个文件,也可以读取该文件的内容。
文件系统是操作系统中最基础、最重要的子系统之一。我们每天使用电脑时,无论是打开文档、保存照片,还是运行程序,都离不开文件系统的支撑。然而,大多数开发者对文件系统的内部工作原理知之甚少——它像一个"黑盒",默默地将我们的数据组织在磁盘上。
笔者在《文件系统技术内幕》中介绍了很多概念,并结合产品级的代码揭示了其实现原理。但是,由于企业级文件系统代码量巨大,学习起来不太容易。本章及接下来的章节,我们将从零实现一个文件系统,代码量尽量小,让大家更加彻底的理解文件系统的方方面面。由于在《文件系统技术内幕》中已经对文件系统的概念和原理进行了深入的介绍,因此接下来的内容将不再重复相关的内容,如果有不熟悉的概念,可以翻阅《文件系统技术内幕》这本书。
以Linux中的文件系统为例,通常实现在内核空间。但是在内核空间实现一个文件系统的门槛是比较高的,本系列文章将借助FUSE,从而在用户空间实现我们的文件系统。首先我们介绍一下 FUSE(Filesystem in Userspace)的相关内容,然后介绍如何搭建开发环境。最后,我们实现一个最简单的文件系统——HelloFS。文件系统非常简单,这是一个只读文件系统,在该文件系统中有一个文件,我们可以查看该文件的内容和属性。在实现成名,这个文件系统中的文件是一个硬编码的文件。虽然是一个只包含一个文件的文件系统,但它包含了 FUSE 文件系统开发的所有核心要素。
1.1 Linux文件系统的整体架构
Linux操作系统支持多大几十个文件系统,这些文件系统包括如Ext4、XFS、Btrfs和ZFS等。为了支持多种文件系统,Linux实现了一个抽象层来屏蔽底层的差异,接下来我们先简单介绍一下这个抽象层。
Linux 内核中存在一个精巧的抽象层,叫做 VFS(Virtual 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 供开发者实现文件系统回调函数,处理与内核的通信细节
- 用户态文件系统程序:这就是我们要开发的程序,需要在这里实现具体的文件系统逻辑,如创建文件,读写文件和删除文件等操作。
下面这张图是应用程序,VFS,FUSE和我们开发的文件系统之间的关系。

在上图中有个非常重要的内容是/dev/fuse ,它是 FUSE 内核模块创建的一个字符设备。正是通过/dev/fuse实现了内核与用户态 FUSE 进程之间的通信。每次文件操作都会通过这个设备传递请求和响应。
当用户态 FUSE 进程启动并挂载文件系统时,它会打开 /dev/fuse,然后进入一个事件循环:不断从该设备读取请求、处理请求、将响应写回。libfuse 库封装了这些底层通信细节,让开发者只需关注文件系统逻辑。
需要知道的是FUSE有两个版本(也即FUSE2和FUSE3),本书使用 FUSE3(libfuse 3.x),它相比 FUSE2 有以下重要改进:
- API 简化:去除了许多已废弃的接口,头文件从
fuse.h改为fuse3/fuse.h readdirplus支持:可以在目录遍历时同时返回文件属性,减少额外的getattr调用- 改进的多线程模型:支持
clone_fd选项,每个线程使用独立的/dev/fuse文件描述符 - 更好的挂载 API:
fuse_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/fuse - FUSE 内核模块将结果返回给 VFS
- VFS 将结果返回给用户程序
这个过程涉及两次用户态/内核态切换(比内核文件系统多),这就是 FUSE 性能损失的来源。但对于我们的学习目的来说,这个开销完全可以忽略。
我们可以对比一下传统文件系统和基于FUSE的文件系统的异同点。传统的文件系统(如 ext4、XFS、Btrfs)都是作为内核模块运行的。基于内核开发的文件系统有如下特定:
- 性能极高:直接在内核空间操作,没有用户态/内核态切换的开销
- 开发难度大:需要精通内核编程,一个 bug 可能导致整个系统崩溃
- 调试困难:不能使用常规的用户态调试工具
- 迭代缓慢:每次修改都需要重新编译内核模块、卸载/加载
基于FUSE开发的文件系统为用户态文件系统,其与内核文件系统存在诸多的差异,主要包括:
- 开发简单:使用普通的 C/C++ 程序开发,可以使用任何用户态库
- 调试方便:可以使用 GDB、Valgrind、AddressSanitizer 等常规工具
- 安全性好:文件系统崩溃不会影响内核,最多只是挂载点不可用
- 性能损失:每次文件操作都需要经过内核-用户态-内核的切换,有一定开销
无论是在内核开发文件系统也好,还是基于FUSE开发文件系统也罢,文件系统的实现原理是一样的。对于学习文件系统原理、快速原型开发、以及许多实际应用场景(如网络文件系统、加密文件系统、归档文件系统),FUSE 是一个极好的选择。
1.3 开发环境搭建
为了能够进行后续的开发,我们需要搭建开发环境。本书所有内容基于Ubuntu20.04开发,但在其他Linux环境应该也是可以运行的。在 Ubuntu/Debian 系统上,执行以下命令安装所需的开发工具和库:
# 安装 FUSE3 开发库
sudo apt-get install libfuse3-dev fuse3
# 安装编译工具链
sudo apt-get install build-essential cmake pkg-config
# 验证安装
pkg-config --modversion fuse3 # 应输出 3.x.x
fusermount3 --version # 应输出版本信息
在 CentOS/RHEL/Fedora 上:
sudo dnf install fuse3-devel fuse3
sudo dnf install gcc-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})
准备好环境后,我们可以先编译运行一下本章开发的程序,非常简单,只需要执行如下几个命令就行。
# 编译
cd code/ch01_hellofs
mkdir build && cd build
cmake ..
make
# 创建挂载点
mkdir -p /tmp/hellofs
# 前台运行(推荐调试时使用 -f 选项),此时程序将阻塞该终端
./hellofs -f /tmp/hellofs
上述-f 选项让 FUSE 进程在前台运行,这样我们可以直接看到日志输出,方便调试。不加 -f 的话进程会以守护进程方式运行在后台。至此,我们开发的文件系统已经运行起来,接下来我们可以访问挂载点目录/tmp/hellofs了。
我们可以在另一个终端中进行测试,比如执行如下命令:
ls /tmp/hellofs
cat /tmp/hellofs/hello.txt
上述命令分布是查看文件的属性及文件的内容。好了,有了初步的了解,接下来我们看看这个文件系统时怎么实现的。
1.4 实现 HelloFS
有了前面的准备工作,接下来让我们实现本书的第一个文件系统。如前文所述,我们实现的文件系统非常简单:它的根目录下只有一个文件 hello.txt,内容是固定的 “Hello, FUSE3!\n”。一个 FUSE3 程序的包含两个核心的内容:
- 定义一个
fuse_operations结构体,在这个结构体中填入我们实现的回调函数指针 - 调用
fuse_main()启动事件循环,也就是等待内核消息
接下来我们详细介绍一下这个最简单的文件系统是如何实现的,先从程序的入口开始介绍。
程序入口与回调注册
C++程序都有一个名称为main的入口函数,我们开发的文件系统也不例外。以下是 hellofs.cpp 中的实际入口代码:
#define FUSE_USE_VERSION 31
#include <fuse3/fuse.h>
static struct fuse_operations hello_oper = {};
int main(int argc, char *argv[])
{
hello_oper.getattr = hello_getattr;
hello_oper.readdir = hello_readdir;
hello_oper.open = hello_open;
hello_oper.read = hello_read;
return fuse_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,对应操作会返回-ENOSYS(Function not implemented)fuse_main():这是 FUSE 的高级 API 入口,它会解析命令行参数(如-f前台运行、-d调试模式、挂载点路径),然后进入事件循环,不断从/dev/fuse读取请求并分发到对应的回调函数
为了能够实现上述最小功能的文件系统,也即获取文件列表(虽然只有一个文件)和文件内容,我们需要实现获取目录内容、打开文件、读取文件和获取文件属性的接口。也就是getattr(获取属性)、readdir(列目录)、open(打开文件)、read(读取数据)四个接口。
getattr() — 文件属性查询
getattr() 是最重要的回调函数——几乎所有文件操作都会先调用它来获取文件/目录的属性。它的作用类似于 stat() 系统调用。以下是 hellofs.cpp 中的完整实现:
static const char *hello_path = "/hello.txt";
static const char *hello_content = "Hello, FUSE3!\n";
static int hello_getattr(const char *path, struct stat *stbuf,
struct fuse_file_info *fi)
{
(void)fi; // 未使用的参数,显式标记避免编译警告
memset(stbuf, 0, sizeof(struct stat)); // 清零,确保所有字段有确定值
if (strcmp(path, "/") == 0) {
stbuf->st_mode = S_IFDIR | 0755; // 目录 + rwxr-xr-x
stbuf->st_nlink = 2; // . 和父目录条目
stbuf->st_uid = getuid(); // 当前用户的 UID
stbuf->st_gid = getgid(); // 当前用户的 GID
return 0;
}
if (strcmp(path, hello_path) == 0) {
stbuf->st_mode = S_IFREG | 0444; // 普通文件 + r--r--r--
stbuf->st_nlink = 1;
stbuf->st_size = strlen(hello_content); // 14 字节
stbuf->st_uid = getuid();
stbuf->st_gid = getgid();
return 0;
}
return -ENOENT; // 其他路径:文件不存在
}
要点解读:
(void)fi:FUSE3 的getattr增加了第三个参数fuse_file_info(FUSE2 没有),未使用时用(void)避免编译器警告memset清零:必须先清零struct stat,否则未设置的字段(如st_blocks、st_blksize)可能包含栈上的垃圾值,导致ls -l显示异常getuid()/getgid():让文件的所有者显示为运行 FUSE 程序的用户,而不是 rootst_size的重要性:内核根据st_size决定读取多少数据。如果st_size = 0,cat命令不会调用read(),文件看起来就是空的- 返回值
-ENOENT:FUSE 回调通过返回负的 errno 值报告错误,-ENOENT对应 “No such file or directory”
readdir() — 目录内容列举
readdir() 负责列出目录的内容。当用户执行 ls 时,会触发这个回调。以下是 hellofs.cpp 中的实现:
static int hello_readdir(const char *path, void *buf,
fuse_fill_dir_t filler,
off_t offset, struct fuse_file_info *fi,
enum fuse_readdir_flags flags)
{
(void)offset; (void)fi; (void)flags; // 未使用的参数
if (strcmp(path, "/") != 0)
return -ENOENT; // 只有根目录,其他路径返回不存在
filler(buf, ".", NULL, 0, (enum fuse_fill_dir_flags)0); // 当前目录
filler(buf, "..", NULL, 0, (enum fuse_fill_dir_flags)0); // 父目录
filler(buf, "hello.txt", NULL, 0, (enum fuse_fill_dir_flags)0); // 我们的文件
return 0;
}
要点解读:
filler函数:libfuse 提供的回调,用于向目录列表中逐个添加条目。每次调用添加一个目录项"."和"..":UNIX 文件系统的标准目录项,分别指向当前目录和父目录。虽然 FUSE 可以不添加它们(内核会自动补上),但显式添加是好习惯- 第三个参数
NULL:表示不提供struct stat信息。内核会对每个条目单独调用getattr获取属性。如果提供了stat,可以减少一次getattr调用(readdirplus 优化) - 第四个参数
0:偏移量设为 0 表示"一次性返回所有条目"模式。大目录(数万文件)应使用非零偏移量支持分批返回
open() 与 read() — 文件打开和数据读取
open() 检查文件是否可以被打开,read() 返回文件内容。以下是 hellofs.cpp 中的实现:
static int hello_open(const char *path, struct fuse_file_info *fi)
{
if (strcmp(path, hello_path) != 0)
return -ENOENT;
if ((fi->flags & O_ACCMODE) != O_RDONLY)
return -EACCES; // 只读文件系统,拒绝写入打开
return 0;
}
static int hello_read(const char *path, char *buf, size_t size,
off_t offset, struct fuse_file_info *fi)
{
(void)fi;
if (strcmp(path, hello_path) != 0)
return -ENOENT;
size_t len = strlen(hello_content);
if (offset >= (off_t)len)
return 0; // 偏移量超过文件末尾,返回 0 = EOF
if (offset + size > len)
size = len - offset; // 截断到实际可用数据长度
memcpy(buf, hello_content + offset, size);
return size; // 返回实际读取的字节数
}
要点解读:
fi->flags & O_ACCMODE:O_ACCMODE是掩码(值为 3),提取打开模式的低两位。O_RDONLY=0、O_WRONLY=1、O_RDWR=2。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/
total 0
drwxr-xr-x 2 root root 0 Jan 1 1970 .
drwxrwxrwt 8 root root 160 Jan 1 00:00 ..
-r--r--r-- 1 root root 14 Jan 1 1970 hello.txt
# 查看文件属性
$ stat /tmp/hellofs/hello.txt
File: /tmp/hellofs/hello.txt
Size: 14 Blocks: 0 IO Block: 4096 regular file
...
恭喜!你已经实现了自己的第一个文件系统。虽然它只有一个硬编码的文件,但它展示了 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。

1072

被折叠的 条评论
为什么被折叠?



