Linux 虚拟文件系统VFS

本文基于 ~/6.18.33.2-microsoft-standard-WSL2 内核源码, 在WSL ubuntu24.04 上试验
涉及的核心文件:include/linux/fs.h、include/linux/dcache.h、include/linux/path.h、include/linux/mount.h、 fs/mount.h、include/linux/nsproxy.h、include/linux/fs_struct.h、include/linux/fdtable.h、 include/linux/sched.h、include/linux/buffer_head.h、fs/namei.c、fs/namespace.c、fs/dcache.c、fs/inode.c、 fs/read_write.c、mm/filemap.c、fs/ext4/ext4.h、fs/ext4/inode.c、fs/ext4/super.c、fs/ext4/readpage.c。

全景图:VFS 四大对象与两座"桥"

Linux 的虚拟文件系统(VFS)用四个核心结构体把"一个文件"拆成四个不同维度:

结构体 定义位置 它回答的问题
struct super_block include/linux/fs.h:1446 这个文件系统实例是谁?块大小、挂载在哪个设备?
struct inode include/linux/fs.h:793 这个文件本身是谁?大小、权限、数据块在哪?
struct dentry include/linux/dcache.h:92 这个路径名是什么?/a/b/c 的层级与缓存?
struct file include/linux/fs.h:1211 这个打开会话是什么?当前读写偏移、操作函数表?

它们之间的关系可以浓缩成一句话:

1
2
3
4
5
6
file ──(f_path.dentry)──▶ dentry ──(d_inode)──▶ inode ──(i_sb)──▶ super_block
   └──(f_inode)───────────────┘                   │(i_mapping)      │(s_bdev)
                                                  ▼                 ▼
                                           address_space        block_device
                                                  │                 │
                                          页缓存(folio)  ◀──▶  磁盘 Block

而这一切要能"动起来",还需要另外两个维度把它们挂到进程上:

  • 进程 → 文件:task_struct->files(files_struct 文件描述符表)→ struct file;
  • 进程 → 命名空间:task_struct->nsproxy(nsproxy)→ mnt_namespace(挂载命名空间)→ mount 树 → super_block;
  • 进程 → 当前路径:task_struct->fs(fs_struct 的 root/pwd)决定相对路径、根目录从哪开始解析。

这两座"桥"——磁盘桥(inode ↔ block)和进程桥(进程 ↔ namespace)。

内核注释里对这四个对象的经典描述(include/linux/fs.h 附近): VFS 把"文件名→文件内容"的解析拆成 dentry(名字)与 inode(内容/元数据)两层, 这样 open("/a/b/c") 时绝大部分路径组件只需要在内存 dcache 中命中,而不必访问磁盘。


struct super_block:文件系统的户口本

结构体逐段精读

include/linux/fs.h:1446 定义(节选关键字段):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
struct super_block {
	struct list_head	s_list;		/* 所有 sb 挂在全局链表上 */
	dev_t			s_dev;		/* 设备号,查找索引 */
	unsigned char		s_blocksize_bits;
	unsigned long		s_blocksize;	/* 块大小,如 4096 */
	loff_t			s_maxbytes;	/* 单文件最大尺寸 */
	struct file_system_type	*s_type;	/* 指向 fs 类型(ext4/xfs/...) */
	const struct super_operations	*s_op;	/* 生命周期操作表 */
	...
	struct dentry		*s_root;	/* 本文件系统根目录 dentry */
	...
	struct mount		*s_mounts;	/* 挂载在此 sb 上的 mount 链表 */
	struct block_device	*s_bdev;	/* 底层块设备 */
	struct file		*s_bdev_file;
	struct backing_dev_info *s_bdi;		/* 回写设备信息 */
	...
	void			*s_fs_info;	/* 文件系统私有信息(ext4 用) */
	...
	char			s_id[32];	/* 如 "sda1" */
	uuid_t			s_uuid;
	...
	struct user_namespace *s_user_ns;	/* 解释 uid/gid 的用户命名空间 */
	...
	struct list_lru		s_dentry_lru;	/* 本 sb 的 dentry LRU */
	struct list_lru		s_inode_lru;	/* 本 sb 的 inode LRU */
	...
	spinlock_t		s_inode_list_lock;
	struct list_head	s_inodes;	/* 本 sb 所有 inode 链表 */
	spinlock_t		s_inode_wblist_lock;
	struct list_head	s_inodes_wb;	/* 需要回写的 inode */
} __randomize_layout;

要点:

  1. 一个"种类"的文件系统 ≠ 一个 super_block。ext4 是一个 file_system_type,但每挂载一个 ext4 分区就有一个 super_block 实例。同类型的所有 sb 通过 file_system_type->fs_supers 哈希链表串起来(include/linux/fs.h:2704)。
  2. s_blocksize 是磁盘 I/O 的最小单位,比如 4096 字节。它与 s_blocksize_bits(12)配合:1 << s_blocksize_bits == s_blocksize。
  3. s_root 指向根目录 dentry,它又指向根 inode。ext4 挂载时用 ext4_iget(sb, EXT4_ROOT_INO, ...) 读入 inode 2(根目录)作为 s_root(见 fs/ext4/super.c:5518)。
  4. s_fs_info 是"私有数据"指针。ext4 挂载时把它指向 struct ext4_sb_info,内核宏 EXT4_SB(sb) 就是 container_of 反推:
1
2
3
4
5
/* fs/ext4/ext4.h */
static inline struct ext4_sb_info *EXT4_SB(struct super_block *sb)
{
	return sb->s_fs_info;
}

这是内核"面向对象"手法的核心:公共部分(super_block)在前,私有部分(ext4_sb_info)通过指针/内嵌关联。struct ext4_inode_info 也用了同样的手法,把 struct inode vfs_inode 内嵌为最后一个字段(fs/ext4/ext4.h:1114),这样 EXT4_I(inode) = container_of(inode, struct ext4_inode_info, vfs_inode)。

磁盘上的 superblock:ext4_super_block

内存 super_block 是"运行时描述",磁盘上的 ext4_super_block 是"持久化描述"。两者通过 buffer_head 桥接——挂载时 ext4 把磁盘第 2 个块(默认 s_sb_block=1,即块号 1)读进内存:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
/* fs/ext4/super.c:5076 附近,ext4_read_super_block() */
bh = ext4_sb_bread_unmovable(sb, logical_sb_block);
...
es = (struct ext4_super_block *) (bh->b_data + offset);
sbi->s_es = es;                       /* 磁盘 superblock 副本常驻内存 */
sb->s_magic = le16_to_cpu(es->s_magic);
if (sb->s_magic != EXT4_SUPER_MAGIC) {
	ext4_msg(sb, KERN_ERR, "VFS: Can't find ext4 filesystem");
	goto out;
}

struct ext4_super_block 里躺着整个文件系统的"总账":块数、inode 数、每块组 inode 数、日志设备、UUID 等。注意 s_es(struct ext4_super_block *)直接指向 bh->b_data,也就是说磁盘 superblock 就住在页缓存的一个 buffer 里,改它=改页缓存,回写=写磁盘。

生命周期与操作表:super_operations

include/linux/fs.h:2459 的 struct super_operations 是 sb 的"方法表",ext4 的实现位于 fs/ext4/super.c(ext4_sops):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct super_operations {
   	struct inode *(*alloc_inode)(struct super_block *sb); /* ext4_alloc_inode */
	void (*destroy_inode)(struct inode *);
	...
	int (*write_inode) (struct inode *, struct writeback_control *wbc);
	void (*evict_inode) (struct inode *);   /* 驱逐 inode(回收时) */
	void (*put_super) (struct super_block *);
	int (*sync_fs)(struct super_block *sb, int wait);
	int (*statfs) (struct dentry *, struct kstatfs *);
	...
};

alloc_inode 由 VFS 在需要新 inode 时调用;ext4 的实现是分配 struct ext4_inode_info(内含 vfs_inode)。这就是为什么每个 VFS inode 背后都"绑着"一个文件系统私有结构。

挂载:从 mount 系统调用到 sb 落地

完整链路(fs/namespace.c → fs/super.c → fs/ext4/super.c):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
mount("ext4", "/mnt", ...)
  └─ do_mount → path_mount → do_new_mount
       └─ vfs_get_tree(fc)                 // fs_context 携带 fs 类型、设备
            └─ fc->fs_type->init_fs_context → ext4_init_fs_context
            └─ get_tree_bdev(fc, ext4_fill_super)
                 └─ sget_fc(): 从 fs_supers 链上找已有 sb,找不到则分配新的
                 └─ ext4_fill_super():
                      ├─ ext4_read_super_block()   // 读磁盘 superblock(见 2.2)
                      ├─ sb_set_blocksize(sb, blocksize)
                      ├─ root = ext4_iget(sb, EXT4_ROOT_INO, ...)  // 读根 inode
                      └─ sb->s_root = d_make_root(root)
       └─ vfs_create_mount(fc)  → struct mount { .mnt.mnt_sb = sb, .mnt.mnt_root = sb->s_root }
       └─ attach_mnt(...)       → 挂入 mnt_namespace 的 mounts 红黑树

struct vfsmount 只有三个关键字段(include/linux/mount.h:58):

1
2
3
4
5
6
struct vfsmount {
	struct dentry *mnt_root;	/* 被挂载子树的根 dentry */
	struct super_block *mnt_sb;	/* 指向 superblock */
	int mnt_flags;
	struct mnt_idmap *mnt_idmap;
};

vfsmount 就是"路径世界"与"文件系统实例"之间的铆钉:路径解析走到某个挂载点,就从 mount(vfsmount 的容器)跳到另一个 sb 的根。而 struct mount(fs/mount.h:43)是 vfsmount 的"管理外壳",多出 mnt_parent、mnt_mountpoint、mnt_ns(所属挂载命名空间)、mnt_mp(挂载点)等字段——命名空间隔离的就是 struct mount 这棵树。


struct inode:文件的身份证

内存 inode 精读

include/linux/fs.h:793:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
struct inode {
	umode_t			i_mode;		/* 文件类型+权限(如 0100644) */
	kuid_t			i_uid;
	kgid_t			i_gid;
	...
	const struct inode_operations	*i_op;	/* 名字空间操作(create/lookup/...) */
	struct super_block	*i_sb;		/* 所属文件系统 */
	struct address_space	*i_mapping;	/* 页缓存映射 */
	...
	unsigned long		i_ino;		/* inode 号(磁盘上的编号) */
	...
	union {
		const unsigned int i_nlink;
		unsigned int __i_nlink;
	};
	dev_t			i_rdev;		/* 设备文件专用 */
	loff_t			i_size;		/* 文件大小(字节) */
	time64_t		i_atime_sec;	/* 访问时间 */
	time64_t		i_mtime_sec;	/* 修改时间 */
	time64_t		i_ctime_sec;	/* 状态改变时间 */
	...
	blkcnt_t		i_blocks;	/* 占用的磁盘块数(512B 为单位) */
	...
	struct rw_semaphore	i_rwsem;	/* inode 大锁 */
	...
	struct hlist_node	i_hash;		/* inode 哈希表节点 */
	...
	struct list_head	i_lru;		/* inode LRU */
	struct list_head	i_sb_list;	/* 挂到 sb->s_inodes */
	union {
		struct hlist_head	i_dentry;	/* 指向本 inode 的所有 dentry */
		struct rcu_head		i_rcu;
	};
	...
	atomic_t		i_count;	/* 引用计数 */
	...
	union {
		const struct file_operations	*i_fop;	/* 文件操作表 */
		void (*free_inode)(struct inode *);
	};
	...
	struct address_space	i_data;		/* 内嵌的页缓存映射! */
	...
	void			*i_private;	/* fs 私有数据 */
} __randomize_layout;

几个必须记住的点:

  • i_ino 是磁盘上的 inode 编号(ext4 里就是 1、2、3…),ls -i 显示的就是它。
  • i_size 是逻辑大小,i_blocks 是物理占用的 512 字节块数(du 命令读它)。
  • i_mapping 默认指向内嵌的 i_data——inode_init_always() 里 inode->i_mapping = mapping;(fs/inode.c:295),其中 mapping 就是 &inode->i_data。i_data 是 struct address_space,它管着这个文件在页缓存里的所有 folio。i_mapping 就是"inode ↔ 内存页"的桥,而内存页又通过块映射指向磁盘块。
  • i_dentry 是"别名链表头":一个 inode 可以被多个名字引用(硬链接),每个名字对应一个 dentry,dentry 通过 d_alias 挂进来。这就是 d_alias 与 i_dentry 配对的原因(struct dentry 的 d_u.d_alias,include/linux/dcache.h:128)。
  • i_fop 是"文件操作表"(ext4 是 ext4_file_operations),open 时被拷贝/引用到 struct file->f_op。

