Q先生的世界

面朝大海,春暖花开

经典系统实践|把 Go 文件系统接到 FUSE:真正挂载起来,并走通 ls/cat/touch 全链路

前面两篇,我们已经把这个最小 Go 文件系统做到了两个层次:

  1. 先实现了最小 on-disk filesystem,包含 superblock、inode、bitmap、directory entry 和 path walk
  2. 再补了一层最小 WAL / journal,让它在崩溃恢复语义上不再完全裸奔

到这里,其实你已经有了一个“能工作的本地文件系统引擎”。

但它还缺最后一层非常关键的东西:

让操作系统真的把它当成一个文件系统来访问。

只要这一步没做,前面的实现仍然更像一个“文件系统库”而不是“文件系统”。

所以这篇文章我们继续往前推一层:

把这个 Go 文件系统接到 FUSE,真正 mount 起来,然后用 lscattouch 去走一遍完整链路。

这篇不会把重点放在“如何调用某个 Go FUSE 库的每一个 API 细节”,而是放在更重要的地方:

  1. 一个用户态文件系统到底是怎么和内核说话的
  2. ls/cat/touch 这类普通命令,最后会落到哪些 FUSE 回调
  3. 这些回调又该怎样映射到我们上一篇自己写的 Lookup/Create/ReadFile/WriteFile/ListDir
  4. 为什么一旦真正 mount 成功,前面那些 inode、目录和路径查找概念会突然变得异常具体

如果你本来就是做对象存储或者存储引擎的,这篇文章还有一个额外价值:

它会把“一个本地存储引擎”如何暴露成标准文件接口这件事,完整串起来。


1. 先把 FUSE 的位置摆清楚

很多人第一次接触 FUSE,会把它理解成“一个让你在用户态实现文件系统的库”。

方向没错,但不够完整。

更准确地说,FUSE 是一套协作机制:

  1. 内核侧有 FUSE 驱动
  2. 用户态有一个文件系统进程
  3. 普通进程对挂载点执行 open/read/write/readdir/create 等操作
  4. 内核把这些文件系统请求转发给用户态 FUSE 进程
  5. 用户态进程返回结果,再由内核回复给调用方

如果把链路画出来,大概是这样:

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 请求转发到了用户态。

所以我们前面自己写的那些 inodedirectorylookupread file 逻辑,并不会被浪费掉;恰恰相反,它们正好就是 FUSE server 背后的核心引擎。


2. 为什么我不建议一上来先学 FUSE API,而是先写 on-disk 引擎

很多教程会反过来,从 FUSE 的 GetattrReaddirReadWrite 回调直接开始写。

这当然也能跑,但有个明显问题:

你很容易写出一个“接口上能响应,但内部没有真实文件系统模型”的 demo。

比如:

  1. 所有目录结构都放在内存 map 里
  2. 所有文件内容都放在内存字典里
  3. 进程退出后一切归零

这种 demo 适合理解 FUSE API,但不适合理解文件系统。

我们前面先写了 on-disk filesystem,再来接 FUSE,顺序正好反过来。

这样做的好处是:

  1. FUSE 层只负责协议适配
  2. 真正的数据和元数据语义已经在下层引擎里
  3. 你会很清楚一条系统调用最终落到了哪个 inode、哪个目录块、哪个 bitmap 更新上

这才是更像真实工程的结构。


3. 先选 Go FUSE 库:目标不是“最炫”,而是“最容易讲清楚”

Go 里常见的 FUSE 方案有几类,这里不展开做生态横评,只说这篇文章的取舍。

如果目标是教学型最小实现,我更倾向于选择这种风格的接口:

  1. 有明确的 Node / Handle 概念
  2. 能把 LookupAttrReadDirAllReadAllCreateWrite 映射出来
  3. 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 层的职责不应该是:

  1. 自己管理 inode table
  2. 自己算 block 偏移
  3. 自己 patch bitmap

FUSE 层的职责应该只是:

  1. 把 VFS / FUSE 请求翻译成引擎操作
  2. 把引擎结果翻译回 POSIX 风格的文件属性和错误码

换句话说:

FUSE 是 adapter,不是 storage core。


5. 你真正要维护的,不只是 path,还有 inode identity

第一次写 FUSE 时,一个很常见的简化是:所有操作都直接按 path 转给后端。

