简介一份面向操作系统内核学习与课程设计的源码资源演示如何基于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这种路径翻译成对应的 inodefile 对象则是进程打开文件后的一次性“会话凭证”记录当前读写偏移量等信息。所谓“基于模块”实现文件系统往浅了说就是编译成内核模块加载往深了说你实现的其实是这一整套 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_bit、set_bit、clear_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_alloc。d_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 在创建新文件时open带O_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_operationssimfs 的读写实现方式我最终选了一个务实策略.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-host和inode的对应关系。对普通文件来说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 文件里。内核模块符号是全局可见的拆成多个文件没有任何额外成本但调试时objdump和addr2line定位代码行会舒服很多。编译出来会看到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.txt和dir1文件内容完好dd写入一个 10KB 文件也能正常读写。到这一步整个文件系统的主链路已经通了。4.3 数据的真实性验证内存文件系统的数据存储在 RAM 里重启即失。这是预期行为因此验证重点不是“数据是否持久化”而是“写入的数据是否能正确读回”。我建议你用dd写满单文件上限40KB边缘数据然后读取对比 md5dd if/dev/urandom of/mnt/simfs/rand.bin bs1024 count38 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_ino、i_mode、i_op、i_fop、i_sb、i_size这些必须在同一个函数里填完。我踩过的坑是i_generation没有初始化导致NFS导出时虽然 simfs 本来不考虑导出内核直接崩溃。建议可以用一个simfs_inode_init辅助函数统一处理。ls卡死还有一个常见原因目录的.iterate旧内核是.readdir回调没有正确处理pos偏移导致遍历目录陷入了无限循环。排查方法是echo t /proc/sysrq-trigger或直接top看 CPU 占用再用dmesg里的栈回溯确认陷入路径。这类问题最终都要靠 printk 定位。5.3 调试技巧内核态找问题不像用户态那么舒服写内核模块不能像用户态程序那样随意打断点。我最常用的调试组合是printkdump_stack/proc文件系统。在关键函数入口打印参数如simfs_lookup: dir_ino%lu name%s在内核崩溃时通过dump_stack()获取调用栈。另外一个非常实用的技巧给模块加一个 debug 开关用module_param暴露给加载参数insmod simfs.ko debug1在代码里用宏控制打印粒度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 的“协议伙伴”而不是简单地实现几个函数。好在一旦跑通第一个文件系统的完整链路后面再做类似项目就是熟门熟路了。本文还有配套的精品资源点击获取
