使用 FUSE 创建一个用户空间文件系统

2025-08-27

这是我再次阅读、重新构建并调试修改一个之前做过的项目时所做的笔记。因此,本文主要包括原理介绍、重新编译和运行项目的步骤、代码解析、以及这一次所修改的内容。

项目本身:

关于 FUSE

FUSE (Filesystem in USEr space),即用户空间文件系统,是一个类 Unix 操作系统中的一个软件接口。它允许用户在不修改内核的情况下、在用户空间创建自定义的文件系统。Linux 通过内核模块(Kernel Module)来支持 FUSE。

Github上的 libfuse 仓库(依据其 Readme)包括 1)内核模块的实现,和 2)在用户空间与内核模块通信的代码库。主流的 Linux 发行版都默认包含 libfuse 代码库。

编译与运行 fischl 文件系统

观察代码仓库的基本结构

fischl 文件系统由 C++ 语言编写,利用 CMake 构建系统。CMake 系统是这样引入 FUSE 的:

# Use pkg-config to get flags for fuse3
find_package(PkgConfig REQUIRED) # 保证 PkgConfig 工具存在
pkg_search_module(FUSE3 REQUIRED fuse3) # 寻找 fuse3 包,并创建名称为 FUSE3 的模块,记录相关信息(包括 FUSE3_INCLUDE_DIRS, FUSE3_LIBRARIES)

# Add the flags from pkg-config for fuse3
target_include_directories(fischl PRIVATE ${FUSE3_INCLUDE_DIRS}) # 编译时需要包含 fuse3 的头文件
target_link_libraries(fischl PRIVATE ${FUSE3_LIBRARIES}) # 需要链接到 fuse3 的库

事实上,fischl 的 main 函数在做了一系列准备工作之后,最终将控制流交给 fuse 函数库的 main 函数:

#include <fuse.h>
...
// in main:
ret = fuse_main(args.argc, args.argv, &fischl_oper, NULL); // 通过 fischl_oper 传入我们实现的诸多文件系统调用 
fuse_opt_free_args(&args);
return ret;

我们实现的诸多文件系统调用,如 mknod, mkdir, open, read, write,最终是要落实到块设备上的,比如 rawdisk.cpp 中的:

// fd is a class variable of RealRawDisk
// in RealRawDisk::RealRawDisk(const char *directory), we have
fd = open(dir, O_RDWR); // Open the block device (an actual device such as /dev/sdX)
...
ssize_t bytesRead = read(fd, buffer, IO_BLOCK_SIZE);
ssize_t bytesWritten = write(fd, buffer, IO_BLOCK_SIZE);

编译和运行代码仓库

如 Readme 中所说,编译的命令是:

mkdir -p build && cd build
cmake ..
make # cmake --build . is same

运行的方法是:

./fischl diskpath n -s mountpoint # 格式化块设备,在块设备上创建新的 fischl 文件系统
./fischl diskpath l -s mountpoint # 假设块设备中已经有一个 fischl 文件系统,去加载它
# -s 是传给 fuse3 的参数,保证我们的文件系统仅被单线程访问
# 如果你是非 root 用户并遇到了形如 "cannot access 'mountpoint': Permission denied" 的报错,在 n/l 之后加入参数 -o allow_other

这样,利用 diskpath 指向的块设备实现的文件系统就被挂载在 mountpoint 上了。

要查看 path 是否已经是挂载点,可以运行命令 mountpoint path

编译过程中报错的可能解决方式

请事先保证 cmake, g++, pkg-config 包已被安装(如果你的 Linux 系统长期未用,应该用类似 apt-get update 的命令更新包列表,避免安装包时发生相关错误)

fuse3 not found

遇到报错 "Package fuse3 was not found in the pkg-config search path." 或者 "None of the required 'fuse3' found",可以先尝试执行 pkg-config --modversion fuse3,直到该命令无错为止。

在 Ubuntu 20.04 上,可能需要安装如下包: sudo apt-get install libfuse3-dev 。如果仍未找到,可能需要手动将 fuse3.pc 的路径添加到 PKG_CONFIG_PATH:

find /usr -name fuse3.pc
export PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH # Change to the actual path
Googletest 子文件夹为空

googletest 是一个 fischl 仓库的一个 git 子模块。它最初是通过 git submodule add https://github.com/google/googletest.git 添加的,可以通过运行 git submodule 命令查看当前仓库包含的所有子模块。

