news 2026/9/30 1:13:05

Linux VFS 四大对象与 open/read 流程详解及内核模块拦截实验

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Linux VFS 四大对象与 open/read 流程详解及内核模块拦截实验

上周帮一个做嵌入式设备的同事看问题,他写了个字符设备驱动,用cat读/dev/xxx一切正常,可换到他自己挂的那个只读分区上读文件,read()返回的错误码却对不上号。他拿着打印出来的-EIO一脸茫然:明明是同一套read逻辑,为什么换了设备就翻车?问题最后落在 VFS 这一层——他压根不知道自己那几行内核代码,是被 VFS 的通用框架先接住、再分发给具体文件系统的。这件事之后我让他把fs/open.c、fs/read_write.c、fs/namei.c三个文件老老实实读了一遍,很多"玄学 bug"瞬间就有了答案。

VFS(Virtual File System,虚拟文件系统)是 Linux 内核里最不起眼、却几乎无处不在的一层。你敲ls、cat、cp,你mount一块 U 盘,你读/proc/cpuinfo里的 CPU 型号,你sync把缓存刷到磁盘,这些动作背后都先经过 VFS,再落到 ext4、xfs、btrfs、tmpfs、procfs 甚至网络文件系统上。它干的事说白了就一句话:把千奇百怪的文件系统,统一伪装成内核里那一套标准的对象和接口。这篇文章我打算把这层"伪装"扒开来给你看——四大核心对象长什么样、一次open到read到底走了多少路、挂载树是怎么拼起来的,最后再动手写个内核模块去拦截read/write,让 VFS 从概念变成你能摸得着的东西。适合有一定 Linux 使用基础、想往内核方向走的人,也适合准备面试被问到"VFS 是什么"却只会背四个结构体名字的朋友。

1. VFS 存在的理由:从一个"读得到但说不清"的困惑说起

1.1 没有 VFS 的世界会乱成什么样

假设内核里没有 VFS 这层抽象,那么每个文件系统都得自己实现一套完整的系统调用处理逻辑。你调open("/mnt/usb/a.txt"),内核得先判断这个路径落在哪个分区上,然后调用 U 盘对应的 FAT 驱动里的open;你调read,又得去找 FAT 驱动里的read;换个 ext4 分区,同样的逻辑要再写一遍。更麻烦的是/proc、/sys这种根本不是"存储设备"的东西——它们没有磁盘块、没有扇区,却要被当成文件来读写。

这种设计会有几个立刻能感受到的后果。第一,每新增一种文件系统,用户态的open、read、write、stat、mmap全都要重写一遍适配层分流逻辑,代码量爆炸。第二,应用开发者会疯掉:读一个文件要不要针对不同文件系统写不同代码?第三,像管道、字符设备、socket 这些"类文件"对象根本没法用统一方式处理。VFS 的价值就在这儿——它在系统调用和具体文件系统之间,硬生生插入了一层中间层,把"统一接口"和"具体实现"彻底解耦。

所以 VFS 本质上是内核里的一个协议:向上,它承诺给用户态一组标准系统调用;向下,它要求每个文件系统实现一组标准操作。中间的转换、缓存、权限检查、路径解析,全部由 VFS 自己扛下来。

1.2 VFS 的定位:内核里的"翻译官 + 调度台"

我更喜欢把 VFS 比作一个跨国公司的前台加调度中心。用户态的应用是来访客户,说的话只有一种:"给我打开 /home/me/a.txt"。而这个公司里坐着十几个部门(ext4、xfs、tmpfs、procfs……),每个部门内部流程完全不同。客户不可能挨个去对接,于是他只跟前台说需求;前台负责查档案(inode)、查地址簿(dentry)、确认他有没有权限(permission),然后把活派给对应部门,最后把结果统一包装成客户能理解的形式返回。

这层"前台"还有个隐性职责:它自己维护了大量缓存。dentry 缓存让路径查找不用每次都走磁盘,page cache 让重复读同一个文件不必反复访问设备,inode 缓存避免了频繁从磁盘读取元数据。这些缓存是 Linux 文件性能的核心来源,也是很多"为什么我删了文件空间还没释放""为什么内存里 dentry 占了几个 G"这类问题的根因。

一句话总结定位:VFS 不生产数据,它只做规范、路由和缓存。理解了这句话,后面所有结构体的设计动机就都顺了。

2. 四大核心对象:把 VFS 拆成四块能看懂的积木

