欢迎光临
我们一直在努力

《从零手写操作系统 (38):虚拟文件系统重构——从ramfs到ext2只读驱动》

前言:让数据跨越重启的边界

        在前面的章节中,我们的文件系统完全驻留在RAM中。这意味着每次断电或重启,所有用户数据、配置文件和程序代码都会化为乌有。一个无法持久化状态的操作系统,永远只是一个高级玩具。

        文件系统的本质是“将线性磁盘块映射为树形命名空间的翻译层”。磁盘只提供按扇区读写的平坦地址空间,而应用程序期望的是/home/user/doc.txt这样的层级路径。VFS(虚拟文件系统)是这个翻译层的抽象接口,它定义了inode/dentry/superblock等通用概念,使得上层应用无需关心底层是ramfs、ext2还是网络存储。本章我们将实现ext2文件系统的只读驱动,让你的OS首次具备从真实磁盘镜像读取数据的能力。

本章里程碑:

  • ✅ VFS抽象层重构:统一inode/dentry/file_operations接口
  • ✅ ext2超级块解析:魔数验证、块大小计算、根inode定位
  • ✅ ext2 inode读取:直接/间接/双重间接块寻址
  • ✅ ext2目录项遍历:变长记录解析与名称匹配
  • ✅ 块设备驱动接口:read_blocks()抽象屏蔽硬件差异
  • ✅ 验证:挂载ext2镜像,成功cat读取其中的文本文件

核心概念:VFS不是“文件系统”,而是“文件系统的合约”
为什么需要VFS而非直接实现ext2?

        如果直接在sys_open中写ext2解析逻辑,未来添加FAT32或tmpfs时需要修改所有系统调用。VFS将“策略”与“机制”分离:

  • 机制(VFS层):路径解析、权限检查、fd管理、缓存框架
  • 策略(具体FS):磁盘布局、inode分配、日志记录、压缩算法

⚠️ 关键洞察:VFS的核心价值在于“可替换性”而非“功能性”。即使你当前只支持ext2,也必须通过VFS接口访问它。因为VFS强制你思考“什么是文件系统的通用契约”——哪些操作是所有FS共有的(open/read/stat),哪些是特定FS独有的(journal commit)。这种抽象思维比ext2的具体实现更重要。如果你的ext2驱动绕过了VFS直接操作缓冲区,你就失去了未来扩展的能力,也失去了与其他内核子系统(如page cache、mmap)集成的机会。

ext2的块寻址:从线性到树形的递归翻译

        ext2 inode包含15个块指针:

  • [0-11]:直接块指针
  • [12]:一级间接块(指向一个块,该块包含更多数据块号)
  • [13]:二级间接块(指向一个块,该块包含一级间接块号)
  • [14]:三级间接块(极少使用)

        这个设计的精妙之处在于“按需深度”:小文件(<48KB,假设4K块)仅用直接块,零额外I/O;中等文件用一级间接,一次额外I/O;大文件才触及二级间接。这避免了为小文件预分配深层索引结构的浪费。实现时必须惰性解析间接块:仅在read偏移量超过直接块范围时才读取间接块,且应缓存已解析的块号避免重复I/O。教学实现中可先不缓存,但数据结构必须预留缓存字段。

只读驱动的纪律:先正确再完整

        许多教程试图一步到位实现读写ext2,结果陷入分配器、日志、一致性等泥潭。只读驱动是学习文件系统结构的最佳切入点:你只需理解“如何找到数据”,无需处理“如何安全修改数据”。这让你聚焦于磁盘布局、inode结构、目录格式等核心知识,而不被事务日志的复杂性淹没。当你能可靠地从ext2镜像读取任意文件后,再添加写入支持就是增量工作,而非从头重构。


实战代码
VFS抽象层定义

// kernel/fs/vfs.h
#ifndef VFS_H
#define VFS_H

#include <types.h>

// ★ 通用inode:屏蔽具体FS的inode差异
struct inode {
uint32_t ino; // inode编号
uint32_t mode; // 文件类型+权限
uint32_t size; // 文件大小(字节)
uint32_t atime/mtime/ctime;

struct super_block *sb; // 所属文件系统
void *private_data; // FS特定的inode数据(如ext2_inode_disk*)
const struct inode_operations *i_op;
};

