如果你折腾过嵌入式开发板上的根文件系统,或者只是维护 Linux 服务器时对着 df -h 发过呆,大概都有过那么一瞬间的念头:这棵目录树到底是怎么长出来的?我第一次真正想“自己写一个文件系统”的冲动,是因为设备厂商给了一块 SPI NOR Flash,里面存着一种私有固件格式,服务器上直接读不进去。翻内核源码准备写模块,看到那扇大门的复杂度之后,我扭头去试了 FUSE3,结果三天就跑出了第一版能挂载、能读写文件的原型。这篇东西就按大纲展开,把基于 FUSE3 从零开发文件系统的完整路径、核心原理、能直接抄的代码和踩过的坑一起讲清楚。
这绝不是一篇“教程复读机”,而是以最终跑通为目标的实战过程记录。适合这么几种人:想把自定义存储格式暴露成操作系统目录的嵌入式开发者,做私有数据管理又不想写驱动的前端/后端同学,以及纯粹对 VFS 好奇、想低成本窥探文件系统内部实现的 Linux 爱好者。FUSE3 的出现把文件系统的实现门槛从“内核模块 + 设备驱动 + 若干子系统的锁”拉低到了“一个普通用户态进程 + 一组回调函数”,这其中的取舍和设计思路,我会一个点一个点拆开说。
1. 为什么最终选了 FUSE3:用户态文件系统的架构取舍
1.1 遇到“自制文件系统”需求时,大多数人的第一反应
我见过很多人的第一反应是去改内核,或者去写一个内核文件系统模块。这没有错,但先看几个现实问题:Linux 内核的 VFS 接口虽然稳定,但每次主版本更新都会有一些基础设施在变;调试一次模块需要编译、加载、起虚拟机或者折腾模块签名;一个简单的指针错误直接就是整机 panic,而不是段错误退出某个用户态进程。就算你敢写,维护成本也摆在那:发行版内核、自编译内核、嵌入式 SDK 内核,每个环境都要重新适配。
FUSE 的定位恰恰就是“不想碰内核也能做文件系统”。它把内核 VFS 层的请求转交给一个用户态守护进程,你的文件系统就是一个普通的可执行程序,通过 /dev/fuse 这个设备文件和内核对话。内核只负责把路径解析到某个点之后,把 open、readdir、write 这类调用原封不动包装成请求,发给用户态进程;用户态进程按照自己的存储格式处理完后,把结果再交回内核。这个模型让文件系统的核心逻辑从“内核态驱动”变成了“普通业务程序”,所有调试工具、测试框架、内存检测手段都能直接用上。
1.2 FUSE3 和内核模块方案的对比
拿一张表格把两者的差异列清楚,后面讨论会方便很多:
| 维度 | 内核文件系统模块 | FUSE3 用户态文件系统 |
|---|---|---|
| 开发语言 | 主要是 C,且要满足内核规范 | 几乎任意语言,C、Rust、Go、Python 都可以 |
| 调试难度 | 需要虚拟机/模块加载;崩溃可能影响整个系统 | 普通进程,gdb、asan、strace 直接上 |
| 性能 | 高,无额外用户态拷贝 | 有上下文切换和设备读写开销,低一个量级 |
| 安全边界 | 一个失误就可能内核 panic | 崩溃只是你的进程退出,挂载点变成不可访问 |
| API 稳定性 | 随内核版本有变动 | libfuse 维护了较稳定的 ABI |
| 适用场景 | 生产级底层存储、追求极致性能 | 快速原型、私有格式、教学研究、兼容层 |
注意性能那一栏,FUSE3 不是让你跟 ext4/xfs 竞争的,它的强项是“把文件系统的表达能力强加到任何你能想象的存储载体上”。比如我后来给那把 SPI NOR Flash 做只读文件系统时,就是直接用 FUSE3 把底层私有压缩格式映射成了只读目录,过程根本没碰内核。
1.3 FUSE3 和 FUSE2 的差异,迁移时要注意什么
如果你在网上搜教程,会看到大量基于 FUSE2 的代码。那时候 libfuse 的版本是 2.x,很多代码到今天还能跑,但新项目我建议直接用 FUSE3,官方已经停止 FUSE2 的新特性维护,现代发行版默认装的也是 fuse3 和 libfuse3-dev。从 FUSE2 到 FUSE3,最直接的变化有这几个:
第一,fuse_operations 结构体的回调指针语义更严谨了,比如 getattr 时要求初始化的字段更明确。第二,挂载选项行为变了,FUSE3 默认要求挂载点为空目录,去掉了 nonempty 选项,想挂到非空目录时不再被允许。第三,allow_other 的启用条件更严格,必须确认 /etc/fuse.conf 里有 user_allow_other,否则普通用户挂载时内核直接拒绝。第四,FUSE3 高层 API 推荐用 fuse_main 配合 struct fuse_operations,但底层 fuse_session API 也更齐全了,适合需要嵌入事件循环的程序。
如果你手头有 FUSE2 的老代码,迁移时最容易炸的三个点就是:fuse_main 的参数结构差异、open 回调中 fuse_file_info 字段的语义变化、以及 allow_other 的权限配置。后面章节会逐一提到。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 搭出第一版骨架:一个能挂载、能卸载的最小程序
2.1 环境准备和工具链
先装好环境。Debian/Ubuntu 系:
bash复制sudo apt install fuse3 libfuse3-dev pkg-config
Fedora/RHEL 系:
bash复制sudo dnf install fuse3 fuse3-devel pkg-config
装完之后验证一下版本:
bash复制pkg-config --modversion fuse3
编译一个 FUSE3 程序的基本命令是:
bash复制gcc -o myfs myfs.c $(pkg-config --cflags --libs fuse3)
这里我一直用 pkg-config 而不是手写 -lfuse3,因为不同发行版的头文件和库路径可能不一样,用 pkg-config 可以避免“gcc 找不到 fuse3 头文件”这种最基础的问题。开发时建议加调试符号:在 CFLAGS 里加 -g -Wall -O0。
2.2 最小 FUSE 程序的构成
一个最小可运行的 FUSE3 程序,核心就两部分:定义 struct fuse_operations,然后调用 fuse_main。先看这个骨架:
c复制#define FUSE_USE_VERSION 31
#include <fuse3/fuse.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <sys/stat.h>
static int minfs_getattr(const char *path, struct stat *st,
struct fuse_file_info *fi)
{
(void) fi;
memset(st, 0, sizeof(struct stat));
if (strcmp(path, "/") == 0) {
st->st_mode = S_IFDIR | 0755;
st->st_nlink = 2;
return 0;
}
return -ENOENT;
}
static struct fuse_operations minfs_ops = {
.getattr = minfs_getattr,
};
int main(int argc, char *argv[])
{
return fuse_main(argc, argv, &minfs_ops, NULL);
}
这段代码干的事:挂载后任何路径查询都只告诉你根目录存在,其他文件一律 ENOENT。但这个骨架是最有价值的起点,因为它能验证你开发的整个链路是通的。
FUSE_USE_VERSION 31 这个宏很重要,它决定编译时选择 FUSE3 的 API。如果你省略或者写 21,编译可能直接报错或者给你一堆奇怪的隐式声明警告。
2.3 首次挂载与调试输出
编译出来一个 minfs 之后,创建一个空挂载点跑一下:
bash复制mkdir -p /tmp/minfs-mnt
./minfs /tmp/minfs-mnt -f -d
-f 是前台运行,-d 是开启调试输出,这时候终端会刷出内核传来的每一个 FUSE 请求,比如 getattr 的路径和结果。另开一个终端:
bash复制ls -la /tmp/minfs-mnt
你会看到目录里面什么都没有,但 ls 已经可以正常访问根目录了,调试终端里则会打印一堆 getattr / 的调用记录。
-d 模式是 FUSE3 开发者的首选调试手段,它会打印每个请求的类型、路径、返回值。等程序稳定后,去掉 -d 用 -f 跑,想后台运行就去掉 -f。但开发早期一定要 -f -d 跑,因为 FUSE 子进程的崩溃信息和日志只有在前台时最容易看。
挂载结束后,卸载命令:
bash复制fusermount3 -u /tmp/minfs-mnt
内核同样支持 umount,但 fusermount3 对普通用户更友好,不需要 root。如果你在开发中把程序 Ctrl+C 杀掉,挂载点可能有残留,下次用 fusermount3 -u 或 umount -l 强制卸载即可,这个坑到后面专门讲。
3. 把 VFS 语义翻译成文件系统逻辑:核心回调逐个攻破
3.1 superblock、inode、dentry 到 FUSE 回调的映射
真正写文件系统之前,先理解经典文件系统里的几个概念,因为在 FUSE 回调里它们都有对应物。
传统的磁盘文件系统里,superblock 描述整个文件系统的元数据:块大小、总块数、inode 表位置、空闲块管理等。在 FUSE 程序里,superblock 就是一个全局结构体,可以直接定义一个 struct myfs_superblock,启动时从磁盘镜像加载到内存,卸载时写回。内核 VFS 并不强求你暴露这些信息,它只看你通过回调返回的 struct stat。
inode 在传统文件系统里描述一个文件/目录的元数据:权限、大小、时间戳、指向数据块的指针。FUSE 里没有强制要求你为每个文件记录 inode,但为了让 ls -i 和硬链接语义正常,建议你在自己的存储结构里维护一个 inode 编号,并在 getattr 里把 st_ino 填上。
dentry 是路径和 inode 之间的映射节点。FUSE 回调接受的 path 参数本质上是内核从挂载点出发解析到的字符串路径,你需要在回调里自己实现“路径字符串到 inode/文件内容”的翻译。这一点和传统内核文件系统很不一样,传统 VFS 会帮你做路径缓存,而你收到的永远是新解析出来的路径。
简单说:你需要做的就是用一堆回调函数,实现“根据路径找到对象”“列出目录内容”“读写文件数据”这几件事,然后把结果翻译成 VFS 期望的格式。
3.2 getattr 与 readdir:让文件能被看到
一切文件系统操作,第一步几乎都是 getattr。不管 ls、stat、cat 还是内核的路径解析,都会先调用 getattr 确认对象是否存在以及它的元数据。getattr 的核心就是填充 struct stat,至少需要填这些字段:
st_mode:文件类型和权限位,S_IFDIR/S_IFREG 加读写执行权限st_nlink:硬链接数,目录至少是 2st_size:文件大小,目录一般是 4096 之类的值st_uid/st_gid:文件属主和组,FUSE3 下不填的话权限判断会出问题st_ino:inode 编号,尽量稳定,别每次调用都临时生成st_mtime/st_ctime:时间戳,可以用当前时间
如果一个路径不存在,返回 -ENOENT 即可,FUSE 会把它转成 errno。
readdir 是让目录内容可见的关键回调。它的工作方式比较特殊,不是让你直接返回一个数组,而是通过 filler 回调逐条填充目录项:
c复制static int minfs_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, 0);
filler(buf, "..", NULL, 0, 0);
filler(buf, "hello.txt", NULL, 0, 0);
return 0;
}
filler 的参数依次是 buf、文件名、stat 指针(可以传 NULL,内核会自己 getattr)、偏移量、和 flags。最小实现只需要填 . 和 ..,以及你想要展示的文件名。FUSE 并不会因为你填了 hello.txt 就自动让文件可读,它只是让 ls 能看到这个名字,真正的读写还要靠后续回调。
经验提醒:readdir 里传 NULL 给 stat,会让内核随后自动调 getattr 填充每个条目的详细信息,这也是 ls -l 工作的基础。如果你在 filler 里手填了 stat,就必须保证和你自己的 getattr 结果一致,否则会出现 ls 显示的信息和 stat 不一样这种怪问题。
3.3 open/read/write:让文件真正能被读写
让文件能被看到之后,下一步就是读写。open 回调负责检查权限、初始化文件句柄,并且可以通过 struct fuse_file_info 返回具体状态。通常做法是:
c复制static int minfs_open(const char *path, struct fuse_file_info *fi)
{
if ((fi->flags & O_ACCMODE) != O_RDONLY)
return -EACCES;
// 你可以把文件对应的 inode 编号存到 fi->fh 里
// fi->fh = myfs_lookup_inode(path);
return 0;
}
不要把路径字符串本身存在 fi->fh 里,因为 open 之后如果有 rename 操作,路径会失效。最好存 inode 编号或者内部对象的 nid。
read 是最朴素的拷贝逻辑:从文件当前偏移读指定字节数,返回实际读到的字节数,返回 0 表示已经读到 EOF。实现的时候边界条件要仔细:
c复制static int minfs_read(const char *path, char *buf, size_t size,
off_t offset, struct fuse_file_info *fi)
{
// 根据 fi->fh 找到 inode
// 如果 offset >= inode->size,返回 0
// 计算实际可读长度 = min(size, inode->size - offset)
// memcpy 并返回长度
return copied;
}
write 同理,只是方向反一下:要根据 offset 扩展文件大小,把数据写入对应的存储位置,返回实际写入的字节数。注意 FUSE 协议允许多次 write 乱序到达,所以不能简单假设“第一次写偏移 0,第二次写偏移 10”,每次都必须以 offset 参数为准。
3.4 目录操作与重命名:把“文件夹”概念立起来
一个完整文件系统肯定要做目录操作。需要实现的回调有:
mkdir:创建空目录,并分配一个新的 inodemknod:创建普通文件unlink:删除文件,释放数据块rmdir:删除空目录rename:重命名或移动对象
这些回调共同构成了目录树的基本操作。如果你跳过这些,你的文件系统就是只读的。
以 mkdir 为例,核心逻辑是:在父目录的目录条目里增加一项,分配一个新的 inode 并标记为目录类型,同时把父目录的 nlink 加 1。unlink 则是反向操作:删除目录条目,置 inode 为空闲。这里最容易踩的坑是 nlink 计数和 inode 复用。如果你在 inode 编号上直接做“释放后立刻复用”,而内核还有一个旧的 file handle 指向这个编号,那就会发生隐错:打开旧文件,结果读到了新文件的内容。严谨做法是给 inode 加 generation 版本号,FUSE 的 struct fuse_file_info 里有 fh,你可以把“inode 编号 + generation”合并编码进去。
rename 比想象的复杂,因为源和目标可能都在同一个目录,也可能跨目录;目标可能已经存在,需要覆盖;目标可能是目录还是文件,处理完全不一样。建议最开始只支持“文件到文件”的 rename,目录树逻辑先稳住,再逐步扩展。
4. 实战 simplefs:在镜像文件上实现一个可持久化的文件系统
4.1 simplefs 镜像布局设计
这一节用一个完整的例子串起来。我把它叫 simplefs,目标是:把文件数据存在一个普通磁盘镜像文件里,通过 FUSE3 挂载后,可以创建、读写、删除文件,并且卸载再挂载后数据仍然在。
先设计镜像布局,参考了经典 MINIX/FAT 的做法,但做了大幅简化。我用 4096 字节作为块大小,镜像总共 4MB:
- 第 0 块:superblock,记录魔数、块大小、inode 数量、数据区起始块
- 第 1 块到第 8 块:inode 表,每块放 16 个 inode,总计 128 个 inode
- 第 9 块到第 1023 块:数据区,共 1015 个数据块
相关结构体定义:
c复制#define SIMPLEFS_MAGIC 0x534D504C
#define BLOCK_SIZE 4096
#define INODES_PER_BLOCK 16
#define MAX_INODES 128
#define DATA_START_BLOCK 9
#define MAX_DATA_BLOCKS 1015
struct simplefs_superblock {
uint32_t magic;
uint32_t block_size;
uint32_t inode_count;
uint32_t data_blocks;
uint32_t data_start;
};
struct simplefs_inode {
uint32_t mode; // S_IFDIR / S_IFREG
uint32_t uid;
uint32_t gid;
uint32_t size;
uint32_t blocks[6]; // 前5个直接块,第6个作为一级间接块指针
uint32_t used; // 0 空闲, 1 已分配
uint32_t mtime;
};
struct simplefs_dentry {
char name[32];
uint32_t ino;
uint32_t type; // 0 file, 1 dir
};
目录文件的组织方式也很朴素:目录数据存放在普通数据块里,每个目录项是 40 字节,一个块可以放 102 个条目。根目录永远是 inode 0。
4.2 格式化工具与 FUSE 主程序
写一个独立的 mkfs.simplefs,负责创建镜像并初始化 superblock 和根目录:
c复制int main(int argc, char *argv[])
{
int fd = open(argv[1], O_CREAT | O_RDWR, 0644);
ftruncate(fd, 4 * 1024 * 1024);
struct simplefs_superblock sb = {
.magic = SIMPLEFS_MAGIC,
.block_size = BLOCK_SIZE,
.inode_count = MAX_INODES,
.data_blocks = MAX_DATA_BLOCKS,
.data_start = DATA_START_BLOCK,
};
pwrite(fd, &sb, sizeof(sb), 0);
// 初始化根目录 inode 0
struct simplefs_inode root = {
.mode = S_IFDIR | 0755,
.used = 1,
.size = 0,
.blocks[0] = DATA_START_BLOCK,
};
pwrite(fd, &root, sizeof(root), BLOCK_SIZE * 1);
// 把根目录数据块清空,塞入 . 和 .. 两个目录项
struct simplefs_dentry entries[2] = {
{ .name = ".", .ino = 0, .type = 1 },
{ .name = "..", .ino = 0, .type = 1 },
};
pwrite(fd, entries, sizeof(entries), BLOCK_SIZE * DATA_START_BLOCK);
fsync(fd);
close(fd);
return 0;
}
FUSE 主程序就稍复杂了。启动时打开镜像文件,用 mmap 或者读入内存,之后所有回调都操作内存里的副本;卸载时把内存写回镜像。简单起见,我这里用 4MB 的内存数组模拟整个存储空间,启动时一次性读入,销毁时写回:
c复制static unsigned char *disk;
static struct simplefs_superblock *sb;
static struct simplefs_inode *itable;
static void disk_load(const char *path)
{
int fd = open(path, O_RDWR);
disk = malloc(4 * 1024 * 1024);
read(fd, disk, 4 * 1024 * 1024);
close(fd);
sb = (struct simplefs_superblock *) disk;
itable = (struct simplefs_inode *) (disk + BLOCK_SIZE);
}
对于原型验证,这种方式完全够用,后面要优化再把写回时机改成 fsync 驱动。
4.3 核心回调实现与编译挂载验证
getattr 的实现,直接根据路径查 inode 填 stat:
c复制static int fs_getattr(const char *path, struct stat *st,
struct fuse_file_info *fi)
{
(void) fi;
memset(st, 0, sizeof(struct stat));
int ino = path_lookup(path);
if (ino < 0)
return -ENOENT;
struct simplefs_inode *in = &itable[ino];
st->st_ino = ino;
st->st_mode = in->mode;
st->st_nlink = 1;
st->st_size = in->size;
st->st_uid = in->uid;
st->st_gid = in->gid;
st->st_mtime = in->mtime;
if (S_ISDIR(in->mode))
st->st_nlink = 2;
return 0;
}
path_lookup 的实现思路是:从根目录 inode 0 开始逐级解析,每级目录就扫描对应数据块里的 dentry 数组,匹配路径分量。
readdir 就是遍历目录的数据块,对每条 dentry 调用 filler:
c复制static int fs_readdir(const char *path, void *buf,
fuse_fill_dir_t filler, off_t offset,
struct fuse_file_info *fi,
enum fuse_readdir_flags flags)
{
int ino = path_lookup(path);
if (ino < 0)
return -ENOENT;
struct simplefs_inode *dir = &itable[ino];
if (!S_ISDIR(dir->mode))
return -ENOTDIR;
int block_idx = dir->blocks[0];
struct simplefs_dentry *entries =
(struct simplefs_dentry *)(disk + block_idx * BLOCK_SIZE);
for (int i = 0; i < 102; i++) {
if (entries[i].ino == 0)
continue;
struct stat st = {0};
st.st_ino = entries[i].ino;
st.st_mode = entries[i].type ? (S_IFDIR | 0755) : (S_IFREG | 0644);
filler(buf, entries[i].name, &st, 0, 0);
}
return 0;
}
read 就是先把 inode 里的直接块映射成磁盘镜像偏移,然后拷贝数据:
c复制static int fs_read(const char *path, char *buf, size_t size,
off_t offset, struct fuse_file_info *fi)
{
struct simplefs_inode *in = &itable[fi->fh];
if (offset >= in->size)
return 0;
if (offset + size > in->size)
size = in->size - offset;
int block_idx = offset / BLOCK_SIZE;
int within = offset % BLOCK_SIZE;
const char *src = (const char *)(disk + in->blocks[block_idx] * BLOCK_SIZE + within);
memcpy(buf, src, size);
return size;
}
write 则要多处理一种情况:文件被追加写时超过原来的最后一个数据块,需要分配新块并写入 blocks 数组:
c复制static int fs_write(const char *path, const char *buf, size_t size,
off_t offset, struct fuse_file_info *fi)
{
struct simplefs_inode *in = &itable[fi->fh];
int block_idx = offset / BLOCK_SIZE;
int within = offset % BLOCK_SIZE;
char *dst = (char *)(disk + in->blocks[block_idx] * BLOCK_SIZE + within);
memcpy(dst, buf, size);
if (offset + size > in->size)
in->size = offset + size;
return size;
}
理论上分配新块还要维护空闲块位图,这里为了原型演示省略了,实际文件系统不能这么干。完整实现里,superblock 里应该记录空闲块链表,或者加一块 block bitmap。
编译和验证:
bash复制gcc -o mkfs.simplefs mkfs.simplefs.c
gcc -o simplefs simplefs.c $(pkg-config --cflags --libs fuse3)
./mkfs.simplefs disk.img
mkdir -p /tmp/sf-mnt
./simplefs disk.img /tmp/sf-mnt -f -d
另外开一个终端:
bash复制echo "hello fuse" > /tmp/sf-mnt/hello.txt
cat /tmp/sf-mnt/hello.txt
mkdir /tmp/sf-mnt/subdir
ls -la /tmp/sf-mnt
如果一切正常,你会看到 /tmp/sf-mnt 下出现 hello.txt 和 subdir。然后卸载再重新挂载,hello.txt 依然存在。到这一步,一个最小的持久化文件系统就算真正跑通了。
4.4 实测效果
我实际跑通后最直观的体感是:cat、echo、mkdir、ls 这些最常用的命令,在挂载目录里用起来就和在 ext4 上没有任何区别。这也是 FUSE 最有成就感的地方,写命令的人以为自己在和普通文件系统打交道,实际背后全是我自己定义的那套 on-disk 格式。
但要注意,现在这个 simplefs 还只是“能用”,离“可靠”差很远。它没有处理文件系统断电一致性问题、没有并发访问控制、没有磁盘碎片管理、没有实现文件锁。下一篇再扩展时,我会从同步机制和崩溃恢复开始补。
5. 缓冲、同步与多线程:FUSE 程序的高阶正确性
5.1 内核缓冲、direct_io、keep_cache 之间的关系
很多第一次写 FUSE 的人都会困惑一个问题:我明明在 read 回调里返回了最新数据,为什么外面 cat 出来是旧的?答案几乎都是内核页缓存。
FUSE 的默认行为是:读请求会被内核 page cache 缓存起来。第一次 read 后,数据进了内核缓存;后面的 read 如果命中缓存,就不会再回调你的用户态程序。这意味着如果你的文件内容被其他进程修改,用户态 FUSE 程序自己知道,但内核不知道,通过文件系统读到的还是旧数据。
想要绕开这种情况,有三种手段:
- 在 open 回调里设置
fi->direct_io = 1:让这个文件的读写绕过内核缓冲,每次读写都直接打到你的回调。代价是性能下降,因为每次读写都有上下文切换,而且无法利用内核的预读和合并。 - 挂载时用
-o direct_io:全局开启 direct_io,效果同上,适合需要强一致性的网络文件系统。 - 在 open 回调里设置
fi->keep_cache = 0:告诉内核不要保留这个文件已有的缓存,每次 open 都重新读取。
默认情况下 FUSE 是“缓存读、不缓存写”的,所以写操作通常能及时到达你的回调,但读操作可能滞后。调试阶段看到旧数据先检查 open 回调里有没有设置 direct_io,这是一个经验教训。
direct_io 和 keep_cache 是互斥的:direct_io=1 时 keep_cache 没意义,因为页面缓存直接不参与。对一致性要求高、数据量不大的场景,直接开 direct_io 省心;对性能敏感的批量读取,保留内核缓存更合适。
5.2 flush、fsync、release 什么时候被调用
理解这几个回调的调用时机,直接决定了你的文件系统是否会在意外掉电时丢数据。
flush:每个 fd 在close()时调用一次,但它不是落盘保证。它更像是一个“我要放飞这个 fd 了,你赶紧把用户态缓存里的东西处理掉”的信号。如果 flush 返回错误,进程会在 close 或随后的 fsync 中收到。fsync:显式同步,对应fsync(fd)和fdatasync(fd)。在这里应该把你用户态缓冲区里的数据真正写到底层存储介质。release:内核不再持有这个文件句柄时调用一次,返回错误也不会传给应用层,所以适合做资源清理,不适合做数据同步。
简单说,数据安全的关键路径是:write 回调写用户态缓存 → fsync 回调写底层存储。如果你的 FUSE 程序在 write 里就直接落盘,那 fsync 可以做成空操作;但如果你的 write 只更新了内存,fsync 里必须做真正的持久化。
很多线上事故是:进程退出正常,但机器断电后文件内容丢失。原因就是 FUSE 程序把所有 write 都落在内存里,只在 close 时写回,而 close 不等同于 fsync。正确做法至少要在 fsync 和 release 时把数据写回镜像文件,并且对镜像文件调用 fsync(fd) 确保落到物理介质。
5.3 多线程挂载与数据竞争
FUSE3 默认是多线程处理请求的,也就是说,如果不加锁,多个文件操作可能同时进入你的回调函数。这个和普通多线程编程的坑一模一样:路径解析和 inode 分配都可能发生竞争。
最省心的做法是全局加一把互斥锁,把每个回调函数包起来:
c复制static pthread_mutex_t big_lock = PTHREAD_MUTEX_INITIALIZER;
static int fs_readdir(...) {
pthread_mutex_lock(&big_lock);
// 真实业务逻辑
pthread_mutex_unlock(&big_lock);
return 0;
}
对原型来说,一把大锁足够了,代码简单且不会死锁。但如果追求性能,就要区分路径解析的锁和 inode 表的锁,甚至做 inode 粒度的细锁。建议不要一开始就上细锁,先用大锁把正确性保证住,再用性能测试工具找出瓶颈。
多线程下最容易忽略的是 inode 回收。假设两个线程同时打开不同的文件,其中一个 unlink 了文件 A,另一个线程正持有 A 的 fh。如果 A 的 inode 被立刻复用给文件 B,第二个线程继续读写时,就会读成 B 的内容。解决思路:要么用引用计数保证 inode 在 fh 关闭前不被复用,要么在 fh 里存“inode 编号 + generation 编号”,读写时校验 generation 是否一致。
6. FUSE3 实战最容易踩的坑和我的排查过程
6.1 挂载完 ls 卡住,排查链路
这个坑我至少见过三次,包括我自己第一次写 FUSE 时就中招了。现象是:挂载成功,但执行 ls /tmp/mnt 直接卡死,Ctrl+C 都杀不掉,看起来像死锁。
排查链路是:
- 先看 FUSE 调试终端,发现 getattr 请求之后没有 readdir 请求。也就是说内核没有发出 readdir,卡在 getattr 的返回值上。
- 检查 getattr 对目录的返回值,发现
st_nlink一直是 1,但我返回的 mode 确实是 S_IFDIR。部分版本的内核对目录 nlink 有校验,nlink 为 1 的目录会导致路径解析失败。 - 把根目录 nlink 改成 2,问题消失。
所以 FUSE3 里目录的 st_nlink 务必设置成 2,这是一个微小的规范细节,却直接决定内核是否会继续下发 readdir 请求。
另一个常见的“ls 卡死”原因是回调用 strcmp(path, "/") 判断根目录,但从 FUSE3 传进来的路径可能是 /,也可能是 /.。建议统一用 strcmp 之前先做路径规范化,或者直接按 / 兜底处理。
6.2 allow_other、权限与安全边界
如果你挂载后,其他用户访问你的挂载目录报 Permission denied,而你自己访问正常,那不是文件系统的问题,是 FUSE 的访问控制机制。
FUSE 默认只允许挂载用户访问挂载点。想让其他用户也能访问,挂载时要加 -o allow_other。但注意,FUSE3 规定普通用户使用 allow_other 前,必须在 /etc/fuse.conf 里取消 # user_allow_other 这一行的注释,否则挂载直接失败。root 用户则不需要。
还有一个容易混淆的点是 -o default_permissions。如果你的 FUSE 程序自己处理了权限检查,可以不传这个选项;如果传了,内核会在调用你的回调之前,先根据文件 mode 和访问者身份做一次权限检查。对新手而言,我建议加上 default_permissions,让内核帮忙做权限判断,你的回调里就不用重复写 uid/gid 检查,减少出错面。
但要理解:default_permissions 检查的是 stat 里的 mode,如果你 getattr 返回的 mode 不合常理,权限判断也会跟着错。比如 getattr 里忘了初始化 st_uid,导致文件属主是 0,非 root 用户去访问就会频繁出现权限问题。
6.3 崩溃后的挂载点僵局与清理
开发阶段,FUSE 程序崩溃或者被 kill -9,挂载点会进入一个“僵尸”状态:目录还在,但执行 ls 会卡住,fusermount3 -u 也提示 busy。这时候用强制卸载:
bash复制fusermount3 -uz /tmp/mnt
或者:
bash复制umount -l /tmp/mnt
-l 是 lazy 卸载,内核会终止该挂载点的文件访问并回收资源。开发调试时如果反复崩溃,建议直接做一个小脚本,每次挂在前先 umount -l 清场。
如果你发现自己写的 FUSE 程序一崩溃,整个 shell 都卡住,那大概率是 shell 当前目录在被删除的挂载点上。可以先行 cd / 再执行清理,避免 shell 因为 pwd 失效而卡死。
6.4 给新手的调试建议
第一个建议:不要一开始就写完整的文件系统,只实现 getattr + readdir 两个回调,先让 ls 能看到你虚构的文件名。这个“让名字可见”的过程能帮你快速理解 FUSE 请求-响应模型,后面再补 read/write 时信心会稳很多。
第二个建议:所有回调都先打印日志。可以用 fprintf(stderr, "[%s] path=%s\\n", __func__, path),配合 -f -d 运行,每个请求的来源和结果都能看到。等稳定后再移除日志,或者用环境变量控制日志级别。
第三个建议:写好单元测试。FUSE 程序不需要复杂的测试框架,直接 shell 脚本就够了:挂载之后依次执行 touch、mkdir、cat、echo、rm,然后卸载重挂验证数据是否还在。我曾经因为一个 rename 回调写错,导致文件移动后内容丢失,就是靠这个朴素脚本抓出来的。
第四个建议:仔细处理进程退出信号。FUSE3 程序默认在前台运行时会处理 SIGINT/SIGTERM,确保收到信号后能正确调用 fuse_session_exit 并清理解析挂载点。自己在 main 里捕获信号时别把它吞掉,否则 Ctrl+C 后挂载点残留,下一轮开发全卡在清理上。
最后说一个我自己的体会:FUSE3 的开发曲线其实是倒过来的,最难的是第 0 天从“写不出任何东西”到“能挂载”,一旦 getattr 和 readdir 打通,后面就是往结构体里不断加回调和调细节的体力活。第一次看到自己定义的文件系统出现在 /tmp/mnt 里、ls 能列出文件的时候,那种满足感和当年编译内核跑起来是一样强的。别被 struct fuse_operations 里那一长串函数指针吓住,你只需要从最小集合开始,然后按需往里面加。
