前言:从“扁平归档”到“层次化存储”
在前面的章节中,我们通过initrd让用户程序能够读取文件,但那只是一个只读的、扁平的文件列表。没有子目录,无法创建新文件,不能重命名或删除,重启后所有修改灰飞烟灭。这不是一个文件系统,只是一个打包器。真正的文件系统是操作系统的骨架——它定义了数据的组织方式、访问语义和持久化契约。
本章我们将实现一个完整的内存文件系统(RamFS),作为未来磁盘文件系统的原型。它将引入Unix文件系统的三大核心抽象:inode(元数据)、dentry(目录项) 和 VFS(虚拟文件系统接口)。你的OS将首次拥有可读写、分层级、支持增删改查的真正存储栈。
本章里程碑:
- ✅ 设计并实现inode结构体与全局inode表
- ✅ 实现目录项(dentry)与树形目录遍历
- ✅ 构建VFS层:统一open/read/write/close/unlink/mkdir接口
- ✅ 实现基于PMEM的RamFS后端,支持动态分配与释放
- ✅ 路径解析算法:处理
.、..、绝对/相对路径 - ✅ 验证完整文件操作:创建目录、写入文件、删除、ls递归
核心概念:三层解耦与inode的本质
VFS ≠ 具体文件系统
Unix文件系统的经典分层模型:
| 层级 | 职责 | 本章实现 |
|---|---|---|
| 系统调用层 | open/read/write/close等用户API | sys_open / sys_read 等 |
| VFS层 | 路径解析、权限检查、fd管理、路由到具体FS | ★ vfs.c / path.c |
| 具体FS层 | inode/dentry/block的物理布局与读写 | ★ ramfs.c |
VFS是中间那层“翻译官”。它让内核代码只关心“打开一个文件”,而不关心这个文件在RamFS、Ext2还是FAT上。今天写的VFS代码,明天换磁盘FS时一行不用改。
inode是“身份”,dentry是“名字”
这是Unix文件系统最精妙的设计分离:
- inode:存储文件的元数据(大小、权限、时间戳、数据块指针)。不含文件名。每个文件有且仅有一个inode。
- dentry:存储
(name, inode_ptr)映射。多个dentry可以指向同一个inode(硬链接)。目录本身也是一个特殊文件,其内容是dentry列表。
⚠️ 关键洞察:
rm file不是删除文件,而是删除一个dentry。只有当inode的引用计数归零时,文件数据才真正释放。这解释了为什么Linux允许删除正在被打开的文件——unlink移除dentry,但open持有的inode引用阻止了数据回收。
RamFS作为教学载体的优势
为什么不直接写Ext2?因为Ext2的on-disk格式复杂(超级块、组描述符、位图、间接块),调试时需要频繁hex dump磁盘镜像。RamFS将所有结构放在内存中,可以用GDB直接检视、用kprintf实时dump整棵树。当你彻底理解RamFS的inode/dentry/VFS交互后,迁移到Ext2只需替换具体FS层的读写函数,上层逻辑完全复用。
实战代码
inode与dentry核心结构
// fs/vfs.h
#define MAX_INODES 256
#define MAX_DENTRY_CHILDREN 32
#define NAME_MAX 31
typedef enum { FT_REG = 1, FT_DIR = 2 } filetype_t;
// ★ inode:纯元数据,无名字
typedef struct inode {
uint32_t ino; // inode编号(数组下标)
filetype_t type;
uint32_t size;
uint32_t refcount; // 引用计数(dentry + open fd)
uint32_t mode; // 权限位(简化:0755/0644)
// RamFS专用:数据直接存内存指针
uint8_t *data; // REG: 文件内容; DIR: dentry数组
uint32_t data_capacity; // 已分配容量
// DIR专用
struct dentry *children[MAX_DENTRY_CHILDREN];
int child_count;
} inode_t;
// ★ dentry:名字 → inode 映射
typedef struct dentry {
char name[NAME_MAX + 1];
inode_t *inode;
struct dentry *parent; // 用于 ".." 解析
} dentry_t;
// 全局inode表
extern inode_t inode_table[MAX_INODES];
extern dentry_t *root_dentry;
// VFS API
int vfs_open(const char *path, int flags);
ssize_t vfs_read(int fd, void *buf, size_t count);
ssize_t vfs_write(int fd, const void *buf, size_t count);
int vfs_close(int fd);
int vfs_unlink(const char *path);
int vfs_mkdir(const char *path);
inode分配与引用计数
// fs/inode.c
#include "vfs.h"
#include "memory.h"
inode_t inode_table[MAX_INODES];
static uint32_t next_ino = 1; // 0保留为无效
inode_t *inode_alloc(filetype_t type) {
for (int i = 1; i < MAX_INODES; i++) {
if (inode_table[i].refcount == 0 && inode_table[i].ino == 0) {
inode_t *node = &inode_table[i];
node->ino = i;
node->type = type;
node->size = 0;
node->refcount = 1;
node->mode = (type == FT_DIR) ? 0755 : 0644;
node->data = NULL;
node->data_capacity = 0;
node->child_count = 0;
return node;
}
}
return NULL; // ENOSPC
}
void inode_ref(inode_t *node) {
if (node) node->refcount++;
}
void inode_unref(inode_t *node) {
if (!node || node->refcount == 0) return;
node->refcount--;
if (node->refcount == 0) {
// ★ 真正释放:回收数据内存,清除inode槽位
if (node->data) {
kfree(node->data);
node->data = NULL;
}
node->ino = 0;
node->type = 0;
}
}
路径解析与目录查找
// fs/path.c
#include "vfs.h"
#include "string.h"
// ★ 核心:将路径字符串解析为dentry
// 返回NULL表示不存在;若create_parent=1,则保证父目录存在
dentry_t *path_resolve(const char *path, int create_parent) {
if (!path || path[0] != '/') return NULL; // 仅支持绝对路径
dentry_t *cur = root_dentry;
const char *p = path + 1; // 跳过根'/'
while (*p) {
// 提取下一个路径分量
const char *slash = strchr(p, '/');
int len = slash ? (slash - p) : strlen(p);
if (len == 0) { p++; continue; } // 处理 "//"
if (len > NAME_MAX) return NULL;
char component[NAME_MAX + 1];
memcpy(component, p, len);
component[len] = '\0';
// 处理 . 和 ..
if (strcmp(component, ".") == 0) {
p += len; if (*p == '/') p++;
continue;
}
if (strcmp(component, "..") == 0) {
cur = cur->parent ? cur->parent : cur; // 根目录的..仍是根
p += len; if (*p == '/') p++;
continue;
}
// 在当前目录中查找子项
inode_t *dir_inode = cur->inode;
dentry_t *found = NULL;
for (int i = 0; i < dir_inode->child_count; i++) {
if (strcmp(dir_inode->children[i]->name, component) == 0) {
found = dir_inode->children[i];
break;
}
}
if (!found) {
// 未找到
if (create_parent && !slash) {
// 最后一个分量 + create模式:返回父目录供调用者创建
return cur;
}
return NULL; // 路径不存在
}
// 中间分量必须是目录
if (slash && found->inode->type != FT_DIR) return NULL;
cur = found;
p += len;
if (*p == '/') p++;
}
return cur;
}
RamFS文件读写与目录操作
// fs/ramfs.c
#include "vfs.h"
#include "memory.h"
// ★ 文件写入:动态扩容
ssize_t ramfs_write(inode_t *node, uint32_t offset, const void *buf, size_t count) {
if (node->type != FT_REG) return -1;
uint32_t end = offset + count;
// 按需扩容(倍增策略)
if (end > node->data_capacity) {
uint32_t new_cap = node->data_capacity ? node->data_capacity : 64;
while (new_cap < end) new_cap *= 2;
uint8_t *new_data = kmalloc(new_cap);
if (!new_data) return -1; // ENOMEM
if (node->data) {
memcpy(new_data, node->data, node->size);
kfree(node->data);
}
// 零填充空洞区域
memset(new_data + node->size, 0, new_cap - node->size);
node->data = new_data;
node->data_capacity = new_cap;
}
memcpy(node->data + offset, buf, count);
if (end > node->size) node->size = end;
return count;
}
// ★ mkdir:创建目录dentry并关联新inode
int ramfs_mkdir(dentry_t *parent_de, const char *name) {
inode_t *parent = parent_de->inode;
if (parent->type != FT_DIR) return -1;
if (parent->child_count >= MAX_DENTRY_CHILDREN) return -1;
// 检查重名
for (int i = 0; i < parent->child_count; i++) {
if (strcmp(parent->children[i]->name, name) == 0) return -1; // EEXIST
}
inode_t *new_inode = inode_alloc(FT_DIR);
if (!new_inode) return -1;
dentry_t *new_de = kmalloc(sizeof(dentry_t));
if (!new_de) { inode_unref(new_inode); return -1; }
strncpy(new_de->name, name, NAME_MAX);
new_de->inode = new_inode;
new_de->parent = parent_de;
parent->children[parent->child_count++] = new_de;
return 0;
}
// ★ unlink:移除dentry,inode引用减一
int ramfs_unlink(dentry_t *parent_de, const char *name) {
inode_t *parent = parent_de->inode;
for (int i = 0; i < parent->child_count; i++) {
if (strcmp(parent->children[i]->name, name) == 0) {
dentry_t *target = parent->children[i];
// 目录必须为空才能删除
if (target->inode->type == FT_DIR && target->inode->child_count > 0) {
return -1; // ENOTEMPTY
}
inode_unref(target->inode);
kfree(target);
// 从父目录数组中移除(移动最后一个填补空缺)
parent->children[i] = parent->children[--parent->child_count];
return 0;
}
}
return -1; // ENOENT
}
初始化与根目录创建
// fs/init.c
void fs_init(void) {
// 清零inode表
memset(inode_table, 0, sizeof(inode_table));
// ★ 创建根目录 "/"
inode_t *root_inode = inode_alloc(FT_DIR);
root_dentry = kmalloc(sizeof(dentry_t));
strcpy(root_dentry->name, "/");
root_dentry->inode = root_inode;
root_dentry->parent = root_dentry; // 根的parent是自己
// 创建初始目录结构
dentry_t *dev = NULL, *tmp = NULL;
ramfs_mkdir(root_dentry, "dev");
ramfs_mkdir(root_dentry, "tmp");
ramfs_mkdir(root_dentry, "home");
kprintf("[FS] RamFS initialized. Root inode=%d\n", root_inode->ino);
}
关键细节解析
1. 为什么inode不含文件名?
如果文件名存在inode中,硬链接就无法实现(两个名字对应同一份元数据)。更深层的原因是:文件名是目录的属性,不是文件的属性。目录是一个特殊的文件,其内容是(name, ino)对的列表。这种分离使得文件系统操作(rename、link、unlink)只需修改目录内容,无需触碰inode本身,极大简化了并发控制和崩溃恢复。
2. 为什么path_resolve要区分create_parent模式?
open("/a/b/c", O_CREAT)需要确保/a/b存在,但c可以不存在。如果path_resolve总是要求完整路径存在,O_CREAT就无法工作。如果总是允许缺失,那么open("/a/b/c", O_RDONLY)在b不存在时会错误地返回父目录而非ENOENT。create_parent标志让同一个解析函数服务于两种截然不同的语义,避免了代码重复。
3. RamFS的数据扩容为什么用倍增而非固定页?
小文件占多数。如果每次write都分配4KB页,一个10字节的配置文件就浪费4086字节。倍增策略(64→128→256...)保证了摊销O(1)的写入复杂度,同时对小文件友好。注意:生产级FS使用extent或间接块映射,避免大文件时的memcpy开销。RamFS的memcpy是可接受的教学简化。
调试Checklist:文件系统排查
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| open返回-1但文件确实存在 | path_resolve路径分量提取错误/大小写敏感问题 | kprintf在resolve每层dump当前component和匹配结果;确认strncpy正确截断 |
| write成功但read读到旧数据 | offset计算错误/data指针未更新/缓存一致性问题 | dump inode.data地址和size前后变化;确认write后更新了node->size |
| unlink后文件仍可访问 | refcount未正确递减/fd仍持有引用 | dump目标inode.refcount;确认close调用了inode_unref;检查是否有泄漏的fd |
| mkdir报EEXIST但目录不存在 | 重名检查遍历范围错误/name比较含尾部'\0'问题 | dump parent.child_count和所有children.name;确认strcmp参数正确 |
| 路径"/a/../b"解析失败 | ".."处理未更新cur指针/根目录parent未自指 | 单步调试path_resolve的..分支;确认root_dentry->parent == root_dentry |
| 内存泄漏 | inode_unref未释放data/kmalloc的dentry未free | 实现fs_dump_stats()定期统计已分配inode数和总data字节数;对比操作前后 |
🔧 黄金法则:文件系统调试的终极武器是树状dump函数。实现
fs_dump_tree(dentry_t *root, int depth),递归打印整棵目录树(缩进显示层级、inode号、类型、大小、refcount)。在每次create/unlink/write前后调用它,视觉化验证状态变迁。不要试图脑内模拟树的结构变化——人脑不擅长追踪引用计数。
本章小结与下一步
今天我们赋予了操作系统真正的“记忆”能力:
- ✅ 实现了inode/dentry分离的经典Unix文件系统模型
- ✅ 构建了VFS抽象层,解耦系统调用与具体存储后端
- ✅ RamFS支持完整的文件生命周期:创建、读写、删除、目录嵌套
- ✅ 路径解析处理了
.、..、多级目录与边界情况 - ✅ 引用计数确保了资源安全释放与硬链接语义基础
从此,你的操作系统拥有了可持久化的层次化存储。当你在自制Shell中执行mkdir /tmp/test && echo hello > /tmp/test/msg && cat /tmp/test/msg && rm /tmp/test/msg并看到预期输出时,你见证的是一个完整存储栈的诞生。
下一章预告:《Ext2文件系统实战:从内存到磁盘的跨越》
RamFS重启即失忆。下一章将实现真正的磁盘文件系统Ext2:读取超级块、解析块组描述符、遍历inode表、读取间接块,让你的OS能够从QEMU虚拟磁盘中启动并持久保存数据。
参考资料
- Linux Kernel:
fs/inode.c,fs/namei.c,fs/ramfs/ - The Design of the UNIX Operating System (Bach), Chapter 5-6
- Ext2 Filesystem Specification: https://www.nongnu.org/ext2-doc/
- xv6 Source:
kernel/fs.c,kernel/file.c - 本系列完整代码:[你的GitHub仓库链接](Commit:
v1f2s3r)
📝 作者注:这是《从零手写操作系统》系列的第18篇。文件系统是整个教程中数据结构最密集、不变量最多的章节。如果你的unlink导致后续open随机崩溃,几乎一定是refcount或dentry数组管理的bug。建议先实现只读的路径解析+inode查找,确认树结构正确后再加入write/unlink/mkdir。文件系统的正确性不是功能问题,是安全性问题——每一个未检查的边界条件都可能成为未来的提权漏洞。下一章,我们让数据真正“活过重启”!



转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/anew___/article/details/166938446



