Q先生的世界

面朝大海,春暖花开

经典系统实践|用 Go 实现一个最小可理解文件系统:Superblock、inode、Bitmap 与目录树

上一篇文章,我们从对象存储研发工程师的角度,把文件系统里最容易在生产中踩坑的几个语义拆了一遍:

  1. VFS 在抽象什么
  2. page cache 为什么既能救你,也能坑你
  3. fsyncrename、目录持久化到底在保证什么
  4. 为什么 immutable chunk 和 manifest publish 几乎是自然演化出来的设计

但如果只停留在“理解操作系统和现成文件系统”,很多概念还是容易浮在半空中。

比如:

  1. inode 到底怎么落盘
  2. block allocator 具体长什么样
  3. 路径查找究竟是在找什么
  4. 目录为什么本质上也是一种特殊文件
  5. 一次 create/write/read/delete 到底会改哪些元数据

所以这篇文章我们反过来做一件更扎实的事:

不用 ext4、也不用 XFS,直接用 Go 写一个“最小可理解文件系统”。

注意,我这里的目标不是做一个生产可用文件系统,更不是要复刻 ext4。

目标只有两个:

  1. 把文件系统最核心的数据结构和数据流亲手搭出来
  2. 用最少的复杂度,把真实文件系统里最重要的约束讲清楚

为了把问题收窄,我们做如下约定:

  1. 文件系统存在一个普通磁盘镜像文件里,比如 fs.img
  2. block 固定大小,比如 4096 字节
  3. 只支持常规文件和目录
  4. 先不做权限、属主、符号链接、硬链接
  5. 先不做 page cache、journal、extent tree、B+Tree
  6. 先实现最核心的 mkfs/create/read/write/list/delete

也就是说,这是一套 toy filesystem,但它不是玩具概念文,而是一套足够接近真实实现的骨架。

读完之后,你应该至少能回答这些问题:

  1. 一个最小文件系统在磁盘上需要哪些区段
  2. superblock、inode table、bitmap、data blocks 分别保存什么
  3. 路径查找如何从 /a/b/c.txt 走到目标 inode
  4. 文件写入时哪些元数据需要一起更新
  5. 为什么崩溃一致性会逼着系统引入 log 或 journal

1. 先定目标:我们到底要实现什么样的文件系统

很多人第一次写“文件系统”,会下意识选择 FUSE。

FUSE 当然很好,它能让你在用户态快速挂一个文件系统出来,调试体验也不错。

但如果你的目标是理解文件系统本体,FUSE 其实有一个问题:

你很容易把注意力放到接口适配上,而不是磁盘布局和元数据组织上。

所以这篇文章我们故意不从 FUSE 开始,而是从一个更底层也更本质的模型开始:

Go process
    -> open("fs.img")
    -> treat it as a block device
    -> manage superblock / inode table / data blocks by ourselves

这个模型的好处是:

  1. 所有状态都在你自己的代码里
  2. 每一次 block read/write 都是显式的
  3. 你能非常清楚地看到元数据是如何组织的
  4. 后面如果你想接到 FUSE,只是多一层接口适配

也就是说:

先实现 on-disk filesystem,再谈系统调用接口。

这是更稳的学习路径。


2. 一个最小文件系统,磁盘上至少要有什么

先不考虑任何高级特性,一个最小文件系统通常至少要有四类区域:

+------------+------------+-------------+-------------+
| superblock | inode area | block bitmap| data blocks |
+------------+------------+-------------+-------------+

分别解释一下。

2.1 superblock

superblock 是整个文件系统的“总说明书”。

它通常记录:

  1. magic number
  2. block size
  3. 总 block 数
  4. inode 总数
  5. 各区域起始位置
  6. root inode 编号

如果把文件系统看作一个小数据库,那 superblock 就像数据库文件头。

2.2 inode area

inode 区保存每个文件或目录的元数据。最小版本里至少要包括:

  1. 类型:文件还是目录
  2. 文件大小
  3. 直接数据块指针
  4. 已分配 block 数

这里先不做多级间接块,先只支持小文件。