2.1 super_block:一个已挂载文件系统的档案袋

struct super_block描述的是一个"已经挂载起来的文件系统实例"。注意这里的措辞:不是"一块磁盘",而是"一次挂载"。同一块磁盘分区你挂两次,就会有两个 super_block 实例,它们各有各的挂载点和状态。这个结构体里存着块大小(s_blocksize)、文件系统类型(s_type)、最大文件大小、inode 总数与剩余数、挂载标志(s_flags,比如只读)、以及一个最关键的东西——s_op,也就是struct super_operations函数指针表。

s_op里的函数是文件系统必须提供给 VFS 的"高层接口",典型的有alloc_inode、destroy_inode、write_inode、sync_fs、statfs。当你在命令行敲sync的时候,内核最终就是遍历所有已挂载的 super_block,挨个调用它s_op->sync_fs,把脏数据和元数据刷到持久化介质上。你敲df -h,得到的数字来自statfs系统调用,底层同样是s_op->statfs在填数据。

这里有个非常实用的观察点:/proc/filesystems列出的是内核当前支持的所有文件系统类型(file_system_type),而/proc/mounts(等价于/proc/self/mounts)列出的是当前实际挂载的实例。前者是"能做什么菜",后者是"已经上了什么菜"。分清楚这两个概念,面试被问到"列出系统支持的文件系统"时你就不会答错。

2.2 inode:文件的身份证,跟文件名一点关系都没有

struct inode代表一个文件(准确说是文件系统中一个对象),它保存的是"这个文件本身"的全部元数据:文件类型、权限位、UID/GID、大小、三个时间戳、硬链接计数i_nlink、指向数据块的映射信息,以及两个函数表i_op(inode_operations)和i_fop(file_operations)。

初学者最容易卡住的地方是:inode 里没有文件名。文件名存在目录项(dentry)里,目录也是文件,它的内容就是一张"名字 → inode 号"的映射表。这就解释了一个经典现象:为什么在同一分区里给文件创建硬链接,多个名字可以指向同一个 inode(ls -l第二列数字变大),而硬链接不能跨分区——因为 inode 号只在单个文件系统内唯一,跨分区就没法引用了,内核直接返回EXDEV。

另一个高频问题是"删了文件磁盘空间没释放"。答案也在 inode 上:rm做的是解除目录项与 inode 的关联,把i_nlink减一。如果此时还有进程打开着这个文件,i_count引用计数不为零,inode 和数据块就不能回收,空间自然不释放。等你把那个进程关掉,内核才真正动手清理。这也是为什么有时候df显示磁盘满了,du加起来却对不上——差的就是这些"看不见的"已删除但被占用的文件。排查方法我在第 6 节会整理成表。

2.3 dentry:被缓存起来的路径,VFS 的性能命脉

struct dentry是"目录项",它把一个名字和一个 inode 绑在一起。/home/me/a.txt这条路径,在 VFS 眼里是三个 dentry 串成的链:home→me→a.txt,每个 dentry 通过d_parent指向父目录,通过d_subdirs挂着子项。

dentry 最重要的设计是dcache:一次路径查找完成之后,这条路径上的所有 dentry 都会留在内存的哈希表里(d_hash)和 LRU 链表里(d_lru)。下次你再访问同一个路径,内核直接从内存拿下,完全不用碰磁盘。这就是为什么第一次ls一个大目录很慢,第二次快得多。

但天下没有免费的午餐。dcache 的代价是内存。你可以用cat /proc/slabinfo | grep dentry看当前缓存的 dentry 数量,在文件数量巨大的服务器上(比如几百万个小文件的邮件队列),dentry 和 inode 缓存吃掉几个 G 内存一点都不稀奇。这属于"可回收内存",系统内存紧张时内核会自动收缩,但如果你需要立刻腾出来,可以手动干预:

# 查看当前 dentry / inode 缓存规模 grep -E 'dentry|inode_cache' /proc/slabinfo # 只回收 dentry 和 inode 缓存,不动脏页(相对安全) sync echo 2 > /proc/sys/vm/drop_caches # 回收 pagecache + dentry + inode(性能影响最大) echo 3 > /proc/sys/vm/drop_caches

注意:drop_caches是运维应急手段,不是日常习惯。它会让后续所有访问重新走磁盘,在对延迟敏感的服务上直接触发性能抖动。生产环境用之前先想清楚代价。