这能工作,但很快会遇到几个问题:

  1. Lookup 之后为什么还要保留 node identity
  2. Getattr 为什么不应该每次都重新全路径扫描
  3. 文件打开后的 handle 和目录节点本身为什么是两回事

所以更合理的模型通常是:

  1. Node 表示命名空间里的一个 inode 视图
  2. 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 往往非常频繁,很多命令都会先问一遍:

  1. 这是文件还是目录
  2. size 多大
  3. mode 是什么
  4. 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
}

注意这一步的思想非常重要:

  1. FUSE Lookup 不是重新发明目录查找
  2. 它只是把内核问你的“这个名字对应什么节点”翻译成后端引擎的一次 lookup

也就是说,FUSE 没有改变文件系统本质,它只是把访问入口标准化了。


8. Readdirls 真正有东西可列

如果只有 LookupAttr,你可能已经能访问一些固定路径,但 ls 还未必能正常列目录。

因为 ls 需要的是:

  1. 打开目录
  2. 读取目录项列表
  3. 对每个名字再补更多属性

在 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 层大致会经历:

  1. lookup("hello.txt")
  2. getattr
  3. open
  4. read(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 的本质是:

  1. 在父目录里查名字
  2. 如果不存在,创建一个空文件
  3. 然后再做时间相关更新

对于我们的最小实现,先不做 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
}

这段代码背后真正发生的,其实还是上一篇讲过的那些事情:

  1. 分配 inode
  2. 修改父目录项
  3. 持久化 inode table
  4. 如果你接了 WAL,还要把这些更新打成一个事务提交

所以表面上是一个 touch,底下其实已经把你那套 toy filesystem 真正驱动起来了。


11. 如果 echo hi > file.txt 呢:它会把写路径也拉通

虽然你这次要求重点演示 ls/cat/touch,但如果文件系统已经接上 FUSE,那么最自然的下一步其实就是:

echo hi > /mnt/toyfs/file.txt

这背后通常会经历:

  1. lookup
  2. createopen
  3. truncate
  4. write
  5. flush/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. lscattouch 到底各自会经过哪些回调

这里我建议你在脑子里把最常见的三条链路记成表格。

12.1 ls /mnt/toyfs

大致会打到:

  1. Root()
  2. Attr()
  3. ReadDirAll()
  4. 对每个目录项,可能额外有 Lookup() / Attr()

12.2 cat /mnt/toyfs/hello.txt

大致会打到:

  1. Lookup("hello.txt")
  2. Attr()
  3. Open()
  4. Read()ReadAll()

12.3 touch /mnt/toyfs/new.txt

如果文件不存在,大致会打到:

  1. 父目录 Lookup("new.txt") 失败
  2. Create("new.txt")
  3. 可能有 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 程序结构理一下

一个最小挂载程序大概只需要做这些事:

  1. 打开 fs.img
  2. 初始化 toy filesystem 引擎
  3. 做一次恢复,如果你前面已经接了 WAL
  4. 创建 FUSE server 对象
  5. 把它挂到某个目录,比如 /tmp/toyfs
  6. 阻塞等待请求

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 上

通常需要:

  1. 内核启用 FUSE
  2. /dev/fuse
  3. 当前用户具备相应挂载权限,或通过合适方式运行

也就是说,代码能跑只是第一步;本机运行环境要满足 FUSE 前提,mount 才真的能成功。

不过这些环境细节不会改变本文的核心逻辑,因为:

协议链路和存储引擎映射方式是一样的。


16. 真正演示一遍:从 mkfs 到 ls/cat/touch

现在把全流程串一下。

第一步,初始化镜像

go run ./cmd/mkfs ./fs.img

这一步会:

  1. 创建空镜像
  2. 写 superblock
  3. 初始化 inode table
  4. 创建 root 目录
  5. 如果你用的是带 WAL 的版本,也会初始化 journal 区

第二步,启动 FUSE server 并挂载

mkdir -p /tmp/toyfs
go run ./cmd/mount ./fs.img /tmp/toyfs

启动后,内核看到 /tmp/toyfs 已经是一个可访问的挂载点。

第三步,验证 ls

ls -la /tmp/toyfs

