经典系统实践|把 Go 文件系统接到 FUSE:真正挂载起来,并走通 ls/cat/touch 全链路
前面两篇,我们已经把这个最小 Go 文件系统做到了两个层次:
- 先实现了最小 on-disk filesystem,包含 superblock、inode、bitmap、directory entry 和 path walk
- 再补了一层最小 WAL / journal,让它在崩溃恢复语义上不再完全裸奔
到这里,其实你已经有了一个“能工作的本地文件系统引擎”。
但它还缺最后一层非常关键的东西:
让操作系统真的把它当成一个文件系统来访问。
只要这一步没做,前面的实现仍然更像一个“文件系统库”而不是“文件系统”。
所以这篇文章我们继续往前推一层:
把这个 Go 文件系统接到 FUSE,真正 mount 起来,然后用 ls、cat、touch 去走一遍完整链路。
这篇不会把重点放在“如何调用某个 Go FUSE 库的每一个 API 细节”,而是放在更重要的地方:
- 一个用户态文件系统到底是怎么和内核说话的
ls/cat/touch这类普通命令,最后会落到哪些 FUSE 回调- 这些回调又该怎样映射到我们上一篇自己写的
Lookup/Create/ReadFile/WriteFile/ListDir - 为什么一旦真正 mount 成功,前面那些 inode、目录和路径查找概念会突然变得异常具体
如果你本来就是做对象存储或者存储引擎的,这篇文章还有一个额外价值:
它会把“一个本地存储引擎”如何暴露成标准文件接口这件事,完整串起来。
1. 先把 FUSE 的位置摆清楚
很多人第一次接触 FUSE,会把它理解成“一个让你在用户态实现文件系统的库”。
方向没错,但不够完整。
更准确地说,FUSE 是一套协作机制:
- 内核侧有 FUSE 驱动
- 用户态有一个文件系统进程
- 普通进程对挂载点执行
open/read/write/readdir/create等操作 - 内核把这些文件系统请求转发给用户态 FUSE 进程
- 用户态进程返回结果,再由内核回复给调用方
如果把链路画出来,大概是这样:
shell / app
-> syscalls(open, read, write, readdir, getattr)
-> VFS
-> FUSE kernel module
-> /dev/fuse
-> userspace FUSE server (our Go process)
-> toy filesystem engine (our code)
-> fs.img
这条链路里最值得你建立直觉的,不是 /dev/fuse 这个细节本身,而是:
FUSE 并没有重新发明文件系统语义,它只是把 VFS 请求转发到了用户态。
所以我们前面自己写的那些 inode、directory、lookup、read file 逻辑,并不会被浪费掉;恰恰相反,它们正好就是 FUSE server 背后的核心引擎。
2. 为什么我不建议一上来先学 FUSE API,而是先写 on-disk 引擎
很多教程会反过来,从 FUSE 的 Getattr、Readdir、Read、Write 回调直接开始写。
这当然也能跑,但有个明显问题:
你很容易写出一个“接口上能响应,但内部没有真实文件系统模型”的 demo。
比如:
- 所有目录结构都放在内存 map 里
- 所有文件内容都放在内存字典里
- 进程退出后一切归零
这种 demo 适合理解 FUSE API,但不适合理解文件系统。
我们前面先写了 on-disk filesystem,再来接 FUSE,顺序正好反过来。
这样做的好处是:
- FUSE 层只负责协议适配
- 真正的数据和元数据语义已经在下层引擎里
- 你会很清楚一条系统调用最终落到了哪个 inode、哪个目录块、哪个 bitmap 更新上
这才是更像真实工程的结构。
3. 先选 Go FUSE 库:目标不是“最炫”,而是“最容易讲清楚”
Go 里常见的 FUSE 方案有几类,这里不展开做生态横评,只说这篇文章的取舍。
如果目标是教学型最小实现,我更倾向于选择这种风格的接口:
- 有明确的
Node/Handle概念 - 能把
Lookup、Attr、ReadDirAll、ReadAll、Create、Write映射出来 - API 不需要你先理解太多复杂缓存和 low-level 协议细节
所以本文下面的代码骨架,会按一种“高层 Node API”来讲。
你可以把它理解成类似下面这种建模:
FS object
-> Root() returns root node
Dir node
-> Attr()
-> Lookup(name)
-> ReadDirAll()
-> Create(name)
File node
-> Attr()
-> Open()
-> ReadAll() or Read(offset, size)
-> Write()
就算你最后选用的具体库和这里示意不完全一样,核心思想也不会变。
4. 把上一篇的 toy filesystem 包装成一个“存储引擎接口”
在接 FUSE 前,先做一个非常关键的整理:
不要让 FUSE 回调直接操作底层 block。
而是先把上一篇的实现整理成一层更像 storage engine 的接口。
例如:
type Engine interface {
Lookup(path string) (inodeNo uint32, inode Inode, err error)
ListDir(path string) ([]DirItem, error)
ReadFile(path string) ([]byte, error)
CreateFile(path string) (uint32, error)
WriteFile(path string, data []byte) error
Mkdir(path string) (uint32, error)
Delete(path string) error
}
为什么要先做这层?
因为 FUSE 层的职责不应该是:
- 自己管理 inode table
- 自己算 block 偏移
- 自己 patch bitmap
FUSE 层的职责应该只是:
- 把 VFS / FUSE 请求翻译成引擎操作
- 把引擎结果翻译回 POSIX 风格的文件属性和错误码
换句话说:
FUSE 是 adapter,不是 storage core。
5. 你真正要维护的,不只是 path,还有 inode identity
第一次写 FUSE 时,一个很常见的简化是:所有操作都直接按 path 转给后端。
这能工作,但很快会遇到几个问题:
Lookup之后为什么还要保留 node identityGetattr为什么不应该每次都重新全路径扫描- 文件打开后的 handle 和目录节点本身为什么是两回事
所以更合理的模型通常是:
Node表示命名空间里的一个 inode 视图Handle表示一次打开后的会话对象
一个最小建模可以这样写:
type FS struct {
eng Engine
}
type DirNode struct {
fs *FS
inodeNo uint32
path string
}
type FileNode struct {
fs *FS
inodeNo uint32
path string
}
type FileHandle struct {
node *FileNode
}
这里的 path 只是为了简单讲解方便保留;更严谨的版本里,很多时候会更多依赖 inode identity。
6. Getattr 是第一块必须写对的拼图
当你在 shell 里执行:
ls -l /mnt/toyfs
很多人会以为最先触发的是 readdir。其实在现实里,getattr 往往非常频繁,很多命令都会先问一遍:
- 这是文件还是目录
- size 多大
- mode 是什么
- mtime 是什么
所以 Attr() 往往是第一个必须写稳的回调。
把我们的 inode 映射成 FUSE 属性,大致可以这么做:
func (n *FileNode) Attr(ctx context.Context, a *fuse.Attr) error {
_, inode, err := n.fs.eng.Lookup(n.path)
if err != nil {
return toFuseErr(err)
}
a.Inode = uint64(n.inodeNo)
a.Size = inode.Size
a.Mode = 0644
return nil
}
目录节点则类似,只是 mode 要带上目录位:
a.Mode = os.ModeDir | 0755
这一步一旦打通,操作系统才能把你的节点当成“像样的文件或目录”来看待。
7. Lookup(name) 本质上就是上一篇的路径查找再包一层
FUSE 里的 Lookup 非常关键,因为它正好对应我们上一篇已经讲透的事情:
在当前目录里找一个名字,然后拿到目标 inode。
所以目录节点的 Lookup(name) 写起来其实非常自然:
func (d *DirNode) Lookup(ctx context.Context, name string) (fs.Node, error) {
childPath := path.Join(d.path, name)
inodeNo, inode, err := d.fs.eng.Lookup(childPath)
if err != nil {
return nil, toFuseErr(err)
}
if inode.Type == TypeDir {
return &DirNode{fs: d.fs, inodeNo: inodeNo, path: childPath}, nil
}
return &FileNode{fs: d.fs, inodeNo: inodeNo, path: childPath}, nil
}
注意这一步的思想非常重要:
- FUSE
Lookup不是重新发明目录查找 - 它只是把内核问你的“这个名字对应什么节点”翻译成后端引擎的一次 lookup
也就是说,FUSE 没有改变文件系统本质,它只是把访问入口标准化了。
8. Readdir 让 ls 真正有东西可列
如果只有 Lookup 和 Attr,你可能已经能访问一些固定路径,但 ls 还未必能正常列目录。
因为 ls 需要的是:
- 打开目录
- 读取目录项列表
- 对每个名字再补更多属性
在 FUSE 侧,这通常就映射到 ReadDirAll() 或带 offset 的 ReadDir。
而我们的后端引擎本来就已经有 ListDir(path),所以包起来很直接:
func (d *DirNode) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) {
items, err := d.fs.eng.ListDir(d.path)
if err != nil {
return nil, toFuseErr(err)
}
out := make([]fuse.Dirent, 0, len(items))
for _, item := range items {
ent := fuse.Dirent{
Inode: uint64(item.InodeNo),
Name: item.Name,
}
if item.Type == TypeDir {
ent.Type = fuse.DT_Dir
} else {
ent.Type = fuse.DT_File
}
out = append(out, ent)
}
return out, nil
}
到这里,ls 的“列名字”部分基本就打通了。
9. Open + Read 对应 cat 的主链路
接下来是最直观的演示命令:
cat /mnt/toyfs/hello.txt
这个命令在内核和 FUSE 层大致会经历:
lookup("hello.txt")getattropenread(offset, size)多次
如果我们为了简单,先实现一个 ReadAll 风格,也完全可以把主逻辑讲清楚:
func (f *FileNode) ReadAll(ctx context.Context) ([]byte, error) {
data, err := f.fs.eng.ReadFile(f.path)
if err != nil {
return nil, toFuseErr(err)
}
return data, nil
}
如果你想更贴近真实世界,可以实现带 offset 的读取:
func (f *FileHandle) Read(ctx context.Context, req *fuse.ReadRequest, resp *fuse.ReadResponse) error {
data, err := f.node.fs.eng.ReadFile(f.node.path)
if err != nil {
return toFuseErr(err)
}
if req.Offset >= int64(len(data)) {
resp.Data = nil
return nil
}
end := min(int(req.Offset)+req.Size, len(data))
resp.Data = data[req.Offset:end]
return nil
}
一旦这段逻辑能跑通,cat 就真的会从你的 fs.img 里把文件内容读出来,而不是从某个内存 map 里读出来。
这时“文件系统”的感觉就真正出来了。
10. touch 看起来简单,实际上会触发 create 语义
很多人以为 touch foo.txt 只是“改个时间戳”。
这是对已有文件而言。
但如果文件不存在,touch 的本质是:
- 在父目录里查名字
- 如果不存在,创建一个空文件
- 然后再做时间相关更新
对于我们的最小实现,先不做 mtime,也没关系。只要能把“文件不存在时创建空文件”走通,touch 的核心语义就已经出来了。
目录节点的 Create 可以写成:
func (d *DirNode) Create(ctx context.Context, req *fuse.CreateRequest, resp *fuse.CreateResponse) (fs.Node, fs.Handle, error) {
childPath := path.Join(d.path, req.Name)
inodeNo, err := d.fs.eng.CreateFile(childPath)
if err != nil {
return nil, nil, toFuseErr(err)
}
node := &FileNode{fs: d.fs, inodeNo: inodeNo, path: childPath}
handle := &FileHandle{node: node}
return node, handle, nil
}
这段代码背后真正发生的,其实还是上一篇讲过的那些事情:
- 分配 inode
- 修改父目录项
- 持久化 inode table
- 如果你接了 WAL,还要把这些更新打成一个事务提交
所以表面上是一个 touch,底下其实已经把你那套 toy filesystem 真正驱动起来了。
11. 如果 echo hi > file.txt 呢:它会把写路径也拉通
虽然你这次要求重点演示 ls/cat/touch,但如果文件系统已经接上 FUSE,那么最自然的下一步其实就是:
echo hi > /mnt/toyfs/file.txt
这背后通常会经历:
lookupcreate或opentruncatewriteflush/release
我们的最小实现可以先不做复杂的增量 offset write,而是直接做“整个文件覆盖写”。
例如:
func (h *FileHandle) Write(ctx context.Context, req *fuse.WriteRequest, resp *fuse.WriteResponse) error {
oldData, err := h.node.fs.eng.ReadFile(h.node.path)
if err != nil && !errors.Is(err, ErrNotFound) {
return toFuseErr(err)
}
newData := applyWrite(oldData, req.Offset, req.Data)
if err := h.node.fs.eng.WriteFile(h.node.path, newData); err != nil {
return toFuseErr(err)
}
resp.Size = len(req.Data)
return nil
}
这当然不是最优实现,但它已经足够把“系统调用写请求 -> FUSE -> toy filesystem -> block / inode / WAL 更新”这条链走通。
12. ls、cat、touch 到底各自会经过哪些回调
这里我建议你在脑子里把最常见的三条链路记成表格。
12.1 ls /mnt/toyfs
大致会打到:
Root()Attr()ReadDirAll()- 对每个目录项,可能额外有
Lookup()/Attr()
12.2 cat /mnt/toyfs/hello.txt
大致会打到:
Lookup("hello.txt")Attr()Open()Read()或ReadAll()
12.3 touch /mnt/toyfs/new.txt
如果文件不存在,大致会打到:
- 父目录
Lookup("new.txt")失败 Create("new.txt")- 可能有
Open()/Flush()/Release()
一旦你把这三条链记住,再调试 FUSE 文件系统时就不会太慌。
13. 从 syscall 角度看一遍,更容易理解全链路
如果你愿意再往系统调用层想一步,这三条链会更立体。
ls
shell 命令最终大致触发:
stat()
open()
getdents() / readdir()
stat() on entries
cat
最终大致是:
open()
read()
read()
...
close()
touch
文件不存在时,大致是:
open(path, O_CREAT)
close()
utimensat() # 如果实现时间戳更新
而 FUSE 的价值就在于:
它让这些标准 syscall 最后都能落到你的 Go 回调里。
这也是为什么我们说,FUSE 更像是 VFS 和用户态文件系统之间的翻译层。
14. 运行起来之前,先把 mount 程序结构理一下
一个最小挂载程序大概只需要做这些事:
- 打开
fs.img - 初始化 toy filesystem 引擎
- 做一次恢复,如果你前面已经接了 WAL
- 创建 FUSE server 对象
- 把它挂到某个目录,比如
/tmp/toyfs - 阻塞等待请求
Go 代码骨架大致可以这样写:
func main() {
eng, err := OpenEngine("./fs.img")
if err != nil {
log.Fatal(err)
}
if err := eng.Recover(); err != nil {
log.Fatal(err)
}
c, err := fuse.Mount(
"/tmp/toyfs",
fuse.FSName("toyfs"),
fuse.Subtype("toyfs-go"),
fuse.LocalVolume(),
fuse.VolumeName("ToyFS"),
)
if err != nil {
log.Fatal(err)
}
defer c.Close()
filesys := &FS{eng: eng}
if err := fs.Serve(c, filesys); err != nil {
log.Fatal(err)
}
<-c.Ready
if err := c.MountError; err != nil {
log.Fatal(err)
}
}
这里真正关键的不是每个选项本身,而是你会发现:
FUSE server 的 main 函数,其实很像在把一个存储引擎注册给操作系统。
15. macOS 和 Linux 上要注意什么
既然这篇标题里说“真正挂载起来”,那就得提一句现实环境。
在 macOS 上
通常需要用户态 FUSE 支持,比如安装 macFUSE,并允许对应系统扩展生效。
在 Linux 上
通常需要:
- 内核启用 FUSE
- 有
/dev/fuse - 当前用户具备相应挂载权限,或通过合适方式运行
也就是说,代码能跑只是第一步;本机运行环境要满足 FUSE 前提,mount 才真的能成功。
不过这些环境细节不会改变本文的核心逻辑,因为:
协议链路和存储引擎映射方式是一样的。
16. 真正演示一遍:从 mkfs 到 ls/cat/touch
现在把全流程串一下。
第一步,初始化镜像
go run ./cmd/mkfs ./fs.img
这一步会:
- 创建空镜像
- 写 superblock
- 初始化 inode table
- 创建 root 目录
- 如果你用的是带 WAL 的版本,也会初始化 journal 区
第二步,启动 FUSE server 并挂载
mkdir -p /tmp/toyfs
go run ./cmd/mount ./fs.img /tmp/toyfs
启动后,内核看到 /tmp/toyfs 已经是一个可访问的挂载点。
第三步,验证 ls
ls -la /tmp/toyfs
这时链路大致是:
- shell 发起目录访问
- VFS 把请求交给 FUSE
- FUSE 调用我们的
Root/Attr/ReadDirAll ReadDirAll再调用底层ListDir("/")- toy filesystem 从 root inode 对应目录块读出目录项
- 最终返回给
ls
第四步,验证 touch
touch /tmp/toyfs/hello.txt
这时会大致触发:
Lookup("hello.txt")失败Create("hello.txt")- 底层引擎执行
CreateFile("/hello.txt") - 分配 inode,更新根目录项
- 如果启用了 WAL,则事务提交后再真正落 home location
第五步,写入一点内容
echo 'hello toyfs' > /tmp/toyfs/hello.txt
这里会打到:
OpenWrite- 底层
WriteFile("/hello.txt", data) - 分配数据块、更新 inode size 和 direct 指针
- 如启用 WAL,则走
Begin -> DATA records -> COMMIT -> home write
第六步,验证 cat
cat /tmp/toyfs/hello.txt
链路则是:
LookupAttrOpenRead- 底层
ReadFile("/hello.txt") - 根据 inode 的 direct blocks 从
fs.img读回数据 - 结果返回给
cat
到这一步,你的 toy filesystem 就真的不只是“会读写镜像文件的 Go 代码”了,而是一个操作系统可以直接使用的文件系统。
17. 调试 FUSE 文件系统时,最有用的不是猜,而是打链路日志
FUSE 调试最容易让人迷糊的地方在于:
- 你敲了一条简单命令
- 内核实际触发了一串回调
- 不同系统、不同工具还可能多打一堆
getattr/access请求
所以调试时最有用的事情,通常不是盲猜,而是给每个关键回调打清晰日志。
比如:
log.Printf("Lookup path=%s name=%s", d.path, name)
log.Printf("ReadDirAll path=%s", d.path)
log.Printf("Create path=%s name=%s", d.path, req.Name)
log.Printf("Read path=%s offset=%d size=%d", h.node.path, req.Offset, req.Size)
log.Printf("Write path=%s offset=%d size=%d", h.node.path, req.Offset, len(req.Data))
这样你一边跑:
ls /tmp/toyfs
cat /tmp/toyfs/hello.txt
touch /tmp/toyfs/new.txt
一边看日志,就会非常直观地建立 syscall 到回调的映射关系。
这一步对理解 FUSE 非常值。
18. 真正挂载以后,你会更清楚哪些东西属于“接口层”,哪些属于“存储层”
这是我觉得这篇实践最重要的收获。
很多人在接 FUSE 之前,会把文件系统理解成一团混在一起的东西。
但一旦你把 toy filesystem 挂起来,分层会突然变得非常清楚:
接口层负责什么
- 接收
Lookup/Getattr/Readdir/Open/Read/Create/Write - 把请求翻译成后端引擎调用
- 把错误翻译成
ENOENT/EISDIR/ENOTDIR等 POSIX 语义
存储层负责什么
- inode 怎么组织
- 目录项怎么保存
- 数据块怎么分配
- WAL 怎么提交和恢复
- 崩溃后哪些状态算数
这两层一旦分开,你后面无论继续做:
- 更真实的本地文件系统
- 一个对象存储的 local disk engine
- 一个自定义 metadata store
都会顺很多。
19. 这篇实现还故意省略了什么
为了把主链路讲清楚,这篇有意识地没有展开一些真实系统里非常重要、但会显著拉高复杂度的东西:
- inode / entry cache
Forget和 lookup reference 计数- offset-based streaming
Readdir Setattr、mtime/ctime、truncate- 文件锁
- 权限检查和 uid/gid
- 并发访问控制
- page cache 一致性
这些在真实文件系统里都很重要。
但对当前这个系列来说,先把 ls/cat/touch 和基本写路径跑通,已经足够让整个系统骨架落地。
20. 总结:FUSE 让你第一次真正“使用”自己写的文件系统
如果把这篇文章压缩成一句话,那就是:
FUSE 的真正价值,不只是让你在用户态写文件系统,而是让你前面实现的那些 inode、目录项、路径查找、WAL 和数据块分配,第一次以标准操作系统接口的形式真正跑起来。
在这之前:
- 你的 toy filesystem 更像一个库
- 你只能通过测试代码去调用
Lookup/ReadFile/WriteFile
接上 FUSE 之后:
ls会真的触发目录读取cat会真的触发文件读取touch会真的触发创建路径echo > file会真的把写请求一路传到你的 block / inode / WAL 更新逻辑
到这一步,前面三篇文章才算真正闭环:
- 先理解文件系统原理
- 再自己实现最小 on-disk filesystem
- 再补上一层 WAL / journal
- 最后通过 FUSE 把它暴露成一个真正可挂载、可访问的文件系统
如果这个系列继续往下写,一个很自然的下一篇方向就是:
在 FUSE 层补上 truncate/setattr/rename/unlink,并认真处理缓存、并发和崩溃恢复之间的边界。