基于Linux内核模块手写内存文件系统:simfs实现与踩坑实录
2026/9/1 6:39:27 网站建设 项目流程

简介:一份面向操作系统内核学习与课程设计的源码资源,演示如何基于ext4源代码修改并构建自定义文件系统模块。资源以Linux内核编译与模块开发为主线,系统覆盖CentOS 7环境搭建、内核源码解压与共享问题解决、Makefile及super.c和sysfs.c关键文件改动,以及模块动态加载与卸载等环节,适合需要独立完成文件系统实验或深入理解内核模块机制的开发者。压缩包体积仅7KB,共包含3个文件,以HTML说明文档为主,另有inscode配置和gitignore辅助文件,便于快速阅读与复用。目前已有125人学习下载。资源同时提供了一整套从环境准备到模块实现的排错思路与操作记录,包含写操作提示这类自定义功能的详细实现说明,能有效支撑操作系统课程设计中的模块化文件系统实践。 手写一个文件系统,听起来是个大工程,但如果你选对了路,其实没有想象中那么难。这条路就是 Linux 内核模块(LKM),也就是标题里说的“基于模块的文件系统实现”。我最近刚把一个简化版的内存文件系统打通,从模块加载、挂载到创建文件、读写数据,全程跑通。这篇文章把这套实现拆开揉碎,代码和踩坑记录都在,想搞懂 VFS 或者自己写个文件系统的朋友,可以直接照着抄。

1. 整体设计与模块化思路

1.1 为什么选择“内核模块”这条路

文件系统的实现方式其实有好几条路,网上最常见的教程是 FUSE(用户态文件系统),优点是开发调试方便,出问题最多崩掉一个用户态进程,不会把整个系统搞挂。但 FUSE 的短板也很明显:每次读写都要在用户态和内核态之间来回切换,性能开销大,而且很多底层语义没法精准控制。

内核模块方案刚好相反,文件系统直接跑在内核态,通过 VFS 层和系统调用对接,性能和行为都更接近真实文件系统。最爽的一点是,不需要重新编译完整内核,把代码编译成一个 .ko 文件,insmod 加载,mount 挂载就能用,卸载也干净。整个开发流程就是“改代码 → 编译模块 → 重新加载 → 测试”,循环速度比改内核源码快得多。

我这次的实现目标定得很明确:做一个运行在内存上的简易文件系统,命名为 simfs,支持最基本的文件操作——创建、打开、读写、删除、创建目录,以及挂载/卸载。不追求功能全面,重点是打通从 VFS 到具体文件系统实现的整条链路。为什么选内存而不是模拟磁盘块设备?因为内存盘可以省去块设备读写层的复杂度,把注意力全部集中在文件系统本身的逻辑上,这对第一版落地来说是最务实的取舍。

1.2 文件系统的“模块化骨架”:VFS 层与四个核心对象

Linux 文件系统能实现“模块化”,底层靠的是 VFS(虚拟文件系统)层。VFS 定义了一套统一的对象模型,包括 super_block(超级块)、inode(索引节点)、dentry(目录项)和 file(文件对象)。任何文件系统,只需要实现这套模型规定的回调函数,就能被内核无缝接入。

用生活化的类比来解释这四者的关系:超级块是整块存储的“总台账”,记录总容量、空闲块数量这些全局信息;inode 是每个文件或目录的“身份证”,记录文件类型、大小、数据块位置;dentry 是路径名的“翻译官”,把/home/test.txt这种路径翻译成对应的 inode;file 对象则是进程打开文件后的一次性“会话凭证”,记录当前读写偏移量等信息。

所谓“基于模块”实现文件系统,往浅了说就是编译成内核模块加载,往深了说,你实现的其实是这一整套 VFS 回调函数。我在设计 simfs 时,就把代码按这个思路分成了几个模块:磁盘布局管理(位图操作)、 inode 管理、目录项管理、文件数据读写。每个模块之间的耦合点到 VFS 接口为止,职责单一,排查问题的时候定位特别快。

2. simfs 的核心数据结构与磁盘布局设计

2.1 内存盘的整体布局

