文件系统入门:目录项、inode、dentry、fd 与 page cache
前面在整理进程、线程、同步这些内容时,我更多是在关注执行流和资源竞争。真正开始看文件系统之后,我发现这部分最容易让人混乱的地方,不是概念本身有多难,而是很多概念都长得很像:
- 目录和目录项
- 目录项和 dentry
- inode 和 fd
- page cache 和磁盘数据块
如果不把这些层次拆开,就很容易越学越乱。所以这篇先不急着展开 ext4、日志恢复这些更底层的话题,而是先把文件系统里最基础的一条主线理顺:
一个路径到底是怎么被解析的,一个文件到底由哪些结构共同表示,磁盘上的结构和内存里的结构又分别是什么。
一、文件系统到底在管理什么
从最粗的角度看,磁盘本质上就是一大片原始存储空间。它可以存字节,但如果没有文件系统,我并不知道:
- 哪些空间已经被占用了
- 哪些空间还是空闲的
- 某段数据属于哪个文件
- 文件的大小、权限、时间这些元信息是什么
- 一个文件应该怎么通过名字被找到
所以文件系统做的事情,本质上就是把原始磁盘空间组织起来,让上层可以通过“文件”和“目录”这种方式去使用它。
这时候我最先要分清的,是下面这几层:
- 磁盘上的文件系统结构
- 路径和名字的组织方式
- 内存里的缓存和对象
- 进程打开文件后的访问方式
二、磁盘上的基本结构:superblock、inode、数据块
从磁盘布局看,文件系统里通常会有几类非常重要的结构。
1. 超级块
超级块记录的是文件系统整体的全局信息,例如:
- block 大小
- inode 总数
- block 总数
- 空闲 inode / 空闲 block 的统计信息
- 文件系统状态等
它更像是整个文件系统的“总配置”和“总概况”。
需要注意的是,超级块一般不会细到把“每一个 block 是否被占用”都直接记录在自己里面。更具体的占用情况,通常还要靠位图之类的结构去管理。
2. inode
inode 是文件系统里非常核心的结构。它记录的不是文件名,而是文件对象本身的元信息,例如:
- 文件类型
- 权限
- 属主属组
- 文件大小
- 时间戳
- 链接计数
- 文件数据所在位置
这里最重要的一点是:
inode 通常不直接保存文件名。
文件名属于目录这一层,inode 负责的是“这个对象本身是什么,以及数据在哪里”。
3. 数据块
文件的数据内容、目录的数据内容,最终都放在数据块里。
这里还要顺手区分一下扇区和 block:
- 磁盘底层常见按 sector 读写,传统大小常见是 512B,现代也常见 4KB
- 文件系统通常按更大的 block 来管理空间,例如 4KB
所以 block 不是运行时临时拼出来的概念,而是文件系统自己选择的管理单位。这样做有利于分配和寻址,也有利于性能。
三、目录和目录项不是一回事
刚开始看文件系统时,我最容易混的是“目录”“目录项”“dentry”这几个词。
1. 目录本身是文件
目录不是一个抽象盒子,它本身就是一种特殊文件。
既然目录是文件,它就也会有:
- 自己的 inode
- 自己的数据块
只不过普通文件的数据块里放的是文本、图片、二进制内容,而目录文件的数据块里放的是:
一组目录项。
2. 目录项不是文件
目录项只是目录文件中的一条记录。它最核心的作用,就是建立“名字到 inode 编号”的映射。
可以粗略理解成这样:
file1 -> inode 101
docs -> inode 205
img -> inode 309所以更准确地说:
- 目录是文件
- 目录项不是文件
- 目录项是目录文件数据中的记录
3. 文件名保存在父目录中
一个文件自己的 inode 里通常没有文件名。文件名一般存放在它的父目录的数据块里,也就是父目录的目录项中。
这也是为什么“文件名”和“文件本体”不是一回事:
- 文件本体由 inode + 数据位置来描述
- 文件名是父目录里的一条映射记录
四、多级目录是怎么串起来的
当路径是这样的:
/var/log/a.txt路径解析并不是一步到位,而是一层层往下走的。
大致过程可以理解成:
- 从根目录
/开始 - 在
/目录的数据块中找到var -> inode xxx - 读取
var对应的 inode,确认它是目录 - 再去
var目录的数据块中找log -> inode yyy - 继续进入
log目录 - 最后在
log目录中找到a.txt -> inode zzz
所以路径查找的本质,就是:
一层层读取目录内容,查目录项,通过名字找到下一级 inode。
如果最终那个 inode 对应的是普通文件,那么再继续通过 inode 去定位文件的数据块;如果对应的是目录,那么它的数据块里又会继续存放下一层目录项。
五、dentry 是什么,它和目录项是什么关系
目录项是在磁盘上的记录,而 dentry 是内存中的结构。
这两个词很像,但层次完全不同。
1. 目录项
目录项存在于磁盘上,是目录文件内容的一部分。它负责保存:
- 文件名
- inode 编号
2. dentry
dentry 是内核里为了加速路径查找而维护的目录项缓存对象。
它不是磁盘上的目录项本身,也不是一个文件,而是 VFS 层里的内存对象。它更像是在缓存这样一种关系:
(父目录, 文件名) -> 查找结果 / inode之所以不能简单理解成“文件名 -> inode”,是因为同名文件可能出现在不同目录下,例如:
/home/a.txt
/tmp/a.txt这两个 a.txt 显然不是一个对象,所以 dentry 更准确地说是在缓存“父目录 + 名字”的查找结果。
六、inode、page cache 和磁盘数据块是什么关系
这几个概念也很容易混。
1. inode 到磁盘数据块
inode 会记录文件数据在磁盘上的位置。具体文件系统实现可能是块指针,也可能是 extent,但核心意思都是一样的:
inode 能帮助文件系统定位文件真正落在磁盘哪些 block 上。
2. inode 到 page cache
page cache 是内存中的文件页缓存。目录内容页和普通文件内容页都可能进入 page cache。
这里不能简单理解成“inode 直接连着一堆页”,更准确的理解是:
inode 会关联一个页缓存映射,page cache 里的页按文件偏移组织在这个映射下面。
所以可以粗略记成:
inode -> page cache mapping -> cached pages3. page cache 不是磁盘 block 本身
page cache 是内存里的缓存页,而磁盘 block 是磁盘上的存储单位。
两者相关,但不是同一个东西。
我现在更习惯把它们分成两条线去看:
- inode -> 磁盘块映射,用来定位落盘位置
- inode -> page cache,用来定位当前缓存到内存里的文件页
七、路径查找到底在做什么
文件系统里经常会提到“路径查找”或者“路径解析”。我现在更愿意把它理解成:
操作系统根据一个路径字符串,一层层找到目标文件或目录对应对象的过程。
比如路径是:
/var/log/a.txt那系统不是一下子就知道 a.txt 在哪里,而是要按层去找:
- 从根目录
/开始 - 在
/里找var - 在
/var里找log - 在
/var/log里找a.txt
这一步解决的不是“文件内容是什么”,而是:
这个路径最终对应的是哪个文件对象。
所以路径查找和真正读文件内容不是一回事。路径查找的结果通常是:
- 最终的 dentry
- 最终的 inode
只有后续真正 read() 的时候,才会进一步去看文件内容页。
八、访问一个路径时,三大缓存和 inode 是怎么配合的
以访问 /var/log/a.txt 为例,内核大致会做下面这些事。
1. 从根目录开始逐级解析路径
每解析一层路径分量,都会优先尝试查找:
- dentry cache
- inode cache
如果缓存命中,就可以直接往下走;如果没有命中,才需要进一步读目录内容。
2. dentry cache 不是 page cache 的替代品,而是更高层的名字缓存
这里我一开始最容易误解的是:路径查找是不是就不用 page cache 了。
其实不是。更准确地说,是:
路径查找优先走 dentry cache,只有 dentry cache 不够用时,才会去借助目录页的 page cache。
原因也不复杂:
- dentry cache 缓存的是“名字查找结果”
- page cache 缓存的是“目录内容页 / 文件内容页”
如果 dentry cache 已经命中,那就没必要再去扫描目录页。
3. dentry miss 时,先通过父目录 inode 去找父目录页
如果某一级目录查找没有命中 dentry cache,这时不是立刻去“创建子 inode”,而是要先回到当前父目录本身。
更准确的流程是:
- 已经拿到父目录的 dentry 和 inode
- 通过父目录 inode 去查它的目录内容页是否在 page cache 中
- 如果不在,就从磁盘把这个父目录的目录页读入 page cache
- 在目录页中扫描目录项,找到目标名字对应的 inode 编号
- 再根据这个 inode 编号去查 inode cache
- 如果 inode cache 里没有,再从磁盘 inode 区加载并创建内存 inode 对象
- 最后建立或补全对应的 dentry
所以这里 page cache 里放的是:
/的目录页/var的目录页/var/log的目录页
它们都属于对应“父目录 inode”的内容页。
4. 路径查找命中和 miss 的链路可以分开看
如果路径查找命中得很好,那么更像是:
父 dentry -> 子 dentry -> 子 inode这时候通常不需要重新扫描目录页。
如果 dentry miss,则更接近:
父 dentry
↓
父 inode
↓
父目录页 page cache(没有则读磁盘)
↓
扫描目录项
↓
子 inode号
↓
inode cache(没有则加载)
↓
子 inode
↓
建立/补全子 dentry所以 page cache 不是每次路径查找都会参与,但只要需要读取目录内容,它就会参与。
九、open 和 read 不是一回事
这个问题前面反复讨论之后,我觉得特别值得单独记一下。
1. open 主要做什么
open() 更像是在做下面几件事:
- 路径解析
- 权限检查
- 打开状态建立
- 返回一个文件描述符
也就是说,open() 的重点是“把这个文件打开”,不是“把这个文件全部读进内存”。
2. read 才会真正触发文件内容读取
通常情况下,目标普通文件的数据页不会在 open() 时整体进入内存,而是在真正 read() 时按需进入 page cache。
所以从一般情况看,可以把它理解成:
open()主要拿到 dentry、inode,并创建打开状态read()才会真正按偏移去读文件内容页
当然,路径解析过程中仍然可能读取目录页、inode 元信息,但这和“把目标文件正文整个读进来”不是一回事。
十、fd、file、inode、dentry 到底怎么串起来
这几个对象是我一开始最容易混掉的,现在整理下来,大致可以这样理解。
1. dentry
负责路径查找,解决的是:
这个名字在这个父目录下,对应谁。
2. inode
负责描述文件对象本身,解决的是:
这个对象是什么,属性如何,数据在哪里。
3. open 通常先完成路径查找,再创建 file 和 fd
我现在更愿意把 open() 理解成两段:
第一段:路径查找
先根据路径字符串,把目标文件对应的 dentry 和 inode 找出来。
第二段:建立打开状态
在找到目标 inode 之后,再创建一个 file 对象,记录这一次打开的状态,例如:
- 打开方式
- 当前偏移量
- 状态标志
随后再在进程的 fd 表里分配一个整数句柄,把 fd -> file 这条映射建立起来。
所以从这个角度看,open() 主要是在做:
路径
↓
路径查找
↓
inode
↓
创建 file
↓
分配 fd这一步更多是在回答:
这个路径到底对应哪个文件,以及这次我要怎么打开它。
它通常还没有真正开始读普通文件的正文内容。真正访问文件内容,往往还是在后面的 read()、write() 或 mmap() 阶段。
4. fd
fd 是进程里看到的整数句柄。它并不直接指向 inode,而是先在进程自己的 fd 表里找到对应的 file。
所以从进程访问文件的角度看,更顺的一条链是:
fd -> file -> inode -> page cache / 磁盘数据而从路径查找的角度看,更顺的一条链是:
路径 -> dentry -> inode十、小结
把前面这些东西串起来之后,我现在更愿意这样理解文件系统:
- 磁盘上,文件系统用 superblock、inode、block、位图等结构来组织空间
- 目录本身是文件,目录的数据块里存的是目录项
- 目录项负责“文件名 -> inode 编号”的映射
- inode 负责记录文件元信息和数据位置
- dentry cache 缓存目录项查找结果,page cache 缓存目录内容页和文件内容页
- 路径查找优先走 dentry cache,dentry miss 时再通过父目录 inode 去查目录页的 page cache
- 从目录页中拿到子 inode 号之后,才会去查 inode cache 或加载对应 inode
open()主要完成路径查找和打开状态创建,read()才会真正按需读取文件内容- 进程真正访问文件时,通常是通过
fd -> file -> inode -> page cache/磁盘这条链完成的
到这里,文件系统里最基础的这条主线就算是先搭起来了。后面再去看硬链接、软链接、删除文件但进程仍可访问、page cache 回写、零拷贝这些问题时,会顺很多。