2.4 file 与 file_operations:进程视角的那只"打开文件"

前面三个对象都是"文件系统视角"的,struct file则是"进程视角"的。它代表一个进程打开的一个文件实例,里面记录着当前读写偏移f_pos、打开模式f_flags、私有数据private_data,以及最重要的f_op——也就是struct file_operations指针。

这里要特别拎清楚一个容易混淆的点:inode 里的i_fop和 file 里的f_op是两回事。i_fop是"这类文件默认用哪套操作",来自 inode 所属的文件系统;f_op是"这次打开实际用哪套操作",可以在打开过程中被替换。典型的例子就是/dev下的设备节点:inode 来自 devtmpfs,本来有一套默认操作,但open设备时会根据设备号把f_op换成具体驱动注册的那套。所以同一张file_operations表,是内核里最常被"换手"的结构之一。

file_operations里有什么,你随便打开一个内核源码目录看一眼就知道:read、write、llseek、poll、unlocked_ioctl、mmap、open、release、fsync。每个文件系统、每个驱动,都是通过填这张表来跟 VFS 对话的。第 5 节我要做的拦截实验,动手术的正是这张表。

2.5 四个对象的对应关系与生命周期

对象结构体代表什么生命周期关键成员
超级块super_block一次已挂载的文件系统实例挂载创建,卸载销毁s_op、s_root、s_blocksize
索引节点inode一个文件对象的元数据首次访问创建,链接与引用归零后回收i_ino、i_nlink、i_op、i_fop
目录项dentry路径中的一段名字到 inode 的映射路径解析时创建,作为缓存长期驻留d_name、d_parent、d_inode
打开文件file某进程一次打开的文件实例open创建,close销毁f_pos、f_flags、f_op

这张表的记忆方法:super_block 是"图书馆",inode 是"书",dentry 是"书脊上的名字和检索卡片",file 是"某位读者手里的借阅凭证"。一个 inode 可以被多个 dentry 指向(硬链接),一个 dentry 对应一个 inode,一个 inode 可以同时被多个 file 打开(多个进程各有一张借阅凭证,各有各的阅读进度f_pos)。

提示:判断"路径"和"文件"的区别,最直接的方法看/proc/self/fd/。每个打开的文件描述符都是一个file对象,而路径只是 dentry 链的一种表现形式。

3. 一次 open 到 read,内核里到底走了多少路

3.1 从系统调用入口到 do_sys_open

用户态调open("/tmp/a.txt", O_RDWR),进入内核后大致是这样一条链路(不同内核版本函数名略有差异,逻辑是一致的):sys_open/do_sys_openat2拿到用户态传进来的路径字符串和标志位,先把路径从用户空间拷贝到内核空间(getname_flags),然后调用do_filp_open。

do_filp_open干的第一件事是分配一个struct nameidata,这个东西是路径解析的上下文容器,里面装着当前走到哪个 dentry、剩下的路径字符串、以及解析过程中的各种标志。然后进入核心的路径解析流程。这一步有个很容易被忽略的细节:路径名的拷贝是有长度限制的,单段路径名不能超过NAME_MAX(通常 255 字节),整条路径在PATH_MAX(4096 字节)之内。所以那些文件名超长导致ENAMETOOLONG的问题,不是文件系统不让你建,是 VFS 在解析前就把你拦住了。

顺便说一个实操经验:路径越深,解析开销越大,不只是字符串比较的开销,还有每一层 dentry 的引用计数增减、权限检查。在某些高频调用的场景里,把配置文件路径从五层深改成两层,压测 QPS 能涨几个点——当然这是存量系统调优的边角料,不是主要矛盾。

3.2 路径解析:从根一路走到目标 dentry

路径解析的核心函数是link_path_walk,它按/切分路径,一段一段地往下走。每一段都要做三件事:在当前 dentry 的子目录链表或 dcache 哈希里查找下一段的名字;如果缓存命中就直接复用,没命中就调用当前目录 inode 的i_op->lookup去存储介质上找;找到之后做权限检查,确认当前进程的 UID/GID 有没有权限进入这个目录。

查找过程中有两个特殊符号要处理:.和..。.直接复用当前 dentry,..走d_parent回到父目录——注意,是走到"挂载结构上的父目录",不是 inode 的物理父目录。这个区别在挂载场景下非常关键,也是很多"为什么cd ..没回到我预期的目录"的根源。

