如果你折腾过嵌入式开发板上的根文件系统,或者只是维护 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
.