simfs 是内存文件系统,没有真实的块设备,但我依然按照块设备文件的思路来规划存储布局,这样以后如果想移植到真实块设备(比如 RAM disk 或者 SPI Flash),逻辑不用大改。

simfs 把整块内存盘划分为四个区域:

区域起始位置作用
超级块block 0记录魔数、总块数、每块字节数、各区域的偏移和大小
块位图block 1记录数据区每个块的分配状态,1 表示已用
inode 区block 2 开始存储 inode 数组,每个 inode 固定大小
数据区由超级块指定存储文件和目录的实际数据

块大小设定为 4096 字节,inode 区总共预留 512 个 inode 位。这些参数都在编译期用宏定义好,挂在超级块里,方便将来改成可配置。这里有一个需要想清楚的决策:inode 区大小固定而不是动态增长。固定大小换来的是管理逻辑大幅简化——inode 号可以直接换算成数组下标,不需要维护复杂的链表结构。对一个教学/验证性质的文件系统来说,牺牲灵活性换取清晰度是值得的。

2.2 最关键的结构体定义

代码的核心结构体定义如下,我删掉了一些装饰性字段,保留主干逻辑:

#define SIMFS_MAGIC 0x13131313 #define SIMFS_BLOCK_SIZE 4096 #define SIMFS_INODE_NUM 512 #define SIMFS_NAME_LEN 255 // 磁盘上的 inode 结构(存入 inode 区) struct simfs_disk_inode { umode_t mode; // 文件类型和权限 loff_t size; // 文件大小 struct timespec64 atime; struct timespec64 mtime; struct timespec64 ctime; u32 block_count; // 已分配的数据块数量 u32 block[10]; // 直接块索引(简化实现,最多支持 10 块 = 40KB) }; // 目录项(存在数据区) struct simfs_dir_entry { char name[SIMFS_NAME_LEN]; u32 inode_no; bool used; }; // 超级块(存在于内存和盘上) struct simfs_sb_info { u32 magic; u32 block_size; u32 total_blocks; u32 inode_region_start; u32 data_region_start; u32 free_blocks; u32 free_inodes; unsigned long *block_bitmap; // 指向块位图内存区域的基地址 struct simfs_disk_inode *inode_table; // 指向 inode 区基地址 };

为什么 inode 里用 10 个直接块索引?这是刻意做的简化。标准 ext2/3/4 用的是“直接块 + 一级间接 + 二级间接 + 三级间接”的多级索引结构,能寻址极大的文件,但实现复杂度也高。simfs 定位是验证文件系统核心机制,10 个直接块意味着单文件最大 40KB,足以测试撑爆场景,代码却简洁得多。如果你以后要支持大文件,只需要增加一个u32 indirect_block字段来实现一级间接索引。

2.3 inode 分配与位图管理

文件系统最核心的操作之一就是分配和释放 inode 和数据块。simfs 用最简单的位图来管理,内核里已经有现成的位图操作 API 可以直接用(find_first_zero_bitset_bitclear_bit),不需要自己写位运算。

static int simfs_alloc_inode_no(struct super_block *sb) { struct simfs_sb_info *sbi = sb->s_fs_info; unsigned long inode_no = find_first_zero_bit(sbi->inode_bitmap, SIMFS_INODE_NUM); if (inode_no >= SIMFS_INODE_NUM) return -ENOSPC; set_bit(inode_no, sbi->inode_bitmap); sbi->free_inodes--; return inode_no; }

find_first_zero_bit的语义是从第 0 位开始找第一个 0 位,返回位序号,正好当 inode 号用。这里有个容易踩的坑:如果你把位图基地址强制转换成unsigned long *,务必保证内存是unsigned long对齐的,否则并发环境下位图操作会出问题。我当时没注意,用了kmalloc(..., GFP_KERNEL)分配位图内存,后来排查到一个偶发崩溃,最后定位到这里,换成kmalloc(bitmap_size, GFP_KERNEL | __GFP_ZERO)后就好了。

3. 关键操作实现:从挂载到读写

3.1 挂载过程:超级块回填与根目录建立