磁盘上的 inode:ext4_inode

磁盘 inode 是 128/256 字节的定长记录,fs/ext4/ext4.h:787:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
struct ext4_inode {
	__le16	i_mode;		/* File mode */
	__le16	i_uid;		/* Low 16 bits of Owner Uid */
	__le32	i_size_lo;	/* Size in bytes */
	__le32	i_atime;	/* Access time */
	...
	__le16	i_links_count;	/* Links count */
	__le32	i_blocks_lo;	/* Blocks count */
	__le32	i_flags;	/* File flags */
	...
	__le32	i_block[EXT4_N_BLOCKS];/* Pointers to blocks ★ */
	...
};

关键字段 i_block[EXT4_N_BLOCKS](15 个 32 位槽):传统间接块模式下它是指向数据块的指针数组;extent 模式下它承载扩展树(extent tree)的根节点。inode 与数据块的关联,全部藏在这 15 个字里。

内存里,struct ext4_inode_info(fs/ext4/ext4.h:1023)第一行就复制了它:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
struct ext4_inode_info {
	__le32	i_data[15];	/* unconverted —— 与磁盘 i_block 对齐的"原始数据" */
	__u32	i_dtime;
	ext4_fsblk_t	i_file_acl;
	ext4_group_t	i_block_group;	/* inode 所在块组,用于分配局部性 */
	...
	loff_t	i_disksize;		/* 磁盘上已生效的大小(区别于内存 i_size) */
	struct rw_semaphore i_data_sem;	/* 保护数据树修改(truncate vs get_block) */
	struct inode vfs_inode;		/* ★ VFS inode 内嵌在最后 */
	struct jbd2_inode *jinode;	/* 日志 */
	...
	struct ext4_es_tree i_es_tree;	/* extent status tree(状态缓存) */
	...
};

内存中 ext4_inode_info 与 struct inode 是"一块内存":container_of(inode, struct ext4_inode_info, vfs_inode)。这就是"VFS 通用 + 文件系统私有"的标准组合拳。

inode 如何从磁盘读进内存

ext4_iget()(fs/ext4/inode.c:5197 的 __ext4_iget)流程:

  1. VFS inode 缓存查找:inode_lookup() 用 (sb, ino) 在全局 inode_hashtable 哈希(fs/inode.c:645 的 hash())里找;命中直接返回(同一个 inode 号在内存里只有一个 inode 对象)。
  2. 缓存未命中 → alloc_inode 分配(ext4 分配 ext4_inode_info,ext4_alloc_inode)→ inode_init_always() 初始化 VFS 部分。
  3. 从磁盘读原始 inode:__ext4_get_inode_loc()(fs/ext4/inode.c:4815)计算磁盘位置并读块:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
static int __ext4_get_inode_loc(struct super_block *sb, unsigned long ino,
				struct inode *inode, struct ext4_iloc *iloc,
				ext4_fsblk_t *ret_block)
{
	...
	iloc->block_group = (ino - 1) / EXT4_INODES_PER_GROUP(sb);
	gdp = ext4_get_group_desc(sb, iloc->block_group, NULL);
	...
	inodes_per_block = EXT4_SB(sb)->s_inodes_per_block;
	inode_offset = ((ino - 1) % EXT4_INODES_PER_GROUP(sb));
	iloc->offset = (inode_offset % inodes_per_block) * EXT4_INODE_SIZE(sb);

	block = ext4_inode_table(sb, gdp);      /* 块组描述符里记录的 inode 表起始块 */
	...
	block += (inode_offset / inodes_per_block);  /* ★ ino → 磁盘块号 */

	bh = sb_getblk(sb, block);              /* 拿/建 buffer_head */
	...
	trace_ext4_load_inode(sb, ino);
	ext4_read_bh_nowait(bh, REQ_META | REQ_PRIO, NULL, ...);  /* 发起块读 */
	wait_on_buffer(bh);
	...
	iloc->bh = bh;
	return 0;
}

这段代码就是"inode 号 → 磁盘块“的标准算法:

1
2
3
4
块组号   = (ino - 1) / inodes_per_group
组内偏移 = (ino - 1) % inodes_per_group
磁盘块号 = inode_table(组) + 组内偏移 / inodes_per_block
块内偏移 = (组内偏移 % inodes_per_block) * inode_size
  1. __ext4_iget 把 iloc.bh->b_data + iloc.offset 处的 struct ext4_inode *raw_inode 逐字段 memcpy/转换进 ext4_inode_info,再 ext4_set_aops() 挂上 ext4_aops/ext4_da_aops(见「读路径全链路」一节代码)。

inode 生命周期

1
2
3
4
5
6
open("/a") ──▶ iget_locked / ext4_iget ──▶ i_count++
     │
     └── 最后一个引用释放(close/unlink)
               └─ iput() → 若 i_nlink==0 → 标记 I_FREEING
                    └─ evict_inode()(ext4_evict_inode: 释放数据块、清日志、从 sb->s_inodes 摘下)
                    └─ destroy_inode → free_inode → 归还 slab

inode 的引用计数 i_count 保证:文件被 unlink 后只要还有进程打开着,inode 就活着(数据不丢);全部关闭后才真正驱逐、释放磁盘块。


struct dentry:路径名的高速缓存

为什么需要 dentry

例如/usr/lib/libc.so.6 这个名字对应磁盘上 inode 的解析,如果每次都从磁盘目录项(dirent)读一遍,路径解析会慢得不可接受。于是内核设计了 dcache(dentry cache):把"父 dentry + 名字 → inode"的结果缓存成树。

struct dentry

include/linux/dcache.h:92:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
struct dentry {
	/* RCU 查找热路径字段 */
	unsigned int d_flags;		/* 状态位(如 DCACHE_MOUNTED) */
	seqcount_spinlock_t d_seq;	/* 顺序锁 */
	struct hlist_bl_node d_hash;	/* dcache 哈希表节点 */
	struct dentry *d_parent;	/* 父目录 dentry */
	union {
		struct qstr __d_name;	/* 仅供 fs/dcache.c 内部 */
		const struct qstr d_name;	/* 名字(qstr: name+len+hash) */
	};
	struct inode *d_inode;		/* 名字指向的 inode,NULL=negative */
	union shortname_store d_shortname;	/* 短名字内联存储(不额外分配) */
	...
	const struct dentry_operations *d_op;	/* 钩子 */
	struct super_block *d_sb;	/* 所属文件系统 */
	...
	struct lockref d_lockref;	/* 锁+引用计数合体 */
	...
	union {
		struct list_head d_lru;	/* LRU 回收链 */
		wait_queue_head_t *d_wait;
	};
	struct hlist_node d_sib;	/* 兄弟链(挂父的 d_children) */
	struct hlist_head d_children;	/* 孩子链 */
	union {
		struct hlist_node d_alias;	/* ★ 挂到 inode->i_dentry(硬链接别名) */
		struct hlist_bl_node d_in_lookup_hash;
		struct rcu_head d_rcu;
	} d_u;
};

要点:

  • d_name 是 struct qstr(名字+长度+预计算 hash),避免路径解析中反复 strlen + hash。
  • d_parent / d_sib / d_children 把 dentry 组织成树,树根是 sb->s_root。
  • d_sb 让每个 dentry 知道自己属于哪个文件系统——跨设备跳转(.. 到挂载点边界)全靠它。
  • d_inode == NULL 的 dentry 叫 negative dentry:缓存"这个名字不存在”,避免对不存在的文件反复访问磁盘。
  • d_alias 与 d_children 是不同的链表:d_children 是"我作为父目录的孩子";d_alias 是"我作为 inode 的别名"。一个 inode 被硬链接 N 次,就有 N 个 dentry 的 d_alias 挂在 inode->i_dentry 上。
  • d_lockref 是"自旋锁+引用计数"原子合一,高频路径少一次锁开销。

dcache 的两重身份:树 + 哈希表

dentry 同时挂在两个结构上:

  1. 哈希表 dentry_hashtable(fs/dcache.c:114):键是 (父 dentry, 名字 hash),用于快速查找:
1
2
3
4
5
static inline struct hlist_bl_head *d_hash(unsigned long hashlen)
{
	return runtime_const_ptr(dentry_hashtable) +
		(hashlen & runtime_const(d_hash_mask));
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
/* fs/dcache.c:2425 d_hash_and_lookup() */
struct dentry *d_hash_and_lookup(struct dentry *dir, struct qstr *name)
{
	name->hash = full_name_hash(dir, name->name, name->len);
	if (dir->d_flags & DCACHE_OP_HASH) {   /* 允许 fs 自定义 hash */
		int err = dir->d_op->d_hash(dir, name);
		...
	}
	return d_lookup(dir, name);
}
  1. 树结构:通过 d_parent + d_sib/d_children,配合 walk_component() 逐级下钻,形成从 s_root 到每个文件的路径树。

d_instantiate:dentry 与 inode 的"婚礼"

d_instantiate(dentry, inode)(fs/dcache.c)把两者绑起来:设置 dentry->d_inode = inode,并把 dentry 的 d_alias 挂进 inode->i_dentry。此后:

  • 通过 dentry 找 inode:dentry->d_inode(d_inode(dentry) 宏);
  • 通过 inode 找所有名字:遍历 inode->i_dentry 上的 d_alias。

dentry 与 mount 的交互

路径解析"走进"一个挂载点时,当前 dentry 会被标记 DCACHE_MOUNTED(include/linux/dcache.h:211),然后 follow_mount()(fs/namei.c)通过 __lookup_mnt()(fs/namespace.c)在 mount_hashtable 里找到挂载在此的 struct mount,把 nd->path 切换成新 vfsmount 的根:

1
2
3
4
5
走到 /mnt (dentry 有 DCACHE_MOUNTED)
  → __lookup_mnt(vfsmount, dentry)   // 哈希键: (父vfsmount, dentry)
  → 命中 struct mount
  → nd->path.mnt   = &mount->mnt
  → nd->path.dentry = mount->mnt.mnt_root   // 跳到被挂载文件系统的根

这就是**“把 /mnt 换成另一个文件系统的根”**在代码里的样子,也是容器里看到不同目录树的根源(见「进程视角」一章)。

路径上的各级目录是怎么串起来的

把 open("/home/charles/vfslab/a.txt") 拆开看,这条路径的每一级并不是一个"字符串",而是一串对象,链在两个世界里同时存在:

① 磁盘世界:目录数据块里的"记录链"

每个目录在磁盘上就是一个 inode,它的数据块里按顺序躺着一条条定长/变长的目录项记录 ext4_dir_entry_2——每条记录 = (inode 号, 名字长度, 名字)。所以磁盘上的链是:

1
根目录 inode(2) 的数据块 ──"home"──▶ inode 57346 的数据块 ──"charles"──▶ inode 42 ...

解析时内核调 inode->i_op->lookup(ext4 是 ext4_lookup,fs/ext4/namei.c),它用 ext4_find_entry 顺着目录数据块把 (名字 → inode 号) 找出来,再 ext4_iget 把下一级的 inode 读进内存。

② 内存世界:dcache 树 + nameidata 游标

目录项被读进内存后,VFS 用一个 struct dentry 代表每一级名字,并织成树:

  • 向上:dentry->d_parent 指向父目录的 dentry(dcache.h:97);
  • 横向:dentry->d_sib 挂进父的 d_children 链表(dcache.h:122-123);
  • 名字→inode:dentry->d_inode(dcache.h:102),由 d_instantiate() 绑定;
  • 回到文件系统:dentry->d_sb(dcache.h:109)声明这一级属于哪个 super_block。

路径解析就是用一个"游标" struct nameidata 在这棵树里逐级下钻(fs/namei.c):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
path_init():  起点 = current->fs->root (绝对路径) 或 current->fs->pwd (相对路径)
   │
   ▼
link_path_walk() 循环, 对每个名字组件:
   walk_component()
     ├─ lookup_fast():  查 dcache 哈希表(父dentry+名字) → 命中则直接前进
     └─ lookup_slow():  dcache 未命中 → 调父目录 inode->i_op->lookup
                          (ext4_lookup 读磁盘目录项) → d_instantiate 织入 dcache
     ├─ 若是挂载点(dentry 有 DCACHE_MOUNTED): follow_mount() 跳进被挂载 fs 的根
     ├─ 若是符号链接: inode->i_op->get_link() 插入一段新的组件链
     └─ 前进: nd->path.dentry = 子 dentry, nd->inode = 子 inode
   │
   ▼
open_last_lookups() → do_open(): 最后一级名字 → file->f_path 记住 (vfsmount, dentry)

③ 链的"断点":挂载点与 ..

  • 挂载点:走到 /mnt 时 dentry 标记 DCACHE_MOUNTED,follow_mount() 在 mount_hashtable 里查到挂在这里的 struct mount,把游标切到 vfsmount->mnt_root——链从 ext4 的 dentry 树跳进了 tmpfs 的 dentry 树(super_block 换了,但路径看着还是连续的)。这正是「动手实验」一章 vfschain 实验里 dev=8:32 → dev=0:30 那一跳。
  • ..(父目录):普通目录直接用 d_parent;但如果 .. 要跨出挂载边界(比如 tmpfs 根目录的 ..),内核会沿 mount->mnt_parent + mount->mnt_mountpoint 回到父文件系统里挂载点所在的 dentry——d_parent 只在一棵 super_block 的树内有效,跨树要靠 struct mount 的父子链(fs/mount.h:44-45)。

④ 一个 inode 可以有多条链(硬链接)

链的末端(inode)可以被多个 dentry 指向:/a/link 和 /b/link 两条链可以汇到同一个 inode(i_nlink=2,见 9.5 实验)。所以"路径"本质是**名字树(dentry)→ 对象(inode)**的映射,而不是一个扁平的字符串。

一句话:目录的磁盘数据块是"持久化的链",dcache 的 d_parent/d_children 是"内存里的链",link_path_walk 的 nd->path 是"沿着链走的游标",挂载点/符号链接是"链的跳转点",fs->root/fs->pwd 是"链的起点"。

一处目录最多能存多少条目?—— 磁盘上限与内存上限

“存目录项的地方"其实有两处,上限完全不同:

① 磁盘上:一个目录 inode 的数据块(持久上限,受 ext4 格式约束)

目录项记录 struct ext4_dir_entry_2(fs/ext4/ext4.h:2409)= 8 字节头部(4B inode + 2B rec_len + 1B name_len + 1B file_type)+ 名字,4 字节对齐:

项目 数值 依据
单个名字长度 ≤ 255 字节 NAME_MAX(include/uapi/linux/limits.h:12)、EXT4_NAME_LEN
单条记录最大 8+255 → 264 字节(对齐后) ext4_dir_rec_len
一个 4K 块能放 1 字名 ~341 条 / 255 字名 ~15 条 4096 / 对齐后记录长
索引块容量 根 ~508、节点 ~510 条 dx_entry(每条 8B) dx_root_limit/dx_node_limit(fs/ext4/namei.c:577/588)
htree 深度 兼容 2 层 / largedir 3 层 EXT4_HTREE_LEVEL_COMPAT=2、EXT4_HTREE_LEVEL=3(ext4.h:2646)
理论整目录上限 兼容模式 ~17 万条;largedir ≈ 508×510≈25.9 万叶子块 × 每块 ~333 条 ≈ 千万级 上面两行相乘
目录子目录数 ≤ 65000(无 dir_nlink 特性时) EXT4_LINK_MAX(ext4.h:328)EXT4_DIR_LINK_MAX
软限制 挂载选项 max_dir_size_kb,默认 0=不限 s_max_dir_size_kb(fs/ext4/namei.c:61)

所以:老格式 ext3/ext4 目录到几万~几十万文件就会因 htree 打满而变慢/告警,largedir 特性(ext4 4.4+ 默认开启)把上限推到千万级。

② 内存里:dcache 没有条数上限,只有内存约束

  • 每个 struct dentry 约 200~300 字节(短名内联在 d_shortname,长名单独分配)。
  • d_children 是链表、dcache 哈希表按内存量分配桶(alloc_large_system_hash("Dentry cache", ...),fs/dcache.c:3220),桶数只影响查找速度,不影响容量。
  • 总量由内存压力 + shrinker 回收(sb->s_dentry_lru),/proc/sys/fs/dentry-state 的前两列是 nr_dentry/nr_unused——第三列的 45 是回收用的"年龄阈值(秒)",不是条数上限,别被误导。

实测(本机,临时创建 2 万个空文件后删除):

1
2
3
4
5
6
7
$ cat /proc/sys/fs/dentry-state          # 创建前:  nr_dentry nr_unused age_limit want_pages nr_negative
18487  12543  45  0  2733  0
$ seq -f 'f%g' 1 20000 | xargs touch      # 创建 20000 个文件
$ cat /proc/sys/fs/dentry-state          # 创建后:  正好 +20010 个 dentry
38497  32555  45  0  2742  0
$ du -s /tmp/dirtest                      # 目录 inode 自己的数据块
492     /tmp/dirtest                       # ≈ 120 个 4K 块 (2万条 × ~24B)

结论一句话:磁盘侧有格式硬上限(块 × htree 深度,千万级封顶);内存侧没有"最多多少个"的硬上限,装多少取决于你有多少可回收内存——1 百万条 ≈ 两三百 MB 可回收的 dentry+inode 缓存。


struct file:进程与文件的会话

结构体精读

include/linux/fs.h:1211:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct file {
	spinlock_t			f_lock;
	fmode_t				f_mode;		/* 打开模式(读/写/追加...) */
	const struct file_operations	*f_op;		/* ★ 操作表(read/write/...) */
	struct address_space		*f_mapping;	/* 页缓存映射(同 inode->i_mapping) */
	void				*private_data;	/* fs/驱动私有 */
	struct inode			*f_inode;	/* 背后的 inode */
	unsigned int			f_flags;	/* O_* 标志 */
	unsigned int			f_iocb_flags;
	const struct cred		*f_cred;	/* 打开者凭证 */
	struct fown_struct		*f_owner;
	union {
		const struct path	f_path;		/* ★ (vfsmount, dentry) */
		struct path		__f_path;
	};
	union {
		struct mutex		f_pos_lock;	/* 位置锁 */
		u64			f_pipe;		/* 管道用 */
	};
	loff_t				f_pos;		/* ★ 当前读写位置 */
	...
	file_ref_t			f_ref;		/* 引用计数 */
} __randomize_layout;

struct file 是每个 open 一份的"会话状态”:f_pos(偏移)、f_mode、f_flags 都随会话;而 f_inode 指向共享的 inode(同一文件打开两次,有两个 file、一个 inode)。f_path 就是那个 (vfsmount, dentry) 对——打开瞬间路径解析的终点被永久记住。

进程 ↔ file:fdtable

task_struct->files 指向 struct files_struct(include/linux/fdtable.h:38):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
struct files_struct {
	atomic_t count;
	...
	struct fdtable __rcu *fdt;	/* 大表指针(表可能扩容) */
	struct fdtable fdtab;		/* 内嵌初始表 */
	...
	struct file __rcu * fd_array[NR_OPEN_DEFAULT];	/* 内嵌默认 fd 数组 */
};

struct fdtable {
	unsigned int max_fds;
	struct file __rcu **fd;		/* fd 数组: 下标=fd 号 */
	unsigned long *close_on_exec;
	unsigned long *open_fds;	/* 打开位图 */
	...
};

于是:fd(一个整数)→ files_struct->fdt->fd[fd] → struct file *。而 task_struct 里这三件套(include/linux/sched.h:1189-1200):

1
2
3
4
5
6
	/* Filesystem information: */
	struct fs_struct		*fs;		/* 根/当前目录 */
	/* Open file information: */
	struct files_struct		*files;		/* 文件描述符表 */
	/* Namespaces: */
	struct nsproxy			*nsproxy;	/* 各类命名空间 */

open 的完整路径

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
open("/etc/hostname", O_RDONLY)
  └─ do_sys_open → do_filp_open → path_openat(nd, ...)     // fs/namei.c:4114
       ├─ path_init(nd, LOOKUP_DEFAULT)                    // fs/namei.c:2540
       │    ├─ '/' 开头 → nd_jump_root → set_root          // 取 current->fs->root
       │    └─ 相对路径 → get_fs_pwd(current->fs, &nd->path) // 取 current->fs->pwd
       ├─ link_path_walk(...)                              // fs/namei.c:2441
       │    └─ walk_component → lookup_fast(查 dcache) → 未命中 → lookup_slow
       │         └─ dir->i_op->lookup → ext4_lookup → ext4_iget(读磁盘 inode)
       │         └─ d_instantiate(dentry, inode)           // 绑进 dcache
       ├─ open_last_lookups(...)                           // 最后一段名字
       └─ do_open → alloc_empty_file → vfs_open
            ├─ file->f_path = nd->path
            ├─ file->f_inode = dentry->d_inode
            ├─ file->f_mapping = inode->i_mapping
            ├─ file->f_op = inode->i_fop → ext4_file_operations
            └─ f_op->open → ext4_file_open

f_op 一经绑定,后续 read() 就直接调 file->f_op->read_iter(ext4 是 ext4_file_read_iter),中间不再经过路径解析。


inode/dentry/file 如何关联到磁盘 Block

磁盘关联分四层:

1
2
3
4
5
file 偏移(字节) ──(>>blkbits)──▶ 文件逻辑块号 lblk ──ext4_map_blocks──▶ 物理块号 pblk
                                                                           │
                                                           (pblk << (blkbits-9)) 扇区
                                                                           ▼
                                                bio → block layer → 磁盘

第一层:文件偏移 → 逻辑块号

1
lblk = file_offset >> inode->i_blkbits;        /* i_blkbits: log2(块大小) */

页缓存索引则用 PAGE_SHIFT:index = iocb->ki_pos >> PAGE_SHIFT(mm/filemap.c:2621)。

第二层:逻辑块号 → 物理块号(ext4_map_blocks)

struct ext4_map_blocks(fs/ext4/ext4.h:258)是映射请求/结果:

1
2
3
4
5
6
struct ext4_map_blocks {
	ext4_fsblk_t m_pblk;	/* 物理块号(结果) */
	ext4_lblk_t m_lblk;	/* 逻辑块号(请求) */
	unsigned int m_len;	/* 块数 */
	unsigned int m_flags;	/* EXT4_MAP_MAPPED / EXT4_MAP_UNWRITTEN ... */
};

ext4_map_blocks()(fs/ext4/inode.c:698)是"lblk → pblk"的总入口,三段式:

① 先查 extent status tree(内存缓存):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/* Lookup extent status tree firstly */
if (ext4_es_lookup_extent(inode, map->m_lblk, NULL, &es)) {
	if (ext4_es_is_written(&es) || ext4_es_is_unwritten(&es)) {
		map->m_pblk = ext4_es_pblock(&es) +
				map->m_lblk - es.es_lblk;   /* 在 extent 内偏移 */
		map->m_flags |= ext4_es_is_written(&es) ?
				EXT4_MAP_MAPPED : EXT4_MAP_UNWRITTEN;
		retval = es.es_len - (map->m_lblk - es.es_lblk);
		...
	}
	...
}

② 未命中缓存 → 查磁盘上的 extent 树(ext4_map_query_blocks → ext4_ext_map_blocks,fs/ext4/extents.c)。extent 树的叶子是 struct ext4_extent:ee_block(逻辑起点) + ee_len(长度) + ee_start(物理起点)。树根就住在 ext4_inode_info->i_data[15] 里——这就是磁盘 inode 的 i_block[15] 槽位的真身。树查找就是"从内存中的 i_data 根节点开始,逐级读磁盘索引块"的过程。

③ 写场景:EXT4_GET_BLOCKS_CREATE 标志 → 未分配则调 ext4_mb_new_blocks(mballoc)从位图里分配新块,并写回 extent 树 + 日志。

物理块号 pblk 是"文件系统块号",要换算成设备扇区:sector = pblk << (blkbits - 9)(4K 块 → 左移 3)。

第三层:物理块号 → 页缓存(address_space / folio / buffer_head)

struct address_space(include/linux/fs.h:506)是"文件 ↔ 页"的总管家:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct address_space {
	struct inode		*host;		/* 回指文件 inode */
	struct xarray		i_pages;	/* ★ 页缓存: folio 的基数树 */
	struct rw_semaphore	invalidate_lock;
	...
	struct rb_root_cached	i_mmap;		/* mmap 的 VMA 红黑树 */
	unsigned long		nrpages;	/* 缓存页数 */
	pgoff_t			writeback_index;
	const struct address_space_operations *a_ops;	/* ★ 方法表 */
	...
};
  • 每个 inode 内嵌一个 i_data(address_space),inode->i_mapping 默认指它(fs/inode.c:295)。
  • i_pages 是 XArray:键 = 页索引(文件偏移»PAGE_SHIFT),值 = struct folio *。
  • a_ops 是文件系统提供的页操作表。ext4 的 ext4_da_aops(fs/ext4/inode.c:4018):
1
2
3
4
5
6
7
8
9
static const struct address_space_operations ext4_da_aops = {
	.read_folio		= ext4_read_folio,
	.readahead		= ext4_readahead,
	.writepages		= ext4_writepages,
	.write_begin		= ext4_da_write_begin,
	.write_end		= ext4_da_write_end,
	.dirty_folio		= ext4_dirty_folio,
	...
};

struct buffer_head(include/linux/buffer_head.h:60)是块级描述符:b_bdev(块设备)、b_blocknr(块号)、b_data(数据指针)、b_page(所在页)、b_state(uptodate/dirty/mapped 等位)。map_bh()(include/linux/buffer_head.h:408)就是把"逻辑块→物理块"结果写进 buffer_head 的瞬间:

1
2
3
4
5
6
7
8
static inline void
map_bh(struct buffer_head *bh, struct super_block *sb, sector_t block)
{
	set_buffer_mapped(bh);
	bh->b_bdev = sb->s_bdev;	/* 设备 */
	bh->b_blocknr = block;		/* ★ 物理块号 */
	bh->b_size = sb->s_blocksize;
}

而 ext4_get_block()(fs/ext4/inode.c:934)就是 ext4 对"给定逻辑块,帮我映射"回调的实现,内部走 _ext4_get_block → ext4_map_blocks → map_bh。

读路径全链路

以 read(fd, buf, 4096) 为例:

① 系统调用入口 fs/read_write.c:552:

1
2
3
4
5
6
7
8
9
ssize_t vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos)
{
	...
	if (file->f_op->read)
		ret = file->f_op->read(file, buf, count, pos);
	else if (file->f_op->read_iter)
		ret = new_sync_read(file, buf, count, pos);   /* ext4 走这里 */
	...
}