如果你在最初 clone 仓库时,没有使用 --recurse-submodules 选项,子模块不会自动克隆。对此可以通过 git submodule initgit submodule update 来 clone googletest 子模块。

可选地用 Loop 设备作为块设备

如果你的运行环境不方便使用外设存储设备或者真正的磁盘分区作为块设备,可以考虑创建一个 Loop 设备(参考)作为块设备来运行 fischl 文件系统。

Loop 设备可以使得文件像设备一样被访问。创建一个 50 MB 的设备文件:dd if=/dev/zero of=/loopfile bs=1024 count=51200

losetup -f 找到第一个可用的 loop 设备编号,如 /dev/loop1 。将其绑定到设别文件上:losetup /dev/loop1 /loopfile 。随后,就可以列出所有 Loop 设备,确认刚才创建的 Loop 设备可用了:losetup -a 。要取消 Loop 设备和文件的绑定,losetup -d /dev/loop1

程序调试方法

输出 FUSE help 的方法是:./fischl diskpath l --help,从而看到 fuse 的选项,包括但不限于

    -h   --help            print help
    -V   --version         print version
    -d   -o debug          enable debug output (implies -f)
    -f                     foreground operation
    -s                     disable multi-threaded operation

因此,调试的方法是 ./fischl diskpath l -d -s mountpoint

内核和进程眼中的文件系统

内核中包括三种文件系统打开表(参考 USNA 的 Unix Filesystem 教程):

注意,这三层结构都在内核空间,是用户不能访问的。

举例说明三张表的运行逻辑:

内核中的三张表,FUSE 的库都已经帮我们实现好了,fischl 不需要实现。

Fischl 文件系统代码解析

Fischl 文件系统使用了分层设计的方法,每一层调用其下一层的提供的接口和服务,为其上一层提供接口和服务

块设备读写层

源文件:rawdisk.hpp

接口:读 / 写第几块

提供以块(被指定为 IO_BLOCK_SIZE=4096 个字节)为单位读写块设备的接口:

virtual int read_block(u_int64_t block_number, char *buffer) = 0;
virtual int write_block(u_int64_t block_number, char *buffer) = 0;

RealRawDisk 提供依块设备名称来构造的方法:

RealRawDisk(const char *directory);

构造函数中,使用 open 来打开块设备文件,使用 ioctl (BLKGETSIZE64 参数)来获知磁盘可用的字节数。

读写时,首先使用 lseek 将读写位置移动到正确的位置,然后使用 read 或 write 系统调用。

文件系统存储资源分配层

源文件:
fs.hpp, fs/{fs_data_types, inode_manager, datablock_manager}.hpp
fs/{fs, fs_data_types, inode_manager, datablock_manager}.cpp

接口:
分配一个 Inode,回收第几个 Inode,读第几个 Inode,写第几个 Inode
分配一个 DataBlock,回收第几个 Datablock,读第几个 Datablock,写第几个 Datablock

ext4 类的文件系统的底层结构主要包括 Superblock,inode list 和 datablock。这三部分在磁盘上顺序排列,构成了一个文件系统。

Superblock, Inodes, datablocks

简要地说,Superblock 占用磁盘上文件系统的第一个块(最容易找到),存储文件系统的一些元信息,比如有 Inode 数组有多长 / 有多少个 Inode,有多少个 Datablock 等等;Inode list 是一个固定长度的数组,每个元素是一个 Inode,负责一个文件的索引(这个数组有多长,文件系统就最多支持多少个文件);Datablocks 负责存储用户创建的文件内容本身。

本层需要处理 Inode 和 Datablocks 的创建(分配)和删除,因此关键任务是记录哪些 Inode 和 Datablocks 是未分配的。对于 Inode 来说,这个问题比较简单,只需要在每个元素中加入一个域来记录当前 Inode 被占用或空闲即可。对于 Datablocks 来说,这个问题更复杂,因为我们默认把每个 Datablock 的全部空间用来记录用户指定的内容,没有空间记录 Datablock 的已分配未分配状态。

Free list 的概念

可以使用名为“空闲链表“ (Free List) 的数据结构来解决该问题:在每个未分配的块(无论 Inode 还是 Datablock)中,记录下一个未分配块的地址;在 Superblock 中,记录第一个未分配块的地址(head)。要分配块 / 删除空闲块,就把 head 分配出去,让 head 指向下一个空闲块;要回收块 / 插入空闲块,就让它记录指向 head 原先的地址,让 head 指向它。