整个文件系统生命周期里,挂载逻辑是最难一次写对的。mount -t simfs命令到达内核后,VFS 会调用注册在文件系统类型结构体里的.mount回调,最终进入simfs_fill_super,把超级块从“裸数据”变成内核可用的struct super_block

static int simfs_fill_super(struct super_block *sb, void *data, int silent) { struct inode *root_inode; // 分配并初始化内存文件系统布局 simfs_init_layout(sb); // 根目录 inode,编号固定为 0 root_inode = simfs_iget(sb, 0); if (IS_ERR(root_inode)) return PTR_ERR(root_inode); sb->s_root = d_make_root(root_inode); if (!sb->s_root) return -ENOMEM; return 0; }

这里simfs_iget(sb, 0)是根据 inode 号从磁盘(这里是内存盘)inode 表中读出一个 inode,创建对应的struct inode内核对象。根目录 inode 预先在simfs_init_layout中初始化好,mode 设为目录类型,权限设为 755。

挂载回调里常见的一个坑是:必须通过d_make_root生成根目录的 dentry,而不是手动dentry_allocd_make_root自己会处理 inode 引用计数和 dentry 的关联,手动操作很容易造成 inode 的引用计数泄漏,表现为卸载文件系统时iput没有把 inode 释放掉,在kmem_cache中留下 slab 泄漏。

3.2 目录项查找:lookup 的实现

VFS 在解析路径/a/b/c.txt时,会逐级调用目录 inode 的.lookup方法,每一级目录返回下一级的 dentry。simfs 的 lookup 实现就是遍历指定目录的数据区,比对目录项名字,命中则调用simfs_iget读回 inode。

static struct dentry *simfs_lookup(struct inode *dir, struct dentry *dentry, unsigned int flags) { struct simfs_dir_entry *entries; int i; entries = simfs_get_dir_entries(dir); for (i = 0; i < SIMFS_MAX_DIR_ENTRIES; i++) { if (entries[i].used && strcmp(entries[i].name, dentry->d_name.name) == 0) { struct inode *inode = simfs_iget(dir->i_sb, entries[i].inode_no); d_add(dentry, inode); // 关键:关联 dentry 和 inode return NULL; } } d_add(dentry, NULL); // 未找到,返回 NULL dentry return NULL; }

d_add(dentry, NULL)的语义是“这个路径在当前目录下不存在”,VFS 会把这个 NULL 结果缓存起来,也就是负 dentry 缓存。如果 lookup 返回的不是 NULL 而是一个ERR_PTR错误码,VFS 会把整个路径解析流程判定为错误,所以区分“文件不存在”和“路径解析出错”很重要。

我刚开始实现时犯了一个错误:在 lookup 中找不到文件时直接返回-ENOENT。表面看没问题,但 VFS 在创建新文件时(openO_CREAT),会先调用 lookup 检查文件是否存在,如果补全返回-ENOENTopen就会直接把整个创建流程中止。正确的做法就是像上面代码里那样返回 NULL dentry,让 VFS 自己根据O_CREAT标记决定是否调用.create回调。

3.3 创建文件和目录:create 与 mkdir

创建文件的核心逻辑在.create回调中,需要“三连”操作:分配 inode 号、初始化 inode 结构、在父目录的数据区中添加一条目录项。

static int simfs_create(struct mnt_idmap *idmap, struct inode *dir, struct dentry *dentry, umode_t mode, bool excl) { struct inode *inode; int ret; ret = simfs_alloc_inode_no(dir->i_sb); if (ret < 0) return ret; inode = simfs_iget(dir->i_sb, ret); inode->i_mode = mode; inode->i_op = &simfs_file_inode_ops; inode->i_fop = &simfs_file_operations; inode->i_size = 0; simfs_add_dir_entry(dir, dentry->d_name.name, ret); d_instantiate(dentry, inode); mark_inode_dirty(inode); return 0; }

创建目录的mkdir回调与 create 高度相似,只有两处不同:inode 的 mode 是S_IFDIR | 0755,并且要初始化内置的...两个目录项。..目录项的 inode 号填父目录的 inode 号,这是让cd ..能够工作的基础。

3.4 文件读写:用 address_space 还是用 file_operations

simfs 的读写实现方式,我最终选了一个务实策略:.read.write直接实现,不走 page cache。因为整体就是内存文件系统,直接拷贝读写比维护 page cache 更简单直观。

static ssize_t simfs_read(struct file *filp, char __user *buf, size_t len, loff_t *ppos) { struct inode *inode = filp->f_mapping->host; struct simfs_disk_inode *diski = SIMFS_DISKI(inode); char *data = simfs_get_data_block(inode, *ppos / SIMFS_BLOCK_SIZE); size_t remain = min(len, (size_t)(inode->i_size - *ppos)); if (copy_to_user(buf, data + (*ppos % SIMFS_BLOCK_SIZE), remain)) return -EFAULT; *ppos += remain; return remain; }

这里的一个隐蔽问题是filp->f_mapping->hostinode的对应关系。对普通文件来说,f_mapping->host就是文件本身的 inode,但对目录来说不是。所以 read/write 实现要时刻记住:你操作的对象是文件而不是目录,VFS 会在你注册的file_operations上调度.read/.write,要防止目录文件被误调用。我在注册simfs_file_operations时只在普通文件的 inode 上设置,目录 inode 的i_fop设置为simfs_dir_inode_operations,这样在机制上就杜绝了误调用。

4. 编译、加载与实测过程

4.1 编写 Makefile 与编译模块

内核模块的编译依赖正在运行的内核源码树或头文件包,Makefile 非常简洁:

obj-m := simfs.o simfs-objs := simfs_main.o simfs_inode.o simfs_dir.o KERNEL_DIR := /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: $(MAKE) -C $(KERNEL_DIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNEL_DIR) M=$(PWD) clean

我把代码拆成了三个源文件,对应前面说的模块化设计:simfs_main.c负责文件系统注册、超级块回填、挂载/卸载;simfs_inode.c负责 inode 分配、释放、读取;simfs_dir.c负责目录项的增删查。我的经验是:写文件系统这种重逻辑项目,哪怕只是教学目的,也值得从一开始就按职责拆文件,不要图省事全部塞进一个 C 文件里。内核模块符号是全局可见的,拆成多个文件没有任何额外成本,但调试时objdumpaddr2line定位代码行会舒服很多。

编译出来会看到simfs.ko。加载前建议先检查内核日志,dmesg里如果出现类似module verification failed的提示,是正常现象,因为本地编译的模块没有签名,不代表有问题。

4.2 加载模块并挂载

整个过程只需要四条命令:

sudo insmod simfs.ko mkdir -p /mnt/simfs sudo mount -t simfs none /mnt/simfs

注意mount -t simfs后面跟的是none而不是设备节点,因为 simfs 是内存文件系统,不需要底层块设备。如果你的实现里打算挂到 RAM 盘上(比如/dev/ram0),这里就需要写对应的设备路径。

挂载成功后,在/mnt/simfs下测试基本文件操作:

echo "hello simfs" > /mnt/simfs/test.txt cat /mnt/simfs/test.txt mkdir /mnt/simfs/dir1 ls -la /mnt/simfs/

我实测的输出是文件夹里能看到test.txtdir1,文件内容完好,dd写入一个 10KB 文件也能正常读写。到这一步,整个文件系统的主链路已经通了。

4.3 数据的真实性验证

内存文件系统的数据存储在 RAM 里,重启即失。这是预期行为,因此验证重点不是“数据是否持久化”,而是“写入的数据是否能正确读回”。我建议你用dd写满单文件上限(40KB)边缘数据,然后读取对比 md5:

dd if=/dev/urandom of=/mnt/simfs/rand.bin bs=1024 count=38 cp /mnt/simfs/rand.bin /tmp/rand_copy.bin md5sum /mnt/simfs/rand.bin /tmp/rand_copy.bin

两个 md5 一致说明读写路径没有丢数据。再用df -h /mnt/simfs观察文件系统容量信息,如果statfs回调实现正确,df不会报错且容量和剩余空间符合预期。

5. 常见问题与排查技巧实录

5.1 模块加载失败:insmod 报错

insmod 报Invalid module format是最常见的错误,原因通常是内核版本或配置不匹配。检查一下/lib/modules/$(uname -r)/build是否可用,确认编译用的内核源码和你当前运行的内核一致。还有一种情况是启用了 Secure Boot,导致未签名模块被拒绝加载,需要在 BIOS 中关闭或对模块签名。

模块能加载但 mount 时报unknown filesystem type,说明 register_filesystem 没有调用成功,或者注册时报了错误没被打印。检查dmesg最后的输出,看simfs_init函数的register_filesystem返回值,常见错误是-EBUSY,说明已经有同名文件系统注册过。

5.2 挂载成功但 ls 崩溃或卡死

这类问题九成出在 inode 初始化不完整。VFS 对 inode 的字段有隐式的默认值预期,如果你在使用inode = new_inode(sb)之后忘记初始化某些字段,后果是未知的。

我的经验是:创建 inode 后立刻做全套初始化,不要零散地在各处补字段。具体来说,i_inoi_modei_opi_fopi_sbi_size这些必须在同一个函数里填完。我踩过的坑是i_generation没有初始化,导致NFS导出时(虽然 simfs 本来不考虑导出)内核直接崩溃。建议可以用一个simfs_inode_init辅助函数统一处理。

ls卡死还有一个常见原因:目录的.iterate(旧内核是.readdir)回调没有正确处理pos偏移,导致遍历目录陷入了无限循环。排查方法是echo t > /proc/sysrq-trigger或直接top看 CPU 占用,再用dmesg里的栈回溯确认陷入路径。这类问题最终都要靠 printk 定位。

5.3 调试技巧:内核态找问题不像用户态那么舒服

写内核模块不能像用户态程序那样随意打断点。我最常用的调试组合是:printk+dump_stack+/proc文件系统。在关键函数入口打印参数(如simfs_lookup: dir_ino=%lu name=%s),在内核崩溃时通过dump_stack()获取调用栈。

另外一个非常实用的技巧:给模块加一个 debug 开关,用module_param暴露给加载参数insmod simfs.ko debug=1,在代码里用宏控制打印粒度:

static bool simfs_debug; module_param(simfs_debug, bool, 0644); #define simfs_log(fmt, ...) \ do { if (simfs_debug) printk(KERN_DEBUG "[simfs] " fmt, ##__VA_ARGS__); } while (0)

这样日常运行不刷屏,出问题时临时开 debug,不用重编模块。

5.4 万一内核 panic 了怎么办

写文件系统模块,panic 是逃不掉的,关键是 panic 之前的现场要怎么抓住。建议在你的开发机或虚拟机上测试,开了 Kdump 更稳。如果 panic 后重启,可以用 crash 工具分析 vmcore,拿到完整的函数调用栈和寄存器状态。对没有 Kdump 的环境,dmesg在 panic 时的最后几十行输出也有很高的价值,建议先在真机上跑serial console或者用虚拟机串口输出捕获。

我实测下来,编写文件系统模块最容易 panic 的地方是:inode 引用计数管理错误、dentry 与 inode 关系错误、越界访问磁盘数据区。这三类问题几乎都是初始化遗漏或索引计算错误,解决手段就是上面说的全套初始化和边界检查。

6. 从 simfs 到真文件系统的扩展路径

simfs 只是一个起点。验证了核心链路之后,我建议按以下顺序扩展:第一,加上地址空间操作(address_space_operations),走真正的 page cache 路径,这会让读写性能发生质变;第二,加入块设备支持,挂到/dev/ram0上,让数据可以落盘;第三,增加多级索引(一级间接、二级间接),突破单文件 40KB 的限制;第四,实现日志或事务机制,确保掉电场景下的数据一致性。

就我个人这次实现的体会来说,最难啃的地方不是某个具体函数,而是理解 VFS 的“约定”——回调函数什么时候被调用、返回值应该是什么语义、哪些引用该由谁释放,这些规则没有一个统一的文档写全,只能靠读内核源码和实际踩坑来积累。写文件系统模块和写普通内核驱动最大的不同就是:你必须先成为 VFS 的“协议伙伴”,而不是简单地实现几个函数。好在一旦跑通第一个文件系统的完整链路,后面再做类似项目就是熟门熟路了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询