// ★ 通用dentry:路径名到inode的映射缓存
struct dentry {
char name[NAME_MAX];
struct inode *inode; // NULL表示负缓存(不存在)
struct dentry *parent;
struct list_head siblings; // 同级目录项链表
const struct dentry_operations *d_op;
};

// ★ 文件系统操作集
struct file_system_type {
const char *name; // "ext2", "ramfs"
int (*mount)(struct super_block *sb, void *data);
void (*umount)(struct super_block *sb);
};

// ★ inode操作集
struct inode_operations {
struct inode *(*lookup)(struct inode *dir, const char *name);
ssize_t (*read)(struct inode *inode, char *buf, size_t len, off_t offset);
int (*stat)(struct inode *inode, struct stat *st);
};

#endif

ext2超级块与inode解析

// kernel/fs/ext2.c
#define EXT2_MAGIC 0xEF53
#define EXT2_ROOT_INO 2

// ★ ext2磁盘上的超级块结构(部分字段)
struct ext2_super_block {
uint32_t s_inodes_count;
uint32_t s_blocks_count;
uint32_t s_block_size; // log2(block_size) – 10
uint32_t s_inodes_per_group;
uint32_t s_inode_size;
uint16_t s_magic; // 必须==EXT2_MAGIC
// … 其他字段省略 …
} __attribute__((packed));

// ★ ext2磁盘上的inode结构
struct ext2_inode_disk {
uint16_t i_mode;
uint16_t i_uid;
uint32_t i_size;
uint32_t i_atime;
uint32_t i_mtime;
uint32_t i_ctime;
uint32_t i_block[15]; // 块指针数组
// … 其他字段省略 …
} __attribute__((packed));

typedef struct ext2_fs_info {
uint32_t block_size;
uint32_t inode_size;
uint32_t inodes_per_group;
uint32_t group_desc_offset; // 组描述符表起始块
struct ext2_super_block sb;
} ext2_fs_info_t;

// ★ 挂载时解析超级块
int ext2_mount(struct super_block *sb, void *data) {
ext2_fs_info_t *info = kmalloc(sizeof(ext2_fs_info_t));

// 读取超级块(始终位于偏移1024)
read_blocks(sb->dev, 2, 1, &info->sb); // 块大小未知前按512B扇区读

if (info->sb.s_magic != EXT2_MAGIC) {
kfree(info);
return -EINVAL;
}

info->block_size = 1024 << info->sb.s_block_size;
info->inode_size = info->sb.s_inode_size;
info->inodes_per_group = info->sb.s_inodes_per_group;

sb->private_data = info;
sb->root_inode = ext2_get_inode(sb, EXT2_ROOT_INO);

printk("ext2: mounted, block_size=%u\\n", info->block_size);
return 0;
}

// ★ 根据inode号读取磁盘inode
struct inode *ext2_get_inode(struct super_block *sb, uint32_t ino) {
ext2_fs_info_t *info = sb->private_data;

// 计算inode所在组和组内索引
uint32_t group = (ino – 1) / info->inodes_per_group;
uint32_t index = (ino – 1) % info->inodes_per_group;

// 读取组描述符获取inode表位置
// (简化:假设组描述符在超级块后一个块)
uint32_t inode_table_block = /* 从组描述符读取 */;

// 计算inode在磁盘上的字节偏移
uint32_t offset = inode_table_block * info->block_size
+ index * info->inode_size;

// 读取inode
struct ext2_inode_disk disk_inode;
read_blocks(sb->dev, offset / 512,
(info->inode_size + 511) / 512, &disk_inode);

// 转换为VFS inode
struct inode *inode = alloc_inode();
inode->ino = ino;
inode->mode = disk_inode.i_mode;
inode->size = disk_inode.i_size;
inode->sb = sb;

// ★ 保存磁盘inode副本供后续块寻址使用
inode->private_data = kmemdup(&disk_inode, sizeof(disk_inode));
inode->i_op = &ext2_inode_ops;

return inode;
}

ext2块寻址与文件读取

// kernel/fs/ext2_read.c