2.3 block bitmap

bitmap 用来表示哪些 data block 已经被占用。

比如:

  1. bit = 0 表示空闲
  2. bit = 1 表示已分配

这是最常见、也最容易实现的空间分配方法。

2.4 data blocks

这里是真正存目录内容和文件内容的地方。

注意一个很关键的点:

目录本身也是数据。

只不过目录数据块里放的不是普通字节流,而是一组 name -> inode 记录。


3. 先把磁盘布局写死,反而更容易理解

为了避免一开始就陷入“通用设计”,我们先给一个非常固定的布局:

  1. block size = 4096
  2. block 0 保留不用
  3. block 1 存 superblock
  4. block 2 ~ 33 存 inode table
  5. block 34 存 data block bitmap
  6. block 35 之后存真正的数据块

这不是因为真实文件系统都这么简单,而是因为:

对于学习型实现,固定布局比抽象灵活性更重要。

可以先定义这些常量:

const (
    BlockSize        = 4096
    TotalBlocks      = 1024
    InodeSize        = 256
    InodeCount       = 512
    SuperBlockNo     = 1
    InodeStartBlock  = 2
    InodeBlocks      = 32
    BitmapBlockNo    = 34
    DataStartBlock   = 35
    RootInodeNumber  = 1
)

这时候整个镜像大小就是:

$$ 1024 \times 4096 = 4\text{ MiB} $$

虽然很小,但已经足够把核心机制跑起来。


4. 用 Go 定义 on-disk 结构时,先克制一下抽象欲望

实现文件系统时,一个很常见的错误是:Go 结构体写得很优雅,但根本不适合稳定落盘。

原因很简单:

  1. Go 结构体有对齐问题
  2. 切片、字符串、map 这种带引用语义的字段不能直接作为磁盘格式
  3. 你需要的是固定大小、可序列化、可随机定位的二进制布局

所以 on-disk 结构应该尽量朴素。

4.1 superblock

type Superblock struct {
    Magic          uint32
    BlockSize      uint32
    TotalBlocks    uint32
    InodeCount     uint32
    InodeStart     uint32
    InodeBlocks    uint32
    BitmapStart    uint32
    DataStart      uint32
    RootInode      uint32
    FreeBlockCount uint32
    _              [4048]byte
}

这里故意补齐到一个 block,原因是:

  1. superblock 单独占一个块最直观
  2. 更新和读取都简单
  3. 以后扩展字段也更方便

4.2 inode

type InodeType uint16

const (
    TypeFree InodeType = 0
    TypeFile InodeType = 1
    TypeDir  InodeType = 2
)

type Inode struct {
    Type       InodeType
    _pad0      uint16
    Size       uint64
    Direct     [12]uint32
    BlockCount uint32
    _pad1      [192]byte
}

这个 inode 很小,也很刻意:

  1. 只支持 direct blocks
  2. 不支持 extent
  3. 不支持 mtime/ctime
  4. 不支持权限

但已经足够表达“文件内容在哪些 block 里”。

4.3 directory entry

目录项可以定义成固定大小:

type DirEntry struct {
    Inode uint32
    Name  [60]byte
}

这样一条记录就是 64 字节,一个 4096 字节 block 恰好能放:

$$ 4096 / 64 = 64 $$

条目录项。

这比变长目录项简单得多,也很适合第一版实现。


5. 代码骨架先搭出来:FileSystem 对象需要持有什么

最小版本里,我们可以先把运行时对象定义得非常直接:

type FileSystem struct {
    f  *os.File
    sb Superblock
}

然后先实现三类最基本能力:

  1. 按 block 读写
  2. 按 inode 编号读写 inode
  3. 分配和回收 data block

5.1 block 读写

func (fs *FileSystem) readBlock(blockNo uint32, buf []byte) error {
    if len(buf) != BlockSize {
        return fmt.Errorf("invalid block buffer size")
    }
    off := int64(blockNo) * BlockSize
    _, err := fs.f.ReadAt(buf, off)
    return err
}

