Linux VFS 四大对象与 open/read 流程详解及内核模块拦截实验
发布时间:2026/9/30 1:13:06来源:尧图网络
上周帮一个做嵌入式设备的同事看问题他写了个字符设备驱动用cat读/dev/xxx一切正常可换到他自己挂的那个只读分区上读文件read()返回的错误码却对不上号。他拿着打印出来的-EIO一脸茫然明明是同一套read逻辑为什么换了设备就翻车问题最后落在 VFS 这一层——他压根不知道自己那几行内核代码是被 VFS 的通用框架先接住、再分发给具体文件系统的。这件事之后我让他把fs/open.c、fs/read_write.c、fs/namei.c三个文件老老实实读了一遍很多玄学 bug瞬间就有了答案。VFSVirtual 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_opinode_operations和i_fopfile_operations。初学者最容易卡住的地方是inode 里没有文件名。文件名存在目录项dentry里目录也是文件它的内容就是一张名字 → inode 号的映射表。这就解释了一个经典现象为什么在同一分区里给文件创建硬链接多个名字可以指向同一个 inodels -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_MAX4096 字节之内。所以那些文件名超长导致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怎么落到具体文件系统路径解析完成拿到了目标 inodedo_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(bx * 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同步 / 异步写入一般用 asyncsync性能极差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 -llazy 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 bs1 count1 # 回到 tracing 终端 echo 0 tracing_on head -60 trace你会看到vfs_read下面挂着一串调用rw_verify_area权限与文件锁校验、security_file_permissionLSM 安全钩子、然后是generic_file_read_iter、filemap_read、copy_page_to_iter。这一串名字就是 VFS 那一层的真实工作内容看一遍比读十页书都管用。同样的方法把vfs_write、vfs_statx、vfs_open都过一遍整个 VFS 的骨架就立体了。5.2 模块代码给指定文件换一套 read/writeftrace 只能看不能改。接下来这个模块的思路是在加载时打开一个指定的目标文件取出它的 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) clean5.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 hit1 bytes10 ret10这类输出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 tracefile -p pidstrace -e tracefile这个组合我特别推荐。它只过滤出文件相关的系统调用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显示空间充足程序却报磁盘空间不足。这种问题一旦爆发排查方向如果一开始就跑偏到磁盘空间上能白白烧掉半天时间。把这个检查加进日常巡检成本几乎为零收益却很高。
网站建设高端定制企业官网