// ★ 将文件偏移转换为物理块号(核心寻址逻辑)
static uint32_t ext2_bmap(struct inode *inode, uint32_t file_block) {
struct ext2_inode_disk *di = inode->private_data;
ext2_fs_info_t *info = inode->sb->private_data;
uint32_t ptrs_per_block = info->block_size / sizeof(uint32_t);

// ★ 直接块
if (file_block < 12) {
return di->i_block[file_block];
}

file_block -= 12;

// ★ 一级间接块
if (file_block < ptrs_per_block) {
uint32_t indirect_block = di->i_block[12];
uint32_t data_block;
read_blocks(inode->sb->dev,
indirect_block * (info->block_size / 512),
1, &data_block); // 实际应读取整个间接块并索引
// 简化:此处仅示意,生产级需缓存间接块
return data_block; // 实际应为indirect_buf[file_block]
}

file_block -= ptrs_per_block;

// ★ 二级间接块
if (file_block < ptrs_per_block * ptrs_per_block) {
uint32_t l1_idx = file_block / ptrs_per_block;
uint32_t l2_idx = file_block % ptrs_per_block;

// 读取一级间接块指针
uint32_t l1_block;
read_l1_indirect(di->i_block[13], l1_idx, &l1_block);

// 读取二级间接块中的数据块指针
uint32_t data_block;
read_l2_indirect(l1_block, l2_idx, &data_block);
return data_block;
}

return 0; // 超出范围
}

// ★ ext2文件读取实现
ssize_t ext2_read(struct inode *inode, char *buf, size_t len, off_t offset) {
ext2_fs_info_t *info = inode->sb->private_data;

if (offset >= inode->size) return 0;
if (offset + len > inode->size) len = inode->size – offset;

size_t total_read = 0;
while (total_read < len) {
uint32_t file_block = (offset + total_read) / info->block_size;
uint32_t block_offset = (offset + total_read) % info->block_size;

// ★ 通过bmap获取物理块号
uint32_t phys_block = ext2_bmap(inode, file_block);
if (phys_block == 0) break; // 空洞

// 计算本次可读字节数
size_t chunk = min(len – total_read,
info->block_size – block_offset);

// ★ 读取物理块到用户缓冲区
read_blocks(inode->sb->dev,
phys_block * (info->block_size / 512),
(chunk + 511) / 512,
buf + total_read);

total_read += chunk;
}

return total_read;
}


关键细节解析

1. 为什么ext2超级块固定在偏移1024?

        因为x86引导扇区占用前512字节,某些旧文件系统签名占用512-1024区域。1024是历史妥协的结果,既避开引导代码,又保证在最小块大小(1024B)下超级块仍位于第一个块的起始位置。这意味着无论块大小是1K/2K/4K,超级块的读取方式相同:总是从字节偏移1024开始读取1024字节。不要假设超级块在“块0”——当块大小为4K时,超级块实际上位于块0的中间位置。实现时必须用字节偏移而非块号定位超级块。

2. 为什么间接块解析必须考虑块大小?

        间接块是一个包含块号的数组,其容量取决于块大小:1K块容纳256个指针,4K块容纳1024个指针。同一个文件在不同块大小的ext2卷上,间接块的层级和覆盖范围完全不同。例如48KB文件在1K块系统中需要一级间接块(12直接+36间接),而在4K块系统中仅需12个直接块。所有寻址计算必须以运行时解析的block_size为基准,绝不能硬编码常量。这也是为什么ext2_mount必须先解析超级块才能进行任何inode操作。

3. 为什么只读驱动也要处理inode缓存?

        即使不写入,重复读取同一inode是常见模式:路径解析/a/b/c需要依次读取根inode、a的inode、b的inode,若b下有多个文件被访问,b的inode会被反复读取。没有缓存会导致大量冗余I/O。教学实现中可用简单的LRU哈希表缓存最近N个inode。缓存键必须是(dev, ino)对,因为不同设备可能有相同inode号。即使是只读驱动,缓存也是性能正确性的必要条件,而非可选优化。