new_sync_read 构造 struct kiocb(携带 ki_filp、ki_pos)后调用 file->f_op->read_iter。

② VFS 通用读 mm/filemap.c:2717 的 filemap_read() → filemap_get_pages()(mm/filemap.c:2616):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
static int filemap_get_pages(struct kiocb *iocb, size_t count,
		struct folio_batch *fbatch, bool need_uptodate)
{
	...
	pgoff_t index = iocb->ki_pos >> PAGE_SHIFT;   /* 偏移 → 页索引 */
	...
	filemap_get_read_batch(mapping, index, last_index - 1, fbatch);  /* 查 XArray */
	if (!folio_batch_count(fbatch)) {
		DEFINE_READAHEAD(ractl, filp, &filp->f_ra, mapping, index);
		...
		page_cache_sync_ra(&ractl, last_index - index);   /* 预读 */
		...
	}
	...
	if (!folio_test_uptodate(folio)) {          /* 页不新鲜 → 真正读盘 */
		err = filemap_update_page(iocb, mapping, count, folio, need_uptodate);
		...
	}
}

③ 缺页 → 文件系统读:filemap_read_folio() 调 mapping->a_ops->read_folio,也就是 ext4_read_folio(fs/ext4/inode.c:3392):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
static int ext4_read_folio(struct file *file, struct folio *folio)
{
	int ret = -EAGAIN;
	struct inode *inode = folio->mapping->host;
	...
	if (ext4_has_inline_data(inode))
		ret = ext4_readpage_inline(inode, folio);
	if (ret == -EAGAIN)
		return ext4_mpage_readpages(inode, NULL, folio);   /* ★ 主线 */
	return ret;
}

④ ext4 组装 bio ext4_mpage_readpages()(fs/ext4/readpage.c:211)——这里就是"lblk→pblk→bio"三步合一的地方:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
int ext4_mpage_readpages(struct inode *inode,
		struct readahead_control *rac, struct folio *folio)
{
	...
	struct block_device *bdev = inode->i_sb->s_bdev;   /* ★ sb 定位设备 */
	...
	while (page_block < blocks_per_folio) {
		...
		map.m_lblk = block_in_file;                  /* 逻辑块号 */
		map.m_len = last_block - block_in_file;
		if (ext4_map_blocks(NULL, inode, &map, 0) < 0)   /* ★ 查映射 */
			...
		...
		if (first_hole != blocks_per_folio) {
			... /* 空洞部分清零 */
		}
		...
		if (bio == NULL) {
			bio = bio_alloc(bdev, bio_max_segs(nr_pages),
					REQ_OP_READ, GFP_KERNEL);
			...
			bio->bi_iter.bi_sector = first_block << (blkbits - 9); /* ★ pblk→扇区 */
			bio->bi_end_io = mpage_end_io;
			...
		}
		length = first_hole << blkbits;
		if (!bio_add_folio(bio, folio, length, 0))   /* 页加入 bio */
			goto submit_and_realloc;
		...
	}
	...
	if (bio)
		submit_bio(bio);                              /* ★ 交给块层 */
	return 0;
}

⑤ 块层 → 磁盘:submit_bio() → 电梯算法/I/O 调度 → block_device 驱动的请求队列 → 磁盘。bio->bi_iter.bi_sector 是 512B 扇区号,所有块设备 I/O 都以它为最终单位。

至此,“文件偏移"走完了一条完整的数据通道: file->f_pos → kiocb->ki_pos → 页索引 → folio(XArray) → (ext4_map_blocks) pblk → bio(bi_sector) → 磁盘

写路径(延迟分配 delalloc 的威力)

写路径同样从 file->f_op->write_iter(ext4_file_write_iter)进入,但数据不立即落盘:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
write() → ext4_file_write_iter → ext4_buffered_write_iter
  └─ generic_perform_write
       ├─ ext4_da_write_begin
       │    ├─ grab_cache_page_write_begin  (拿 folio)
       │    └─ ext4_da_get_block_prep → ext4_map_blocks(CREATE) 
       │         └─ 只做"延迟分配": 在 extent status tree 记 EXT4_MAP_DELAYED
       │            (物理块先不分配!只记账)
       ├─ copy_page_from_iter (用户数据拷进页)
       └─ ext4_da_write_end → ext4_mark_inode_dirty
            └─ folio_mark_dirty: folio 打上 DIRTY 标记, 挂入写回队列

真正分配物理块发生在回写(writeback)时:ext4_writepages()(fs/ext4/inode.c)→ mpage_map_and_submit_extent() → 此时才 ext4_map_blocks(CREATE) 真分配 → ext4_bio_write_folio()(fs/ext4/page-io.c:457)组装写 bio 提交。这个设计让多个小写入合并成连续的大块分配,大幅减少磁盘碎片——代价是断电可能丢未落盘数据,因此有 sync/fsync 和日志(jbd2)兜底。

三条旁路

  • Direct I/O(O_DIRECT):绕过页缓存,ext4_dio_write_iter 直接 ext4_map_blocks → bio。
  • DAX(持久内存):ext4_dax_aops,noop_dirty_folio,CPU 直接访问持久内存映射,零拷贝。
  • mmap:mmap 文件 = 把 address_space->i_mmap 红黑树里的 VMA 与页缓存 folio 建立映射,缺页时走 read_folio 拉数据,写回仍走同一套 writeback。

进程视角:namespace / fs_struct / files_struct 如何组织

进程视角的"三件套”

include/linux/sched.h:1189 起:

1
2
3
4
5
6
	/* Filesystem information: */
	struct fs_struct		*fs;		/* 根目录+当前目录 */
	/* Open file information: */
	struct files_struct		*files;		/* fd 表 */
	/* Namespaces: */
	struct nsproxy			*nsproxy;	/* 命名空间集合 */