func (fs *FileSystem) writeBlock(blockNo uint32, buf []byte) error {
    if len(buf) != BlockSize {
        return fmt.Errorf("invalid block buffer size")
    }
    off := int64(blockNo) * BlockSize
    _, err := fs.f.WriteAt(buf, off)
    return err
}

这里没有 page cache、没有 bio、没有 iomap,只有最核心的事情:

给我一个 block number,我就能读写那一块。

这就是所有更复杂机制的起点。


6. inode table 本质上就是“按编号随机访问的元数据数组”

假设每个 inode 固定 256 字节,那么我们可以直接按编号算它落在哪个 block、block 内偏移是多少。

func inodeOffset(inodeNo uint32) (blockNo uint32, offset int) {
    idx := inodeNo - 1
    byteOffset := idx * InodeSize
    blockNo = InodeStartBlock + byteOffset/BlockSize
    offset = int(byteOffset % BlockSize)
    return
}

接着实现读取 inode:

func (fs *FileSystem) loadInode(inodeNo uint32) (Inode, error) {
    var inode Inode
    blockNo, offset := inodeOffset(inodeNo)

    buf := make([]byte, BlockSize)
    if err := fs.readBlock(blockNo, buf); err != nil {
        return inode, err
    }

    r := bytes.NewReader(buf[offset : offset+InodeSize])
    if err := binary.Read(r, binary.LittleEndian, &inode); err != nil {
        return inode, err
    }
    return inode, nil
}

保存 inode 也类似:

func (fs *FileSystem) storeInode(inodeNo uint32, inode Inode) error {
    blockNo, offset := inodeOffset(inodeNo)

    buf := make([]byte, BlockSize)
    if err := fs.readBlock(blockNo, buf); err != nil {
        return err
    }

    var tmp bytes.Buffer
    if err := binary.Write(&tmp, binary.LittleEndian, &inode); err != nil {
        return err
    }
    copy(buf[offset:offset+InodeSize], tmp.Bytes())
    return fs.writeBlock(blockNo, buf)
}

这一步一旦实现,你就已经真正拥有了“文件元数据落盘能力”。


7. mkfs 其实就是初始化一组彼此一致的元数据

很多人会把 mkfs 理解成“格式化磁盘”。

从实现角度看,它本质上是在做三件事:

  1. 清空整张磁盘镜像
  2. 写入初始 superblock
  3. 创建 root inode 和根目录数据块

伪代码可以写成:

function mkfs(image):
    zero_all_blocks(image)

    write_superblock()
    mark_metadata_blocks_used()

    root_inode = alloc_inode(TypeDir)
    root_block = alloc_block()

    root_inode.direct[0] = root_block
    root_inode.size = size_of_dot_entries
    write_inode(root_inode)

    write_dir_entries(root_block, [".", ".."])

翻译成 Go,大致会是:

func Mkfs(path string) error {
    f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0644)
    if err != nil {
        return err
    }
    defer f.Close()

    if err := f.Truncate(TotalBlocks * BlockSize); err != nil {
        return err
    }

    fs := &FileSystem{f: f}
    fs.sb = Superblock{
        Magic:          0x20240324,
        BlockSize:      BlockSize,
        TotalBlocks:    TotalBlocks,
        InodeCount:     InodeCount,
        InodeStart:     InodeStartBlock,
        InodeBlocks:    InodeBlocks,
        BitmapStart:    BitmapBlockNo,
        DataStart:      DataStartBlock,
        RootInode:      RootInodeNumber,
        FreeBlockCount: TotalBlocks - DataStartBlock - 1,
    }

    if err := fs.storeSuperblock(); err != nil {
        return err
    }
    if err := fs.initBitmap(); err != nil {
        return err
    }
    if err := fs.initRootDir(); err != nil {
        return err
    }
    return fs.f.Sync()
}

这一段虽然简化了很多细节,但已经很好地说明:

mkfs 不是魔法,它只是把文件系统最初那组自洽状态写出来。


8. 路径查找的本质:不停读取目录文件

很多人第一次学文件系统时,会觉得路径查找是一个抽象的“系统调用行为”。

