简介这份资源面向操作系统课程设计的学习者与内核模块开发入门者提供一套通过修改ext4源码构建模块化文件系统的完整代码包帮助解决内核编译、模块加载与文件系统挂载等实践难题。压缩包共3个文件包含inscode工程配置、html说明页面与gitignore忽略规则整体约7KB体量轻便便于快速导入开发环境查阅。已有125人学习关注适合作为课程设计或内核实验的参考素材。资源围绕ext4edit模块展开涉及Makefile、super.c、sysfs.c等关键文件的修改思路并覆盖写操作提示等自定义功能的实现方式同时记录了内核解压、文件共享、编译配置及模块动态加载卸载过程中遇到的问题与解决路径。读者可据此理解Linux内核编译流程与文件系统模块化设计机制将理论知识与实际开发衔接减少重复踩坑提升开发效率。1. 基于模块的文件系统实现从零搭一个能挂载、能读写的最小内核模块很多人第一次听到「基于模块的文件系统实现」脑子里浮现的是内核源码树里那一堆看不懂的struct file_operations。但真动手做过一次就会发现它本质上就是三件事注册一个文件系统类型、实现几个回调、把内存里的数据结构伪装成目录和文件。我最早做这个是为了在一个嵌入式板子上跑一个只读配置分区内核自带的方案太重索性自己写了个模块编译成.ko之后insmod就能挂载读写逻辑全在内存里掉电即失反而正好符合需求。这篇文章面向的是已经会写简单字符设备驱动、想再往上走一步的工程师。如果你只会写hello world模块跟着走也能跑通但建议先把file_operations和inode的关系搞清楚。下面所有代码都是可编译、可挂载、可ls、可cat的最小实现不依赖任何外部框架内核版本以 5.x 为基准4.x 需要微调几个函数签名。2. 模块化文件系统的骨架从 register_filesystem 到 mount 回调2.1 为什么选内核模块而不是 FUSE做文件系统有两条路用户态的 FUSE和内核态的模块。FUSE 开发快调试方便但每次读写都要走一次用户态和内核态的上下文切换延迟高而且依赖libfuse和/dev/fuse。如果你的场景是嵌入式设备、容器镜像层、或者需要极低延迟的临时文件系统内核模块是更合适的选择。内核模块的代价是调试痛苦。一个空指针解引用直接 panic没有后悔药只能靠printk和dmesg一点点定位。我一般会先在 QEMU 里跑确认稳定了再上真机。另一个代价是内核 API 不稳定5.x 和 6.x 之间register_filesystem的签名就变过所以代码里要加版本判断。选内核模块的另一个理由是你可以直接复用内核的页缓存、dentry 缓存和 VFS 层。VFS 帮你处理了路径解析、权限检查、文件描述符管理你只需要实现「怎么读一个 inode 的数据」和「怎么列一个目录的项」。这也是为什么一个最小文件系统模块可以只有两三百行。2.2 最小骨架file_system_type 和 mount 的注册流程整个模块的入口是file_system_type结构体它告诉内核「我叫什么名字、挂载的时候调用哪个函数」。注册用register_filesystem注销用unregister_filesystem。挂载时内核调用你提供的mount回调你在里面创建根 inode 和根 dentry返回一个dentry指针。#include linux/module.h #include linux/fs.h #include linux/pagemap.h #include linux/init.h #include linux/slab.h #define MYFS_MAGIC 0x4D594653 /* MYFS */ static struct dentry *myfs_mount(struct file_system_type *fs_type, int flags, const char *dev_name, void *data); static struct file_system_type myfs_type { .owner THIS_MODULE, .name myfs, .mount myfs_mount, .kill_sb kill_litter_super, /* 卸载时清理 dentry 和 inode */ }; static int __init myfs_init(void) { int ret register_filesystem(myfs_type); if (ret) pr_err(myfs: register failed %d\n, ret); return ret; } static void __exit myfs_exit(void) { unregister_filesystem(myfs_type); } module_init(myfs_init); module_exit(myfs_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(some developer); MODULE_DESCRIPTION(A minimal module-based filesystem);这段代码编译成myfs.ko之后insmod myfs.ko会在/proc/filesystems里看到myfs。但此时还不能挂载因为myfs_mount还没实现。kill_litter_super是内核提供的通用卸载函数它会遍历该超级块下的所有 dentry 并释放适合这种纯内存文件系统。如果你自己管理了额外的内存池就需要写自己的kill_sb。MYFS_MAGIC是一个魔数用于在statfs里标识文件系统类型随便取一个不冲突的值即可。register_filesystem返回 0 表示成功返回负数说明名字已经被占用或者内存不足。常见错误是忘记在exit里注销导致模块卸载后文件系统类型还留在内核里再次insmod会失败。2.3 挂载回调里到底要做什么根 inode 和根 dentry 的创建mount回调的核心任务是创建根 inode然后用d_make_root把它包装成根 dentry。根 inode 的i_op和i_fop决定了这个文件系统支持哪些操作。对于目录i_op里要有lookupi_fop里要有iterate老内核叫readdir。static struct inode *myfs_make_inode(struct super_block *sb, umode_t mode) { struct inode *inode new_inode(sb); if (!inode) return NULL; inode-i_ino get_next_ino(); inode-i_mode mode; inode-i_atime inode-i_mtime inode-i_ctime current_time(inode); if (S_ISDIR(mode)) { inode-i_op myfs_dir_inode_ops; inode-i_fop myfs_dir_ops; inc_nlink(inode); /* 目录的 . 链接 */ } else { inode-i_op myfs_file_inode_ops; inode-i_fop myfs_file_ops; } return inode; } static struct dentry *myfs_mount(struct file_system_type *fs_type, int flags, const char *dev_name, void *data) { struct super_block *sb; struct inode *root_inode; struct dentry *root_dentry; sb sget(fs_type, NULL, set_anon_super, flags, NULL); if (IS_ERR(sb)) return ERR_CAST(sb); sb-s_magic MYFS_MAGIC; sb-s_op myfs_super_ops; root_inode myfs_make_inode(sb, S_IFDIR | 0755); if (!root_inode) { deactivate_locked_super(sb); return ERR_PTR(-ENOMEM); } root_dentry d_make_root(root_inode); if (!root_dentry) { iput(root_inode); deactivate_locked_super(sb); return ERR_PTR(-ENOMEM); } return root_dentry; }sget创建一个新的超级块set_anon_super表示这个文件系统没有对应的块设备是纯内存的。get_next_ino从内核的全局 inode 计数器里取一个号保证唯一。d_make_root会消耗掉root_inode的引用计数所以失败时不需要额外iput但成功返回后你不能再碰root_inode。这里有个容易翻车的点inc_nlink对目录是必须的因为目录至少有一个.链接。如果不加ls -la看到的链接数会是 0某些工具会报错。文件不需要这个操作。3. 目录和文件的读写实现lookup、iterate 和 read/write 回调3.1 目录项怎么组织用链表还是数组纯内存文件系统最简单的做法是给每个目录 inode 挂一个私有数据结构里面存一个子项链表。每个子项包含名字、inode 指针、类型。lookup遍历链表找名字iterate遍历链表把名字填进dir_context。struct myfs_dirent { struct list_head list; struct inode *inode; char name[NAME_MAX 1]; }; struct myfs_inode_info { struct list_head children; /* 仅目录使用 */ struct list_head sibling; /* 挂到父目录的 children 上 */ struct inode vfs_inode; }; static inline struct myfs_inode_info *MYFS_I(struct inode *inode) { return container_of(inode, struct myfs_inode_info, vfs_inode); }用container_of把 VFS 的inode嵌进自己的结构体这是内核里最常见的做法。children链表只在目录里有效文件里为空。sibling用于从父目录的链表里摘除自己。分配 inode 的时候要用alloc_inode而不是new_inode因为需要多分配自己的私有部分。static struct inode *myfs_alloc_inode(struct super_block *sb) { struct myfs_inode_info *info kmalloc(sizeof(*info), GFP_KERNEL); if (!info) return NULL; INIT_LIST_HEAD(info-children); INIT_LIST_HEAD(info-sibling); inode_init_once(info-vfs_inode); return info-vfs_inode; } static void myfs_destroy_inode(struct inode *inode) { kfree(MYFS_I(inode)); }inode_init_once初始化 VFS 部分的字段必须调用。destroy_inode在 inode 引用计数归零时被调用这里直接释放私有结构。注意不要在这里释放子项因为子项的 inode 有自己的生命周期由 VFS 的 dentry 缓存管理。3.2 lookup 的实现从名字到 inode 的映射lookup的签名是struct dentry *(*lookup)(struct inode *dir, struct dentry *dentry, unsigned int flags)。你在里面根据dentry-d_name找到对应的子 inode然后用d_splice_alias把 inode 和 dentry 关联起来。static struct dentry *myfs_lookup(struct inode *dir, struct dentry *dentry, unsigned int flags) { struct myfs_inode_info *dir_info MYFS_I(dir); struct myfs_dirent *de; struct inode *inode NULL; if (dentry-d_name.len NAME_MAX) return ERR_PTR(-ENAMETOOLONG); spin_lock(dir-i_lock); list_for_each_entry(de, dir_info-children, list) { if (strncmp(de-name, dentry-d_name.name, dentry-d_name.len) 0 de-name[dentry-d_name.len] \0) { inode igrab(de-inode); /* 增加引用计数 */ break; } } spin_unlock(dir-i_lock); if (!inode) return NULL; /* 不存在让 VFS 返回 -ENOENT */ return d_splice_alias(inode, dentry); }igrab在自旋锁里增加 inode 的引用计数防止在d_splice_alias之前被释放。d_splice_alias处理了 dentry 已经存在的情况比直接d_add更安全。如果找不到返回 NULLVFS 会自动返回-ENOENT不需要你手动构造错误。这里有个血泪经验dentry-d_name.name不保证以\0结尾所以比较的时候必须同时检查长度和结尾字符。我最早只用了strncmp结果一个叫foo的文件和一个叫foobar的文件互相干扰排查了半天。3.3 iterate 的实现把目录项填进 dir_contextiterate的签名是int (*iterate)(struct file *file, struct dir_context *ctx)。你需要遍历子项对每个子项调用dir_emit。注意ctx-pos是偏移量要正确处理否则ls会重复或漏项。static int myfs_iterate(struct file *file, struct dir_context *ctx) { struct inode *dir file_inode(file); struct myfs_inode_info *dir_info MYFS_I(dir); struct myfs_dirent *de; loff_t pos 0; if (!dir_emit_dots(file, ctx)) return 0; spin_lock(dir-i_lock); list_for_each_entry(de, dir_info-children, list) { if (pos ctx-pos) { unsigned int type d_type_from_mode(de-inode-i_mode); if (!dir_emit(ctx, de-name, strlen(de-name), de-inode-i_ino, type)) { spin_unlock(dir-i_lock); return 0; } } pos; } spin_unlock(dir-i_lock); ctx-pos pos; return 0; }dir_emit_dots负责输出.和..省得自己写。d_type_from_mode把 inode 的 mode 转成DT_DIR或DT_REG。dir_emit返回 0 表示缓冲区满了此时要停止遍历并返回VFS 会再次调用iterate从ctx-pos继续。ctx-pos的更新必须在遍历结束后做否则会跳项。3.4 文件读写read 和 write 回调的最小实现文件的read和write用simple_read_from_buffer和simple_write_to_buffer就够了数据存在 inode 的私有结构里。为了简单每个文件固定分配一页。struct myfs_file_info { char *data; size_t size; }; static ssize_t myfs_read(struct file *file, char __user *buf, size_t len, loff_t *ppos) { struct inode *inode file_inode(file); struct myfs_file_info *fi inode-i_private; if (!fi || !fi-data) return 0; return simple_read_from_buffer(buf, len, ppos, fi-data, fi-size); } static ssize_t myfs_write(struct file *file, const char __user *buf, size_t len, loff_t *ppos) { struct inode *inode file_inode(file); struct myfs_file_info *fi inode-i_private; if (!fi || !fi-data) return -EIO; return simple_write_to_buffer(fi-data, PAGE_SIZE, ppos, buf, len); }i_private是 inode 里的一个通用指针用来挂私有数据。simple_read_from_buffer自动处理ppos和用户态拷贝simple_write_to_buffer类似。注意simple_write_to_buffer的第二个参数是缓冲区总大小不是剩余空间它内部会用ppos算偏移。如果写入超过一页会被截断返回实际写入的字节数。4. 避坑与排查模块文件系统最容易翻车的五个地方4.1 挂载后 ls 报 Input/output error现象mount -t myfs none /mnt成功但ls /mnt返回Input/output error。原因iterate里dir_emit的type参数传了错误的值或者ctx-pos没有正确更新导致 VFS 认为目录项损坏。另一个常见原因是lookup返回了ERR_PTR而不是 NULL。解决先用dmesg看有没有myfs相关的pr_err。如果没有在iterate开头加pr_info(iterate pos%lld\n, ctx-pos)确认pos是否从 0 开始递增。检查d_type_from_mode的返回值DT_UNKNOWN会让某些工具报错。4.2 rmmod 时提示 Module is in use现象rmmod myfs返回Module myfs is in use但已经umount了。原因有 dentry 或 inode 的引用计数没释放。最常见的是lookup里igrab之后没有对应的iput或者d_splice_alias失败时没有释放 inode。解决cat /proc/modules看引用计数lsmod确认。用find /proc/*/fd -lname *myfs*检查是否有进程还持有文件描述符。在myfs_destroy_inode里加pr_info看卸载时是否所有 inode 都被销毁。如果某个 inode 一直不销毁检查它的 dentry 是否被别的地方引用了。4.3 写入数据后读出来是乱码现象echo hello /mnt/test然后cat /mnt/test输出乱码或空。原因simple_write_to_buffer的第二个参数传成了len而不是缓冲区总大小导致偏移计算错误。或者i_private在 inode 创建时没有初始化fi-data是野指针。解决确认simple_write_to_buffer(fi-data, PAGE_SIZE, ppos, buf, len)的第二个参数是PAGE_SIZE。在myfs_make_inode里对文件类型分配myfs_file_info并kzalloc一页数据。写入后检查fi-size是否更新simple_write_to_buffer不会自动更新fi-size需要手动加。4.4 并发访问导致内核 panic现象两个进程同时ls和cat偶尔 panic报Unable to handle kernel NULL pointer dereference。原因children链表没有加锁lookup和iterate同时遍历时链表被修改。或者igrab在自旋锁外调用inode 已经被释放。解决所有对children的读写都用dir-i_lock保护。igrab必须在锁内调用因为锁外 inode 可能被iput释放。如果需要在锁外做耗时操作先igrab再解锁。注意dir_emit可能会睡眠不能在自旋锁里调用所以iterate里要先在锁内收集名字解锁后再dir_emit。4.5 内核版本升级后编译失败现象在 5.15 上编译通过的代码在 6.1 上报too few arguments to function register_filesystem或implicit declaration of simple_write_to_buffer。原因内核 API 在不同版本间有变化。register_filesystem在 6.x 里参数没变但simple_write_to_buffer在某些版本里被移到了fs/libfs.c之外。current_time在 6.6 之后需要struct inode *参数。解决用#if LINUX_VERSION_CODE KERNEL_VERSION(6,6,0)做条件编译。simple_write_to_buffer如果找不到自己写一个循环用copy_from_user实现。current_time的兼容写法是#if LINUX_VERSION_CODE KERNEL_VERSION(6,6,0)用current_time(inode)否则用inode_set_ctime_current(inode)。编译前先看include/linux/fs.h里的声明。5. 进阶技巧用 debugfs 验证 inode 生命周期和引用计数写完基本功能之后最头疼的是引用计数泄漏。VFS 的 dentry 和 inode 缓存很复杂一个igrab忘了iput就会导致模块永远卸不掉。我的习惯是在模块里加一个 debugfs 接口实时打印当前所有 inode 的引用计数和 dentry 状态。#include linux/debugfs.h static struct dentry *myfs_debugfs_root; static int myfs_debug_show(struct seq_file *m, void *v) { struct super_block *sb; struct inode *inode; spin_lock(sb_lock); list_for_each_entry(sb, super_blocks, s_list) { if (sb-s_magic ! MYFS_MAGIC) continue; seq_printf(m, superblock %p, root ino %lu\n, sb, sb-s_root-d_inode-i_ino); spin_lock(inode_sb_list_lock); list_for_each_entry(inode, sb-s_inodes, i_sb_list) { seq_printf(m, inode %lu mode %o count %d\n, inode-i_ino, inode-i_mode, atomic_read(inode-i_count)); } spin_unlock(inode_sb_list_lock); } spin_unlock(sb_lock); return 0; } static int myfs_debug_open(struct inode *inode, struct file *file) { return single_open(file, myfs_debug_show, NULL); } static const struct file_operations myfs_debug_fops { .owner THIS_MODULE, .open myfs_debug_open, .read seq_read, .llseek seq_lseek, .release single_release, }; /* 在 myfs_init 里加 */ myfs_debugfs_root debugfs_create_dir(myfs, NULL); debugfs_create_file(inodes, 0444, myfs_debugfs_root, NULL, myfs_debug_fops); /* 在 myfs_exit 里加 */ debugfs_remove_recursive(myfs_debugfs_root);挂载文件系统后cat /sys/kernel/debug/myfs/inodes会列出所有 inode 的引用计数。正常情况根目录 inode 的 count 至少是 1被根 dentry 持有每个打开的文件 count 加 1关闭后减 1。如果某个 inode 的 count 在umount之后还不为 0说明有泄漏。我一般会做三轮验证第一轮挂载后不操作umount看 count 是否归零第二轮创建文件、写入、读取、删除再umount看是否有残留第三轮用stress-ng或者自己写个多线程脚本并发读写跑十分钟看 count 是否稳定。如果第三轮 count 持续增长基本可以确定是igrab和iput不配对。还有一个技巧是用kmemleak。内核配置里打开CONFIG_DEBUG_KMEMLEAK挂载后echo scan /sys/kernel/debug/kmemleak然后cat /sys/kernel/debug/kmemleak如果有myfs_alloc_inode相关的未释放块会直接列出来。这个比手动数引用计数更直接但需要内核支持嵌入式环境不一定有。最后说一个我踩过的坑debugfs的single_open在读取时会持有sb_lock如果myfs_debug_show里再调用会睡眠的函数直接死锁。所以seq_printf里不要做kmalloc或者copy_to_user。另外debugfs_remove_recursive必须在unregister_filesystem之前调用否则 debugfs 的 dentry 可能还引用着文件系统的 inode导致卸载失败。这套最小实现大概三百行编译一次两秒钟挂载卸载循环一百次不出错就算稳了。我现在的习惯是每加一个新回调就先跑一遍 debugfs 看引用计数确认没有泄漏再继续。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?