到目前为止,内核能跑进程、能做系统调用,但所有数据都在内存里——进程一死,什么都没了。

这一章做文件系统:把数据写到"磁盘"(我们用内存模拟),下次还能读回来。


先把概念搞清楚

动手之前,先理解五件事。

为什么需要文件系统

磁盘本质上就是一个大字节数组。没有文件系统,你只能说"读第 1234 字节",没法说"读 /etc/passwd"。

文件系统做的事就是在这个字节数组上建立一套命名和组织规则,让你可以用路径找到数据,而不是手动记偏移量。

inode:文件名和内容分离

Unix 最重要的设计之一:inode 描述文件内容,目录存文件名,两者分开。

inode 记录的是"这个文件是什么"——大小、权限、数据在磁盘哪几块——但不存文件名。文件名只是一个指向 inode 的标签,存在目录里。

这意味着同一个 inode 可以被多个名字指向,这就是硬链接。重命名文件也不需要移动任何数据,只改目录项。

超级块:文件系统的自我描述

挂载一块磁盘时,内核第一件事是读超级块。超级块告诉内核这个文件系统的结构:inode 区从哪里开始、数据块从哪里开始、总共多少块、还有多少空闲。

超级块损坏 = 整个文件系统不可读。所以 ext4 会在磁盘多个位置备份超级块。

目录是普通文件

目录没有什么神奇的内部结构,它就是一个普通文件,内容是一张表:文件名 → inode 号

ls 的本质是读这张表然后打印。路径解析 /a/b/c 就是:读根目录找 a 的 inode → 把 a 当目录读,找 b 的 inode → 把 b 当目录读,找 c 的 inode。

空闲管理:位图

磁盘上哪些块被占用、哪些空闲,用位图记录——1 个 bit 对应 1 个块,0 表示空闲,1 表示已用。分配空间就是找第一个 0 位翻成 1。

这五个概念搞清楚,下面的代码就是它们的直接翻译。


文件系统解决什么问题

内存是易失的,文件系统负责两件事:

  1. 持久化:数据写到磁盘,断电不丢。
  2. 命名:把一串字节绑定到一个人类可读的路径(比如 /etc/passwd)。

最简单的文件系统只需要三样东西:

  • 超级块(Superblock):整个文件系统的"目录",记录它有多大、inode 从哪里开始、数据块从哪里开始。
  • inode:每个文件的元数据——大小、权限、指向数据块的指针。inode 不存文件名。
  • 数据块(Data Block):真正的文件内容。

文件名存在哪?存在目录文件里。目录本身也是一个文件,内容是"文件名 → inode 号"的映射表。


磁盘布局

我们设计一个极简布局,磁盘按 512 字节的扇区(sector)划分:

[ 扇区 0 ]       超级块 Superblock
[ 扇区 1~7 ]     inode 表(最多存 56 个 inode,每个 64 字节)
[ 扇区 8~N ]     数据块区(每块 512 字节)

用代码定义:

// fs/simple_fs.h

#define BLOCK_SIZE      512
#define INODE_SIZE      64
#define INODES_PER_BLK  (BLOCK_SIZE / INODE_SIZE)   // 8
#define INODE_BLOCKS    7
#define MAX_INODES      (INODE_BLOCKS * INODES_PER_BLK)  // 56
#define DATA_START_BLK  8
#define MAX_DIRECT      8    // inode 里直接指针个数

// 超级块
typedef struct {
    uint32_t magic;          // 魔数,用来识别这是不是我们的文件系统
    uint32_t total_blocks;   // 磁盘总块数
    uint32_t inode_start;    // inode 区起始块号(=1)
    uint32_t data_start;     // 数据区起始块号(=8)
    uint32_t free_inode_map; // inode 位图,56 个 inode 用 uint64_t 就够
    uint64_t free_block_map; // 数据块位图(最多 64 块,演示用)
} __attribute__((packed)) Superblock;

#define FS_MAGIC 0x53464653   // "SFFS"

// inode
typedef struct {
    uint32_t size;            // 文件字节数
    uint16_t mode;            // 文件类型 + 权限(简化版)
    uint16_t nlinks;          // 硬链接数
    uint32_t blocks[MAX_DIRECT]; // 直接数据块指针(块号)
} __attribute__((packed)) Inode;

// 目录项
typedef struct {
    char     name[28];        // 文件名,最多 27 字节 + null
    uint32_t inode_num;       // 对应的 inode 号,0 表示空槽
} __attribute__((packed)) DirEntry;

#define DIRENTS_PER_BLK (BLOCK_SIZE / sizeof(DirEntry))  // 512/32 = 16