命名空间(namespace)与文件系统相关的有两个:

  1. mount namespace(mnt_namespace):隔离"挂载树"——不同 mnt ns 的进程看到不同的目录树结构、不同的挂载点。
  2. user namespace(user_ns):隔离 uid/gid 映射——super_block 的 s_user_ns 用它解释磁盘上的 uid/gid(fs.h:1562 注释:“Owning user namespace and default context in which to interpret filesystem uids, gids, quotas…")。

nsproxy:命名空间的总管

include/linux/nsproxy.h:32:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
/*
 * A structure to contain pointers to all per-process
 * namespaces - fs (mount), uts, network, sysvipc, etc.
 * ...
 * The nsproxy is shared by tasks which share all namespaces.
 * As soon as a single namespace is cloned or unshared, the
 * nsproxy is copied.
 */
struct nsproxy {
	refcount_t count;
	struct uts_namespace *uts_ns;
	struct ipc_namespace *ipc_ns;
	struct mnt_namespace *mnt_ns;		/* ★ 挂载命名空间 */
	struct pid_namespace *pid_ns_for_children;
	struct net 	     *net_ns;
	struct time_namespace *time_ns;
	struct time_namespace *time_ns_for_children;
	struct cgroup_namespace *cgroup_ns;
};

注意注释里的关键设计:nsproxy 被"共享所有命名空间"的线程/进程共用(引用计数),只要有一个命名空间被 clone/unshare,nsproxy 整体复制一份。fork() 时 copy_namespaces()(kernel/nsproxy.c:147)的快速路径:

1
2
3
4
5
6
7
	if (likely(!(flags & (CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC |
			      CLONE_NEWPID | CLONE_NEWNET |
			      CLONE_NEWCGROUP | CLONE_NEWTIME)))) {
		...
		get_nsproxy(old_ns);      /* 没有新命名空间 → 直接共享 */
		return 0;
	}

mnt_namespace:隔离的挂载树

fs/mount.h:10:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
struct mnt_namespace {
	struct ns_common	ns;		/* 通用命名空间头(ns_type/inum) */
	struct mount *	root;			/* ★ 本命名空间挂载树根 */
	struct {
		struct rb_root	mounts;		/* 全部 mount 的红黑树 */
		struct rb_node	*mnt_last_node;
		struct rb_node	*mnt_first_node;
	};
	struct user_namespace	*user_ns;
	...
	unsigned int		nr_mounts;	/* 挂载数 */
	...
};

struct mount(fs/mount.h:43)是"一棵树节点 + 一个 vfsmount”:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
struct mount {
	struct hlist_node mnt_hash;		/* mount_hashtable 节点 */
	struct mount *mnt_parent;		/* 父挂载 */
	struct dentry *mnt_mountpoint;		/* 挂载点 dentry(在父文件系统里) */
	struct vfsmount mnt;			/* ★ vfsmount(mnt_root, mnt_sb) */
	union {
		struct rb_node mnt_node;	/* ns->mounts 红黑树节点 */
		...
	};
	...
	struct list_head mnt_mounts;		/* 子挂载链表 */
	struct list_head mnt_child;		/* 链入父的 mnt_mounts */
	...
	struct mnt_namespace *mnt_ns;		/* ★ 所属挂载命名空间 */
	struct mountpoint *mnt_mp;		/* 挂载点 */
	...
	int mnt_id;				/* 挂载 ID(/proc/self/mountinfo) */
	...
};

于是**“挂载"就是三件事**:

  1. mnt_parent / mnt_mountpoint 把新 mount 挂到父文件系统的某个 dentry 上;
  2. mnt_mounts / mnt_child 维护父子树;
  3. mnt_ns 声明它属于哪个命名空间。

而 struct mount 与 struct vfsmount 的关系是同一块内存:real_mount(mnt) = container_of(mnt, struct mount, mnt)(fs/mount.h:112)。

全局与命名空间的"哈希/树"双视图

  • 全局:mount_hashtable(fs/namespace.c:86)以 (parent vfsmount, mountpoint dentry) 为键,用于路径解析时"这个 dentry 上有没有挂载"的快速查询。
  • 命名空间内:mnt_namespace->mounts 红黑树,ns->root 是树根。/proc/self/mountinfo 就是遍历当前进程命名空间的这棵红黑树生成的。

fs_struct:路径解析的"起点”

include/linux/fs_struct.h:9:

1
2
3
4
5
6
7
struct fs_struct {
	int users;
	seqlock_t seq;		/* 保护 root/pwd 的读写 */
	int umask;
	int in_exec;
	struct path root, pwd;	/* ★ 根目录、当前目录 */
} __randomize_layout;

struct path 只有两个指针(include/linux/path.h:8):

1
2
3
4
struct path {
	struct vfsmount *mnt;	/* 在哪棵挂载树上 */
	struct dentry *dentry;	/* 哪个目录项 */
} __randomize_layout;

path 是"路径"的最小表示:(vfsmount, dentry) 唯一确定"从哪个文件系统的哪个目录"出发。

路径解析的起点(fs/namei.c:2540 path_init):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
	/* Absolute pathname -- fetch the root */
	if (*s == '/' && !(flags & LOOKUP_IN_ROOT)) {
		error = nd_jump_root(nd);     /* → set_root(): nd->root = current->fs->root */
		...
		return s;
	}

	/* Relative pathname -- get the starting-point it is relative to. */
	if (nd->dfd == AT_FDCWD) {
		...
		get_fs_pwd(current->fs, &nd->path);   /* ★ 相对路径从 current->fs->pwd 出发 */
		nd->inode = nd->path.dentry->d_inode;
	}

set_root()(fs/namei.c:999):

1
2
3
4
5
6
7
8
static int set_root(struct nameidata *nd)
{
	struct fs_struct *fs = current->fs;
	...
	get_fs_root(fs, &nd->root);   /* nd->root = fs->root */
	nd->state |= ND_ROOT_GRABBED;
	...
}

绝对路径以 fs->root 为根,相对路径以 fs->pwd 为起点——这就是 chroot 改变"根"、chdir 改变"当前目录"后,路径解析行为随之改变的本质。注意:fs_struct 不是命名空间(它可以被 clone(CLONE_FS) 共享),但命名空间切换会同步重定向 fs->root/pwd 到新命名空间对应的 mount(见下节)。

CLONE_NEWNS:创建新挂载命名空间时发生了什么

unshare -m / clone(CLONE_NEWNS) → copy_namespaces()(kernel/nsproxy.c:147)→ create_new_namespaces() → copy_mnt_ns()(fs/namespace.c:4108):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
struct mnt_namespace *copy_mnt_ns(u64 flags, struct mnt_namespace *ns,
		struct user_namespace *user_ns, struct fs_struct *new_fs)
{
	...
	if (likely(!(flags & CLONE_NEWNS))) {
		get_mnt_ns(ns);		/* 不创建新 ns → 引用共享 */
		return ns;
	}

	old = ns->root;

	new_ns = alloc_mnt_ns(user_ns, false);
	...
	/* First pass: copy the tree topology */
	copy_flags = CL_COPY_UNBINDABLE | CL_EXPIRE;
	...
	new = copy_tree(old, old->mnt.mnt_root, copy_flags);   /* ★ 克隆整棵挂载树 */
	...
	new_ns->root = new;
	...
	/* Second pass: switch the tsk->fs->* elements and mark new vfsmounts
	 * as belonging to new namespace. */
	p = old;
	q = new;
	while (p) {
		mnt_add_to_ns(new_ns, q);		/* 挂进新命名空间的红黑树 */
		new_ns->nr_mounts++;
		if (new_fs) {
			if (&p->mnt == new_fs->root.mnt) {	  /* 重定向 fs->root */
				new_fs->root.mnt = mntget(&q->mnt);
				rootmnt = &p->mnt;
			}
			if (&p->mnt == new_fs->pwd.mnt) {	  /* 重定向 fs->pwd */
				new_fs->pwd.mnt = mntget(&q->mnt);
				pwdmnt = &p->mnt;
			}
		}
		...
	}
	ns_tree_add_raw(new_ns);
	return new_ns;
}

要点:

  • copy_tree() 克隆的是 struct mount(挂载树节点),不是 struct super_block!新命名空间里的每个 mount 仍然指向同一个 super_block / inode——文件系统是全局共享的,隔离的只是"挂载拓扑视图"。这就是为什么容器里 mount 自己的东西不影响宿主,但对同一文件的修改双方都看得见。
  • 如果 unshare -m 的进程有私有 fs_struct(CLONE_FS 已拆),新挂载树的对应节点会被写回 fs->root.mnt / fs->pwd.mnt,保证"当前目录"在新视图里依旧成立。

命名空间的"身份证":ns_common 与 /proc/pid/ns/mnt

每个命名空间都以 struct ns_common 开头(include/linux/ns_common.h:40):

1
2
3
4
5
6
7
8
struct ns_common {
	u32 ns_type;				/* CLONE_NEWNS / CLONE_NEWPID ... */
	struct dentry *stashed;			/* 本 ns 在 nsfs 里缓存的 dentry */
	const struct proc_ns_operations *ops;	/* 输出/比较用 */
	unsigned int inum;			/* ★ 命名空间序号(唯一) */
	refcount_t __ns_ref;
	...
};

mnt_namespace 的 ns 字段就是它。/proc/<pid>/ns/mnt 是一个 magiclink,其 inode 号(ls -l /proc/self/ns/mnt 看到的 4026531840 之类)就是 ns->ns.inum——两个进程 ns/mnt 的 inode 相同 ⇔ 它们共享同一个 mount namespace。这也是容器逃逸检测与安全审计常用的比对手段。

完整链条:进程 → 命名空间 → sb → inode → 磁盘

把第 2~7 章串起来,一个进程访问 /etc/hostname 的完整"关系链":

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
task_struct
 ├─ files → files_struct → fd[3] → struct file
 │                                   ├─ f_path.dentry → dcache 里的 dentry("hostname")
 │                                   │      ├─ d_parent → ... → d_parent → s_root dentry
 │                                   │      ├─ d_sb → super_block(根文件系统)
 │                                   │      └─ d_inode → inode(hostname)
 │                                   ├─ f_inode → 同上 inode
 │                                   ├─ f_mapping → inode->i_data(address_space)
 │                                   │                └─ i_pages(XArray) → folio → bio → 磁盘
 │                                   └─ f_op → ext4_file_operations
 ├─ fs → fs_struct { root, pwd }   ← 路径解析起点
 └─ nsproxy → mnt_ns → mnt_namespace { root, mounts 红黑树 }
                        └─ mount { mnt_parent, mnt_mountpoint }
                             └─ vfsmount { mnt_root = s_root, mnt_sb = super_block }
                                                  │
                                                  ├─ s_bdev → block_device → bio 扇区
                                                  └─ s_fs_info → ext4_sb_info
                                                        └─ s_es → 磁盘 superblock 副本
inode ──i_sb──▶ super_block
inode ──i_mapping/i_data──▶ address_space ──a_ops──▶ ext4_da_aops
inode ──EXT4_I──▶ ext4_inode_info ──i_data[15]──▶ extent 树 ──▶ pblk ──▶ 扇区

完整关系图:从进程到磁盘扇区的一整条链

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
                    ┌────────────────────────── 进程(task_struct) ──────────────────────────┐
                    │                                                                        │
              fs ───┴─── files                                                    nsproxy ──┴──
              │           │                                                            │
   ┌──────────▼─────┐  ┌──▼────────────────┐                                   ┌────────▼────────┐
   │ fs_struct      │  │ files_struct      │                                   │ mnt_namespace   │
   │  root / pwd    │  │  fdtable[]:fd───┐ │                                   │  root           │
   └──┬─────────────┘  └─────────────────┼─┘                                   │  mounts(rbtree) │
      │                                  ▼                                     └───┬────────────┘
      │                    ┌──────────────────────┐                                  │
      │                    │ struct file          │                                  │
      │                    │  f_path(vfsmount,    │◀─────────────────────────────────┤
      │                    │        dentry)       │   mount { mnt_ns = mnt_namespace }
      │                    │  f_inode             │                                  │
      │                    │  f_mapping           │   vfsmount { mnt_root, mnt_sb } ─┼──▶ super_block
      │                    │  f_pos / f_op        │                                  │
      │                    └──┬───────┬───────────┘                                  │
      │                       │       │                                             │
      │                       ▼       ▼                                             │
      │              ┌────────────┐ ┌──────────────────────────┐                     │
      │              │ dentry     │ │ inode                    │                     │
      │              │ d_parent   │ │  i_ino / i_size / i_uid  │                     │
      │              │ d_children │ │  i_sb ──────────────────────────────────────────┘
      │              │ d_inode ───┼─▶  i_mapping ─┐            │
      │              │ d_alias    │ │  i_data(address_space)   │
      │              └────────────┘ │            │             │
      │                             └────────────┼─────────────┘
      │                                          ▼
      │                            ┌───────────────────────────┐
      │                            │ address_space             │
      │                            │  host → inode             │
      │                            │  i_pages: XArray          │
      │                            │    index → folio          │
      │                            │  a_ops → ext4_da_aops     │
      │                            └─────────────┬─────────────┘
      │                                          │ read_folio / writepages
      │                                          ▼
      │                            ┌───────────────────────────┐
      │                            │ ext4_map_blocks           │
      │                            │  lblk → pblk              │
      │                            │  (extent 树/状态树)        │
      │                            └─────────────┬─────────────┘
      │                                          ▼
      │                            ┌───────────────────────────┐
      │                            │ bio { bi_sector = pblk<<k }│
      │                            └─────────────┬─────────────┘
      │                                          ▼
      │                            ┌───────────────────────────┐
      │                            │ block layer → 磁盘         │
      │                            │ super_block.s_bdev 定位设备│
      │                            └───────────────────────────┘
  1. file → path → dentry → inode → sb(打开文件会话)
  2. inode → i_mapping/i_data → address_space → folio → bio → 磁盘(数据通道)
  3. inode → i_sb → sb → s_bdev / s_fs_info(ext4_sb_info)(设备与私有数据)
  4. task → nsproxy → mnt_ns → mount 树 → vfsmount → sb(命名空间视图)

动手实验:在 6.18.33.2 (WSL2 ubuntu24.04) 上试验

inode 的字段 ↔ 磁盘块的映射(vfsmap.c)

目的:把 struct inode 的关键字段(fstat 可见)与内核 ext4_map_blocks() 的"逻辑块→物理块"结果(FIEMAP 可见)打印出来。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/* vfsmap.c — 把"文件逻辑块 → 磁盘物理块"的映射打出来
 * 即内核 ext4_map_blocks() 在用户态可见的等价结果 (FS_IOC_FIEMAP) */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <sys/stat.h>
#include <linux/fiemap.h>
#include <linux/fs.h>

int main(int argc, char **argv)
{
	if (argc < 2) { fprintf(stderr, "用法: %s <文件>\n", argv[0]); return 1; }
	int fd = open(argv[1], O_RDONLY);
	if (fd < 0) { perror("open"); return 1; }

	struct stat st;
	if (fstat(fd, &st) < 0) { perror("fstat"); return 1; }

	unsigned blk = st.st_blksize;		/* 文件系统块大小(如 4096) */
	printf("== struct inode 的部分字段 (fstat) ==\n");
	printf("  i_ino       = %lu\n", (unsigned long)st.st_ino);
	printf("  i_size      = %lld 字节\n", (long long)st.st_size);
	printf("  i_blocks    = %lld 个 512B 块 (物理占用)\n", (long long)st.st_blocks);
	printf("  i_nlink     = %lu\n", (unsigned long)st.st_nlink);
	printf("  块大小      = %u 字节 (st_blksize, 即 i_blkbits=log2(%u))\n", blk, blk);
	printf("  i_atime     = %ld  i_mtime = %ld  i_ctime = %ld\n",
	       (long)st.st_atime, (long)st.st_mtime, (long)st.st_ctime);
	printf("\n== ext4_map_blocks 的结果: 逻辑块 → 物理块 (FS_IOC_FIEMAP) ==\n");
	printf("  逻辑块号(lblk)  物理块号(pblk)  长度(块)  标志\n");

	struct fiemap *fm = calloc(1, sizeof(*fm) + 128 * sizeof(struct fiemap_extent));
	if (!fm) { perror("calloc"); return 1; }
	fm->fm_start = 0;
	fm->fm_length = st.st_size;
	fm->fm_extent_count = 128;
	if (ioctl(fd, FS_IOC_FIEMAP, fm) < 0) { perror("FS_IOC_FIEMAP"); return 1; }
	struct fiemap_extent *ex = (struct fiemap_extent *)(fm + 1);
	for (unsigned i = 0; i < fm->fm_mapped_extents; i++) {
		printf("  %12llu   %14llu   %8llu   0x%x%s\n",
		       ex[i].fe_logical / blk, ex[i].fe_physical / blk,
		       ex[i].fe_length / blk, ex[i].fe_flags,
		       (ex[i].fe_flags & FIEMAP_EXTENT_LAST) ? "  <- 最后一块" : "");
	}
	printf("  (pblk 换算扇区: pblk << %u = 第 %llu 个 512B 扇区)\n",
	       __builtin_ctz(blk / 512), (unsigned long long)(ex[0].fe_physical / 512));
	free(fm);
	close(fd);
	return 0;
}

真实输出(对 6 字节的小文件 a.txt):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$ ./vfsmap a.txt
== struct inode 的部分字段 (fstat) ==
  i_ino       = 57411
  i_size      = 6 字节
  i_blocks    = 8 个 512B 块 (物理占用)
  i_nlink     = 1
  块大小      = 4096 字节 (st_blksize, 即 i_blkbits=log2(4096))
  i_atime     = 1788079167  i_mtime = 1788079167  i_ctime = 1788079167

== ext4_map_blocks 的结果: 逻辑块 → 物理块 (FS_IOC_FIEMAP) ==
  逻辑块号(lblk)  物理块号(pblk)  长度(块)  标志
             0          2505969          1   0x1  <- 最后一块
  (pblk 换算扇区: pblk << 3 = 第 20047752 个 512B 扇区)

对照内核代码:

打印的字段 内核来源 说明
i_ino = 57411 struct inode::i_ino(fs.h:814) 磁盘上的 inode 编号
i_blocks = 8 struct inode::i_blocks(fs.h:839) 6 字节文件也占 1 个 4K 块 = 8 个 512B 单位
i_blkbits = 12 struct inode::i_blkbits(fs.h:837) i_blocksize() = 1<<12 = 4096
lblk 0 → pblk 2505969 ext4_map_blocks()(fs/ext4/inode.c:698) 文件第 0 块落在磁盘第 2505969 个 4K 块
扇区号 20047752 bio->bi_iter.bi_sector(fs/ext4/readpage.c:364) 2505969 << 3 = 20047752,块层以 512B 扇区为单位

小提示:i_blocks=8 意味着"物理占用"比"逻辑大小"大——文件系统按块分配,这是 du 与 ls -l 大小不一致的原因。

file / dentry / inode 的三角关系(vfsdup.c)

目的:直观看到"两次 open = 两个 struct file(各自 f_pos),共享一个 inode;/proc/self/fd 显示的是 file->f_path 记住的 dentry;unlink 只删 dentry,inode 靠 file 引用存活"。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
/* vfsdup.c — 演示 struct file / struct dentry / struct inode 的区分 */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>

static void show_fd_path(int fd)
{
	char link[64], buf[256];
	snprintf(link, sizeof(link), "/proc/self/fd/%d", fd);
	ssize_t n = readlink(link, buf, sizeof(buf) - 1);
	if (n < 0) { perror("readlink"); return; }
	buf[n] = 0;
	printf("    /proc/self/fd/%d -> %s\n", fd, buf);
}

int main(int argc, char **argv)
{
	const char *path = (argc > 1) ? argv[1] : "a.txt";

	int wfd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
	write(wfd, "0123456789ABCDEF", 16);	/* 写长一点方便演示 f_pos */
	close(wfd);

	int fd1 = open(path, O_RDONLY);
	int fd2 = open(path, O_RDONLY);
	struct stat s1, s2;
	fstat(fd1, &s1);
	fstat(fd2, &s2);
	printf("1) 同一文件 open 两次:\n");
	printf("    fd1=%d  fd2=%d\n", fd1, fd2);
	printf("    inode(fd1)=%lu  inode(fd2)=%lu → 共享同一个 struct inode? %s\n",
	       (unsigned long)s1.st_ino, (unsigned long)s2.st_ino,
	       (s1.st_ino == s2.st_ino) ? "YES" : "NO");
	printf("    但它们是两个 struct file: 各自有独立的 f_pos\n");

	char b1[16] = {0}, b2[16] = {0};	/* 留足空间给 NUL */
	read(fd1, b1, 8);			/* fd1 的 f_pos: 0 -> 8 */
	read(fd2, b2, 8);			/* fd2 的 f_pos: 0 -> 8 (互不影响) */
	printf("    fd1 读到 \"%s\"  (f_pos 0→8)\n", b1);
	printf("    fd2 读到 \"%s\"  (自己的 f_pos 也从 0 开始)\n", b2);

	printf("\n2) struct file->f_path 记住的 dentry (经 /proc/self/fd):\n");
	show_fd_path(fd1);
	show_fd_path(fd2);

	printf("\n3) unlink 之后: dentry 被删, inode 仍靠 file 引用存活\n");
	char tmp[64];
	snprintf(tmp, sizeof(tmp), "%s.del", path);
	int fd3 = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0644);
	write(fd3, "ghost", 5);
	unlink(tmp);
	printf("    unlink(\"%s\") 后 /proc/self/fd/%d 仍然可读, 显示:\n", tmp, fd3);
	show_fd_path(fd3);
	struct stat s3;
	fstat(fd3, &s3);
	printf("    fstat 仍然成功 → inode #%lu 还活着 (i_count>0)\n",
	       (unsigned long)s3.st_ino);
	close(fd3);
	printf("    close(fd3) 后 inode 才被 evict\n");
	close(fd1); close(fd2);
	return 0;
}