其实在最小模型里,它非常朴素:

  1. 从 root inode 开始
  2. 把路径按 / 切开
  3. 在当前目录的数据块里找名字
  4. 找到对应 inode 后继续往下走

也就是说,路径查找本质上就是一轮又一轮目录扫描。

伪代码:

function lookup(path):
    inode = root_inode
    for part in split(path, "/"):
        ensure inode is directory
        entries = read_dir_entries(inode)
        inode_no = find_name(entries, part)
        inode = load_inode(inode_no)
    return inode

Go 代码可以写成:

func (fs *FileSystem) Lookup(path string) (uint32, Inode, error) {
    curNo := uint32(RootInodeNumber)
    cur, err := fs.loadInode(curNo)
    if err != nil {
        return 0, Inode{}, err
    }

    for _, part := range splitClean(path) {
        if cur.Type != TypeDir {
            return 0, Inode{}, fmt.Errorf("not a directory: %s", part)
        }
        nextNo, err := fs.lookupInDir(cur, part)
        if err != nil {
            return 0, Inode{}, err
        }
        curNo = nextNo
        cur, err = fs.loadInode(curNo)
        if err != nil {
            return 0, Inode{}, err
        }
    }
    return curNo, cur, nil
}

这就是为什么上一篇文章里说:

目录本身也是文件。

因为路径查找并没有什么神秘能力,它只是在读一种特殊格式的数据文件。


9. create 文件时,至少要同时改三类东西

我们现在来实现最常见的操作:

create /docs/readme.txt

这个动作表面上像是“建了一个文件”,但底层至少会动三类状态:

  1. 分配一个新 inode
  2. 修改父目录内容,新增一条目录项
  3. 把新 inode 写回 inode table

如果新文件一开始为空,可能还不需要分配 data block;但元数据这三步已经跑不掉了。

伪代码:

function create(parent_dir, name):
    inode_no = alloc_inode(TypeFile)
    inode = empty_file_inode()

    dir_entries = read_dir(parent_dir)
    dir_entries.append(name -> inode_no)

    write_inode(inode_no, inode)
    write_dir(parent_dir, dir_entries)

这里马上就能看到崩溃一致性问题:

  1. 如果 inode 写了,但目录没写,会出现“孤儿 inode”
  2. 如果目录写了,但 inode 没写,路径会指向一个无效对象

这就是为什么真实文件系统最终会引入事务、journal 或 log。

最小版本里,我们先不解决这个问题,但一定要把它看见。


10. block allocator 不复杂,但它是所有写路径的入口

bitmap allocator 是整个 toy filesystem 里最值得先写对的一块。

思路很直接:

  1. 读取 bitmap block
  2. 从前往后找到第一个空闲 bit
  3. 把它置 1
  4. 持久化 bitmap
  5. 返回对应 data block number

代码可以写成:

func (fs *FileSystem) allocBlock() (uint32, error) {
    buf := make([]byte, BlockSize)
    if err := fs.readBlock(BitmapBlockNo, buf); err != nil {
        return 0, err
    }

    for i := uint32(DataStartBlock); i < TotalBlocks; i++ {
        bit := i - DataStartBlock
        byteIdx := bit / 8
        mask := byte(1 << (bit % 8))
        if buf[byteIdx]&mask == 0 {
            buf[byteIdx] |= mask
            if err := fs.writeBlock(BitmapBlockNo, buf); err != nil {
                return 0, err
            }
            fs.sb.FreeBlockCount--
            if err := fs.storeSuperblock(); err != nil {
                return 0, err
            }
            return i, nil
        }
    }
    return 0, io.EOF
}

这段代码已经体现出文件系统实现里一个非常常见的特征:

数据本身还没写,元数据更新已经先开始了。

这也是为什么元数据路径经常比数据路径更难写对。


11. 写文件的第一版,先只支持覆盖式小文件

为了控制复杂度,我们先做一个限制:

  1. 文件大小不能超过 12 * 4096
  2. 写入接口一次覆盖整个文件
  3. 不支持 append
  4. 不支持洞洞文件