整个过程中,如果遇到符号链接,link_path_walk会停下来递归处理,并且有嵌套层数限制(MAXSYMLINKS,通常是 40 层)。超过就返回ELOOP。我见过一个生产事故就是两个目录互相软链,脚本里循环访问,最后把ELOOP报错刷满了日志。这类问题用namei -l /path/to/file一看就清楚了:

namei -l /etc/alternatives/java # 会逐段打印每一级的权限、所有者、以及是否是符号链接

3.3 打开之后的 read/write,怎么落到具体文件系统

路径解析完成,拿到了目标 inode,do_dentry_open开始干活:检查 inode 类型是否允许被打开(目录用O_RDWR打开会被拒),从 inode 继承默认的f_op,然后调用文件系统自己的open回调做最后的准备工作——比如设备驱动在这里初始化硬件、分配私有数据。成功后返回一个文件描述符给用户态。

真正有意思的是read和write的落地过程。用户态调read(fd, buf, n),内核走vfs_read。这里有一串检查:文件是否以可读模式打开(FMODE_READ)、f_op->read或f_op->read_iter是否存在、缓冲区地址是否合法。检查通过后,才会调用f_op->read。

而对于绝大多数普通文件,f_op指向的是generic_file_read_iter,它根本不直接跟磁盘打交道,而是去address_space里的page cache找数据。命中就直接copy_to_user返回;没命中才触发一次实际的块设备读(readpage/readahead),把数据页放进 page cache,再拷贝给用户。这就是为什么read系统调用的耗时分布常常是双峰的:缓存命中几十微秒,未命中几毫秒。你写数据的时候也一样,write只是把数据写进 page cache 并标记为脏页,真正落盘由内核的回写线程(kworker配合writeback机制)异步完成——所以write返回成功,不等于数据已经在磁盘上。

这也是sync存在的原因,以及"为什么断电会丢数据"的底层解释。想精确控制落盘时机,用fsync(fd)或者fdatasync;想全局刷,用sync系统调用(命令行就是sync命令)。性能敏感的场景要慎重使用fsync,它会把该文件的所有脏数据和元数据同步写下去,在机械盘上单次开销可以到毫秒级。

3.4 亲手验证:盯着 inode 和 dentry 看

光看代码印象不深,动手验证一遍。先造一个文件,看它的 inode 号和挂载点:

cd /tmp && echo "hello vfs" > a.txt stat a.txt # 看 Inode、Links、Device df -i /tmp # 看这个分区 inode 使用情况 ls -i a.txt # 只看 inode 号

然后创建硬链接,观察变化:

ln a.txt b.txt stat a.txt b.txt | grep -E 'File|Inode|Links' # 两个文件 inode 相同,Links 变成 2

再试试删除一个,但保持文件被打开,看看空间是否释放:

# 终端 A python3 -c " f = open('/tmp/bigfile','wb') f.write(b'x' * 100_000_000) f.flush() import time time.sleep(300) " # 终端 B rm -f /tmp/bigfile df -h /tmp # 空间没有减少 lsof +L1 # 会列出 deleted 状态但占用空间的文件 kill <终端A的python进程PID> df -h /tmp # 现在空间释放了

lsof +L1这个命令是我排查"磁盘满但找不到大文件"时的第一反应,比满世界find快得多。它列出所有 link count 小于 1(也就是已被删除)但仍被进程持有的文件及其占用大小。

4. 挂载机制:VFS 怎么把多棵目录树拼成一棵

4.1 vfsmount 与"覆盖"这件事

Linux 里所有挂载点最终拼成一棵以/为根的树,而承载每个挂载实例的结构是struct vfsmount(较新内核里叫struct mount),它把 super_block 和一个 dentry(挂载点)关联起来。当你在/mnt上挂了一个 U 盘,/mnt原本对应的 dentry 并没有被删除,而是被新挂载的根 dentry "盖住"了。

这就解释了那个经典困惑:挂载之前往/mnt里放了一堆文件,挂载后怎么都看不见了。不是被删了,是被覆盖了。卸载之后文件原样还在。反过来,如果你往一个已经作为挂载点的目录里写文件,写进去的其实是 U 盘里的内容,不是"原目录"。这一点在容器和自动化脚本里特别容易踩坑,写部署脚本时我一般会先findmnt -T /target/path确认这个路径到底落在哪个挂载实例上。

findmnt -T /var/lib/docker # 查这个路径属于哪个挂载点 findmnt --list # 树形列出所有挂载 cat /proc/self/mountinfo # 内核视角的挂载信息,字段比 /proc/mounts 更全

