到目前为止,内核能跑进程、能做系统调用,但所有数据都在内存里——进程一死,什么都没了。
这一章做文件系统:把数据写到"磁盘"(我们用内存模拟),下次还能读回来。
先把概念搞清楚
动手之前,先理解五件事。
为什么需要文件系统
磁盘本质上就是一个大字节数组。没有文件系统,你只能说"读第 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。
这五个概念搞清楚,下面的代码就是它们的直接翻译。
文件系统解决什么问题
内存是易失的,文件系统负责两件事:
- 持久化:数据写到磁盘,断电不丢。
- 命名:把一串字节绑定到一个人类可读的路径(比如
/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 系统调用不绑死在某一种文件系统上。