这是一个很强的限制,但它会让主路径非常清楚。

11.1 最小写入逻辑

function write_file(inode_no, data):
    inode = load_inode(inode_no)
    free old blocks

    split data into blocks
    for each chunk:
        b = alloc_block()
        write_block(b, chunk)
        inode.direct[i] = b

    inode.size = len(data)
    inode.block_count = n
    write_inode(inode_no, inode)

对应 Go 代码大致如下:

func (fs *FileSystem) WriteFile(inodeNo uint32, data []byte) error {
    inode, err := fs.loadInode(inodeNo)
    if err != nil {
        return err
    }
    if inode.Type != TypeFile {
        return fmt.Errorf("not a file")
    }

    if len(data) > len(inode.Direct)*BlockSize {
        return fmt.Errorf("file too large")
    }

    if err := fs.freeInodeBlocks(&inode); err != nil {
        return err
    }

    needed := (len(data) + BlockSize - 1) / BlockSize
    for i := 0; i < needed; i++ {
        blockNo, err := fs.allocBlock()
        if err != nil {
            return err
        }

        buf := make([]byte, BlockSize)
        start := i * BlockSize
        end := min(len(data), start+BlockSize)
        copy(buf, data[start:end])

        if err := fs.writeBlock(blockNo, buf); err != nil {
            return err
        }
        inode.Direct[i] = blockNo
    }

    inode.Size = uint64(len(data))
    inode.BlockCount = uint32(needed)
    return fs.storeInode(inodeNo, inode)
}

这段代码虽然很朴素,但它有两个非常重要的教学价值:

  1. 你可以一眼看见数据块和 inode 元数据的关系
  2. 你也能一眼看见它的崩溃一致性问题

比如:

  1. 新 block 分配完了,但 inode 还没写,可能泄露空间
  2. inode 已经指向新 block,但某些 data block 还没写完整,可能读到脏内容
  3. 覆盖式更新会先 free 旧 block,这在崩溃场景下风险很大

真实文件系统不会这么天真,但第一版实现应该先把这种天真摆在明处。


12. 为什么真正安全的写法会自然走向 copy-on-write

看到上一节,你应该会很自然地问:

覆盖写既然这么危险,那更合理的方式是什么?

答案通常是:

  1. 先分配新 block
  2. 把新内容全部写好
  3. 最后再原子更新 inode 指针或更高层 manifest
  4. 旧 block 延后回收

这就是最小意义上的 copy-on-write。

在我们的 toy filesystem 里,也可以把写路径改成这样:

function safe_write_file(inode_no, data):
    inode = load_inode(inode_no)
    new_blocks = alloc_and_write_all(data)

    new_inode = inode
    new_inode.direct = new_blocks
    new_inode.size = len(data)

    write_inode(inode_no, new_inode)
    free_old_blocks_later(inode.direct)

你会发现,这和上一篇对象存储文章里的 immutable chunk + manifest publish 几乎是一回事。

这不是巧合。

因为只要系统想在崩溃场景下减少“半写状态”,它就很容易从 overwrite 走向 CoW。


13. read 路径反而通常比 write 更简单

读取文件时,逻辑通常非常直接:

  1. 通过路径找到 inode
  2. 根据 inode.direct[] 找到数据块
  3. 依次读出并拼接
  4. inode.size 截断尾部 padding

Go 代码:

func (fs *FileSystem) ReadFile(inodeNo uint32) ([]byte, error) {
    inode, err := fs.loadInode(inodeNo)
    if err != nil {
        return nil, err
    }
    if inode.Type != TypeFile {
        return nil, fmt.Errorf("not a file")
    }

    out := make([]byte, 0, inode.Size)
    for i := uint32(0); i < inode.BlockCount; i++ {
        buf := make([]byte, BlockSize)
        if err := fs.readBlock(inode.Direct[i], buf); err != nil {
            return nil, err
        }
        out = append(out, buf...)
    }
    return out[:inode.Size], nil
}

这里值得注意的是,read 路径之所以看起来简单,不是因为文件系统整体简单,而是因为:

写路径已经提前把复杂度都付掉了。

这在很多存储系统里都成立。


14. 目录为什么本质上是特殊文件,这件事在代码里会非常明显

我们来实现一个最小 ListDir

如果目录 inode 的 direct block 里存的是一串 DirEntry,那么列目录其实只是:

  1. 读取目录 inode
  2. 读取它对应的数据块
  3. DirEntry 定长切分
  4. 过滤 inode == 0 的空槽位

代码形态会非常接近普通文件读取,只是解释方式不同:

func (fs *FileSystem) listDirEntries(dir Inode) ([]DirEntry, error) {
    if dir.Type != TypeDir {
        return nil, fmt.Errorf("not dir")
    }

    var entries []DirEntry
    for i := uint32(0); i < dir.BlockCount; i++ {
        buf := make([]byte, BlockSize)
        if err := fs.readBlock(dir.Direct[i], buf); err != nil {
            return nil, err
        }

        for off := 0; off < BlockSize; off += 64 {
            var ent DirEntry
            r := bytes.NewReader(buf[off : off+64])
            if err := binary.Read(r, binary.LittleEndian, &ent); err != nil {
                return nil, err
            }
            if ent.Inode != 0 {
                entries = append(entries, ent)
            }
        }
    }
    return entries, nil
}

你看,目录没有任何神秘性。

它只是“内容被解释为目录项数组的文件”。

这件事一旦在代码里真正写过一次,很多路径查找和目录更新问题就不再抽象了。


15. delete 操作为什么总是比看起来更危险

删除文件最朴素的想法是:

  1. 从父目录删除名字
  2. 释放数据块
  3. 释放 inode

但这三步的顺序其实非常敏感。

如果先释放 inode 或 block,再删目录项,路径可能短暂指向已回收对象。

如果先删目录项,再释放 block,崩溃后可能形成垃圾空间。

最小版本里,我们通常会优先保证“路径不可见”而不是“空间立即回收”:

function delete(parent, name):
    inode_no = remove_dir_entry(parent, name)
    mark_inode_deleted(inode_no)
    enqueue_blocks_for_gc(inode_no)

这也是很多成熟系统喜欢把删除拆成两阶段的原因:

  1. 先逻辑删除
  2. 再物理回收

对象存储里的 tombstone 和后台 GC,骨子里也是同一种思想。


16. 一旦认真考虑崩溃一致性,你就会被迫引入 WAL 或 journal

我们前面故意先写了一个“功能上成立,但崩溃时很脆弱”的文件系统。

这是有意的,因为如果你没亲眼看到脆弱点,就很难真正理解 journal 为什么存在。

回头看一次简单的写文件:

  1. 分配 bitmap
  2. 写 data block
  3. 更新 inode
  4. 可能更新目录
  5. 可能更新 superblock 的 free count

这里任意一步中途掉电,都可能让文件系统变成半更新状态。

所以更合理的方向通常是:

  1. 先把“我要做哪些修改”记进 WAL
  2. WAL fsync
  3. 应用真正的数据块和元数据块修改
  4. 标记事务完成

最小伪代码可以写成:

function txn_write(op):
    txn = begin_txn()
    txn.log(bitmap_change)
    txn.log(inode_change)
    txn.log(data_block_change)
    write_wal(txn)
    fsync(wal)

    apply_changes(txn)
    mark_txn_committed(txn)
    fsync(wal)

当然,真正的 journal / WAL 设计比这复杂很多,但这里最重要的是认识到:

只要一次逻辑操作会改多个物理位置,你迟早要面对事务问题。


17. 为什么 Go 很适合拿来写这种教学型文件系统

如果只看系统编程传统,很多人会本能地觉得“文件系统就该用 C 写”。

从生产内核视角看,这当然没问题;但从教学和原型验证视角,Go 其实非常合适。

原因大概有四个:

  1. os.File.ReadAt/WriteAt 很适合模拟 block device
  2. encoding/binary 很适合处理固定格式的 on-disk 结构
  3. 错误处理清晰,适合把失败路径写明白
  4. 开发效率高,便于快速迭代 toy filesystem