调试Checklist:文件系统驱动排查
症状可能原因排查方法
mount失败 魔数校验错/块大小计算错/超级块偏移误算 hexdump镜像偏移1024处确认EF53;验证1024<<s_block_size结果;测试不同块大小的镜像
lookup返回NULL 目录项解析错/名称比较未处理填充零/inode号读取错位 dump目录块原始hex对比预期格式;验证strlen vs NAME_MAX比较逻辑;测试短文件名和长文件名
read返回乱码 bmap寻址错/块偏移计算溢出/未处理文件空洞 对已知文件手动计算预期块号与bmap输出对比;测试读取跨块边界的偏移;验证phys_block==0时空洞填零
大文件读取截断 间接块解析未实现/ptrs_per_block计算错/整数溢出 测试>48KB文件触发间接块;dump间接块内容验证指针数组;检查uint32_t乘法是否溢出
挂载后ls为空 根inode号非2/目录项长度字段解析错/未跳过.和.. 验证EXT2_ROOT_INO==2;dump根目录块确认rec_len累加正确;测试显式cat /test.txt绕过目录遍历
性能极差 无inode缓存/每次read都重新解析间接块/块设备未缓冲 添加inode命中计数;测试连续读取同一文件的耗时变化;验证块设备层是否有readahead

🔧 黄金法则:文件系统调试的终极武器是磁盘镜像hexdump + VFS状态联合视图。永远不要猜测磁盘内容:用xxd image.ext2 | grep -A5 "offset"直接观察超级块、inode表、目录块的原始字节。同时运行debug_dump_vfs()打印已挂载FS、缓存inode列表、最近路径解析轨迹。文件系统bug几乎总是“磁盘布局认知偏差”:你以为inode_size是128但实际是256、你以为目录项以null结尾但实际以rec_len对齐、你以为间接块指针是小端但忘了验证。只有将hexdump的物理真相与VFS内存中的逻辑真相并置对比,才能发现这些静态数据结构的认知裂缝。不要在应用层加printf调试FS——你看到的只是VFS过滤后的投影,磁盘上的原始字节才是唯一真理。


本章小结与下一步

今天我们让OS从“易失内存”进化为“持久存储”:

  • ✅ VFS抽象层定义了inode/dentry/file_operations通用合约
  • ✅ ext2超级块解析验证魔数、计算块大小、定位根inode
  • ✅ ext2 inode读取支持直接/一级/二级间接块寻址
  • ✅ 目录项遍历正确处理变长记录与名称匹配
  • ✅ 验证:成功挂载ext2镜像并读取其中文件

        从此,你的操作系统拥有了跨越重启的数据持久化能力。当你第一次看到自己实现的ext2驱动从真实磁盘镜像中读出文件内容、路径解析正确穿越多级目录、inode缓存使重复访问接近零延迟时,你见证的是OS从“运行时环境”到“数据管理平台”的本质跃迁。

下一章预告:《页缓存与Buffer Cache:统一磁盘I/O的内存加速层》

        当前的文件读取每次都直接访问磁盘。下一章将实现页缓存和buffer cache,使热数据驻留内存、冷数据惰性加载,并为未来的写入支持奠定一致性基础。


参考资料
  • Understanding the Linux Kernel Ch.17-18 (VFS & Ext2)
  • ext2 Filesystem Specification (official docs)
  • Linux Kernel: fs/ext2/inode.c, fs/ext2/dir.c
  • Marshall Kirk McKusick, "The Design and Implementation of the FreeBSD Filesystem"
  • 本系列完整代码:[你的GitHub仓库链接](Commit: e1x2t3r)

📝 作者注:这是《从零手写操作系统》系列的第38篇。文件系统是整个教程中数据结构最静态、规范最精确、调试最依赖原始字节的章节。ext2的每个字段都有确定的偏移和含义,没有运行时协商余地。强烈建议先用hexdump手工解析一个小型ext2镜像,确认你对超级块、inode表、目录格式的理解与文档一致,再开始编码。把“超级块解析”、“inode读取+bmap”、“目录遍历”分成三个独立里程碑,并用hexdump+VFS dump双重验证每一步的磁盘布局认知,是避免在静态数据结构迷宫中迷失的关键纪律。 下一章,我们让I/O从“同步阻塞”走向“缓存加速”!

赞(0)
未经允许不得转载:171主机测评 » 《从零手写操作系统 (38):虚拟文件系统重构——从ramfs到ext2只读驱动》
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址