/proc/self/mountinfo我强烈建议你去看一眼。它比/proc/mounts多出挂载 ID、父挂载 ID、根路径、挂载选项、以及可选的标签字段,是排查复杂挂载拓扑(尤其是容器里那些overlay和bind)的利器。

4.2 挂载命名空间与根文件系统

现代 Linux 里,挂载树不是全局唯一的。每个进程可以属于一个独立的mount namespace,看到自己的一份挂载视图。容器技术就是靠这个实现隔离的:容器内看到的/proc、/sys、/都是挂载命名空间里重新组织过的视图,跟宿主机的挂载表互不干扰。

而这一切的起点是rootfs——内核启动时最先挂载的那个内存文件系统,它只是个临时根,目的是让内核能有一个目录结构可以操作。紧接着内核会通过initramfs或直接挂载真正的根文件系统(root filesystem),然后把/切换到它上面,这个动作叫pivot_root。嵌入式设备上"根文件系统起不来"这类问题,往往就发生在这一步:内核参数root=指定的设备找不到,或者根文件系统里的init程序缺失。

这里插一句嵌入式方向的实操经验。做嵌入式产品时,我最常用的调试手段是把内核命令行里的root=改成root=/dev/ram0,用一个预先打包好的最小 rootfs 启动,确认内核本身没问题,再回头排查真正的存储设备。这样能把"内核驱动问题"和"根文件系统内容问题"分开,省掉大量瞎猜时间。

4.3 常用挂载参数与实操命令

挂载参数直接影响性能和安全性,几个最常用的:

参数作用使用场景
ro/rw只读 / 读写挂载系统分区保护、数据分区日常
noatime不更新访问时间高频读场景,显著减少写放大
nodiratime不更新目录访问时间目录特别多的场景
sync/async同步 / 异步写入一般用 async,sync性能极差
remount不改挂载点重新挂载动态调整只读、参数
bind把已有目录挂到别处容器、chroot 环境
defaults通用默认组合/etc/fstab里的常客

几条我常用的命令,直接可以抄:

# 只读重新挂载根分区(应急保护) mount -o remount,ro / # 给数据盘加上 noatime,减少不必要的元数据写 mount -o remount,noatime /data # 把目录挂到别处,做隔离视图 mount --bind /srv/app/v1 /srv/app/current # 查看某个挂载点的实际生效参数 findmnt -T /data -o TARGET,SOURCE,FSTYPE,OPTIONS

注意:/etc/fstab里写错的挂载项会导致系统起不来,改完先用mount -a在当前系统里验证一遍,别直接重启赌运气。另外慎用umount -l(lazy umount),它只是把挂载点从命名空间里摘掉,实际的设备引用可能还挂着,后续想真正卸载会更麻烦。

5. 动手做实验:拦截 read/write,看 VFS 真的在工作

5.1 思路与安全边界

想把 VFS 从概念变成手感,最直接的办法是盯住file_operations这张表。这一节我准备做两件事:先用 ftrace 无侵入地观察vfs_read被调用的全过程,再写一个内核模块,把某个目标文件的read和write换成自己的函数,只做计数和日志。全程只在实验虚拟机里操作,只统计字节数,不修改数据内容、不隐藏任何东西。

注意:内核模块跑在内核态,一个空指针就能把机器搞崩。所有实验都在快照过的虚拟机里做,不要在生产机、不要在你的主力开发机上直接试。做完记得卸载模块。

ftrace 的方式几乎是零风险的,先来这个。目标是看清楚"用户态一次cp到底触发了多少次vfs_read"。

# 需要 root,且 debugfs 已挂载 mount | grep debugfs || mount -t debugfs none /sys/kernel/debug cd /sys/kernel/debug/tracing echo 0 > tracing_on echo nop > current_tracer echo > trace echo > set_ftrace_filter # 只跟踪 vfs_read 和 vfs_write 两个函数 echo 'vfs_read' >> set_ftrace_filter echo 'vfs_write' >> set_ftrace_filter echo function_graph > current_tracer echo 1 > tracing_on # 另开一个终端,做一次文件读取 dd if=/tmp/a.txt of=/dev/null bs=1 count=1 # 回到 tracing 终端 echo 0 > tracing_on head -60 trace