几个设计选择说明一下:

  • inode 不存文件名。这是 Unix 的经典设计,允许一个 inode 被多个目录项引用(硬链接)。
  • 只用直接块指针。真实 ext2 还有一级/二级/三级间接指针,这里为了简单只做直接块,限制单文件最大 8×512=4KB。
  • 位图管理空闲资源。一个 bit 代表一个 inode 或数据块是否被占用。

初始化文件系统

// fs/simple_fs.c

// 我们用一段内存模拟磁盘
#define DISK_BLOCKS 72
static uint8_t disk[DISK_BLOCKS * BLOCK_SIZE];

static Superblock *sb = (Superblock *)disk;

void fs_format() {
    memset(disk, 0, sizeof(disk));

    sb->magic        = FS_MAGIC;
    sb->total_blocks = DISK_BLOCKS;
    sb->inode_start  = 1;
    sb->data_start   = DATA_START_BLK;
    sb->free_inode_map = 0;   // 所有 inode 空闲
    sb->free_block_map = 0;   // 所有数据块空闲
}

fs_format() 相当于 mkfs——把磁盘清零,写入初始超级块。


分配 / 释放 inode

// 分配一个空闲 inode,返回 inode 号(从 1 开始),失败返回 0
uint32_t alloc_inode() {
    for (int i = 0; i < MAX_INODES; i++) {
        if (!(sb->free_inode_map & (1ULL << i))) {
            sb->free_inode_map |= (1ULL << i);
            // 清空 inode 内容
            Inode *node = get_inode(i + 1);
            memset(node, 0, sizeof(Inode));
            node->nlinks = 1;
            return i + 1;
        }
    }
    return 0;  // 没有空闲 inode
}

void free_inode(uint32_t inum) {
    sb->free_inode_map &= ~(1ULL << (inum - 1));
}

Inode *get_inode(uint32_t inum) {
    // inode 从第 1 块开始,每块 8 个
    uint32_t blk  = sb->inode_start + (inum - 1) / INODES_PER_BLK;
    uint32_t off  = (inum - 1) % INODES_PER_BLK;
    return (Inode *)(disk + blk * BLOCK_SIZE) + off;
}

get_inode 的逻辑:inode 号 → 所在块 → 块内偏移 → 指针。


分配数据块

uint32_t alloc_block() {
    for (int i = 0; i < 64; i++) {
        if (!(sb->free_block_map & (1ULL << i))) {
            sb->free_block_map |= (1ULL << i);
            uint32_t blkno = sb->data_start + i;
            memset(disk + blkno * BLOCK_SIZE, 0, BLOCK_SIZE);
            return blkno;
        }
    }
    return 0;
}

void free_block(uint32_t blkno) {
    sb->free_block_map &= ~(1ULL << (blkno - sb->data_start));
}

uint8_t *get_block(uint32_t blkno) {
    return disk + blkno * BLOCK_SIZE;
}

读写文件

有了 inode 和数据块,读写就是:文件偏移 → 块号 → 块内偏移

// 写文件:往 inode 对应的内容里写 len 字节
int fs_write(uint32_t inum, uint32_t offset, const void *buf, uint32_t len) {
    Inode *node = get_inode(inum);
    uint32_t written = 0;

    while (written < len) {
        uint32_t blk_idx = (offset + written) / BLOCK_SIZE;  // 第几个直接块
        uint32_t blk_off = (offset + written) % BLOCK_SIZE;  // 块内偏移

        if (blk_idx >= MAX_DIRECT) return -1;  // 超出直接块上限

        // 按需分配块
        if (node->blocks[blk_idx] == 0) {
            uint32_t blkno = alloc_block();
            if (!blkno) return -1;
            node->blocks[blk_idx] = blkno;
        }

        uint8_t *blk = get_block(node->blocks[blk_idx]);
        uint32_t to_write = BLOCK_SIZE - blk_off;
        if (to_write > len - written) to_write = len - written;

        memcpy(blk + blk_off, (uint8_t *)buf + written, to_write);
        written += to_write;
    }

    if (offset + len > node->size)
        node->size = offset + len;

    return written;
}

// 读文件
int fs_read(uint32_t inum, uint32_t offset, void *buf, uint32_t len) {
    Inode *node = get_inode(inum);
    if (offset >= node->size) return 0;
    if (offset + len > node->size) len = node->size - offset;

    uint32_t read_bytes = 0;
    while (read_bytes < len) {
        uint32_t blk_idx = (offset + read_bytes) / BLOCK_SIZE;
        uint32_t blk_off = (offset + read_bytes) % BLOCK_SIZE;

        if (blk_idx >= MAX_DIRECT || node->blocks[blk_idx] == 0) break;

        uint8_t *blk = get_block(node->blocks[blk_idx]);
        uint32_t to_read = BLOCK_SIZE - blk_off;
        if (to_read > len - read_bytes) to_read = len - read_bytes;

        memcpy((uint8_t *)buf + read_bytes, blk + blk_off, to_read);
        read_bytes += to_read;
    }
    return read_bytes;
}

