开局先说个我自己的经历。刚工作那会儿排查一个"磁盘满了但du看不到大文件"的问题,折腾了半天才发现是进程删掉日志后句柄没释放,空间挂在/proc里下不来;后来又有一次,一台机器写入卡顿,最后定位到是脏页回刷策略和挂载参数没调好。这两次都指向同一个东西:Linux的VFS虚拟文件系统。你可以把VFS理解成内核里的"文件系统翻译官"——它自己几乎不存数据,但所有open、read、write、stat、mount都要先经过它,再由它分发给底下真正干活的ext4、XFS、NFS、procfs、tmpfs。搞运维的、写驱动的、做安全加固的、准备面试的,其实都绕不开VFS。这篇就把VFS从抽象对象到实操观测、从调用链路到踩坑排查,一层层拆开讲透,尽量让你看完能自己动手验证,而不是只记住几个名词。
1. 为什么Linux非要套一层VFS
1.1 没有VFS的内核会乱成什么样
先从一个反直觉的问题入手:Linux上有几十种文件系统,本地磁盘的ext4、XFS,内存里的tmpfs,网络上的NFS、CIFS,内核暴露状态的procfs、sysfs、debugfs,还有各类设备节点、管道、socket。如果不存在统一抽象层,那么每一个文件系统都要自己实现一套完整的系统调用入口:cat要读ext4,就得调ext4的read;要读NFS,就得调NFS的read。用户空间程序不可能为每种文件系统写一套分支逻辑,cp、grep、tar这些工具也没法通用。
所以内核必须引入一个"中间层",把所有文件系统的能力收敛成一套固定接口。上层系统调用只认这套接口,比如read最终统一走vfs_read;下层各文件系统只负责实现这套接口里自己关心的部分,比如ext4实现ext4_file_read_iter。这个中间层就是VFS,Virtual File System,虚拟文件系统。它带来的直接好处是:内核新增一种文件系统,上层工具和系统调用完全不用改,只要求新文件系统按约定填好那几张函数表。这就是"一切皆文件"这句口号能落地的技术前提——不是因为Linux文件多,而是因为VFS把"文件"抽象成了统一的操作集合,任何对象只要能实现读、写、查找这些操作,就能被当作"文件"来用。
1.2 VFS的职责边界:它管什么,又不管什么
这里必须把边界划清楚,否则很容易越学越糊。VFS负责的是这么几件事:命名空间和路径解析(/a/b/c怎么一步步找到目标)、对象生命周期管理(缓存、引用计数、释放)、通用操作分发(把read路由给具体文件系统)、若干跨文件系统共享的缓存逻辑(比如page cache、dentry cache)。它不负责的是:数据块在磁盘上怎么摆放、日志怎么记录、元数据怎么校验、空间怎么分配。这些全部是具体文件系统的活。
用生活化的类比:VFS像是一个物流总调度台,它知道每个包裹该送到哪个网点、怎么编号、怎么查在途状态,但它不管货车怎么装、油怎么加、路线怎么规划。网点(具体文件系统)才管这些。新手常见的误解是把page cache算成VFS的完全私有财产,其实更准确的说法是:page cache是VFS提供给所有"基于块设备、愿意用通用读写路径"的文件系统的公共缓存层,文件系统可以选择用,也可以绕过(比如直接用direct I/O),甚至可以自己另搞一套。理解了这条边界,后面看各种现象就不会张冠李戴。
1.3 选这套设计的代价与收益
任何设计都有取舍。VFS这套抽象的代价是:多了一层间接调用,每次read都要先经过VFS再跳到具体实现,函数指针跳转和结构体嵌套带来一点开销;同时抽象意味着"最小公倍数",某些文件系统的特殊能力无法通过通用接口完全暴露,只能靠专属的ioctl或者扩展操作来补。收益则是巨大的:统一的权限检查(inode_permission集中在VFS做)、统一的缓存管理、统一的挂载语义、统一的安全钩子入口(LSM就挂在VFS的关键路径上)。安全产品、审计工具、加密文件系统、快照文件系统,基本都是贴着VFS提供的这些扩展点做文章的。换句话说,VFS不只是"让文件能打开",它还是整条存储栈的公共骨架,所有外挂能力都得挂在这副骨架上。
2. 四大核心对象:sb、inode、dentry、file 逐个拆
2.1 super_block:一个已挂载文件系统实例的"户口本"
struct super_block描述的是一次具体的挂载实例。注意区分两个概念:struct file_system_type是"文件系统类型",比如"ext4"这个类型本身,系统启动时通过register_filesystem注册进全局链表;而super_block是"这个类型被挂载了一次"所产生的运行时对象。同一个ext4设备挂载两次(比如bind mount或不同挂载点),通常共享同一个sb;但像tmpfs每次挂载都会生成独立的sb。
sb里关键的字段:s_op指向super_operations,这是文件系统级别的操作表,包含alloc_inode、destroy_inode、write_inode、sync_fs、statfs、evict_inode等;s_root是整个文件系统树的根dentry,挂载的本质就是把s_root嫁接到父文件系统的某个dentry上;s_fs_info是文件系统私有的挂载信息,ext4会把自己的ext4_sb_info挂在这里;s_list把所有sb串成链表,cat /proc/filesystems和/proc/mounts的数据就来源于这些链表。理解sb的一个好办法是把它当成"这块设备被内核接管后的状态容器":块设备句柄、挂载选项、统计计数、缓存策略都挂在这上面,卸载时才整体回收。
2.2 inode:文件元数据的真正载体
struct inode是VFS里最重要的对象,代表一个"文件实体",注意它和"文件名"是解耦的——同一个inode可以有多个名字,这就是硬链接。关键字段包括:i_ino(inode号)、i_mode(类型和权限位)、i_size(大小)、i_nlink(硬链接计数)、i_uid/i_gid、三个时间戳(i_atime/i_mtime/i_ctime)、i_op(inode_operations,管lookup、create、link、unlink、mkdir这类目录和元数据操作)、i_fop(file_operations,管read、write、mmap这类数据操作)、i_mapping(指向address_space,page cache的入口)、i_count(引用计数)、i_dentry(指向所有引用它的dentry的链表)。
这里有个非常经典的C语言技巧值得单独说:磁盘上的inode结构和内存中的struct inode不是一回事。EXT4的磁盘inode定义和VFS的struct inode完全不同,内核对不上怎么办?做法是让具体文件系统自己定义一个更大的结构体,比如struct ext4_inode_info,把struct inode vfs_inode作为其中一个成员嵌进去,然后用container_of宏从vfs_inode的地址反推出外层结构体的地址。这相当于用组合加偏移实现了"继承",是内核里最常用的面向对象模拟手法,看懂这一手,很多内核结构就通了。
注意:
i_count是内存引用计数,i_nlink是磁盘硬链接计数,两者含义完全不同。文件被unlink后i_nlink变0,但只要还有进程持有引用,i_count不为0,inode就不会释放,磁盘空间也就不会回收——后面第5章会专门讲这个坑。
2.3 dentry:路径解析的加速器
struct dentry叫目录项,它把一个"名字"和一个"inode"关联起来。很多人第一次看会觉得它多余:既然有了inode代表文件,为什么还要dentry?答案是性能。路径/usr/local/bin/python如果每次都从磁盘目录块里逐级查找名字,开销会大到无法接受。dentry cache(简称dcache)把这些"名字→inode"的映射缓存在内存里,热路径上一个open几乎可以完全不碰磁盘。
dentry的关键字段:d_name(名字,是一个qstr,含指针和长度)、d_parent(父dentry)、d_inode(指向对应inode,负dentry时为NULL)、d_subdirs和d_child(把树结构串起来)、d_op(dentry_operations,比如d_revalidate、d_hash、d_compare,网络文件系统靠这些做缓存一致性)、d_sb、d_lockref(锁和引用计数)。
还有两个概念必须掌握:**负dentry(negative dentry)**是指d_inode == NULL的dentry,表示"这个名字之前查过,确实不存在"。别小看它,大量程序会反复探测不存在的文件,负dentry把这种无效查找也缓存下来,避免每次都去读磁盘。dentry状态分为used、unused、negative三种,/proc/sys/fs/dentry-state里的几个数字就是它们各自的计数。dcache内存不够时可以回收,unused状态的dentry是优先回收对象,这就是为什么你有时候发现"目录刚访问过很快,过一会儿又慢了"。
2.4 file:站在进程视角的打开文件
struct file代表"一个进程打开了一个文件"这件事,注意它和inode的区别:inode是文件本身,file是"打开"这个动作产生的实例。同一个文件被两个进程打开,会有两个struct file,但共享同一个inode。fork之后父子进程会共享同一个struct file(因为file的引用计数f_count加1),所以共享文件偏移量;而重新open一次则生成新的file,偏移量独立。
关键字段:f_path(包含挂载点和dentry,注意它同时携带vfsmount信息,这是路径解析能处理挂载点跨越的关键)、f_op(指向文件系统给的file_operations,读写的真正入口)、f_pos(当前读写偏移)、f_flags(O_RDONLY、O_NONBLOCK等)、f_count(引用计数)、f_cred(打开时的凭证,权限检查基于它)、private_data(文件系统私有数据,比如设备驱动常把自定义结构挂这里)。VFS的很多"骚操作"就是围绕f_op做文章:在open返回前替换掉file->f_op,就能在不影响其他文件的前提下劫持某个文件的所有读写——这正是各类内核级监控、加密、审计模块的常见切入点,第3.4节会展开。
四个对象的关系可以用一张表快速对照:
| 对象 | 代表什么 | 生命周期 | 关键操作表 | 常见误区 |
|---|---|---|---|---|
| super_block | 一次挂载实例 | 挂载到卸载 | super_operations | 与file_system_type混淆 |
| inode | 文件实体(元数据) | 缓存中,引用归零可回收 | inode_operations / file_operations | 误以为inode含文件名 |
| dentry | 名字到inode的映射 | dcache缓存,可回收 | dentry_operations | 误以为每个文件必有dentry |
| file | 一次打开动作 | open到close | file_operations | 误以为多进程打开共用一个file |
3. 一次open到read,内核里到底走了哪些路
3.1 挂载:把一棵文件系统树嫁接到全局目录树
挂载的本质,是把一个super_block的根dentry(s_root)挂到现有目录树的某个dentry上。流程大致是:系统调用进入do_mount,最终到do_new_mount;先调vfs_get_tree,它会找到对应file_system_type的挂载方法(EXT4对应ext4_mount,内部走mount_bdev);接着mount_bdev打开块设备,调用sget或sget_fs_type去查找或新建super_block,并执行文件系统的fill_super回调来读取超级块、初始化根inode;最后graft_tree把新树的根接到目标挂载点上,并生成struct mount(挂载点信息,包含mnt_mountpoint和父mount)。
这里有个很多人疑惑的现象:挂载点目录下原有的文件"消失"了。原因就在于路径解析走到挂载点时,VFS会检测到该dentry上挂了别的文件系统(通过d_flags里的DCACHE_MOUNTED和__lookup_mnt),于是自动切换到新树的根,原目录内容被"遮蔽"但没被删除,卸载后又会露出来。理解这一点,排查umount失败时就多了一个视角——挂载点被占用,往往不是文件被删了,而是路径解析还卡在跨越点上。
3.2 路径解析:dcache如何省下绝大部分磁盘访问
open("/usr/local/bin/python")进入path_openat,核心是link_path_walk和walk_component。对路径里的每一段名字,先尝试lookup_fast,也就是走RCU-walk模式在dcache里查。RCU-walk是一种不加锁、只读的快速查找模式,命中就能直接拿到dentry,开销极低;没命中或者遇到需要确认的场景,就退化成ref-walk模式,加锁并可能触发真正的磁盘查找。
磁盘查找走lookup_slow,最终调用父目录inode的i_op->lookup,EXT4对应ext4_lookup,它会去读目录数据块、找到目录项、加载目标inode(或者判定为负dentry),然后构造dentry挂进dcache。整个过程里,热路径的dcache命中率通常极高,strace里你甚至看不到明显的系统调用耗时——因为磁盘I/O根本不是每次open都发生。这也解释了为什么"删除大量文件后重新遍历会变慢":dcache被回收了,第一次遍历要重建缓存,第二次才快。
3.3 read/write落地:从vfs_read到块设备提交
读的链路:sys_read→ksys_read→vfs_read,VFS做参数校验和权限相关检查(前面open时已经检查过,这里是补充检查),然后调用file->f_op->read_iter。对EXT4来说就是ext4_file_read_iter,通常进一步进入generic_file_read_iter,在page cache里找对应页:命中直接拷到用户缓冲区,未命中触发filemap_read和预读,最终由address_space的a_ops->readahead或readpage下发I/O,EXT4走ext4_mpage_readpages把连续的页合并成bio提交给块层。
写的链路:sys_write→vfs_write→f_op->write_iter。对普通文件的缓冲写,数据先落进page cache并把该页标记为脏,系统调用就返回了——这就是为什么写文件看起来"快"。真正的落盘由内核回写线程(现代内核是kworker配合writeback机制)在后台异步完成,或者被fsync、fdatasync、sync强制触发。所以**write返回成功不等于数据在磁盘上**,断电可能丢最近几秒的写,这是运维事故的经典来源。想搞清这一点,你可以自己写个测试:写一大块数据后立刻断电源,再看文件完整性,比看一百篇文档都管用。
3.4 在内核里拦截file_operations的几种思路
这一节是给做内核模块开发和学习的同学准备的,属于自建实验环境下的技术探讨。想"看住"文件读写,常见路子有这么几条,各有取舍:
第一种,kprobe/ftrace。挂在vfs_read、vfs_write或具体f_op函数上打印参数,优点是无需改动原逻辑、内核一般自带支持,缺点是性能开销明显、无法真正阻断调用,只能观测。
第二种,替换file->f_op。在伪造的open(比如自己实现的文件系统,或通过类似overlayfs的思路叠一层)里,把新打开文件的file->f_op换成自己的包装表,转发调用前先做处理。优点是粒度细、只影响目标文件;缺点是要处理好private_data、偏移量、并发。
第三种,stackable文件系统。像ecryptfs那样,自身是个完整文件系统,把底层文件系统当作后端,所有IO在中间过一道。这是最"正规"的做法,可长期维护,但开发量大。
第四种,LSM钩子。利用security_file_open、security_file_permission等官方安全钩子,在关键决策点介入,适合做访问控制类需求,工程上比前几种稳。
第五种,修改系统调用表。听起来直接,但现代内核里sys_call_table通常是只读的,硬改得先处理页表写保护,还容易触发内核自保护机制,且随版本变化极易失效,极不推荐。
/* kprobe 观测 vfs_read 的最小示意(仅用于自建实验环境) */ static struct kprobe kp = { .symbol_name = "vfs_read" }; static int handler_pre(struct kprobe *p, struct pt_regs *regs) { struct file *f = (struct file *)regs->di; if (f && f->f_path.dentry) pr_info("vfs_read: %s\n", f->f_path.dentry->d_name.name); return 0; }注意:以上代码仅为原理示意,不同内核版本的
pt_regs参数寄存器(x86_64是di,ARM64是regs[0])和符号可见性都不同,编译前务必对照你所用内核的头文件。任何此类模块都应只在你自己的实验机器上加载,做好备份,别放到生产环境。
选择哪条路,取决于你的真实目标:只是想"看清楚"就用kprobe;要"改行为"就用包装或叠层文件系统;要做安全策略优先考虑LSM。别一上来就想着动系统调用表,那是投入产出比最差的一条路。
4. 动手观测:命令行里能看见的VFS痕迹
4.1 从/proc和/sys读出挂载与缓存状态
VFS虽在内核里,但状态被大量导出到伪文件系统,动手看是理解它最快的方式:
cat /proc/filesystems # 已注册的文件系统类型,nodev表示不需要块设备 cat /proc/mounts # 当前挂载,格式:设备 挂载点 类型 选项 0 0 cat /proc/self/mountinfo # 更详细,含mount id、父id、可选的传播属性 cat /proc/sys/fs/dentry-state # nr_dentry nr_unused age_limit ... cat /proc/sys/fs/inode-state # nr_inodes nr_free_inodes ... cat /proc/slabinfo | grep -E 'dentry|inode_cache' # 各缓存对象占用/proc/mounts其实是/proc/self/mounts的软链,内容基于当前进程的挂载命名空间。如果你用了容器技术,会发现容器里的/proc/mounts和宿主机不同,这就是挂载命名空间隔离的体现——VFS的挂载视图是按namespace维度的。/proc/self/mountinfo比mounts多了shared:1这类传播属性,做mount --bind和mount --make-*实验时你会用到。
4.2 dcache和inode缓存的观测与清理
dentry-state第一列是dentry总数,第二列是处于unused状态的(可回收),第三列age_limit是目标保留时间(秒)。如果你发现nr_dentry持续膨胀,通常是有程序在遍历大量文件,缓存暂时没收。inode-state的第三列开始是各种分配统计,nr_free_inodes为0而nr_inodes接近上限时,就要警惕后面讲的inode耗尽问题。
想清缓存做实验:
sync # 先把脏页落盘,避免丢数据 echo 1 > /proc/sys/fs/drop_caches # 清page cache echo 2 > /proc/sys/fs/drop_caches # 清dentry和inode缓存 echo 3 > /proc/sys/fs/drop_caches # 全清注意:
drop_caches是全局动作,会短暂拖慢整机性能(缓存全失效后要重建),生产环境不要随手敲。做性能对比实验时,每次测量前用同一套清理策略,结果才有可比性。
4.3 用strace和bpftrace看清调用路径
最简单的观测手段是strace,看某个程序到底碰了哪些文件:
strace -f -e trace=openat,read,write,close -o trace.log ./your_program grep -c openat trace.log # 数一下打开次数想在内核态看得更深,bpftrace一行就能统计vfs_read的调用分布(需要内核开启对应支持):
bpftrace -e 'kprobe:vfs_read { @[comm] = count(); }'跑几秒后按Ctrl+C,它会按进程名输出vfs_read的调用次数排行。这个用法我常用来判断"到底是谁在疯狂读盘"——比看iostat更直接,因为它能告诉你是哪个进程的哪类调用。如果要看的是具体文件,可以把kprobe:vfs_read换成对vfs_read参数取dentry->d_name.name的聚合。这类内核态观测需要root和对应内核支持,测试机上玩,别在生产上长跑。
4.4 数据落盘:sync、fsync与脏页策略
sync把整个系统的脏页刷盘;fsync(fd)只刷某个文件及其必要的元数据;fdatasync(fd)只刷数据(必要时才刷元数据),比fsync轻。数据库这类应用通常对关键提交点用fsync保证持久性,代价是每次都要等I/O完成,吞吐会掉。相关的调优参数:
sysctl vm.dirty_ratio # 脏页占总内存比例上限,超过后写操作同步阻塞 sysctl vm.dirty_background_ratio # 超过此比例后台开始回刷 sysctl vm.dirty_expire_centisecs # 脏页最长存活时间(单位1/100秒) sysctl vm.dirty_writeback_centisecs # 回刷线程唤醒间隔经验上,写密集型的机器(比如日志收集)如果发现周期性卡顿,多半是脏页积压后集中回刷造成的。适当降低dirty_background_ratio让回刷更平缓,或者把dirty_expire_centisecs调小,往往能改善抖动。但这不是银弹:参数调小会牺牲部分批量合并写带来的吞吐优势,得结合你的负载类型实测。我一般会先在测试环境用fio跑混合负载,对比几组参数下的P99延迟,再决定线上值。
4.5 用procfs给VFS挂一个自己的文件
想真正摸到VFS,最好的方式是让内核里出现一个属于你的文件。最简单的做法是用procfs注册一个读写接口:
#include <linux/proc_fs.h> #include <linux/uaccess.h> static char buf[64]; static ssize_t my_read(struct file *f, char __user *u, size_t n, loff_t *off) { return simple_read_from_buffer(u, n, off, buf, strlen(buf)); } static ssize_t my_write(struct file *f, const char __user *u, size_t n, loff_t *off) { if (n >= sizeof(buf)) return -EINVAL; if (copy_from_user(buf, u, n)) return -EFAULT; buf[n] = '\0'; return n; } static const struct file_operations my_fops = { .owner = THIS_MODULE, .read = my_read, .write = my_write, }; static int __init my_init(void) { proc_create("my_vfs_demo", 0666, NULL, &my_fops); return 0; } static void __exit my_exit(void) { remove_proc_entry("my_vfs_demo", NULL); } module_init(my_init); module_exit(my_exit); MODULE_LICENSE("GPL");配合一个标准的内核模块Makefile编译加载后,你会看到/proc/my_vfs_demo:
echo "hello vfs" > /proc/my_vfs_demo cat /proc/my_vfs_demo这个例子虽小,但完整串起了VFS的核心链条:file_operations被VFS调用,simple_read_from_buffer帮你处理了偏移量语义,copy_from_user完成了用户态和内核态的数据搬运。写一遍、调一遍、故意写错权限或者返回错误码看内核怎么反应,比读十章文档理解得都深。想更进一步,可以试试用debugfs或configfs,或者挑战写一个能mount上去的独立文件系统。
5. 踩过的坑与高频问题排查
5.1 磁盘有空间却报"No space left on device"
这是最经典的VFS相关误判。df -h显示还有几十G,但创建文件就是失败。八成是inode耗尽:
df -i # 看 IUse% 是否接近100%inode数量在格式化时确定(EXT4可以用mke2fs -N调整,但事后调整麻烦),如果某个目录塞了上百万个小文件,inode先用光,块空间再多也没用。定位哪个目录:
find / -xdev -type d -printf '%p\n' | while read d; do echo "$(find "$d" -maxdepth 1 | wc -l) $d"; done | sort -rn | head或者用du --inodes(部分版本支持)。解决方向是清理小文件、归档合并,长期方案是迁移到支持动态inode的文件系统,或者格式化时预留更多inode。
5.2 文件删了空间不释放:deleted but open
上一章提到的i_nlink和i_count分离,这里就见真章了。进程删掉文件后,如果还有别的进程打开着它,inode不释放、空间不回收,du看不到但df显示占用。排查:
lsof +L1 # 列出link数小于1的已打开文件 ls -l /proc/*/fd 2>/dev/null | grep deleted找到持有进程后,重启该进程或者让它关闭句柄,空间立刻回来。紧急情况下可以清空而非删除:
: > /path/to/big.log # 截断,句柄保留但空间释放这个技巧在日志服务上极其常用——直接rm日志后服务还握着句柄,磁盘不降反升,用截断就避免了。但要注意:多进程共享同一日志句柄时,截断后偏移量可能混乱,最好还是让服务自己支持reopen信号(很多服务响应SIGUSR1重开日志)。
5.3 umount失败:device is busy
umount报target is busy,原因通常有几类:有进程的工作目录在挂载点里、有进程持有该文件系统上的打开文件、或者上层还嵌套着别的挂载。排查三板斧:
lsof +D /mnt/point # 谁打开了里面的文件(大目录会很慢) fuser -vm /mnt/point # 谁在用这个挂载点 cat /proc/self/mountinfo | grep /mnt/point # 确认是否还有嵌套挂载实在找不到进程,fuser -km可以强杀占用者,但生产环境别乱用。还有一个隐蔽原因:某些进程的cwd指向已卸载目录的旧inode,这时lsof可能也难定位,可以用ls -l /proc/*/cwd | grep deleted辅助。养成习惯:跑服务的目录别放在临时挂载点下面。
5.4 中文文件名乱码到底是谁的锅
很多人一乱码就怀疑VFS,其实要分清层次。在VFS和大多数本地文件系统里,文件名就是一串字节,内核不附加编码语义(除了少数有明确约定的文件系统)。乱码通常来自两个地方:一是挂载参数,比如FAT/exFAT/NTFS这类跨平台文件系统需要指定iocharset和codepage,不建议用默认的ascii;二是终端或程序的locale和编码不匹配,比如服务端是UTF-8、客户端用GBK解。排查顺序:
locale # 看当前编码环境 ls | iconv -f utf-8 -t gbk # 验证是不是显示层问题如果是挂载参数问题,重新挂载时指定:
mount -o iocharset=utf8,codepage=936 /dev/sdX /mnt/data关键是先判断乱码发生在存储层还是显示层:用xxd看文件名字节,如果字节本身正常,那就是显示端的事,改挂载参数没用。
5.5 内核模块卸载不了:Module in use
卸载模块时报Module XXX is in use,本质是引用计数没归零。常见原因:还有文件通过该模块的file_operations打开着、还有进程占着该模块创建的文件系统挂载点、或者模块的exit函数里忘了清理proc_create创建的节点。排查:
lsmod | grep your_module # 第三列是引用计数 cat /proc/modules | grep your_module从模块开发角度,.owner = THIS_MODULE会自动维护打开文件的引用,但你自己创建的对象(proc节点、字符设备、文件系统)需要在exit里显式remove或unregister,否则计数下不来。我见过最典型的坑是把proc_create放在init里、却忘了在exit里对应remove_proc_entry,结果模块永远卸不掉,只能重启。
| 现象 | 排查命令 | 根因 | 处理方式 |
|---|---|---|---|
| No space left on device | df -i | inode耗尽 | 清理小文件或调整inode数 |
| df满但du看不到大文件 | lsof +L1 | 文件被删句柄未关 | 截断文件或重启进程 |
| umount报busy | lsof +D、fuser -vm | 有进程占用挂载点 | 迁移工作目录、关句柄 |
| 文件名乱码 | locale、xxd | 编码或挂载参数不匹配 | 调整iocharset/locale |
| 模块无法卸载 | lsmod | 引用计数未归零 | 补齐资源释放逻辑 |
6. 面试高频考点与我的实操体会
6.1 高频问题速查
VFS这块几乎是Linux岗位的必问区,我把常被问到的整理成问答形式,方便对着自测:
问:VFS四大对象是什么,各自职责?答:super_block管一次挂载实例,inode管文件实体(元数据),dentry管名字到inode的映射与缓存,file管一次打开动作。要点在于强调"inode不含文件名,名字在dentry上"。
问:硬链接为什么不能跨文件系统?答:硬链接本质是让多个dentry指向同一个inode。跨文件系统的话,inode分属不同的super_block,inode号可能冲突,i_nlink的语义也跨不过去,所以设计上禁止。软链接不一样,它存的是路径字符串,可以跨。
问:软链接在VFS层怎么实现?答:软链接文件有独立inode,读它的内容就是读一段路径字符串,路径解析遇到软链接会重新走一遍link_path_walk做符号链接展开,并受MAXSYMLINKS(通常40层)限制防循环。
问:open一个文件,究竟读了几次磁盘?答:取决于缓存。路径各级如果在dcache里,可能零次;inode没缓存要读一次;数据页没缓存,read时才触发。热路径常常完全不碰磁盘,这也是要强调"缓存"概念的原因。
问:page cache和buffer cache有什么区别?答:现在内核里两者基本统一为page cache,address_space粒度为页。历史上buffer cache按块组织,用于缓存块设备的块和文件系统元数据;如今元数据也通过page cache走,buffer_head仍作为块层的一部分存在,但不再是独立的大缓存体系。
问:挂载点下面的原文件去哪了?答:没删,被遮蔽了。路径解析到挂载点时切换到新文件系统树的根,卸载后原内容恢复可见。
问:/proc、/sys是什么文件系统?答:都是伪文件系统(procfs、sysfs),不基于块设备,/proc/filesystems里带nodev标记。它们通过实现file_operations把内核数据映射成"文件",是VFS抽象能力的最佳示范。
6.2 一些实打实的经验
我个人的体会是,VFS这套东西光看结构定义会越看越晕,真正开窍的瞬间发生在你同时用两条线去看:一条是代码路径(从sys_read往下追到具体文件系统的read_iter),一条是现场现象(/proc里的计数、strace里的调用、df -i和lsof +L1的输出)。只看代码,你不知道线上会撞上什么;只看现象,你不知道为什么。
还有个反复踩到的坑值得提醒:不要迷信"删除即释放"。做日志清理、临时文件管理、容器镜像瘦身时,一定要确认持有句柄的进程已经关闭或重启,否则你删了半天空间纹丝不动。稳妥的做法是先truncate再让服务重开,或者用lsof +L1验证。
最后给做内核模块和存储方向的同学一个建议:想深入VFS,别从写复杂文件系统开始,先写一个能读写的proc节点,再写一个能mount上去的只读伪文件系统,最后再试着叠一层包装文件系统去看清file_operations的转发逻辑。每一步都用bpftrace或者ftrace验证你的函数真的被调用了。这套路径走一遍,比背十条面试答案管用得多——因为你会亲眼看到,VFS到底在什么时候把控制权交给了谁。