真实输出:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
$ ./vfsdup a.txt
1) 同一文件 open 两次:
    fd1=3  fd2=4
    inode(fd1)=57411  inode(fd2)=57411 → 共享同一个 struct inode? YES
    但它们是两个 struct file: 各自有独立的 f_pos
    fd1 读到 "01234567"  (f_pos 0→8)
    fd2 读到 "01234567"  (自己的 f_pos 也从 0 开始)

2) struct file->f_path 记住的 dentry (经 /proc/self/fd):
    /proc/self/fd/3 -> /home/charles/vfslab/a.txt
    /proc/self/fd/4 -> /home/charles/vfslab/a.txt

3) unlink 之后: dentry 被删, inode 仍靠 file 引用存活
    unlink("a.txt.del") 后 /proc/self/fd/5 仍然可读, 显示:
    /proc/self/fd/5 -> /home/charles/vfslab/a.txt.del (deleted)
    fstat 仍然成功 → inode #57408 还活着 (i_count>0)
    close(fd3) 后 inode 才被 evict

对照内核代码:

  • 两个 fd 的 inode 相同 → fstat 走的 fd → files_struct->fdt->fd[fd] → file → f_inode(fs/stat.c),同一 inode 对象。
  • 各自的 f_pos → 每个 struct file 独占的 f_pos 字段(fs.h:1233),所以两次 read 都从 0 开始、互不影响。
  • /proc/self/fd/3 -> /home/charles/vfslab/a.txt → 这是 file->f_path(fs.h:1224)里的 dentry 树被 d_path()(fs/d_path.c)序列化成字符串。
  • (deleted) → unlink 只把 dentry 从目录中摘除(d_delete),inode 的 i_count 因 open 的 file 引用还大于 0,所以 inode 与数据都活着;直到最后一个 file close,iput() 才把 i_nlink==0 的 inode 送进 evict_inode()(fs/inode.c)真正回收。这也是"删不掉正在被读的文件"(Linux 语义)的原因——对比 Windows 的"文件被占用无法删除"。

直接测量页缓存(vcache.c)

目的:用 posix_fadvise(POSIX_FADV_DONTNEED)(无需 root,按文件丢弃缓存)和 /proc/meminfo 的 Cached,精确看到"页缓存 = 文件大小"——这就是 struct address_space::i_pages(XArray)里装的 folio。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/* vcache.c — 直接测量"页缓存"(address_space->i_pages) 的占用
 * posix_fadvise(POSIX_FADV_DONTNEED) = 内核 mapping->invalidate_folio 一路 */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>

static long meminfo_cached_kb(void)
{
	FILE *f = fopen("/proc/meminfo", "r");
	char line[128];
	long kb = -1;
	while (fgets(line, sizeof(line), f))
		if (sscanf(line, "Cached: %ld kB", &kb) == 1) break;
	fclose(f);
	return kb;
}

int main(int argc, char **argv)
{
	if (argc < 2) { fprintf(stderr, "用法: %s <文件>\n", argv[0]); return 1; }
	int fd = open(argv[1], O_RDONLY);
	struct stat st;
	fstat(fd, &st);
	printf("文件: %s  (inode %lu, 大小 %.1f MB)\n", argv[1],
	       (unsigned long)st.st_ino, st.st_size / 1048576.0);

	posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);	/* 从"不在缓存"开始 */
	long c0 = meminfo_cached_kb();
	printf("\n[1] 清缓存后 Cached 基线          : %ld kB\n", c0);

	char buf[65536];
	ssize_t n;
	while ((n = read(fd, buf, sizeof(buf))) > 0) ;	/* 读一遍 → 填页缓存 */
	long c1 = meminfo_cached_kb();
	printf("[2] cat 一遍后 Cached           : %ld kB  (+%ld kB ≈ 文件大小 → 全部进页缓存)\n",
	       c1, c1 - c0);

	lseek(fd, 0, SEEK_SET);
	while ((n = read(fd, buf, sizeof(buf))) > 0) ;	/* 再读一遍 */
	long c2 = meminfo_cached_kb();
	printf("[3] 再 cat 一遍后 Cached        : %ld kB  (增量≈0 → 命中缓存, 不再读盘)\n", c2);

	posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);	/* 逐出该文件所有 folio */
	long c3 = meminfo_cached_kb();
	printf("[4] fadvise(DONTNEED) 后 Cached : %ld kB  (-%ld kB ≈ 文件大小 → 页被逐出)\n",
	       c3, c2 - c3);
	close(fd);
	return 0;
}

真实输出(对 512MB 文件):

1
2
3
4
5
6
7
8
$ dd if=/dev/zero of=big.bin bs=1M count=512 && sync
$ ./vcache big.bin
文件: big.bin  (inode 57412, 大小 512.0 MB)

[1] 清缓存后 Cached 基线          : 386436 kB
[2] cat 一遍后 Cached           : 910724 kB  (+524288 kB ≈ 文件大小 → 全部进页缓存)
[3] 再 cat 一遍后 Cached        : 910724 kB  (增量≈0 → 命中缓存, 不再读盘)
[4] fadvise(DONTNEED) 后 Cached : 386436 kB  (-524288 kB ≈ 文件大小 → 页被逐出)

三个数字都精确等于 512 MiB = 524288 kB——页缓存就是"文件内容在内存里的完整副本",这一份副本就是 address_space->i_pages 里那些 folio,由 filemap_read/filemap_get_pages(mm/filemap.c:2616)在缺页时经 ext4_read_folio 填充,posix_fadvise(DONTNEED) 则触发 invalidate_folio 逐出。

延迟分配 delalloc(写 ≠ 立刻占磁盘)

目的:看到 write() 之后物理块还没有分配(数据在页缓存里),sync 回写时才分配——这是前面「写路径(延迟分配 delalloc 的威力)」一节讲过的 ext4 延迟分配(ext4_da_aops)。

1
2
3
4
5
6
7
8
$ echo "data-for-delalloc" > d.bin
$ filefrag -v d.bin          # sync 之前
 ext: logical_offset:  physical_offset:  length:  flags:
   0:        0..0:         0..0:           0:      last,unknown_loc,delalloc,eof
$ sync                       # 回写
$ filefrag -v d.bin          # sync 之后
 ext: logical_offset:  physical_offset:  length:  flags:
   0:        0..0:     2506049..2506049:    1:      last,eof

delalloc 标志、物理块为 0 —— 正是 ext4_da_write_begin 只记账(extent status tree 里标 EXT4_MAP_DELAYED)不分配(fs/ext4/inode.c:698 的 ext4_map_blocks 走 EXT4_GET_BLOCKS_CREATE 才分配),sync 触发 ext4_writepages() 回写时块 2506049 才真正被 mballoc 分配。

硬链接 = 两个 dentry,一个 inode(d_alias)

目的:直观看到 struct inode::i_dentry 别名链表(fs.h:867)与 dentry::d_alias(dcache.h:128)。

1
2
3
4
5
6
7
$ ln d.bin d.hard
$ ls -li d.bin d.hard
57408 -rw-r--r-- 2 charles charles 18 Aug 30 16:42 d.bin
57408 -rw-r--r-- 2 charles charles 18 Aug 30 16:42 d.hard
$ stat -c '%n  inode=%i  nlink=%h' d.bin d.hard
d.bin  inode=57408  nlink=2
d.hard  inode=57408  nlink=2

两个名字(dentry)、同一个 i_ino=57408、i_nlink=2:内核里这两个 dentry 的 d_alias 都挂在 inode 的 i_dentry 链表上;i_nlink 就是"还有几个名字引用这个 inode",unlink 一个名字它就减 1。

多 extent 文件与空洞

目的:看一个文件的数据块在磁盘上不连续地分布(这正是"文件逻辑块 ≠ 物理块连续"的直观证据,也是 ext4_map_blocks 要查 extent 树的原因)。

1
2
3
4
5
6
7
8
9
$ dd if=/dev/zero of=multi.bin bs=1M count=16          # 写第 0~16MB
$ dd if=/dev/zero of=multi.bin bs=1M count=16 seek=32 conv=notrunc   # 写 32~48MB
$ dd if=/dev/zero of=multi.bin bs=1M count=16 seek=64 conv=notrunc   # 写 64~80MB
$ sync && filefrag -v multi.bin
File size of multi.bin is 83886080 (20480 blocks of 4096 bytes)
 ext:  logical_offset:     physical_offset:  length:  flags:
   0:        0..4095:    2600960..2605055:    4096:
   1:     8192..12287:    2605056..2609151:    4096:
   2:    16384..20479:    2609152..2613247:    4096:  last,eof