你会看到vfs_read下面挂着一串调用:rw_verify_area(权限与文件锁校验)、security_file_permission(LSM 安全钩子)、然后是generic_file_read_iter、filemap_read、copy_page_to_iter。这一串名字就是 VFS 那一层的真实工作内容,看一遍比读十页书都管用。同样的方法把vfs_write、vfs_statx、vfs_open都过一遍,整个 VFS 的骨架就立体了。

5.2 模块代码:给指定文件换一套 read/write

ftrace 只能看,不能改。接下来这个模块的思路是:在加载时打开一个指定的目标文件,取出它的 inode,保存原来的i_fop,然后用我们自己的一份file_operations副本替换掉它,把read和write两个指针换成自己的包装函数。包装函数只做计数和打印,然后原样调用被保存的原始函数——这叫"包裹式替换",行为上对上层完全透明。

/* vfs_hook_demo.c —— 仅用于内核学习实验,勿用于生产环境 */ #include <linux/module.h> #include <linux/kernel.h> #include <linux/init.h> #include <linux/fs.h> #include <linux/fdtable.h> #include <linux/namei.h> #include <linux/uaccess.h> #include <linux/slab.h> #define TARGET_PATH "/tmp/a.txt" static struct file *target_file; static const struct file_operations *orig_fop; static struct file_operations my_fop; static unsigned long read_hits; static unsigned long write_hits; static unsigned long long bytes_read; static unsigned long long bytes_written; static ssize_t (*orig_read)(struct file *, char __user *, size_t, loff_t *); static ssize_t (*orig_write)(struct file *, const char __user *, size_t, loff_t *); static ssize_t my_read(struct file *filp, char __user *buf, size_t len, loff_t *ppos) { ssize_t ret; ret = orig_read ? orig_read(filp, buf, len, ppos) : -EINVAL; if (ret > 0) { read_hits++; bytes_read += ret; } pr_info("vfs_hook: read hit=%lu bytes=%llu ret=%zd\n", read_hits, bytes_read, ret); return ret; } static ssize_t my_write(struct file *filp, const char __user *buf, size_t len, loff_t *ppos) { ssize_t ret; ret = orig_write ? orig_write(filp, buf, len, ppos) : -EINVAL; if (ret > 0) { write_hits++; bytes_written += ret; } pr_info("vfs_hook: write hit=%lu bytes=%llu ret=%zd\n", write_hits, bytes_written, ret); return ret; } static int __init vfs_hook_init(void) { target_file = filp_open(TARGET_PATH, O_RDWR, 0); if (IS_ERR(target_file)) { pr_err("vfs_hook: 打开 %s 失败: %ld\n", TARGET_PATH, PTR_ERR(target_file)); return PTR_ERR(target_file); } orig_fop = target_file->f_inode->i_fop; if (!orig_fop || !orig_fop->read || !orig_fop->write) { pr_err("vfs_hook: 目标文件不支持 read/write\n"); filp_close(target_file, NULL); return -EOPNOTSUPP; } orig_read = orig_fop->read; orig_write = orig_fop->write; /* 复制一份原始操作表,只替换两个入口 */ memcpy(&my_fop, orig_fop, sizeof(my_fop)); my_fop.read = my_read; my_fop.write = my_write; target_file->f_inode->i_fop = &my_fop; pr_info("vfs_hook: 已接管 %s 的 read/write\n", TARGET_PATH); return 0; } static void __exit vfs_hook_exit(void) { if (target_file) { target_file->f_inode->i_fop = orig_fop; filp_close(target_file, NULL); } pr_info("vfs_hook: 已还原,read=%lu write=%lu\n", read_hits, write_hits); } module_init(vfs_hook_init); module_exit(vfs_hook_exit); MODULE_LICENSE("GPL"); MODULE_DESCRIPTION("VFS file_operations demo");

配套的 Makefile 用内核自带的 kbuild 体系:

obj-m += vfs_hook_demo.o KERNELDIR ?= /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: $(MAKE) -C $(KERNELDIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) clean

5.3 编译、加载与验证

在实验机里按顺序来:

# 1. 装好内核头文件(Debian/Ubuntu 系) sudo apt install -y build-essential linux-headers-$(uname -r) # 2. 准备目标文件 echo "hello vfs" > /tmp/a.txt # 3. 编译 make # 4. 看一眼模块信息 modinfo ./vfs_hook_demo.ko # 5. 加载 sudo insmod ./vfs_hook_demo.ko # 6. 触发一次读,观察内核日志 cat /tmp/a.txt sudo dmesg | tail -5