当然,Go 也有两个要特别小心的地方:

  1. 不要把切片、字符串、map 直接当成落盘结构
  2. 要明确 on-disk format 和 in-memory object 是两套东西

把这条边界守住,Go 会让这类练习非常顺手。


18. 如果你准备继续往下做,下一步该加什么

当最小版本跑通后,最自然的演进路径通常不是“立刻全量复刻 ext4”,而是按复杂度逐层加能力。

我会建议按这个顺序来:

第一层:把当前骨架补完整

  1. inode bitmap
  2. mkdir
  3. unlink
  4. 文件截断
  5. 基本 fsck

第二层:让数据结构更像真实文件系统

  1. indirect block
  2. extent
  3. 变长目录项
  4. free list 或更高效的 allocator

第三层:开始处理一致性和性能

  1. WAL / journal
  2. write-ahead metadata update
  3. page cache 模拟
  4. delayed allocation

第四层:接入真实接口

  1. FUSE 适配
  2. readdir/open/read/write/create 映射
  3. 让宿主机真的能 mount 这个文件系统

这个顺序的好处是:

每一层都只增加一小段复杂度,但又能明显感觉到系统在变得更真实。


19. 一份更完整的最小骨架

如果把前面的关键片段收拢起来,一个最小版本大致会长这样:

type FileSystem struct {
    f  *os.File
    sb Superblock
}

func Open(path string) (*FileSystem, error) {
    f, err := os.OpenFile(path, os.O_RDWR, 0644)
    if err != nil {
        return nil, err
    }
    fs := &FileSystem{f: f}
    if err := fs.loadSuperblock(); err != nil {
        _ = f.Close()
        return nil, err
    }
    if fs.sb.Magic != 0x20240324 {
        _ = f.Close()
        return nil, fmt.Errorf("invalid filesystem")
    }
    return fs, nil
}

func (fs *FileSystem) Create(path string) error
func (fs *FileSystem) Lookup(path string) (uint32, Inode, error)
func (fs *FileSystem) ReadFile(inodeNo uint32) ([]byte, error)
func (fs *FileSystem) WriteFile(inodeNo uint32, data []byte) error
func (fs *FileSystem) ListDir(path string) ([]string, error)
func (fs *FileSystem) Delete(path string) error

你会发现,这套接口其实已经很接近很多上层存储引擎内部对 local store 的抽象了。

原因也不神秘:

无论你做的是文件系统、对象存储本地盘引擎,还是一个 append-only log store,本质上都在回答同一类问题:

  1. 元数据怎么组织
  2. 数据块怎么定位
  3. 空间怎么分配
  4. 更新如何保持一致

20. 总结:自己写一遍最小文件系统,很多概念就真正落地了

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

理解文件系统,最稳的方法之一不是继续背概念,而是亲手用 Go 写一个足够小、但结构完整的 on-disk filesystem。

当你真的把 superblock、inode、bitmap、directory entry、path walk、block allocator 和 write path 写出来之后,很多以前看起来抽象的概念会突然变得非常具体:

  1. inode 不再只是书上的名词,而是 inode table 里一个固定偏移的数据结构
  2. 路径查找不再神秘,而是不断读取目录文件并做名字匹配
  3. 写入不再只是“把字节写进去”,而是数据块、bitmap、inode、目录项一起变化
  4. 崩溃一致性不再是口号,而是每一步修改顺序稍有不慎就会出错

这也是为什么我一直觉得:

对象存储工程师如果认真写过一次 toy filesystem,再回头看 ext4、XFS、LSM、WAL、manifest publish,很多设计会突然显得异常统一。

因为它们本质上都在做同一件事:

把“数据放哪、怎么找、怎么改、崩溃后怎么算数”这四个问题,组织成一套尽量高效且可恢复的规则。

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

在这个 Go 文件系统骨架上,再补一层最小 WAL / journal,把崩溃一致性真正落到代码里。

那时候你会更直观地看到,为什么几乎所有成熟存储系统最后都会长出日志。