读写逻辑是对称的,核心就是那个三行的偏移计算。


目录:文件名到 inode 的桥

目录是一个普通文件,内容是一张 DirEntry 表。

// 在目录 dir_inum 下创建一个名字叫 name、指向 target_inum 的目录项
int dir_add(uint32_t dir_inum, const char *name, uint32_t target_inum) {
    Inode *dir = get_inode(dir_inum);

    // 遍历现有目录项,找空槽
    uint32_t entry_size = sizeof(DirEntry);
    uint32_t max_entries = (dir->size) / entry_size;

    for (uint32_t i = 0; i <= max_entries; i++) {
        DirEntry de;
        if (i < max_entries) {
            fs_read(dir_inum, i * entry_size, &de, entry_size);
            if (de.inode_num != 0) continue;  // 已占用
        }
        // 写入新条目
        strncpy(de.name, name, 27);
        de.name[27] = '\0';
        de.inode_num = target_inum;
        fs_write(dir_inum, i * entry_size, &de, entry_size);
        return 0;
    }
    return -1;
}

// 在目录 dir_inum 下按名字查找,返回 inode 号
uint32_t dir_lookup(uint32_t dir_inum, const char *name) {
    Inode *dir = get_inode(dir_inum);
    uint32_t entry_size = sizeof(DirEntry);
    uint32_t count = dir->size / entry_size;

    for (uint32_t i = 0; i < count; i++) {
        DirEntry de;
        fs_read(dir_inum, i * entry_size, &de, entry_size);
        if (de.inode_num && strcmp(de.name, name) == 0)
            return de.inode_num;
    }
    return 0;
}

创建文件 / 路径解析

// 在根目录下创建文件(简化版,不支持多级目录)
uint32_t fs_create(uint32_t root_inum, const char *name) {
    // 检查是否已存在
    if (dir_lookup(root_inum, name)) return 0;

    uint32_t inum = alloc_inode();
    if (!inum) return 0;

    Inode *node = get_inode(inum);
    node->mode   = 0x8000;  // 普通文件
    node->size   = 0;

    dir_add(root_inum, name, inum);
    return inum;
}

真实内核的 open() 系统调用最终会走到类似的逻辑:解析路径 → 找到/创建 inode → 返回文件描述符。


跑起来看看

void fs_test() {
    fs_format();

    // 创建根目录(inode 1)
    uint32_t root = alloc_inode();
    get_inode(root)->mode = 0x4000;  // 目录

    // 创建文件 hello.txt
    uint32_t inum = fs_create(root, "hello.txt");

    // 写内容
    const char *msg = "Hello, FileSystem!\n";
    fs_write(inum, 0, msg, strlen(msg));

    // 读回来
    char buf[64] = {0};
    uint32_t ino2 = dir_lookup(root, "hello.txt");
    fs_read(ino2, 0, buf, sizeof(buf) - 1);

    kprintf("read back: %s", buf);  // Hello, FileSystem!

    // 检查 inode
    Inode *node = get_inode(ino2);
    kprintf("size: %d bytes, block[0]: %d\n", node->size, node->blocks[0]);
}

输出:

read back: Hello, FileSystem!
size: 19 bytes, block[0]: 8

block[0] = 8 表示数据存在第 8 块,也就是数据区的第一块——和布局设计完全吻合。


这里省掉了什么

真实文件系统要复杂得多,我们这里忽略了:

省掉的部分 真实做法
多级目录 路径解析 "/" 分割,递归查找
间接块指针 inode 里加一级/二级/三级指针,支持大文件
日志(Journaling) ext3/ext4 在每次写前先写日志,崩溃可以回滚
缓存 page cache,避免每次都读磁盘
权限检查 mode 字段解析,uid/gid 比对
持久化到真实磁盘 PIO/DMA 操作 ATA 控制器

但核心结构——超级块定位全局信息、inode 描述单个文件、目录是特殊文件——在 ext2、xfs、btrfs 里都是这个思路。


下一章

有了文件系统,进程就可以通过路径名打开文件了。下一章做 VFS(虚拟文件系统层)——在具体文件系统上面加一层抽象,让 open/read/write 系统调用不绑死在某一种文件系统上。

源码在这里:github.com/tongpengfei/learn-with-ai