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

本章实例: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 定义了一组标准的操作接口(如 readwriteopenclose 等),每种具体的文件系统只需要实现这些接口,就可以被内核统一管理。当请求到达VFS时,VFS就可以根据具体文件系统注册的函数调用具体文件系统的功能。这就是为什么你可以在同一个 Linux 系统上同时使用 ext4、XFS、NFS 等不同文件系统,而用户程序完全不需要修改。

1.2 FUSE 架构剖析

通过VFS的架构图可以看出,FUSE也是位于VFS直线的一个文件系统,不同之处在于到这个内核模块的数据并不会被持久化,而是转发给了用户态的一个成为libfuse的模块。换而言之,FUSE包含两个模块,内核态的FUSE文件系统和用户态的开发库libfuse。libfuse类似VFS,也提供了一套接口。如果基于libfuse开发用户态文件系统,只需要实现定义的接口就行。基于FUSE开发的文件系统的整个生态包含如下几个组件。

  1. FUSE 内核模块fuse.ko):注册为一个文件系统类型,负责接收 VFS 的请求并转发到用户空间
  2. libfuse 用户态库:提供 API 供开发者实现文件系统回调函数,处理与内核的通信细节
  3. 用户态文件系统程序:这就是我们要开发的程序,需要在这里实现具体的文件系统逻辑,如创建文件,读写文件和删除文件等操作。

下面这张图是应用程序,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 文件描述符
  • 更好的挂载 APIfuse_session_mount() 替代了旧的 fuse_mount()
  • writeback cache:内核端的回写缓存支持,显著提升写入性能

在编写代码时,需要注意包含正确的头文件(<fuse3/fuse.h>),并在编译时链接 fuse3 库。

基于FUSE,整个IO的流程相对于传统文件系统会有比较大的变化。以 open("/mnt/fuse_test/hello.txt", O_RDONLY) 为例,一次完整的 FUSE 文件操作的流程如下:

  1. 用户程序调用 open() 系统调用
  2. 内核 VFS 识别出 /mnt/fuse_test/ 是一个 FUSE 挂载点,因此将调用FUSE内核模块的接口
  3. FUSE 内核模块将请求打包,写入 /dev/fuse 设备
  4. libfuse 库(在用户态 FUSE 进程中)从 /dev/fuse 读取请求
  5. 用户态 FUSE 程序处理请求(例如调用我们实现的 open 回调函数)
  6. 处理结果通过 libfuse 写回 /dev/fuse
  7. FUSE 内核模块将结果返回给 VFS
  8. 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 程序的包含两个核心的内容:

  1. 定义一个 fuse_operations 结构体,在这个结构体中填入我们实现的回调函数指针
  2. 调用 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_blocksst_blksize)可能包含栈上的垃圾值,导致 ls -l 显示异常
  • getuid()/getgid():让文件的所有者显示为运行 FUSE 程序的用户,而不是 root
  • st_size 的重要性:内核根据 st_size 决定读取多少数据。如果 st_size = 0cat 命令不会调用 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_ACCMODEO_ACCMODE 是掩码(值为 3),提取打开模式的低两位。O_RDONLY=0、O_WRONLY=1、O_RDWR=2。HelloFS 是只读的,只允许 O_RDONLY
  • read 的偏移量处理:内核可能分多次读取文件。第一次 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 文件系统的完整工作流程:通过实现 getattrreaddiropenread 四个回调函数,我们就能让 Linux 内核把我们的程序当作一个真正的文件系统来对待。

可以通过在 FUSE 的调试模式下(-d 选项)运行来观察这些请求,具体命令格式如下:

./hellofs -d /tmp/hellofs

-d 选项会启用 FUSE 的调试输出,打印每一个进入的请求和返回的响应,这对理解 FUSE 的工作机制非常有帮助。


完整代码见 code/ch01_hellofs/hellofs.cppcode/ch01_hellofs/CMakeLists.txt

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

数据存储张

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值