
1. 从一个挂载一个结构到两个结构并存vfsmount与mount的历史分工如果你第一次翻开Linux内核的VFS源码大概会有一种是不是看错了的感觉struct mount和struct vfsmount都有而且前者里面还嵌着一个后者。不少人刚开始理解挂载树时都默认这两个结构是同一个东西或者以为vfsmount就是挂载节点。这个误解其实情有可原毕竟在老内核里vfsmount确实是挂载树上的节点只是后来内核改了设计把真正的树节点换成了struct mount又为了兼容大量接口保留了vfsmount作为这个新结构的第一块内容。读VFS代码第一关就是把这两个结构的关系捋清楚。1.1 老内核里的vfsmount既是树节点也是对外接口在2.6时代以前我们的挂载树确实是一棵由vfsmount组成树。当时一个文件系统实例被挂到某个目录上内核就分配一个struct vfsmount里面记录这个文件系统的根dentry、超级块对象、挂载标志然后用mnt_mountpoint和mnt_parent连接成树。路径查找的时候顺着dentry走到挂载点就跳到对应的vfsmount的mnt_root上继续往下走。所以那时候你提到挂载点脑子里想的就是这个vfsmount节点没什么分歧。后来内核引入了一系列重构最典型的是挂载命名空间的实现被反复修改自动挂载、传播事件、绑定挂载这些特性越堆越多老vfsmount既要充当树节点、又要承载传播逻辑、还要背负引用计数越来越臃肿。如果要往里面加一个hlist_node用于哈希链表再加一堆list_head用于父子挂载链表你会发现每个字段都是真实需求但放在一起非常不优雅。而更关键的问题是很多内核子系统只需要关心这个挂载的根在哪里、属于哪个超级块、挂载标志是什么并不想关心挂载树内部的传播关系。内核开发者们于是决定做一个拆分对外保留一个轻量视图struct vfsmount内部再包一层完整的树节点struct mount。1.2 引入struct mount之后为什么内核不干脆删掉vfsmount到了现在的内核真实的挂载树节点长这样以6.x内核为例我隐去了一些CONFIG相关的累积字段struct mount { struct hlist_node mnt_hash; // 挂在 mount_hashtable 上的哈希节点 struct mount *mnt_parent; // 挂载点的父挂载 struct dentry *mnt_mountpoint; // 父挂载里的挂载点目录 struct vfsmount mnt; // 嵌入的公共视图 // 后面还有 list 节点、命名空间指针、RCU 字段、引用计数等 };注意那个struct vfsmount mnt它被放在了结构体的开头。这不是随便放的而是故意安排因为很多老接口的参数还是struct vfsmount *调用方拿到一个struct mount *时直接mnt-mnt就能传出去反过来如果一个函数只拿到struct vfsmount *它可以用container_of(vfsmnt, struct mount, mnt)找回完整的struct mount。也就是说vfsmount变成了mount结构里的头部信息相当于一个公共子类。内核之所以没有彻底删掉vfsmount一方面是因为历史包袱太重调用链上到处是struct vfsmount *型参数全部改一遍影响面巨大另一方面隔离也确实有好处像follow_up、lookup_mnt这类只关心挂载根和挂载点的路径操作看到vfsmount就够用了不需要把一大包传播链表字段全暴露出来。所以你在看内核代码时凡是看到函数签名是struct vfsmount *mnt的心里要清楚它很可能只是拿到了真正的struct mount的一个视图指针。要访问完整的挂载树字段你要么在外层函数里直接用struct mount *要么自己做container_of。比如在内核源码里经常见到这种模式static inline struct mount *real_mount(struct vfsmount *mnt) { return container_of(mnt, struct mount, mnt); }real_mount()这个函数就是这两者关系的缩影。记住这一点再读fs/namespace.c和fs/dcache.c里的路径查找代码基本就不会迷路。2. 解剖struct mount与struct vfsmount字段不是摆设全是挂载树的关键指针很多刚接触VFS的人一看到struct mount几十行字段就头大为什么一个挂载点要记录这么多东西其实每个字段都对应一个真实问题你要知道这棵树怎么挂上去的怎么找到父挂载怎么找兄弟怎么在哈希表里查怎么处理引用计数。我按功能拆开讲这样记起来会容易得多。2.1 vfsmount里的四个核心字段根目录、超级块、标志、用户命名空间struct vfsmount虽然是轻量视图但它不是随便放几个装饰性字段。先看这段定义struct vfsmount { struct dentry *mnt_root; /* 这个挂载的文件系统根目录 */ struct super_block *mnt_sb; /* 这个挂载对应的超级块 */ int mnt_flags; /* 挂载标志如只读、nosuid等 */ struct user_namespace *mnt_userns; /* 挂载所有者相关的用户命名空间 */ };mnt_root指向文件系统的根dentry这是路径查找时从挂载点跳转后的入口。mnt_sb指向超级块一个超级块可以被多个挂载引用比如同一块盘挂载两次所以它不是一一对应的。mnt_flags则是那一串MS_RDONLY、MS_NOSUID之类的标志位。mnt_userns在做文件系统权限检查时很关键容器场景下尤其重要同一个文件系统被不同用户命名空间挂载vfsmount里的这个字段决定了写文件时按哪个命名空间来映射uid/gid。很多人在用/proc/self/mountinfo时看到的root、mount point、挂载选项其实直接来自vfsmount和对应的mount结构。mnt_root在show_mountinfo里会被格式化成字符串就是那串挂载的文件系统内部路径。2.2 struct mount里的树状字段parent、mountpoint、child、list都是怎么用的真正把整棵挂载树串起来的是struct mount里的这些链表指针mnt_parent指向父挂载。什么叫父挂载比如你把USB盘挂载到/media/usb而/media本身可能在一个单独的挂载里那么USB挂载的mnt_parent就是/media所属的那个struct mount。mnt_mountpoint指向父挂载里的一个dentry也就是具体的挂载点目录。注意它指向的是父文件系统里的目录项不是子文件系统的目录项。mnt_child挂在父挂载的mnt_mounts链表上的节点用来枚举某个挂载下面所有子挂载。mnt_list挂在当前挂载命名空间里所有挂载的链表上的节点。mnt_hash用来快速查找一个挂载尤其是lookup_mnt需要根据(挂载点dentry, 父挂载)哈希定位。mnt_mp指向struct mountpoint这是VFS维护的挂载点对象负责把挂载点和dentry关联起来一个dentry可以有多个挂载点吗实际上一个dentry可以被多次挂载所以mountpoint可以对应多个mount。理解这些字段最好的练习就是自己画一棵挂载树。假设你有一个根挂载/然后把临时文件系统挂到/tmp又把一个bind mount挂到/tmp/abc。那么/是根挂载mnt_parent指向自己根挂载的父挂载是自己这是特殊规则。/tmp挂载的mnt_parent指向/挂载mnt_mountpoint是根文件系统里路径为/tmp的dentry。/tmp/abc挂载的mnt_parent指向/tmp挂载mnt_mountpoint是/tmp文件系统里路径为/abc的dentry。当我们说遍历某个挂载下面的所有子挂载时其实就是遍历父挂载的mnt_mounts链表每个节点就是子挂载的mnt_child。按这个逻辑挂载树可以无限深比如你在一个已经挂载的文件系统里再挂一个文件系统mnt_parent链就会多一层。路径解析的时候如果看到某个dentry正好是一个挂载点就需要通过__lookup_mnt找到对应的mount再跳到那个mount-mnt.mnt_root上继续找路径。这个过程在下节详细说。2.3 引用计数与ID字段mnt_count、mnt_id、mnt_group_idstruct mount里还有几个元数据字段它们不像树字段那么直观但在调试和用户态观测时经常出现mnt_count挂载的引用计数保护的是这个struct mount不会被释放。它分为普通计数和percpu计数mnt_pcpmnt_count等于所有percpu计数之和。每次路径解析时拿引用、打开文件时钉住挂载、bind mount时增加引用都会影响它。mnt_id全局唯一的挂载ID就是/proc/self/mountinfo里第一个数字。这个ID在挂载生命周期内不变。mnt_group_id用于绑定挂载和传播事件的组ID。同一个组里的挂载共享传播事件比如mount --bind生成新挂载时会继承父挂载的组ID。mnt_ns指向所属的挂载命名空间如果一个挂载被多个命名空间共享比如在容器里看到和宿主机相同的挂载这个指针会变化。这些字段也解释了为什么struct mount不能只是个瘦结构。路径查找、文件生命周期管理、容器隔离、挂载传播全都压在它身上。搞清楚了这些你再看用户态工具输出就能大概猜出内核里发生了什么事。3. 路径解析遇挂载点从dentry跳转到挂载根前面把结构拆完了接下来就是重头戏当用户程序执行open(/mnt/disk/etc/passwd, ...)这类系统调用时内核是如何从路径第一级开始一路经过多个挂载点最终落到目标文件上的。路径解析是VFS最核心也最绕的流程而挂载相关的代码主要就干一件事判断当前路径组件是不是一个挂载点是的话就切换到对应的挂载文件系统根然后继续往下走。3.1 路径查找中的follow_managed与__lookup_mnt现代内核用path_lookupat或path_openat这类函数解析路径它们会一套struct nameidata记录当前路径位置。当走到某个分量的dentry后进程会调用follow_managed来判断这个dentry是否需要跳转。follow_managed这个名字有点抽象其实它处理所有managed的dentry包括自动挂载autofs、符号链接跳转、以及普通的挂载点。判断是否普通挂载点核心就是检查这个dentry是否在某个mountpoint对象上并且当前命名空间里是否挂载了文件系统。如果检查命中路径解析会调用__lookup_mnt去mount_hashtable里找具体的struct mount。哈希表的key是挂载点父母挂载的mnt_id或者指针、挂载点dentry的地址这样在大量挂载中也能快速命中。我简化一下__lookup_mnt的逻辑struct mount *__lookup_mnt(struct vfsmount *mnt, struct dentry *dentry) { struct hlist_head *head m_hash(mnt-mnt_sb, dentry); struct mount *p; hlist_for_each_entry_rcu(p, head, mnt_hash) { if (p-mnt_parent-mnt mnt p-mnt_mountpoint dentry) return p; } return NULL; }你看到它比较的是mnt_parent和mnt_mountpoint这就是为什么这两个字段是理解挂载树的关键。一旦找到对应的struct mount路径解析就切换到mount-mnt.mnt_root继续解析剩余路径分量。3.2 顺着这个逻辑手动走一遍/mnt/disk/etc/passwd假设现在的挂载情况是根挂载/然后有一个磁盘挂载到/mnt/disk。你执行open(/mnt/disk/etc/passwd)路径解析从根dentry开始逐项解析解析到mnt分量得到根文件系统下的/mntdentry。继续解析disk分量得到/mnt/diskdentry。检查这个dentry是不是挂载点发现是于是调用__lookup_mnt在当前命名空间中找到对应的struct mount它的mnt_mountpoint正是那个/mnt/diskdentrymnt_parent就是根挂载。路径查找把当前目录切换到该struct mount的mnt_root磁盘文件系统的根dentry。继续解析etc和passwd现在是在磁盘文件系统中查找了。这个过程中struct vfsmount和struct mount都有参与path-mnt在路径查找中其实是一个struct vfsmount *它指向mount-mnt。所以你在调试代码里经常会看到dentry和vfsmount成对出现比如struct path { struct vfsmount *mnt; struct dentry *dentry; }。这是VFS对外的路径锚点表示方法内部真正需要完整挂载树逻辑时再用real_mount(path-mnt)。3.3 挂载点的切换不是单向的upward traversal与绑定挂载路径查找不光会向下穿越挂载还会向上回溯。比如解析..时如果当前dentry已经是挂载根而且是普通挂载内核需要回到父挂载的挂载点继续走。follow_up函数干的就是这个从当前struct mount找到mnt_parent和mnt_mountpoint然后把路径切换回去。这里有个细节根挂载的mnt_parent指向自己所以follow_up必须判断这种情况否则会死循环。绑定挂载bind mount就更有意思了。假设你做了一个mount --bind /mnt/disk /backup那么/backup挂载的mnt_root其实还是磁盘文件系统的根mnt_mountpoint则是根文件系统上的/backupdentry。访问/backup/etc/passwd时内核先走到/backup这个挂载点跳转到mount-mnt_root然后继续访问。这里mnt_parent仍然指向根挂载但mnt_root并没有变化。所以绑定挂载和普通挂载在结构上的差异主要是mnt_mountpoint的位置不同真正的内容查看还是依赖mnt_root。这一段逻辑搞明白后你再看文件系统自动挂载、FUSE挂载以及容器挂载命名空间里的诡异问题都会顺手很多。路径解析的本质就是沿着dentry走遇到挂载点就换一个文件系统根继续走struct mount就是提供这个转换的企业记录。4. 从/proc/mountinfo到内核结构排查挂载树异常的实际做法光会看源码还不够真出问题的时候你得能在运行中的内核里把挂载树翻出来和用户态看到的对应上。线上环境最常见的挂载问题无非这几种挂载点卸载不掉、挂载树越挂越乱、容器的挂载传播没按预期走、以及mount namespace泄漏。下面这些方法我都是直接在物理机或容器宿主机上验证过的。4.1 先学会看懂/proc/self/mountinfo里每一个字段在调试之前先把观察数据读懂。/proc/self/mountinfo的每一行长这样36 35 0:0 / / 0 0 - tmpfs tmpfs rw,relatime从左到右mount ID、parent ID、major:minor、root、mount point、挂载选项、可选字段、分隔符、文件系统类型、挂载源、超级块选项。mount ID就是struct mount里的mnt_id。parent ID就是mnt_parent-mnt_id。如果parent ID一直向上最终会顶到根挂载。major:minor对应mnt_sb-s_dev。root是mnt_root在它自己文件系统内的路径通常就是/。mount point是mnt_mountpoint在父文件系统里的路径。所以你现在应该有这个能力打开/proc/self/mountinfo随便找一行读它的parent ID然后顺着ID向上找就能重建出内核里的挂载树链。我在排查挂载泄漏时经常写一个小的Python脚本按parent ID构建出一棵ASCII树一眼就能看出有没有异常深度、循环或孤立节点。4.2 用crash或gdb直接查看struct mount对象当用户态观测不够时就需要上内核调试工具。我比较常用crash因为它有专门针对VFS的辅助命令。比如crash mount可以列出当前挂载树里所有挂载对象输出里会包含struct mount的地址、struct vfsmount的地址、挂载点、父挂载等。配合struct mount的地址再dump具体字段crash struct mount ffff888012345678这样能看到mnt_parent、mnt_mountpoint、mnt_count、mnt_hash这些字段的实际值。如果你在用gdb也可以用手动方式(gdb) p *(struct mount *)0xffff888012345678 p ((struct mount *)0xffff888012345678)-mnt_parent注意调试时的地址变化。在生产环境用crash的mount命令能看到所有命名空间里的挂载吗mount命令默认看的是全局挂载树但它也会处理根命名空间。如果是容器的问题需要先foreach mount或者按名字找到对应进程的fs_struct再顺着mnt_ns去遍历。4.3 实战案例挂载点卸载不掉为什么有一次线上出现umount一直返回device is busy但明明没有进程在这个目录下读写。我先用fuser -m /data看Holding进程全是空的说明不是普通文件占用。此时有两个可能一是某个进程的cwd、root或exe指向了这个挂载点即使没有打开文件也会把挂载钉住二是有其他挂着子挂载umount没有加-l时子挂载会让父挂载卸载失败。我最终是用crash查的。先找到那个挂载对应的struct mount然后看它的mnt_parent-mnt_mounts链表里有没有子挂载。结果真的发现一个被隐藏的bind mount挂在下面。如果用umount -l强制分离表面上能卸载但mountinfo里会变成一个/开头的孤立挂载内存里的mnt_root和超级块依然被引用进程退出时才会释放。想要彻底清理还得找出是谁持有这个挂载的引用计数。此时struct mount里的mnt_count是重点指标。它是个percpu计数不能只看单CPU值可以用mnt_get_count辅助函数获取总和。如果mnt_count一直不为0说明有文件或路径对象引用着这个挂载自然无法释放。这类问题用结构体理解起来特别清晰struct mount的生命周期由mnt_count保护而路径对象struct path持有vfsmount指针时会通过mntget增加引用。你只要找到系统中所有还指向这个挂载的路径对象问题就定位了。常见来源有/proc/self/fd里的打开文件、某个进程的cwd、root、甚至一个“看似无害”的name_to_handle_at句柄。4.4 容器共享挂载和namespace泄漏看mnt_ns链容器场景下挂载命名空间是一个核心隔离维度。每个struct mount都带了一个mnt_ns指针。同一块磁盘的挂载可能被多个命名空间共享也可能每个命名空间有自己的挂载实例。如果容器内做了一次bind mount而这个挂载不会自动传播到宿主机你会在容器里看到多一条mountinfo记录但宿主机全局挂载树里看不到对应的struct mount。只有通过crash foreach mount遍历所有命名空间才能找到那条记录。排查namespace泄漏更得靠结构体链。当一个命名空间的所有引用都释放后它下面的struct mount也应该被释放。如果你发现/proc/slabinfo里mnt_cache数量不断增长那大概率是某个进程要么打开着文件要么待在一个挂载路径下没退出导致挂载引用计数始终不为0。此时用crash写一个脚本遍历所有task_struct的fs_struct-root和pwd把指向目标struct mount的进程列出来问题就很直观。这种排查方式比盲目重启容器靠谱得多。5. 那些年容易看走眼的细节vfsmount指针的隐藏语义与典型坑最后这部分我专门收集几个我见过、也经常被同事问起的细节。它们不算多深奥但确实会让初次阅读VFS源码的人卡住很久。5.1 不要用vfsmount去遍历挂载树正如前面所说struct vfsmount只是一个公共视图。你拿到的struct vfsmount *往往来自某个struct path的mnt成员如果你试图直接mnt-mnt_parent或者mnt-mnt_child编译器一定会报错因为这些成员根本不在vfsmount里。正确做法是先real_mount(mnt)拿到struct mount *再访问树字段。我看到很多初学者写内核模块时为了遍历子挂载会先自己去kallsyms里找符号其实只要把vfsmount转回mount一切就顺了。注意real_mount()不是所有内核版本都导出给模块的如果你在模块里调用它需要注意版本和符号导出问题。调试hack时可以用container_of代替思路完全相同。5.2 mnt_count的计数器可能是负数先别慌mnt_count是一个原子计数但它的实际实现使用了percpu计数器。你在crash里看到某个CPU上的计数是负数不一定代表有bug可能是percpu计数的分摊方式导致的。想要取准确值用内核辅助函数static inline int mnt_get_count(struct mount *mnt) { return totalrefcount(mnt-mnt_pcp); }在调试时直接看mnt_count字段可能会得到小于0的数值那不是系统崩了是你没算总数。以后写脚本时不要被这个假象骗了。5.3 mnt_mountpoint是别人家的dentry不是自己的根这是最容易搞混的一个点。mnt_mountpoint指向的是父文件系统里的那个目录dentry也就是挂载点的位置。而mnt.mnt_root指向的是子文件系统里的根dentry。两者之间没有直接的父子关系它们分属不同的super_block。我在一次现场排查中看到有人想通过path-dentry和path-mnt判断这个dentry是不是挂载点结果他比较的是dentry mnt-mnt_root这当然不对。正确方法是比较dentry mountpoint_dentry(mnt)或者用is_mounted(dentry)这类辅助函数。如果已经深入到路径查找中间可以直接看managed()和d_mountpoint()。5.4 挂载传播与struct mount为什么一个挂载在另一个namespace里是同一个容器里经常做的bind mount传播最终实现也发生在struct mount层面。copy_mnt_ns会为新的挂载命名空间创建新的struct mount实例但如果传播类型是共享挂载它会通过mnt_group_id维护一批共享挂载之间的关系。一个挂载事件发生时内核在mount_setattr里找到所有共享组里的挂载给每个挂载创建一个新副本或者把事件传播出去。这里最微妙的是mnt_ns和mnt_group_id的区别mnt_ns告诉你这个挂载属于哪个命名空间mnt_group_id告诉你一群挂载之间要怎样传播事件。同一组挂载可能分散在不同命名空间它们通过链表串起来但各自有独立的struct mount实例和独立的引用计数。理解这一点再看mount --make-shared的行为就是直接在修改struct mount的mnt_group_id和传播链表关系。5.5 vfsmount在文件系统驱动里出现的场景写文件系统驱动时你可能经常在回调函数里看到struct vfsmount *参数比如iterate_mounts遍历、mnt_user_ns()等。驱动里最常用的其实是mnt-mnt_sb和mnt-mnt_userns千万不要尝试通过container_of把它变成struct mount然后去改树结构那是namespace层的事。你只应该把它当作一个挂载上下文来用比如检查只读标志、取得用户命名空间、获取超级块。这也是为什么VFS要把vfsmount放前面底层文件系统只需要这一小块信息不需要知道挂载树怎么组织。最后再分享一个我自己的调试习惯如果你平时读内核代码建议在fs/namespace.c里多搜一下real_mount、mntget、mntput和__lookup_mnt这四个函数把这四个函数的关系理清等于把挂载结构的核心逻辑过了一遍。我自己的做法是在源码里给struct mount和struct vfsmount各画一个注释图一个标出哪些字段是树结构哪些是公共视图哪些是命名空间相关。之后再遇到什么mountinfo异常、umount busy、容器挂载隔离的问题就先对照这个注释图再配合crash确认实际对象内容问题定位速度会快很多。这个注释图我已经用了好几年每次更新内核版本时只需要小修一下算是这套结构知识最有价值的落地产物。