INODE_MANAGER_FREELIST

INode_Manager_Freelist 类使用了上述机制来快速分配和删除 Inode。其中的函数,format 负责了空闲链表的初始化,free_inode 负责回收 Inode,new_inode 负责分配 Inode。如果阅读代码,有如下细节:

DATABLOCK_MANAGER_BITMAP

尽管我们把 Datablock 中的全部空间用于记录用户数据,但我们可以将 Datablock 内部再分出一个层级:每 2048 个 Datablock 中,有一个块(称为 Bitmap Block)专门作为剩下 2047 个 Datablock 的 bitmap,记录它们当中哪些被分配了。只有 Bitmap 块会被我们挂上空闲链表。

单文件操作层

源文件:
fs.hpp, fs/fs_file_io.cpp

接口:对于第几个文件(Inode),

这一层把 Datablock 的概念隐藏了,抽象为对文件的读写。这关系到 “Inode 如何充当文件内容的索引” 的问题。Inode 中显然需要存储数据块的编号:有的数据块内存储的就是文件内容本身,这些数据块被称为“直接块” (Direct Blocks);有的数据块内存储的是一张直接块的编号构成的列表,称为“一级间接块”(Single Indirect Blocks);有的数据块内存储的是一张一级间接块的编号构成的列表,称为“二级间接块”,同理还有“三级间接块”,这是一个层级的结构。

Inode 中存储若干直接块、一个一级间接块、一个二级间接块、一个三级间接块,按顺序“遍历”它们,就相当于按顺序读取了文件的字节流。

具体到代码层面:

sweep_datablocks 函数中个人有疑问的几点:

文件系统顶层接口层

源文件:
files.h, fischl.h
files.cpp, fischl.cpp

接口:FUSE 定义的 high-level API 接口,为一系列函数,它们的规约在libfuse/include/fuse.h 中定义
getattr, mkdir, rmdir, mknod, link, unlink, symlink, readlink, rename, truncate, chmod, chown, open, read, write, statfs, release

文件系统暴露给用户的接口不是通过 INODE 访问文件的,而是通过字符串类型的“路径”,从“树根”开始,经过若干级文件夹,到达文件。文件夹是一种特殊的文件(其特殊类型在 Inode 内被标注出来),它在每个数据块中存储的内容是一张 子文件名 对应 Inode 编号的表。

软链接是一种特殊的文件类型(其特殊类型在 Inode 内被标注出来),在文件内存储另一个文件的路径以“指向”那个文件;硬链接就是两个路径指向同一个 inode 的现象(由于引用是带方向的,所以不会导致文件系统中出现环路)。软链接不会改变被指 Inode 的引用计数,硬链接会增加被指 Inode 的引用计数;通过路径删除文件后,软链接会失效,而硬链接不会失效(另一条路径仍然可以访问文件)。

getattr

依据文件路径查询文件属性,与系统调用 stat() 类似。函数内首先通过 namei 将文件路径转换为 Inode 编号,然后依据 Inode 元信息中的 Permissions 位检测它是否是 1)文件夹,2)软链接(symlink)还是 3)其它文件,然后分类处理。

注意,文件的 mode 包含两部分信息:第一是文件类型(文件夹?软链接?字符设备?块设备?管道?套接字?),第二是文件的权限位

阅读文件路径指向的软链接,将其存储的文件路径读到传入的 buf 中。

三个操作都是创建文件:mkdir 创建文件夹、symlink 创建一个软链接,mknod 创建其它文件。通用的流程是,找到父文件夹的 inode,在其下寻找是否有同名文件,如果没有,则创建新文件。最后,父在文件夹中创建对应表项。

对于 symlink,要向该文件内写入被指文件的路径。

对于文件夹,要在新建的文件夹中创建文件名为 "." 和 ".." 文件,指向当前文件夹和父文件夹

可以简单地理解为删除文件或删除文件夹,实质上是删除路径对文件的引用,所以在有硬链接引用计数大于 1 的情况下路径指向的 inode 未必被删除。

unlink 和 rmdir 的函数实现几乎一模一样:首先删除父文件夹中的记录,然后调用内部函数 unlink_inode (inode_number) ,该函数的作用是

rename

函数接受老路径、新路径和一个 flag 参数,取值可能是 RENAME_NOREPLACE (若新路径已经存在,则不能覆盖,应返回错误),或 RENAME_EXCHANGE(若新路径已经存在,则交换新旧路径)

创建文件的硬链接,增加引用计数即可