解读:文件逻辑块 04095 在物理块 26009602605055;逻辑块 819212287 接在物理块 2605056(正好接续——ext4 尽力做了局部性分配);逻辑块 1638420479 在物理块 2609152。中间的逻辑空洞(40968191、1228816383)在页缓存里读出来是 0,在 ext4_mpage_readpages(fs/ext4/readpage.c:330)里就是"first_hole 部分清零、不发 bio"。

命名空间的"身份证"(vfsns.c)

目的:用 readlink /proc/self/ns/mnt 拿到 ns_common::inum(ns_common.h:44),验证"共享命名空间的进程 inum 相同",并演示读取他人 ns 需要 ptrace 权限。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
/* vfsns.c — 命名空间身份证: ns_common.inum */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

static long ns_inum(const char *path)
{
	char buf[128];
	ssize_t n = readlink(path, buf, sizeof(buf) - 1);
	if (n < 0) return -1;			/* 权限受限等 */
	buf[n] = 0;
	unsigned long v = 0;
	sscanf(buf, "%*[^:]:[%lu]", &v);	/* 解析 "mnt:[4026532211]" */
	return (long)v;
}

static void print_ns_inum(const char *label, const char *path)
{
	long v = ns_inum(path);
	if (v < 0)
		printf("  %-8s: (读取失败, 权限受限)  %s\n", label, path);
	else
		printf("  %-8s: %lu   (%s)\n", label, v, path);
}

int main(void)
{
	setvbuf(stdout, NULL, _IONBF, 0);	/* fork 子进程会继承 stdout 缓冲, 关掉避免错乱 */

	printf("== 本进程 (PID %d) 各命名空间的 inum ==\n", getpid());
	print_ns_inum("mnt", "/proc/self/ns/mnt");
	print_ns_inum("pid", "/proc/self/ns/pid");
	print_ns_inum("net", "/proc/self/ns/net");
	print_ns_inum("uts", "/proc/self/ns/uts");

	long my_mnt = ns_inum("/proc/self/ns/mnt");
	printf("\n== 与其他进程比较 ==\n");
	print_ns_inum("PID 1", "/proc/1/ns/mnt");
	printf("  (读取他人 ns 需要 ptrace 权限 → 无权限时 EACCES, 这也是安全机制之一)\n");

	pid_t shell = fork();
	if (shell == 0) {
		execl("/bin/sh", "sh", "-c",
		      "echo \"  子 shell (PID $$): mnt inum = $(readlink /proc/self/ns/mnt)\"",
		      (char *)NULL);
		_exit(1);
	}
	waitpid(shell, NULL, 0);

	pid_t child = fork();
	if (child == 0) {
		long v = ns_inum("/proc/self/ns/mnt");
		printf("\n== fork() 的子进程 (PID %d) ==\n", getpid());
		printf("  mnt inum = %lu → 与父进程共享 mount ns? %s\n",
		       v, v == my_mnt ? "YES" : "NO");
		printf("  pid inum = %lu (PID namespace 共享, 但 PID 编号不同)\n",
		       ns_inum("/proc/self/ns/pid"));
		fflush(NULL);
		_exit(0);
	}
	waitpid(child, NULL, 0);

	printf("\n== 打开 /proc/self/ns/mnt: 它是 nsfs 文件系统里的一个 inode ==\n");
	system("ls -l /proc/self/ns/mnt");
	return 0;
}

真实输出:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
$ ./vfsns
== 本进程 (PID 5024) 各命名空间的 inum ==
  mnt     : 4026532211   (/proc/self/ns/mnt)
  pid     : 4026532213   (/proc/self/ns/pid)
  net     : 4026531833   (/proc/self/ns/net)
  uts     : 4026532212   (/proc/self/ns/uts)

== 与其他进程比较 ==
  PID 1   : (读取失败, 权限受限)  /proc/1/ns/mnt
  (读取他人 ns 需要 ptrace 权限 → 无权限时 EACCES, 这也是安全机制之一)
  子 shell (PID 5026): mnt inum = mnt:[4026532211]

== fork() 的子进程 (PID 5028) ==
  mnt inum = 4026532211 → 与父进程共享 mount ns? YES
  pid inum = 4026532213 (PID namespace 共享, 但 PID 编号不同)

== 打开 /proc/self/ns/mnt: 它是 nsfs 文件系统里的一个 inode ==
lrwxrwxrwx 1 charles charles 0 Aug 30 16:42 /proc/self/ns/mnt -> mnt:[4026532211]

三个教学点:

  1. 所有进程(含 fork 的子进程、新开的 shell)的 mnt inum 都是 4026532211 → 它们共享同一个 mnt_namespace(也就是共享同一棵 mount 树,copy_mnt_ns 的快速路径 get_mnt_ns(ns),fs/namespace.c:4121)。
  2. mnt=4026532211 ≠ 4026531840(初始 ns 的 inum) → 这台 WSL2 本身就跑在"私有"的 mount namespace 里(WSL 的初始化机制),容器场景下 docker run 的容器进程会得到一个全新的 inum——用 readlink /proc/1/ns/mnt 比对 inode 号即可检测容器/逃逸。
  3. 读取 /proc/1/ns/mnt 报 EACCES → 对他人 ns 文件的访问受 ptrace_may_access 检查(fs/proc/namespaces.c 的 proc_ns_get_link 走 ptrace_may_access(task, PTRACE_MODE_READ_FSCREDS)),这是容器逃逸防护的一环。

strace 看路径解析与 negative dentry

目的:看 open 的路径解析入口——AT_FDCWD 表示相对路径从 current->fs->pwd 出发(fs/namei.c:2590 的 get_fs_pwd),以及大量 ENOENT 正是 dcache 里的 negative dentry(dcache.h:216 DCACHE_MISS_TYPE)在起作用——这些名字被缓存为"不存在",内核无需反复查磁盘。

1
2
3
4
5
6
7
8
9
$ strace -e trace=openat cat /etc/hostname 2>&1 | head -8
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/usr/lib/locale/locale-archive", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/usr/share/locale/locale.alias", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/usr/lib/locale/C.UTF-8/LC_IDENTIFICATION", O_RDONLY|O_CLOEXEC) = -1 ENOENT ...
openat(AT_FDCWD, "/usr/lib/locale/C.utf8/LC_IDENTIFICATION", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/usr/lib/x86_64-linux-gnu/gconv/gconv-modules.cache", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/usr/lib/locale/C.UTF-8/LC_MEASUREMENT", O_RDONLY|O_CLOEXEC) = -1 ENOENT ...

(小实验:cd /etc && strace -e trace=openat cat hostname,会看到 openat(AT_FDCWD, "hostname", ...)——相对路径,起点是 pwd。)

沿着路径逐级走一遍(vfschain.c)

目的:把"路径上的各级目录是怎么串起来的"变成看得见的东西——从 / 开始用 openat(O_PATH) 逐级下钻(这正是内核 link_path_walk() 的缩影),打印每一级的 inode 与设备号;再用 openat("..") 反向走回根,验证 d_parent 链。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
/* vfschain.c — 把一条路径的每一级"串"出来 */
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/sysmacros.h>

#define MAXLVL 64

int main(int argc, char **argv)
{
	const char *path = (argc > 1) ? argv[1] : "/home/charles/vfslab/a.txt";
	if (path[0] != '/') { fprintf(stderr, "需要绝对路径\n"); return 1; }

	char tmp[1024];
	snprintf(tmp, sizeof(tmp), "%s", path);
	char *comps[MAXLVL];
	int n = 0;
	char *save = NULL;
	for (char *c = strtok_r(tmp, "/", &save); c; c = strtok_r(NULL, "/", &save))
		comps[n++] = c;

	printf("路径: %s\n", path);
	printf("== 正向: 从 / 逐级 openat 下钻 (内核 link_path_walk 的缩影) ==\n");

	int dirfds[MAXLVL];	/* 各级目录 fd 保留, 供反向 ".." 验证 */
	int ndirs = 0;

	int fd = open("/", O_PATH | O_DIRECTORY);
	dirfds[ndirs++] = fd;
	struct stat st;
	fstat(fd, &st);
	unsigned prev_maj = major(st.st_dev), prev_min = minor(st.st_dev);
	printf("  [0] \"/\" %*s inode=%-8lu dev=%u:%u  目录\n", 14, "",
	       (unsigned long)st.st_ino, major(st.st_dev), minor(st.st_dev));

	for (int i = 0; i < n; i++) {
		int is_last = (i == n - 1);
		int nfd = openat(fd, comps[i], O_PATH | (is_last ? 0 : O_DIRECTORY));
		if (nfd < 0) { perror(comps[i]); return 1; }
		fstat(nfd, &st);

		int newdev = (major(st.st_dev) != prev_maj || minor(st.st_dev) != prev_min);
		printf("  [%d] \"%s\"%*s inode=%-8lu dev=%u:%u  %s%s\n",
		       i + 1, comps[i],
		       (int)(14 - (int)strlen(comps[i])), "",
		       (unsigned long)st.st_ino, major(st.st_dev), minor(st.st_dev),
		       S_ISDIR(st.st_mode) ? "目录" : "文件",
		       newdev ? "   ← 设备号变了!" : "");

		if (S_ISDIR(st.st_mode))
			dirfds[ndirs++] = nfd;	/* 注意: 不 close, 否则 fd 号会被复用 */
		prev_maj = major(st.st_dev);
		prev_min = minor(st.st_dev);
		fd = nfd;
	}

	printf("\n== 反向: 用 openat(\"..\") 走回 / (dentry->d_parent 链) ==\n");
	int cur = (S_ISDIR(st.st_mode)) ? ndirs - 1 : ndirs - 2;
	for (int i = cur; i >= 0; i--) {
		fstat(dirfds[i], &st);
		int match = 0;
		if (i > 0) {
			int p = openat(dirfds[i], "..", O_PATH | O_DIRECTORY);
			if (p >= 0) {
				struct stat ps, qs;
				fstat(p, &ps);
				fstat(dirfds[i - 1], &qs);
				match = (ps.st_ino == qs.st_ino);
				close(p);
			}
		}
		printf("  [%d] inode=%-8lu dev=%u:%u  %s\n",
		       i, (unsigned long)st.st_ino,
		       major(st.st_dev), minor(st.st_dev),
		       match ? "\"..\" 正好命中上一级 → d_parent 链成立"
			      : (i == 0 ? "(根)" : "\"..\" 检查"));
	}
	return 0;
}

真实输出——同一文件系统内(全部 dev=8:32,ext4):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$ ./vfschain /home/charles/vfslab/a.txt
路径: /home/charles/vfslab/a.txt
== 正向: 从 / 逐级 openat 下钻 (内核 link_path_walk 的缩影) ==
  [0] "/"                inode=2        dev=8:32  目录
  [1] "home"           inode=57346    dev=8:32  目录
  [2] "charles"        inode=42       dev=8:32  目录
  [3] "vfslab"         inode=52633    dev=8:32  目录
  [4] "a.txt"          inode=57411    dev=8:32  文件

== 反向: 从最后一级用 openat("..") 走回 / (dentry->d_parent 链) ==
  [2] inode=42       dev=8:32  ".." 正好命中上一级 → d_parent 链成立
  [1] inode=57346    dev=8:32  ".." 正好命中上一级 → d_parent 链成立
  [0] inode=2        dev=8:32  (根)

真实输出——跨过挂载点(/mnt/wsl 是 tmpfs):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ ./vfschain /mnt/wsl
路径: /mnt/wsl
== 正向: 从 / 逐级 openat 下钻 (内核 link_path_walk 的缩影) ==
  [0] "/"                inode=2        dev=8:32  目录
  [1] "mnt"            inode=49153    dev=8:32  目录
  [2] "wsl"            inode=1        dev=0:30  目录   ← 设备号变了!

== 反向: 从最后一级用 openat("..") 走回 / (dentry->d_parent 链) ==
  [2] inode=1        dev=0:30  ".." 正好命中上一级 → d_parent 链成立
  [1] inode=49153    dev=8:32  ".." 正好命中上一级 → d_parent 链成立
  [0] inode=2        dev=8:32  (根)

两个输出的解读:

  1. 正向链:每一级是一个 dentry(名字)→ inode(d_inode),inode 号逐级变化:2 → 57346 → 42 → 52633 → 57411。内核里这些 dentry 通过 d_parent(向上)、d_sib/d_children(横向)织成一棵树;每一级的内容(下一级名字→inode 号)来自父目录 inode 的磁盘数据块(ext4_dir_entry_2 记录,经 ext4_lookup 读取)。
  2. 跨挂载点:走到 wsl 时 dev 从 8:32(ext4)变成 0:30(tmpfs),inode=1 是 tmpfs 的根——内核在 DCACHE_MOUNTED 的 dentry 上经 follow_mount()/__lookup_mnt() 把游标切进了另一个 super_block 的 dentry 树。
  3. 反向 .. 链:每一级 openat("..") 命中的 inode 与正向记录的上一级完全一致——普通目录走 d_parent;而 tmpfs 根的 .. 能跨回 ext4 的 /mnt,靠的是 mount->mnt_parent + mnt_mountpoint(fs/mount.h:44-45)。
  4. 顺带一提:程序里"不 close 目录 fd,否则 fd 号被复用导致反向全打到同一个文件"这个坑,就是真实内核里"引用计数/句柄生命周期"问题的缩小版。

