ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从零实现模块化Linux文件系统:VFS、inode与Flash适配实践

从零实现模块化Linux文件系统:VFS、inode与Flash适配实践 简介这是一份面向操作系统课程学习者与Linux内核开发初学者的模块化文件系统实现代码包。资源基于ext4源码进行二次改造完整覆盖从内核编译、自定义文件系统模块ext4edit创建到动态加载与卸载的流程并实验了写操作提示等扩展功能便于理解VFS、模块开发及文件系统挂载等核心概念也可作为操作系统课程设计的实用参考。压缩包共包含3个文件主体为html格式的说明文档另外有inscode与gitignore等工程配套文件整体大小仅7KB内容轻量精炼。目前已有125人学习浏览资源聚焦关键实现步骤与常见排错经验给出修改Makefile、super.c与sysfs.c等核心文件的具体思路并附带了可运行的模块代码与配置片段能有效帮助读者在实践层面降低源码级开发的上手难度。 做了几个嵌入式项目后我越来越觉得文件系统只要不亲手实现一次就永远停留在“会挂载、会读写”的熟练工层面。真正动手去写一个基于模块的文件系统才发现VFS、inode、目录项、块分配、同步机制这些东西彼此咬得有多紧。这篇文章就把我这次实现中的设计拆解和踩坑记录整理出来代码用C写目标平台是Linux内核模块读者如果是正在学内核存储、或者想为嵌入式Flash设备做轻量文件系统的人应该能从里面找到参考。项目的起因很简单一块SPI NOR Flash需要存放多个配置文件和历史日志裸读写地址的方式已经完全管不住了。上FATFS虽然快但胖文件系统在磨损均衡、掉电保护、目录扩展这些地方并不友好而且移植过去还得自己扒代码。自己写一个模块化的小型文件系统一是能把存储介质差异挡住二是每个功能点都能独立替换后续无论换NOR还是NAND上层逻辑都不用动。1. 为什么非要把文件系统拆成模块一次裸Flash环境的重构思考1.1 裸Flash直接读写的痛点最开始我处理Flash数据的方案很原始划分区域每块区域固定存一种类型的记录。比如0x00000~0x0FFFF存配置0x10000~0x2FFFF存日志。代码里到处是read_flash(addr, buf, len)和write_flash(addr, buf, len)。这个方案在单个设备上能用但有几个问题越来越明显配置数据长度变化时固定分区要么浪费空间要么放不下后续迁移分区边界要动整个存储布局。日志按多条记录追加没有统一索引查询一条历史记录要把整个区域读一遍耗时随Flash容量线性增长。Flash有擦除块对齐、块寿命限制直接裸写很快就遇到坏块驱动里得堆大量磨损均衡逻辑。每换一种Flash介质所有存取逻辑重写一遍。所以需要一个真正意义上的文件系统把“存储介质”和“数据结构组织”彻底分开。1.2 模块化设计的边界划分“基于模块”不是简单地多建几个.c文件而是要设计出清晰的边界让每一层都可以独立替换。我把整个系统拆成四层层职责对外接口接口层对接POSIX/VFS语义mfs_read/write/open/close核心逻辑层inode、目录项、权限、文件偏移管理mfs_lookup/mfs_create/mfs_readpage存储抽象层块分配、释放、块缓存、擦写管理mfs_alloc_block/mfs_bread/mfs_bwrite设备驱动层屏蔽不同物理介质差异block_read/block_write/block_erase这个分层最重要的原则是核心逻辑层不允许出现任何与具体介质相关的代码。它只跟抽象出来的mfs_device结构体打交道这个结构体内部保存了读块、写块、擦除块、同步的函数指针。后面我要把RAM盘换成NOR Flash只需要重写设备驱动层的函数指针文件系统其余部分一行代码都不用改。2. 从零搭建最小文件系统的结构骨架四个核心层与三个注册表2.1 层与层之间的函数指针契约模块化文件系统里函数指针是核心手段。我在每个层都定义了一组操作结构体/* 设备驱动层操作集 */ struct mfs_device_ops { int (*read)(struct mfs_device *dev, void *buf, sector_t sector, u32 count); int (*write)(struct mfs_device *dev, const void *buf, sector_t sector, u32 count); int (*erase)(struct mfs_device *dev, sector_t sector, u32 count); int (*sync)(struct mfs_device *dev); }; /* 核心逻辑层暴露给接口层的操作集 */ struct mfs_inode_ops { int (*lookup)(struct mfs_inode *dir, struct dentry *dentry); int (*create)(struct mfs_inode *dir, struct dentry *dentry, umode_t mode); int (*readpage)(struct file *file, struct page *page); int (*writepage)(struct file *file, struct page *page); };为什么用函数指针而不是直接调用因为文件系统要支持多个实例同时挂载每个实例可能跑在不同的物理设备上通过操作集可以让同一套代码同时管理RAM盘和Flash盘。这个思路跟Linux本身驱动模型完全一致理解之后看VFS代码会特别顺。2.2 模块注册机制让文件系统成为可插拔组件既然是模块化文件系统当然要支持直接编译成内核模块用insmod加载用rmmod卸载。所以第一步是向VFS注册自己的文件系统类型static struct file_system_type mfs_fs_type { .owner THIS_MODULE, .name mfs, .mount mfs_mount, .kill_sb mfs_kill_super, .fs_flags FS_REQUIRES_DEV, }; static int __init mfs_init(void) { int err register_filesystem(mfs_fs_type); if (err) return err; return mfs_init_caches(); // 初始化inode/dentry缓存 } static void __exit mfs_exit(void) { unregister_filesystem(mfs_fs_type); mfs_destroy_caches(); } module_init(mfs_init); module_exit(mfs_exit); MODULE_LICENSE(GPL);注册这个结构体后只要系统有任何块设备就可以用mount -t mfs /dev/ram0 /mnt/mfs来挂载。VFS会根据.mount函数返回的超级块对象来创建根目录这就算接入内核了。2.3 关键数据结构定义一个文件系统最少需要三类元数据超级块描述整个文件系统、inode描述一个文件、目录项记录。/* 磁盘上的超级块 */ struct mfs_sb_disk { __le32 magic; /* 魔数用于识别设备上是否mfs */ __le32 block_size; /* 逻辑块大小 */ __le64 block_count; /* 总块数 */ __le64 inode_count; /* inode总数 */ __le64 root_inode; /* 根目录inode号 */ __le64 free_block_hint; /* 分配时的起始搜索块 */ }; /* 磁盘上的inode */ struct mfs_inode_disk { __le32 mode; /* 文件类型与权限 */ __le64 size; /* 文件长度 */ __le32 uid, gid; __le64 ctime, mtime; __le64 block; /* 这里简化单数据块索引真实项目用扩展索引 */ };实际实现里我还在超级块后放了一张“块位图”用0/1表示每个数据块是否已分配。这样的设计占空间小查找相邻空闲块快适合后面做顺序写优化。3. 核心读写链路源码拆解挂载、查找、写入一个文件的全过程3.1 挂载时发生了什么挂载过程是理解整个文件系统的钥匙。mount -t mfs /dev/ram0 /mnt/mfs走到内核后VFS调用mfs_mount流程如下使用mount_bdev绑定块设备申请super_block对象。从设备第一块读出磁盘超级块做magic校验。用磁盘超级块信息填充内存中的mfs_sb_info结构体初始化块位图缓存、inode缓存。读取根目录inode调用d_make_root生成VFS根目录dentry。关键代码大概长这样static struct dentry *mfs_mount(struct file_system_type *fs_type, int flags, const char *dev_name, void *data) { struct dentry *root mount_bdev(fs_type, flags, dev_name, data, mfs_fill_super); if (IS_ERR(root)) return root; return root; } static int mfs_fill_super(struct super_block *sb, void *data, int silent) { struct mfs_sb_disk *dsb; struct mfs_sb_info *sbi; sbi kzalloc(sizeof(*sbi), GFP_KERNEL); sbi-bm_buf kzalloc(bm_size, GFP_KERNEL); sb-s_fs_info sbi; dsb (struct mfs_sb_disk *)read_block(sb, 0); if (le32_to_cpu(dsb-magic) ! MFS_MAGIC) { pr_err(mfs: magic mismatch, not a mfs device\n); return -EINVAL; } sb-s_magic le32_to_cpu(dsb-magic); sb-s_op mfs_super_ops; sb-s_blocksize le32_to_cpu(dsb-block_size); /* 读根目录inode */ inode mfs_iget(sb, le64_to_cpu(dsb-root_inode)); sb-s_root d_make_root(inode); return 0; }这里有个容易被新手忽略的细节sb-s_op里要提供alloc_inode、destroy_inode、sync_fs等回调否则VFS默认行为会导致后续文件创建时崩溃。3.2 按路径查找inode的实现文件系统核心操作之一是路径查找open(/mnt/mfs/foo/bar.txt)最终会调用到mfs_lookup逐级目录向下查找。我的实现里根目录是一个固定inode目录内容就是一组mfs_dentry_record的数组每个记录保存子项名称和对应的inode号。static int mfs_lookup(struct inode *dir, struct dentry *dentry) { struct mfs_inode_disk *di; struct mfs_dir_record *rec; sector_t data_block; u32 records_per_block; u64 size i_size_read(dir); u64 off; di mfs_get_disk_inode(dir); data_block le64_to_cpu(di-block); records_per_block sb-s_blocksize / sizeof(struct mfs_dir_record); for (off 0; off size; off sb-s_blocksize) { rec (struct mfs_dir_record *)read_block(sb, data_block off / sb-s_blocksize); for (i 0; i records_per_block; i) { if (rec[i].inode ! 0 strncmp(rec[i].name, dentry-d_name.name, dentry-d_name.len) 0) { inode mfs_iget(sb, le64_to_cpu(rec[i].inode)); d_add(dentry, inode); return 0; } } } return -ENOENT; }这个实现只支持一级目录平铺代码看起来简单但它把目录查找的逻辑和存储介质彻底隔离了。想支持多级目录只需要把目录inode也当成一个“文件”里面存的是子目录记录查找路径时循环调用lookup即可。3.3 写入路径与块分配写文件要处理三件事更新inode的size、时间戳分配新的数据块把数据从页缓存写到块设备。精简后的writepage流程static int mfs_writepage(struct page *page, struct writeback_control *wbc) { struct inode *inode page-mapping-host; struct mfs_sb_info *sbi MFS_SB(inode-i_sb); sector_t block; u64 pos page_offset(page); if (pos i_size_read(inode)) return 0; block mfs_bmap(inode, pos); if (block 0) { block mfs_alloc_block(sbi); /* 从位图中取一个空闲块 */ if (!block) return -ENOSPC; mfs_bmap_set(inode, pos, block); } return mfs_block_write(inode, page, block); }块分配这里我用了最简单的“从超级块记录的hint位置顺序扫描位图”好处是连续创建文件时很容易找到相邻块坏处是反复删除写入后位图碎片化导致分配变慢。这个点后面测试里暴露得很明显算是一个可以优化的方向。4. 同步与掉电安全sync、脏块回收和写顺序4.1 为什么不能等掉电再flushFlash设备最怕意外掉电。如果文件数据块已经写了但元数据比如inode的size、目录记录还没写那下次挂载时文件要么内容对不上要么直接丢失。这个问题的本质是“多块写入不具备原子性”。真实文件系统会引入journal日志或COW机制我在这个项目里做了一个简化版的双区同步策略每个文件的数据块、inode更新、目录项更新都按顺序写。超级块里维护一个单调递增的“事务序号”。挂载时检查事务序号如果发现元数据没有完整落盘就回滚到上一个有效事务。4.2 同步机制实现VFS有自己的sync_fs回调它会在sync命令或umount时被调用。我在这里实现了“先把所有脏inode写盘再写超级块最后执行设备sync”的顺序static int mfs_sync_fs(struct super_block *sb, int wait) { struct mfs_sb_info *sbi MFS_SB(sb); struct mfs_inode *mi; spin_lock(sbi-inode_list_lock); list_for_each_entry(mi, sbi-inode_list, list) { if (mi-vfs_inode.i_state I_DIRTY) { mfs_write_inode(mi-vfs_inode, NULL); /* 写inode元数据 */ } } spin_unlock(sbi-inode_list_lock); /* 写超级块保存最新事务序号 */ mfs_write_super_block(sb); /* 最后才是块设备层的flush保证写盘顺序 */ mfs_device_sync(sb); return 0; }顺序很重要必须先写数据块、再写元数据、最后才提交超级块。我最初把设备sync放在写超级块之前结果测试中被强制断电后超级块指向的新inode明明已经落盘但对应数据块还没写透文件读出来就是空的。4.3 掉电后一致性一个简化版日志区思路上面说的是多块写入顺序但单次崩溃点仍然可能存在于“文件数据块已分配、inode还未更新”的间隙。为了简化我采用了一个“提交记录”方案每个事务开始时在Flash尾部分配一块日志区写入目标事务涉及的全部元数据快照。挂载时如果日志区存在未完成的提交记录就放弃这次未完成事务的逻辑并使用上一次超级块里的有效状态。这个方案会损失一部分Flash空间但实现起来远比完整journal简单对小文件系统是划算的。5. 与VFS对接最容易踩的五个坑5.1 第一次mount就Oops忽略module_init刚开始我把文件系统代码全部写在一个文件里但忘了加module_init(mfs_init)执行mount -t mfs ...时内核直接报错说“unknown filesystem type”。排查了半小时才发现注册文件系统类型的代码根本没有执行。这类问题用dmesg看的话会看到一行类似mfs: Unknown parameter magic的提示其实是驱动没有加载。5.2 inode引用计数导致的无法卸载文件系统模块实现了一个alloc_inode之后我以为只要每次iget时i_count就行实际VFS的i_count是内核管理的只需要正确配destroy_inode回调并释放自己分配的内存。最初我把iget和iput的调用搞反了rmmod时一直卡住因为inode缓存一直没有清干净。后来通过slabinfo看到每次打开文件都会多一块无法释放的内存逐步定位到destroy_inode里没有调用kmem_cache_free。5.3 锁的顺序i_rwsem 与 private_lock 死锁VFS里inode-i_rwsem是保护文件内容的锁而我们自己可能还有一把mfs_inode的内存锁。有一次并发读写测试直接死锁lockdep报告提示我同时在i_rwsem和自定义锁上持锁并且顺序相反。修复方法是定死一个规则永远先获取VFS的i_rwsem再获取文件系统内部锁。凡是触发这个顺序的地方都改掉死锁消除。5.4 权限检查与时间戳容易被测试用例打脸很多自研文件系统只关注数据读写忽略了inode-i_op-setattr、permission等回调。结果chmod、touch之后文件属性没变化cp命令也会因为无法更新源文件时间戳而报错。最简单的做法是不要在文件系统层绕过VFS的权限检查而是确保mfs_inode_disk里保存的mode/uid/gid字段能在setattr中被正确写回并调用mark_inode_dirty触发后续同步。5.5 无法格式化ioctl分发优先级挂载前要格式化设备我用一个独立的mfs_mkfs命令实现但它总是提示“设备被占用”或“ioctl找不到”。原因是mkfs工具通过BLKRRPART等块设备ioctl工作时需要以读写方式打开设备而且不能持有已挂载文件系统。我最初的测试脚本先挂载了设备再执行格式化自然失败。正确流程是先卸载再调用ioctl(fd, BLKRRPART, 0)然后再mkfs.mfs。6. 实测数据与调优记录在RAM块设备上跑通后的性能表现6.1 测试方法与负载模型为了排除Flash本身慢速接口的干扰我先把文件系统跑在/dev/ram0上用bonnie和一组自研脚本分别测试顺序写、随机写、大量小文件创建和删除。测试环境是树莓派4B内核版本5.10ram块大小4KB。6.2 结果分析初始版本数据很惨淡顺序写吞吐约38MB/s远低于ram盘本身70MB/s的速度瓶颈在“每写一个块就要同步一次inode”。创建10000个空文件耗时8.6秒主要开销是每次create都要扫描整个根目录。删除文件后位图碎片化严重后续挂载时重建缓存变慢。针对这三个问题我做了三个优化批量脏inode回收写文件时不立即同步inode而是等sync_fs或脏inode数量超过阈值时再批量写回。顺序写吞吐提升到53MB/s。目录缓存索引在目录inode的内存结构里加一个rb_root红黑树每次lookup先在红黑树里查查不到才扫描磁盘块。小文件创建时间从8.6秒降到1.2秒。分配hint推进删除块时不是简单地清位图而是用一个last_used指针记录最近释放的块新分配优先分配该块附近的空间减少位图扫描长度。随机写性能稳定不少。6.3 后续还能怎么扩展模块化架构的好处到这一步才真正显现。我后来在设备驱动层加了一个简单的擦写均衡策略因为上层块分配完全通过函数指针调用所以没有改动文件系统核心就在NOR Flash上跑通了。接下来如果想做加密文件系统只需要在设备驱动层包一层加解密想支持NAND坏块管理也是在驱动层做坏块替换不要动上面的inode和目录逻辑。我个人体会是写文件系统很像搭积木最怕的不是单个模块复杂而是模块之间的耦合没有控制好。这篇代码里最关键的“模块化”体现在三个地方设备操作集隔离介质差异、VFS操作集隔离系统调用差异、块分配层隔离空间管理差异。每一条都能让你在面对新需求时只动自己该动的那一块。最后再分享一个调试技巧先在内存设备上把文件系统逻辑验证完再移植到真实Flash设备因为这个阶段的内核崩溃会发生在write系统调用里比在Flash上反复擦写崩溃好排查得多。本文还有配套的精品资源点击获取
返回列表