预期能看到vfs_hook: read hit=1 bytes=10 ret=10这类输出,bytes数应该跟wc -c /tmp/a.txt的结果对得上。这里有个细节值得注意:cat一次读取的字节数不一定是文件大小,具体取决于cat内部缓冲区大小和文件系统返回的行为,可能出现多次read。看到 hit 计数不是 1 不要慌,先strace -e read cat /tmp/a.txt看看用户态实际发了几次调用,两边对上就说明你的模块工作正常。

5.4 卸载与必须知道的坑

验证完必须还原,否则你后续所有针对这个文件的操作都在被你的模块包装,一旦模块出问题就麻烦了:

sudo rmmod vfs_hook_demo sudo dmesg | tail -3 # 确认打印了"已还原" cat /tmp/a.txt # 再读一次,应该不再有新日志

这里有几个我在实验中真实踩过的坑,值得单独说清楚。第一,不是所有文件都能这么搞。上面代码要求orig_fop->read和->write都存在,而现代内核里很多文件系统走的是read_iter/write_iter路径(比如 ext4 就是),read指针可能是NULL或者是老式兼容入口。你在 ext4 上做实验的话,很可能触发"目标文件不支持 read/write"这个分支,此时正确的做法是换成包裹read_iter/write_iter,参数签名是struct kiocb *那一套,思路完全一样。

第二,有些内核版本会把i_fop放进只读区域。如果你 insmod 之后直接 panic 或者报写保护错误,说明这个内核在启动后把相关内存标记成了只读。这种情况我不建议去改 CPU 的写保护位硬写,太容易出事;换个思路,用 kprobe/kretprobe 挂到vfs_read上做观测,或者干脆用 bpftrace 这类工具,效果更好也更安全:

# 用 bpftrace 统计每个进程调用 vfs_read 的次数 sudo bpftrace -e 'kprobe:vfs_read { @[comm] = count(); }'

第三,并发问题。上面这个例子里我没做任何加锁,read_hits++在多线程并发读同一个文件时是有竞争风险的。做演示够了,但如果你要拿类似的代码去做正事,计数得换成原子操作(atomic_long_inc)。这个坑很典型:内核代码里所有看起来"就加一"的操作,都得先问一句"会不会有并发"。

第四,模块卸载前必须确保没有其他进程还在用旧的f_op。实际上f_op是打开时从 inode 拷贝到file结构里的,已经在跑的进程不受你替换 inode 的影响,这一点常常被人误解。也就是说你的拦截只对"替换之后新打开的文件"生效。想全量拦截,得在open环节动手,那就是另一个层次的复杂度了。

6. 常见问题与排查实录

6.1 高频问题速查表

下面这张表是我这些年被问得最多、也是面试里出现频率最高的一批问题,全部能在 VFS 这层找到解释。

现象根本原因排查命令处理方式
磁盘满了,du加起来对不上已删除但仍被进程持有的文件占用空间lsof +L1重启或杀掉持有进程
df -h有空间,创建文件却报 No spaceinode 耗尽df -i清理小文件;重建时调大 inode 数
ls -l /proc/xxx显示大小为 0procfs 文件是动态生成的,没有真实数据块stat /proc/cpuinfo属正常现象,不是故障
硬链接创建失败 EXDEVinode 号只在单个文件系统内唯一stat -c %d,%i file改用符号链接或放到同一分区
卸载报 device is busy有进程的工作目录或打开文件在该挂载点下lsof +D /mnt、fuser -m /mnt退出相关进程后再卸载
挂载后看不到原目录内容原目录被挂载点覆盖findmnt -T /mnt属正常行为,卸载后恢复
第一次访问慢,之后快dcache 和 page cache 命中cat /proc/slabinfo | grep dentry属正常缓存行为
write成功但断电丢数据数据只在 page cache,尚未落盘—关键数据用fsync
软链循环访问报 ELOOP符号链接嵌套超过上限namei -l /path修复链接指向
内存被 dentry/inode 吃掉内核缓存,属可回收内存slabtop、/proc/meminfo内存紧张时内核自动回收

6.2 一组够用的观测命令

排查 VFS 相关问题,我常用的工具其实就那么几个,整理成一组可以直接抄的:

# 看挂载拓扑,比 mount 输出好读 findmnt # 看某个进程打开了哪些文件 ls -l /proc/<pid>/fd lsof -p <pid> # 看某个挂载点有没有被占用 fuser -mv /mnt/data # 看内核 slab 缓存,重点盯 dentry 和 inode_cache slabtop -o | head -20 # 实时看文件系统事件(需要装 inotify-tools) inotifywait -m -r /etc # 跟踪某个进程的所有文件相关系统调用 strace -f -e trace=file -p <pid>

strace -e trace=file这个组合我特别推荐。它只过滤出文件相关的系统调用(openat、stat、access、readlink等),输出干净得多。排查"程序启动慢""配置读不到""权限被拒"这类问题,往往几条输出就能定位。不过要提醒一句,strace会给目标进程带来明显性能开销,在延迟敏感的生产服务上慎用,或者只在短时间窗口内采样。

6.3 我自己踩过的几个坑

说几个纯粹来自实操、文档上不会写的经验。

第一个是关于drop_caches的。早年我做压测,为了让"每次测试都从冷缓存开始",在每轮测试前都echo 3 > drop_caches。结果数据波动特别大,同一份代码两次跑出来的结果能差 30%。后来才想明白:drop_caches回收缓存需要时间,而且是异步的,你回完立刻发压,系统还在忙着回收,CPU 全耗在 slab 收缩上,测出来的自然是噪声。正确做法是回完缓存之后sleep一段时间,或者监控/proc/vmstat里的相关计数稳定下来再开始。这个教训价值不低。

第二个是关于sync的误解。很多人觉得"我调了sync,数据一定安全了"。实际上sync返回只是说明"提交的写请求已经发给设备了",如果设备的写缓存没开 FUA/刷写语义,数据可能还在设备自己的易失缓存里。所以真正的持久化保证,得看具体存储设备的配置。我见过一次断电后文件系统损坏的案例,日志里那些本该落盘的事务确实发出去了,但都在盘上的写缓存里没刷下来。后来统一关掉了关键存储设备的写缓存,问题再没复现。这件事让我对"sync 等于安全"这个说法彻底改观。

第三个是关于路径解析的开销。有一次排查一个服务响应慢的问题,火焰图上link_path_walk占了不小的比例。原因很朴素:这个服务每次处理请求都要打开十几个配置文件,路径都很深,还都是软链。解决办法是把配置在启动时一次性读进内存,不再反复走路径解析。改完之后那部分开销直接从火焰图上消失了。这件事提醒我,VFS 的路径解析不是"无成本的基础设施",高频调用路径下它是实打实的热点。

第四个是关于/tmp的。默认情况下/tmp在很多发行版里是 tmpfs,也就是纯内存文件系统。有次一个脚本往/tmp里写了个几十 G 的临时文件,直接把内存吃满,触发 OOM Killer 把业务进程干掉了。排查的时候看df -h /tmp显示空间被占满,free -h内存也快见底,一开始还以为是两回事,后来对着findmnt -T /tmp一看就明白了。现在我做部署时都会确认一遍/tmp到底是磁盘还是内存,写大临时文件的脚本一律显式指定到一个磁盘目录。

再多说一句关于 inode 的。新接手一个系统时,我有个固定动作:df -i先看一眼 inode 使用率。原因很简单,磁盘空间监控一般都会做,但 inode 使用率很少有人监控,而小文件密集的系统(缓存目录、会话文件、邮件队列)恰恰最容易把 inode 耗尽,表现还特别有迷惑性——df -h显示空间充足,程序却报"磁盘空间不足"。这种问题一旦爆发,排查方向如果一开始就跑偏到磁盘空间上,能白白烧掉半天时间。把这个检查加进日常巡检,成本几乎为零,收益却很高。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 1:12:59

汤小丹《计算机操作系统》课后题全解析:从进程调度到页面置换

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 1:12:59

PLC现场调试实战:从IO点表到PID调节的14条生存法则

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 1:09:49

计算机网络笔试题高效训练指南:从概念到实战的完整路径

简介&#xff1a;这份计算机网络笔试题文档面向准备计算机基础课程考试、校招笔试或考研复试的在校学生与求职者&#xff0c;聚焦网络原理核心知识点的自测与查漏补缺。内容以填空与单项选择两大题型为主&#xff0c;覆盖OSI七层参考模型、局域网与广域网划分、总线型/环形/星形…

作者头像 李华
网站建设 2026/9/30 1:09:49

Linux USB摄像头驱动开发:V4L双URB与双帧缓冲实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华