chmod, chown

改变文件的 mode (如前文所述,为类型+访问权限) 和所有者(用户的 uid 和组的 gid)

open, release, read, write, truncate

顾名思义。注意这里的 open 是 FUSE 框架为我们定义的 open 接口,并非暴露给用户的 open 系统调用。read 和 write 同理。

之前提到,用户进程读写文件的连接状态是在 Open File Table 内核数据结构中定义的,由 FUSE 包办。因此,这里我们定义的 open 并没有创建一个“连接”,而 read, write 也是无状态的(每次都要提供 offset)—— FUSE 处理好连接状态后,调用我们实现的 open, read, write 来真正读写块设备。

可选功能:内存中文件树

源文件:
direntry.h
direntry.cpp

接口:给定一个路径字符串,找到对应文件的信息,包括 Inode
实现为 direntry.h 中的 fischl_find_entry 和 files.h 中的 FilesOperation::namei
它是文件系统顶层接口层下属的一个内部服务

每次做 namei 操作都依照文件夹层级结构逐级访问文件夹的 DataBlock 的话,会导致 namei 操作进行过多次 IO。为了优化,我们考虑建立一棵内存中的文件树来表示文件的层级结构。

具体而言,这里的设计如下:用 FileNode 表示单个文件,用 TreeNode 表示文件树的内部节点(即文件夹)。那么:TreeNode 含有一个哈希表将文件名映射到文件(FileNode),又含有指向父文件夹的指针;FileNode 含有文件信息,以及如果文件是文件夹,一个该文件夹(TreeNode)的指针。顺带一提,哈希表的冲突解决方式是链地址法(separate chaining),保持整个哈希表不变,在每个桶中维护一个链表。

这样,在访问文件夹对应的 DataBlock 时将获得的数据用于构建内存中文件树,namei 大部分时间就能够在内存中完成了。不过要注意其它操作在修改硬盘上的数据时,可能需要同步修改文件树。

Q: 修改文件系统状态的操作,如创建 inode,是实时落实到硬盘上的吗?
A: 是的,这些修改状态的操作必须既落实到硬盘上,也落实到内存中文件树上。这里的内存中文件树相当于一个 write-through 的 cache,硬盘上的数据时时刻刻都是最新的

改进 fischl 文件系统

提供改进的代码分支:https://git.ziaowang.top/admin/iloveos/commits/branch/adaptive_num_inodes/

让用户自行决定 INODE BLOCKS 个数

与其把分配多少个 Inode Datablock 硬编码,不如在 mkfs 时依据 disksize 来 {自适应地 / 确定一个可行范围让用户自行} 决定这个数字。之后,把这个数字放到 superblock 里面。

实现 STATFS 接口

statfs 的接口如下:

static int fischl_statfs(const char* path, struct statvfs* stbuf) {
  // Your code...
}

语义为把 path 所在的文件系统的统计信息放到 statvfs 数据结构中,具体结构见 statvfs 的 man page

要测试这个接口,只需要在你的文件系统中运行 statfs 命令。

STATFS 接口冒烟测试

进行冒烟测试 (smoke test):

# ./fischl /dev/loop0 n -s ../../mpoint
====Initializing RawDisk====
Number of sectors: 1024000
Disk size (in bytes): 524288000
The block device contains 128000 blocks
Choose the number of blocks to store inodes: 1 ~ 125951
12800
FORMAT 0
# cd ../../mpoint
# statfs .
{
        "Type": 1702057286,
        "Bsize": 4096,
        "Blocks": 115199,
        "Bfree": 115198,
        "Bavail": 0,
        "Files": 102400,
        "Ffree": 102399,
        ...
}

解释:创建文件系统分配了 12800 个 4K 数据块用来存储 inode,因此总共允许的文件数目(Files)为 12800 * (4K/512) = 102400 个。创建完文件系统,可用的 inode 和 datablock 各自比总数少了一个,推测是创建 root inode 分配了一个 inode 和一个 datablock 用来存储根目录。

依据 rmdir 的语义更正其实现

在实验过程中因为种种原因发现一个问题:rmdir 的 FUSE3 接口rmdir 系统调用 以及 rmdir 命令 一致要求检查目标文件夹是否为空,仅当其为空时才允许删除,否则报错。先前的 fischl 并没有实现这一语义检查。

为了实现正确的语义,在 files.cpp 的 FilesOperation::fischl_rmdir 中检查文件夹是否为空,若非空则报错。