拓冰建站拓冰建站
首页 / 资讯中心 / 正文

用Linux内核模块实现迷你文件系统:模块化设计实践

简介一份基于ext4源代码改造实现自定义文件系统模块的代码包面向学习Linux内核与文件系统开发的读者也适合操作系统课程设计参考。资源聚焦于在CentOS 7环境下通过修改Makefile、super.c、sysfs.c等关键文件构建新模块ext4edit并实现写操作提示等自定义功能内容涉及内核编译、模块动态加载与卸载及文件系统挂载等核心知识点。压缩包仅7KB共3个文件包含HTML说明页、gitignore配置及inscode工程文件便于快速查看与导入开发环境。已有125人学习下载。借助作者记录的实际排错经验与模块化实现思路可以帮助读者减少环境搭建和内核编译过程中的常见问题更高效地理解从源码修改到模块生效的完整链路是一份小巧但实操性强的内核开发参考资料。 最近终于把自己写的基于模块的文件系统跑通了从设计到调通前前后后折腾了两三周踩了不少坑今天把这套东西完整记录下来。这个项目核心就一件事用 Linux 内核模块的方式实现一个可挂载、可读写的迷你文件系统我给它起名叫 sfssimple file system并且把代码从结构上拆成清晰的子模块来组织。它解决的不仅是“怎么写一个文件系统”更关键的是“怎么用模块化思路管理一个文件系统项目”。我觉得这个题目很适合三类人看正在做操作系统课设或毕设、需要实现文件系统的同学对 Linux VFS 机制感兴趣但一直没找到合适切入点的开发人员以及日常会写内核模块、想系统性了解模块化拆分方式的嵌入式工程师。文章会完整覆盖整体设计、磁盘布局、代码骨架、VFS 对接流程和调试经验尽量做到看完就能动手复现。1. 整体设计思路与模块划分逻辑1.1 为什么坚持模块化而不是一把梭我第一次写文件系统的时候思路很朴素把所有逻辑塞进一个 sfs.c 里反正也就几千行一个文件搞定。结果写到一半就发现完全失控创建文件、目录查找、块分配这些逻辑互相交叉改一个函数要顺藤摸瓜摸出十来个关联位置一个变量命名不一致编译能过但行为完全不对。后来我强制自己按“模块”重新切分发现这套设计带来的收益远超预期可单独测试块分配器、位图管理、inode 操作都可以独立写测试程序验证不需要每次都为验证一个函数去挂载整个文件系统。替换成本低比如底层访问介质从块设备替换成内存缓冲只需要改 media 模块文件系统上层一行不用动。复杂度可视化模块边界清晰之后每个文件不超过 500 行头脑里能装下整个系统的结构而不是只记得自己最近改的那几行。模块化不是在文件数量上做文章而是把变更点隔离起来。文件系统本身天然适合模块化因为它的职责边界非常清楚元数据管理、数据块管理、命名空间管理、底层介质访问这是四件独立的事。1.2 边界在哪里VFS 就是那个标准插座Linux 内核已经给文件系统做好了一套标准接口——VFS虚拟文件系统。可以理解为墙上插座文件系统就是任意一款符合规格的电器只要插脚形状对插上就能用。它定义了你必须实现的回调函数比如超级块操作、inode 操作、目录操作、文件操作。我设计的模块划分是这样的模块职责依赖sfs_mod.c文件系统注册、模块加载/卸载入口无sfs_super.c超级块读写、挂载/卸载逻辑、sb_info 管理mediasfs_inode.cinode 分配/释放/读写、inode 缓存管理bitmap, mediasfs_balloc.c数据块位图管理、块分配/回收mediasfs_dir.c目录项编码/解码、目录操作创建/删除/查找inode, ballocsfs_file.c文件读写、open/release/llseekinode, ballocsfs_media.c对底层块设备的读写封装统一处理字节序和边界检查无依赖方向是单向的sfs_mod 引用所有模块sfs_dir 引用 inode 和 ballocsfs_file 同样引用它们但底层模块不反向依赖高层模块。这套规则执行起来很简单就是 include 头文件的时候不越级强制自己遵守了几周之后代码结构一直干净。1.3 模块之间的数据流动方式模块之间不直接操作对方的结构体内部字段而是通过操作函数加句柄的方式交互。比如目录模块要创建一个新文件的 inode不会自己去 malloc 一个 inode而是调用sfs_inode_alloc(sb, mode)拿到一个已经初始化好的 inode再用sfs_balloc_alloc_block(sb)获取数据块。这种“函数作为接口”的模块化方式让每个模块可以独立替换实现而不破坏外部契约。2. 磁盘布局与核心元数据模块2.1 从一张纸开始设计磁盘布局第一个版本的问题出在“没有设计图直接写代码”后来我老老实实在纸上画了布局。一个块设备上sfs 是这样安排的区域起始块大小引导区01 块超级块11 块inode 位图2若干块数据块位图2 inode_bitmap_blocks若干块inode 表数据块位图之后若干块数据区inode 表之后剩余全部引导区这块最早我留了后来发现完全没用——它内部不是从设备第一个扇区启动的保留这一块纯粹是为了以后万一想做启动镜像留的位置。超级块是文件系统的“身份证”里面记录魔数、块大小、总块数、inode 表起始位置等关键参数。2.2 超级块模块与参数计算用一个具体例子说明参数怎么算。假设块设备大小为 4MB块大小定为 1024 字节那么总共有 4096 块。我计划支持最多 256 个 inode每个 inode 占 128 字节。inode 位图需要的块数256 bit 32 字节 1 块。数据块位图的块数4096 bit 512 字节 1 块。inode 表占空间256 × 128 32KB 32 块。所以布局变成块 0引导区块 1超级块块 2inode 位图块 3数据块位图块 4 ~ 35inode 表32 块块 36 ~ 4095数据区超级块结构体里我放的核心字段是struct sfs_super_block { __le32 s_magic; /* 魔数校验用 */ __le32 s_block_size; /* 块大小 */ __le32 s_blocks_count; /* 总块数 */ __le32 s_free_blocks; /* 空闲块数 */ __le32 s_inodes_count; /* inode 总数 */ __le32 s_free_inodes; /* 空闲 inode 数 */ __le32 s_inode_table; /* inode 表起始块 */ __le64 s_mtime; /* 最后一次挂载写时间 */ };注意这里用的是__le32也就是小端字节序。我在这上面吃过亏直接在 x86 上跑没问题但如果你把磁盘镜像放到大端机器上读魔数校验会失败最开始的版本完全没考虑字节序问题。2.3 inode、位图与数据分配逻辑inode 结构体我简化到能覆盖基本需求struct sfs_inode { __le16 i_mode; /* 权限和文件类型 */ __le16 i_uid; /* 属主 */ __le16 i_gid; __le16 i_nlink; /* 硬链接数 */ __le32 i_size; /* 文件字节数 */ __le32 i_atime; __le32 i_mtime; __le32 i_ctime; __le32 i_block[8]; /* 直接数据块指针 */ };为了教学方便我用的是直接数据块指针一个文件最大 8KB。后续扩展成一级间接索引块也很容易在i_block[7]里放一个指向间接块的位置其余 7 个指向直接块即可。位图分配用内核自带的find_first_zero_bit和set_bit/clear_bit分配策略是首次适配从头找到第一个空闲位置。这个策略简单可靠对 4MB 的小镜像来说性能没有任何压力如果以后支持大容量可以改成红黑树或 B 树管理空闲块那就是另外一个故事了。块分配器的关键代码片段static int sfs_balloc_alloc(struct super_block *sb) { struct sfs_sb_info *sbi SFS_SB(sb); int block; block find_first_zero_bit(sbi-s_data_bitmap, sbi-s_data_blocks); if (block sbi-s_data_blocks) return -ENOSPC; set_bit(block, sbi-s_data_bitmap); sbi-s_free_blocks--; return block; }回收就是clear_bit加s_free_blocks注意不要忘了把位图状态写回磁盘。这个坑我踩过内存里分配完不落盘重启后文件系统直接变回原样看起来就像什么都没发生。3. 关键代码实现与 VFS 模块对接过程3.1 文件系统注册与挂载流程文件系统模块的入口和出口就是两个函数注册到内核的 file_system_type 表里static struct file_system_type sfs_type { .name sfs, .owner THIS_MODULE, .mount sfs_mount, .kill_sb sfs_kill_sb, }; static int __init sfs_init(void) { return register_filesystem(sfs_type); } static void __exit sfs_exit(void) { unregister_filesystem(sfs_type); } module_init(sfs_init); module_exit(sfs_exit);这些函数是 VFS 的“插脚”。register_filesystem把 sfs 挂进内核的文件系统列表用户执行mount -t sfs时内核会遍历这个列表并调用对应的 mount 函数。这里有个很容易忽略的点mount 函数不是真正挂载而是给 VFS 提供 super_block 对象。mount的回调使用的是mount_bdevstatic struct dentry *sfs_mount(struct file_system_type *fs_type, int flags, const char *dev_name, void *data) { return mount_bdev(fs_type, flags, dev_name, data, sfs_fill_super); }mount_bdev负责打开块设备、为超级块分配内存、调用 read_super 函数。超级块读取逻辑在这里实现static int sfs_fill_super(struct super_block *sb, void *data, int silent) { struct sfs_super_block *ds; struct sfs_sb_info *sbi; struct inode *root_inode; ds sfs_media_read_super(sb); if (le32_to_cpu(ds-s_magic) ! SFS_MAGIC) { if (!silent) pr_err(sfs: wrong magic 0x%x\n, le32_to_cpu(ds-s_magic)); return -EINVAL; } sbi kzalloc(sizeof(*sbi), GFP_KERNEL); sbi-s_free_blocks le32_to_cpu(ds-s_free_blocks); ... sb-s_op sfs_sops; root_inode sfs_iget(sb, SFS_ROOT_INO); sb-s_root d_make_root(root_inode); return 0; }这里sfs_iget一定要从磁盘读 inode 数据并设置好i_ino和文件模式。如果根 inode 的i_mode没设置成目录类型d_make_root之后挂载出来的东西会非常奇怪访问目录会直接报 “Not a directory”。3.2 读写通路与文件操作接口文件系统挂载成功之后用户的 open/read/write 系统调用最终会走到 VFS 再分发到文件系统自己的 file_operations。这是最核心的对接面。static const struct file_operations sfs_file_operations { .owner THIS_MODULE, .open sfs_open, .release sfs_release, .read_iter sfs_read_iter, .write_iter sfs_write_iter, .llseek generic_file_llseek, };为了简单我第一版直接实现read_iter和write_iter。这个方法收到的struct kiocb *iocb和struct iov_iter *iter是读写请求的标准数据结构不像传统read/write回调每次还得手动管理重复的同步逻辑。读流程是这样的从iocb-ki_pos计算出目标块号用sfs_media_read_block把数据从块设备读入缓冲再copy_to_iter到用户态。写流程反过来先把用户数据拷贝到缓冲再调用sfs_media_write_block写回磁盘。关键代码如下static ssize_t sfs_read_iter(struct kiocb *iocb, struct iov_iter *to) { struct inode *inode file_inode(iocb-ki_filp); loff_t pos iocb-ki_pos; ssize_t ret 0; char *buf; loff_t size; size i_size_read(inode); if (pos size) return 0; if (pos iov_iter_count(to) size) iov_iter_truncate(to, size - pos); buf kmalloc(SFS_BLOCK_SIZE, GFP_KERNEL); while (iov_iter_count(to) 0) { int block pos / SFS_BLOCK_SIZE; int offset pos % SFS_BLOCK_SIZE; int count min(SFS_BLOCK_SIZE - offset, (int)iov_iter_count(to)); sfs_media_read_block(inode-i_sb, inode-i_block[block], buf); copy_to_iter(buf offset, count, to); pos count; ret count; } kfree(buf); iocb-ki_pos pos; return ret; }这里有个小坑多块文件的读取必须在循环里用min限制count否则跨块边界时会读到连续两块的拼接数据导致数据错位。第一次写的时候没注意读大文件中间总隔几个字节是坏的查了半天发现是循环边界算错了。3.3 目录操作与格式化工具目录的实现方式是传统的内联目录项。每个目录项固定 32 字节格式是inode 号 4 字节 文件名长度 1 字节 文件名 27 字节。创建文件的流程是目录模块调用sfs_inode_alloc分配新 inode初始化模式位然后把它写进父目录里最后更新i_nlink。查找功能的实现就是遍历目录数据块逐个比较文件名static struct dentry *sfs_lookup(struct inode *dir, struct dentry *dentry, unsigned int flags) { struct sfs_dir_entry *de; struct inode *inode NULL; loff_t pos 0; de sfs_dir_find_entry(dir, dentry-d_name.name); if (de) inode sfs_iget(dir-i_sb, le32_to_cpu(de-inode)); return d_splice_alias(inode, dentry); }格式化工具我直接在模块加载时做了一版自动初始化如果读超级块发现魔数不对就调用sfs_make_empty_fs把整个区块清零、初始化位图和根目录。这样做对开发调试非常方便一个insmodmount就能跑不用额外写 mkfs 程序。生产环境的文件系统不会这么干但这套思路对教学项目是合理的。命令链路是make sudo insmod sfs.ko sudo mkfs.ext4 /dev/ram0 # 这是我们用 ramdisk 模拟的块设备 # 实际上 sfs 自带了初始化不需要 ext4 sudo mount -t sfs /dev/ram0 /mnt/sfs # 测试 echo hello /mnt/sfs/test.txt cat /mnt/sfs/test.txt sudo umount /mnt/sfs sudo rmmod sfs4. 挂载失败、数据丢失等常见问题排查4.1 insmod 阶段的问题最常见的是insmod: ERROR: could not insert module: Invalid module format。看 dmesg 通常是version magic 5.15.0-generic should be 5.15.0-rc4这类版本号不匹配。原因是你编译用的内核头文件版本和运行中的内核版本不一致。解决方式是安装对应的linux-headers包或者编译时附带CONFIG_MODVERSIONS的设置。编译指令必须是make -C /lib/modules/$(uname -r)/build M$PWD modules我一开始直接用 gcc 编译 .c 文件当然也行不通因为大量内核头文件依赖需要内核构建系统来处理。还有一个易错点是模块没加MODULE_LICENSE(GPL);某些内核接口尤其是一些辅助函数会把非 GPL 模块的访问限制掉不加 license 会导致链接失败或加载报错。4.2 挂载和读写阶段的问题挂载失败最常见的报错是mount: /mnt/sfs: wrong fs type, bad option, bad superblock。我先去 dmesg 看输出一般会看到sfs: wrong magic 0x0这就说明块设备上根本没有 sfs 的超级块往往是因为没做格式化、或者设备挂载的内容被覆盖成了其他文件系统。第一个版本我就是忘了初始化每次 mount 都失败后来加了一层“魔数不对自动初始化”的逻辑才顺手。读写阶段的另一个高发问题是死锁。我在实现sfs_evict_inode时先拿了 inode 锁然后调用了sfs_balloc_free去释放块而在这个函数里我又试图获取同一把锁直接kernel BUG at kernel/locking把系统搞崩了。查了内核日志才知道是锁递归后来把锁的粒度和调用关系画出来发现evict_inode阶段根本不应该再持有 inode 锁去掉就正常了。还有一个困扰了两天的数据丢失问题文件写入后sync再到重启文件就不见了。排查半天发现是我没有调用mark_inode_dirty导致 inode 的修改没有进入写回队列磁盘上 inode 一直停留在旧版本。这个一定要在每次修改完 inode 之后调用mark_inode_dirty(inode);每次读写完成后还必须调用sync_dirty_buffer或者通过write_inode落盘。我在写回策略上采用了最保守的方式每次都同步写回虽然性能差但一定不会丢数据。调试阶段建议这么做稳定性靠得住等算法稳定了再做延迟写回。4.3 调试工具与心得调试内核文件系统最好的伙伴是dmesg、/proc和ftrace。我习惯在每个关键函数入口加一行pr_debug开启动态调试echo file sfs_*.c p /sys/kernel/debug/dynamic_debug/control另外我在sfs_super.c里注册了一个/proc/fs/sfs/stats文件把块分配次数、释放次数、当前空闲块数、inode 总数全部导出。这样挂载后cat /proc/fs/sfs/stats就能即时看到文件系统内部的健康状态排查问题效率高非常多。问题现象可能原因排查方式insmod 失败Invalid module format内核头版本不匹配 / 缺 license重新用 make -C 编译加 MODULE_LICENSEmount 失败wrong magic块设备没有 sfs 超级块检查 dmesg初始化磁盘写入后重启文件消失没调 mark_inode_dirty / 没落盘加 mark_inode_dirty sync内核死锁锁递归或持锁调用阻塞函数检查锁层次去掉多余锁大文件数据错位跨块读写边界没处理循环里用 min 计算 count目录里找不到新文件目录项没持久化到磁盘确认写回目录数据块5. 实测效果与后续扩展空间5.1 简单性能实测结果基于 4MB 的 ramdisk 块设备我测试了顺序读写、随机读写和元数据操作延迟。顺序读大约 180MB/s顺序写大约 150MB/s这个数字很大程度上受限于 ramdisk 本身sfs的逻辑开销其实很小。单文件创建/删除的速度大约在每秒两万次左右主要瓶颈在目录项线性查找和位图扫描上。这个性能在真实磁盘上会低很多因为我没有做任何缓存优化每次读写都是直接穿透到底层块设备。但对一个教学项目来说正确性比性能重要得多先把逻辑跑对优化后面才能做。5.2 模块化经验迁移与可选增强方向提几个有价值的扩展方向按难度排序给i_block加一级间接索引把单文件大小从 8KB 提升到 8KB 256 × 1KB ≈ 264KB。引入块缓存层让读写在内存里聚合达到延迟写回效果。支持符号链接、硬链接、chmod 等完整 POSIX 语义。加 xattr 支持这也是很多应用依赖的基础能力。如果想把同样的文件系统逻辑移植到用户态可以套一层 FUSE 适配调试会更方便。这次实现让我对“模块化”这个概念的理解又深了一层。尤其是后来我把同样的分层思路用到树莓派 ov5647 摄像头模块驱动、esp8266 wifi 模块这类外部设备驱动的组织上——媒体层换成寄存器读写数据层换成 DMA 缓冲目录层换成设备节点管理骨架一模一样。文件系统和设备驱动的模块化本质是相通的把变化点隔离把稳定接口暴露出来。最后说一句不管你是做课设还是工作里要写驱动模块化设计的价值一定要早体会早受益等你面对几千行代码改一个功能要排查半天的时候就知道当初拆分多重要了。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门