这时链路大致是:

  1. shell 发起目录访问
  2. VFS 把请求交给 FUSE
  3. FUSE 调用我们的 Root/Attr/ReadDirAll
  4. ReadDirAll 再调用底层 ListDir("/")
  5. toy filesystem 从 root inode 对应目录块读出目录项
  6. 最终返回给 ls

第四步,验证 touch

touch /tmp/toyfs/hello.txt

这时会大致触发:

  1. Lookup("hello.txt") 失败
  2. Create("hello.txt")
  3. 底层引擎执行 CreateFile("/hello.txt")
  4. 分配 inode,更新根目录项
  5. 如果启用了 WAL,则事务提交后再真正落 home location

第五步,写入一点内容

echo 'hello toyfs' > /tmp/toyfs/hello.txt

这里会打到:

  1. Open
  2. Write
  3. 底层 WriteFile("/hello.txt", data)
  4. 分配数据块、更新 inode size 和 direct 指针
  5. 如启用 WAL,则走 Begin -> DATA records -> COMMIT -> home write

第六步,验证 cat

cat /tmp/toyfs/hello.txt

链路则是:

  1. Lookup
  2. Attr
  3. Open
  4. Read
  5. 底层 ReadFile("/hello.txt")
  6. 根据 inode 的 direct blocks 从 fs.img 读回数据
  7. 结果返回给 cat

到这一步,你的 toy filesystem 就真的不只是“会读写镜像文件的 Go 代码”了,而是一个操作系统可以直接使用的文件系统。


17. 调试 FUSE 文件系统时,最有用的不是猜,而是打链路日志

FUSE 调试最容易让人迷糊的地方在于:

  1. 你敲了一条简单命令
  2. 内核实际触发了一串回调
  3. 不同系统、不同工具还可能多打一堆 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 挂起来,分层会突然变得非常清楚:

接口层负责什么

  1. 接收 Lookup/Getattr/Readdir/Open/Read/Create/Write
  2. 把请求翻译成后端引擎调用
  3. 把错误翻译成 ENOENT/EISDIR/ENOTDIR 等 POSIX 语义

存储层负责什么

  1. inode 怎么组织
  2. 目录项怎么保存
  3. 数据块怎么分配
  4. WAL 怎么提交和恢复
  5. 崩溃后哪些状态算数

这两层一旦分开,你后面无论继续做:

  1. 更真实的本地文件系统
  2. 一个对象存储的 local disk engine
  3. 一个自定义 metadata store

都会顺很多。


19. 这篇实现还故意省略了什么

为了把主链路讲清楚,这篇有意识地没有展开一些真实系统里非常重要、但会显著拉高复杂度的东西:

  1. inode / entry cache
  2. Forget 和 lookup reference 计数
  3. offset-based streaming Readdir
  4. Setattr、mtime/ctime、truncate
  5. 文件锁
  6. 权限检查和 uid/gid
  7. 并发访问控制
  8. page cache 一致性

这些在真实文件系统里都很重要。

但对当前这个系列来说,先把 ls/cat/touch 和基本写路径跑通,已经足够让整个系统骨架落地。


20. 总结:FUSE 让你第一次真正“使用”自己写的文件系统

如果把这篇文章压缩成一句话,那就是:

FUSE 的真正价值,不只是让你在用户态写文件系统,而是让你前面实现的那些 inode、目录项、路径查找、WAL 和数据块分配,第一次以标准操作系统接口的形式真正跑起来。

在这之前:

  1. 你的 toy filesystem 更像一个库
  2. 你只能通过测试代码去调用 Lookup/ReadFile/WriteFile

接上 FUSE 之后:

  1. ls 会真的触发目录读取
  2. cat 会真的触发文件读取
  3. touch 会真的触发创建路径
  4. echo > file 会真的把写请求一路传到你的 block / inode / WAL 更新逻辑

到这一步,前面三篇文章才算真正闭环:

  1. 先理解文件系统原理
  2. 再自己实现最小 on-disk filesystem
  3. 再补上一层 WAL / journal
  4. 最后通过 FUSE 把它暴露成一个真正可挂载、可访问的文件系统

如果这个系列继续往下写,一个很自然的下一篇方向就是:

在 FUSE 层补上 truncate/setattr/rename/unlink,并认真处理缓存、并发和崩溃恢复之间的边界。