fd 表与 mountinfo

目的:看进程的 files_struct(fd 表)和它视角下的挂载树(mnt_namespace->mounts 红黑树的文本化输出)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ ls -l /proc/self/fd
total 0
lr-x------ 1 charles charles 64 ... 0 -> /dev/null
lrwx------ 1 charles charles 64 ... 1 -> socket:[25202]
lrwx------ 1 charles charles 64 ... 2 -> socket:[25204]
lr-x------ 1 charles charles 64 ... 3 -> /proc/5077/fd

$ awk 'NR<=2 || $5=="/" {print NR": "$0}' /proc/self/mountinfo
1: 67 71 0:30 / /mnt/wsl rw,relatime shared:1 - tmpfs none rw
2: 68 71 0:32 / /usr/lib/wsl/drivers ro,nosuid,nodev,noatime - 9p drivers ro,...
3: 71 59 8:32 / / rw,relatime - ext4 /dev/sdc rw,discard,errors=remount-ro,data=ordered

mountinfo 每行字段与内核结构对照(fs/proc_namespace.c 的 show_mountinfo):

字段 值 内核来源
挂载 ID 71 mount::mnt_id
父挂载 ID 59 mount::mnt_parent->mnt_id
主:次设备号 8:32 sb->s_dev(/dev/sdc = 8:32)
挂载内根 / vfsmount::mnt_root 相对路径
挂载点 / mount::mnt_mountpoint
挂载选项 rw,relatime vfsmount::mnt_flags
文件系统类型 ext4 sb->s_type->name
来源 /dev/sdc sb->s_bdev
super 选项 rw,data=ordered... sb->s_op->show_options(ext4 实现)

根 inode = 2,验证 EXT4_ROOT_INO

1
2
3
4
$ stat -c '根目录 /  inode=%i (源码 EXT4_ROOT_INO=2)' /
根目录 /  inode=2 (源码 EXT4_ROOT_INO=2)
$ stat -c '/home  inode=%i' /home
/home  inode=57346

ext4 固定 inode 1=坏块、2=根目录(fs/ext4/ext4.h:313 EXT4_BAD_INO=1、EXT4_ROOT_INO=2),挂载时 ext4_iget(sb, EXT4_ROOT_INO, ...)(fs/ext4/super.c:5518)读入的就是它,成为 sb->s_root。

unshare -m - run program in new namespaces

1
2
$ unshare -m true
unshare: unshare failed: Operation not permitted

这正是 copy_namespaces()(kernel/nsproxy.c:147)里的权限检查:

1
2
	} else if (!ns_capable(user_ns, CAP_SYS_ADMIN))
		return -EPERM;

普通用户没有 CAP_SYS_ADMIN,创建新 mount namespace 被拒。

顺带说明:本环境连 sudo 也用不了——/proc/self/status 显示 NoNewPrivs: 1、CapEff: 0、Seccomp: 2,内核级禁止 setuid 提权(与密码无关)。所以下面这些 root 实验是通过docker 特权容器执行的(本账号在 docker 组,docker run --privileged 的容器内 uid=0 且拥有 CAP_SYS_ADMIN,等于一条文档化的 root 通道;/dev/sdc 用 bind mount 带进容器,宿主机二进制用 LD_LIBRARY_PATH + 宿主动态加载器直接跑,零安装)。

在有 root 的机器/容器上,

1
2
3
4
5
6
7
8
9
$ docker run --rm --privileged alpine:3.20 sh -c '
>   ls -l /proc/self/ns/mnt
>   unshare -m sh -c "ls -l /proc/self/ns/mnt; mount -t tmpfs none /mnt; \\
>                     grep \" /mnt \" /proc/self/mounts"
>   grep " /mnt " /proc/1/mounts'
lrwxrwxrwx 1 root root 0 ... /proc/self/ns/mnt -> mnt:[4026532222]   ← 容器自己的 mnt ns
lrwxrwxrwx 1 root root 0 ... /proc/self/ns/mnt -> mnt:[4026532362]   ← unshare -m 又新建一个
none /mnt tmpfs rw,relatime 0 0                                      ← tmpfs 只在新 ns 里可见
                                                                     ← 外面(/proc/1/mounts)看不到它

三个 inum 对照(宿主进程是 4026532211,见 9.7 实验):

视角 mnt ns inum 说明
宿主 shell 4026532211 本机 WSL2 的 mnt ns
docker 容器 4026532222 每个容器默认拿到新 mnt ns(copy_mnt_ns 克隆整棵挂载树)
容器里 unshare -m 4026532362 copy_namespaces + copy_mnt_ns + copy_tree 再克隆一次

tmpfs 挂载只出现在 unshare 之后那个命名空间的 mounts 里,外面看不到——这就是"挂载视图隔离"的字面意思(fs/namespace.c:4108 的 copy_mnt_ns 第二遍会把 fs->root/fs->pwd 同步重定向到新树)。

root 实验(已在本机用特权容器中执行)

以下实验原本需要 CAP_SYS_ADMIN / 块设备读权限,本环境已通过 docker run --privileged + bind-mount /dev/sdc 执行

① debugfs 读磁盘 inode:imap 精确定位 + 公式验证

1
2
3
$ debugfs -R "imap <57411>" /dev/sdc
Inode 57411 is part of block group 7
	located at block 12933, offset 0x0200

用 dumpe2fs -h 的常量亲手算一遍(__ext4_get_inode_loc 的同一套公式):

步骤 计算 结果
常量 Block size=4096, Inodes per group=8192, Inode size=256(dumpe2fs -h) inodes_per_block = 4096/256 = 16
块组 (57411-1) / 8192 7 ✓
组内偏移 (57411-1) % 8192 66
表内块 66 / 16 4 → 块 = 12929 + 4 = 12933 ✓
块内偏移 (66 % 16) × 256 0x200 ✓

(12929 来自 dumpe2fs 完整输出的 Group 7: Inode table at 12929-13440。)公式与 debugfs 结果完全一致。

② 直接把 inode 的原始字节 dd 出来并解码

inode 57411 位于"磁盘块 12933 + 偏移 0x200":dd if=/dev/sdc bs=4096 skip=12933 count=1 | dd bs=1 skip=$((0x200)) count=256,得到 256 字节,对照 struct ext4_inode(fs/ext4/ext4.h:787)逐字段解码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
偏移  字节(小端)        字段           解码值           对照 fstat
0x00  a4 81            i_mode         0x81a4 = 0100644   0644 普通文件 ✓
0x02  e8 03            i_uid          1000               uid=1000 ✓
0x04  10 00 00 00      i_size_lo      16                 16 字节 ✓
0x18  e8 03            i_gid          1000               gid=1000 ✓
0x1A  01 00            i_links_count  1                  nlink=1 ✓
0x1C  08 00 00 00      i_blocks_lo    8                  8×512B ✓
0x20  00 00 08 00      i_flags        0x00080000         EXT4_EXTENTS_FL ✓
0x28  0a f3 01 00 ...  i_block[15]    extent 树根: magic=0xF30A, entries=1, max=4, depth=0
0x34  00 00 00 00      ee_block       0                  (逻辑块 0)
0x38  01 00 00 00      ee_len/start_hi 1 块              (长度 1)
0x3C  ca b5 39 00      ee_start_lo    0x39b5ca = 3784906 (物理块号)

磁盘上 inode 就是一块按 struct ext4_inode 排布的字节——内存里的 struct inode 是它的运行时翻译。

③ 三路验证:原始字节 == debugfs stat == filefrag

用新建文件(fresh.bin,10KB = 3 块)做端到端对照,三方完全一致:

1
2
3
4
5
6
$ filefrag -v fresh.bin          # 内核内存视图
   0:        0..2:  2458911..2458913:   3:  last,eof
$ debugfs -R "stat <213294>" /dev/sdc   # 磁盘视图(debugfs)
EXTENTS: (0-2):2458911-2458913
$ dd ... | od                         # 原始字节: extent: ee_block=0, ee_len=3, ee_start=0x0025851f
0025851f = 2458911 ✓

注:对 a.txt(inode 57411)原始块读到 ee_start=0x39b5ca(3784906),而 filefrag/debugfs 显示 3782090——挂载中的 rw 文件系统上,磁盘 inode 块可能滞后于内核视图(日志/回写时序),这也是为什么内核要先读进内存再校验、以及 debugfs 这类工具只适合离线/只读检查的原因。

④ drop_caches:清空全系统页缓存

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ dd if=/dev/zero of=big.bin bs=1M count=512 && sync
$ grep Cached /proc/meminfo
Cached:   864964 kB
$ docker run --rm --privileged alpine:3.20 sh -c 'echo 3 > /proc/sys/vm/drop_caches'
$ grep Cached /proc/meminfo
Cached:   176196 kB        ← 回落 ~689MB (不止我们的 512MB, 全系统的页都被清了)
$ time cat big.bin > /dev/null
real 0m0.126s
$ grep Cached /proc/meminfo
Cached:   700536 kB        ← 冷读又把它读回页缓存

echo 3 > /proc/sys/vm/drop_caches 等价于触发各 super_block 的 s_dentry_lru/s_inode_lru 和 address_space 的逐出路径(fs/drop_caches.c),比 posix_fadvise(DONTNEED)(9.3)范围更大。

⑤ 挂载传播:private → shared → slave

在私有命名空间里(不影响宿主)用 mountinfo 的 optional 字段观察 mnt_share/mnt_slave 链表(fs/mount.h:68-71)的效果:

1
2
3
4
5
6
7
$ mount -t tmpfs none /mnt
$ grep " /mnt " /proc/self/mountinfo
165 482 0:84 / /mnt rw,relatime - tmpfs none rw        ← 无 shared/slave 字段 = private
$ mount --make-shared /mnt
165 482 0:84 / /mnt rw,relatime shared:230 - tmpfs none rw   ← 加入共享组 230
$ mount --make-slave /mnt
165 482 0:84 / /mnt rw,relatime - tmpfs none rw        ← 变为 slave(无 master 时无字段)

mountinfo 每行第 6 个字段(shared:N / master:N / unbindable)直接对应 mount::mnt_share(循环链表)与 mnt_slave(+mnt_master 指针)的拓扑。


总结

  • super_block 是文件系统实例的"户口本",内存版(fs.h:1446)与磁盘版(ext4_super_block)通过 buffer_head 桥接,s_bdev/s_fs_info 分别连向设备和私有数据。
  • inode 是文件本体的"身份证",i_ino 定位磁盘 inode 表(__ext4_get_inode_loc 的三段除法),i_data/i_mapping 把文件接到页缓存,i_block[15]/i_data[15] 承载 extent 树,实现 lblk→pblk(ext4_map_blocks)。
  • dentry 是路径名的缓存:d_parent/d_children 组成树,d_hash 提供哈希查询,d_inode/d_alias 把名字接到 inode,d_sb 把每个名字归属到文件系统。
  • file 是进程的打开会话:f_path 记住解析结果,f_pos 记住当前位置,f_op 承载读写方法;files_struct 用一张表把进程 fd 和 file 串起来。
  • 磁盘关联是一条四层通道:偏移 >> blkbits → lblk → ext4_map_blocks → pblk → (pblk<<k) 扇区 → bio → submit_bio,中间页缓存(address_space/XArray/folio)与 buffer_head 是内存侧的两大缓冲。
  • namespace 隔离的是"视图"而非"数据":nsproxy->mnt_ns 里的 mount 树(copy_mnt_ns/copy_tree 克隆)决定你看到哪些挂载,但 super_block/inode 全局共享;fs_struct 的 root/pwd 决定路径解析的起点;ns_common.inum 则是命名空间的身份证,/proc/pid/ns/mnt 的 inode 号可用来判断两个进程是否共享挂载视图。

super_block 回答"哪个文件系统",inode 回答"哪个文件",dentry 回答"哪个名字",file 回答"哪个会话";页缓存与块映射把这一切接到磁盘,mnt_namespace 与 fs_struct 把这一切挂到进程。

Licensed under CC BY-NC-SA 4.0
使用 Hugo 构建
主题 Stack 由 Jimmy 设计