想象你走进一座巨大的图书馆:书架按编号排列,但想快速找到《操作系统导论》,不能靠运气乱逛——你得先去目录室。目录室里是一排排索引卡柜,每张卡片上写着「书名 → 档案编号」。顺着编号,管理员才能从书库取出正文。这座图书馆里,卡柜是「目录」,卡片是「目录项」,而拿着路径名从总服务台一路问过去的过程,正是「路径解析」。 为了让查书更快,图书馆还做了三件事:把卡片按哈希分抽屉、在服务台贴「记忆便签」、在馆区之间修「连廊」。它们对应内核中的哈希目录树、dentry 缓存和挂载机制。这篇文章会用这套「图书馆寻书」的视角,把目录的数据结构、路径解析的每一步、硬链接循环防护与挂载点跨越讲透,让你在排查磁盘 IO、设计容器镜像或理解分布式存储时,拥有一张清晰的「寻址地图」。

岗位关联

学懂目录与路径解析,你就拿下了文件系统最核心的寻址逻辑——无论排查 IO 瓶颈、设计镜像层,还是做分布式元数据,这套底层查找机制都是你分析问题的起点。

目录
  1. 可视化演示
  2. 目录的本质
  3. 目录项结构
  4. 路径解析流程
  5. 哈希加速查找
  6. dentry缓存
  7. 硬链接与循环
  8. 挂载点跨越
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

步进演示路径/home/user/docs的逐级解析过程,展示从根目录开始通过目录项查找inode、dentry缓存加速与挂载点跨越的路径解析机制。

本节配有交互动效(进入视口后自动加载,约 3KB)。

路径解析机制 (Path Resolution) 目录项存储文件名到inode映射 当前解析路径高亮显示 dentry缓存加速重复查找 lookup("/home/user/docs") = 4次inode查找 逐级解析直到到达目标文件

目录的本质

你是图书馆的新管理员。读者问《操作系统导论》在哪,你没有直接冲向书架,而是走进那间贴着「目录室」标签的屋子——里面是一排排索引卡柜。柜子本身不装书,装的是卡片:每张写着「书名 → 档案编号」。有趣的是,这个卡柜自己也有一个档案编号,和普通书一样被登记在册。在文件系统里,目录就是这样一种「装着卡片的特殊档案」。

在操作系统中,目录(directory)并不是一个凭空多出来的「容器」,而是一种特殊类型的文件。普通文件的数据块里是正文内容,比如一篇论文的字节;目录文件的数据块里则是一长串目录项(directory entry)。每个目录项完成一次映射:文件名 → inode 编号。inode 才是文件真正的档案,记录着权限、属主、大小和数据块位置。你 ls 一个目录时,内核其实是在读这个文件的数据块。

由于目录也是文件,它的许多行为就有了合理解释:目录大小会随条目增长而变大,可能产生「空洞」;在目录中创建文件,本质是向数据块追加一条目录项;删除文件时释放的是 inode 和数据块,目录项本身只是被标记为空闲置。这也是为什么删除海量小文件后,目录文件未必立刻变小——很多运维同学第一次看到磁盘空间「没释放干净」,根因就在于此。

理解「目录是文件」,就像管理员想通卡柜也是档案:目录会增长、会空洞、能被缓存,这一切都来自文件的本质。后面要讲的所有加速手段,本质上都是在与「一个文件里塞满了目录项」这个事实做斗争。

dirent=name+inode_no+rec_len\text{dirent} = \text{name} + \text{inode\_no} + \text{rec\_len}
目录项就是一张索引卡:卡面写文件名,卡背贴档案编号,卡边标注尺寸。
  1. name(文件名)目录项中记录的可变长度文件名,最长 255 字节,是查找时的「书名」。
    name∈[1,255] bytes\text{name} \in [1, 255]\ \text{bytes}
  2. inode_no(inode 编号)该文件在 inode 表中的编号,是通往元数据与数据块的「档案号」。
    inode_no∈N+\text{inode\_no} \in \mathbb{N}^+
  3. rec_len(记录长度)这条目录项占用的总字节数,让内核知道下一条目录项从哪里开始。
    rec_len=align_4(header+name_len)\text{rec\_len} = \text{align\_4}(\text{header} + \text{name\_len})

目录项结构

你凑近看一张索引卡,它并不只是「书名 + 编号」那么朴素。卡片顶部有固定印刷的格式位,中间是可变书名,末尾还留了空白补位。图书馆这样设计,是为了让卡片在抽屉里对齐,也在书被调走时,能把新卡塞进同一格。

目录项(dirent)也遵循同样的格式纪律。以 Linux 的 ext 系列文件系统为例,每个目录项由固定头部和可变文件名组成:头部保存 inode 编号、记录长度 rec_len、文件名长度 name_len 和文件类型;后面紧跟着最长 255 字节的文件名。rec_len 是这条目录项的总长度,必须是 4 的倍数。

为什么需要 rec_len 和对齐?因为目录数据块是连续的字节流,内核必须靠 rec_len 才能知道「这一条卡片结束、下一条从哪里开始」。删除目录项时,内核通常不物理清除,而是把 rec_len 并入相邻空闲空间,供新文件复用——这正是目录空洞的来源。而 4 字节对齐,是为了兼容历史磁盘格式,让旧版驱动也能安全遍历。

格式即协议。目录项的结构设计,让目录文件看起来「乱」却高效:变长名字压缩空间,长度字段让遍历有据可依,对齐让硬件和旧系统都不抱怨。就像馆规约束下的卡片,乱中有序。

rec_len=align_4(sizeof(dirent_head)+name_len)\text{rec\_len} = \text{align\_4}(\text{sizeof}(\text{dirent\_head}) + \text{name\_len})
每条目录项都是一张「格式统一、尺寸可变」的卡片,头部固定、名字伸缩。
  1. dirent_head(固定头部)保存 inode 编号、记录长度、名字长度和文件类型,结构大小固定。
    sizeof(dirent_head)=8∼12 bytes\text{sizeof}(\text{dirent\_head}) = 8 \sim 12\ \text{bytes}
  2. name_len(名字长度)用 1 字节记录文件名长度,因此单个文件名上限为 255。
    name_len≤255\text{name\_len} \le 255
  3. align_4(四字节对齐)把总长度向上取整到 4 的倍数,保证后续目录项从对齐地址开始。
    align_4(x)=(x+3) &∼3\text{align\_4}(x) = (x + 3) \ \&\sim 3

路径解析流程

当你拿到一张写着 /home/user/docs/doc 的借阅条,你不会瞬移到文件面前。现实流程是:先到总服务台,在根目录的卡柜里查 home 的编号;来到 home 馆区,又查 user;再到 user 查 docs……每走一层,就查一次卡片。

内核的路径解析(path resolution)完全复刻了这套流程。以绝对路径为例,它从根目录的 dentry(内存中的根目录节点)出发,把路径按 / 拆成多个分量,对每个分量在当前目录的目录项中执行一次 lookup:先查 dentry 缓存,未命中则读目录数据块,得到下一级 inode,再继续。最后的分量就是目标文件。相对路径则把起点换成当前工作目录的 dentry。

路径解析的开销与两个因素强相关:路径深度 L 和每级目录的单次查找代价 C(d)。每进入一级,缓存未命中时就要读一次目录数据块,所以深度越深、目录越大,耗时越长。这就是为什么把文件均匀摊开在浅层目录里,程序启动会更快——根因不在「路径字符长」,而在于要查的卡片层数多。

路径解析就是逐级问路:每到一个馆区,先查卡、再上路。这个朴素的循环构成了文件系统寻址的地基,也解释了「深路径慢」的直觉——不是玄学,是每层都要动手翻一次卡。

Tpath=∑i=1LC(di)T_{\text{path}} = \sum_{i=1}^{L} C(d_i)
总耗时等于每一级「查卡代价」的累加,路径越深,卡片层数越多。
  1. L(路径深度)路径分量的个数。/home/user/docs/doc 的深度为 4。
    L=count(path segments)L = \text{count}(\text{path segments})
  2. d_i(第 i 级目录)路径中每一层的目录对象,它有自己的数据块和目录项集合。
    di=第 i 层的目录 inoded_i = \text{第 } i \text{ 层的目录 inode}
  3. C(d_i)(单级查找代价)在目录 d_i 中查找目标名字的开销,含内存哈希命中或磁盘 IO。
    C(di)=查找 + 可能的磁盘读取C(d_i) = \text{查找 + 可能的磁盘读取}

哈希加速查找

当藏书量达到百万级时,管理员发现顺序翻卡太要命。于是图书馆引入检索引擎:把书名算成编号,按编号分抽屉,查书时直接到对应抽屉里翻——绝大多数情况一拉就中。

文件系统也有同样的引擎。ext4 的 htree(哈希树目录)把目录项组织成索引结构:创建目录项时保存一个由文件名计算的哈希值;查找时先算出目标名字的哈希,沿哈希索引定位到应该存放的数据块,再在小范围内比对名字。这样在大型目录中查找,只需读一两个索引块加一个数据块,而不必从头扫描整个目录文件。

哈希加速的本质是「用名字的指纹换位置」,但它也有软肋:哈希碰撞会把多个名字挤到同一个槽位,查找退化;当目录中文件数极其巨大时,即使有索引,缓存压力和磁盘 IO 也会让性能明显下滑。内核通过增大哈希位宽、分裂索引块等方式缓解,但无法根除——这是所有哈希结构的宿命。

与图书馆的检索引擎同理,哈希把「平均每次查卡」从 O(n) 拉低到接近 O(1)。代价是设计哈希函数和处理冲突——这些工程细节读者不必手写,但排查「大目录为什么慢」时,就有了清晰抓手:多半是哈希退化或索引块不在缓存里。

slot=hash(name) mod N\text{slot} = \text{hash}(\text{name}) \bmod N
一个姓名经过哈希函数算出抽屉号,直接拉开抽屉找卡片,而不是逐个柜子翻。
  1. hash(name)(名字哈希)把字符串映射为一个无符号整数,同一名字永远得到同一结果。
    hash:name→Z\text{hash} : \text{name} \rightarrow \mathbb{Z}
  2. mod N(取模分桶)把哈希值映射到 N 个索引槽之一,决定卡片应该放在哪个抽屉。
    slot∈[0,N−1]\text{slot} \in [0, N-1]
  3. 平均 O(1) 命中绝大多数查找只需一次桶定位加小范围比对,远快于全目录扫描。
    Tavg=O(1)T_{\text{avg}} = O(1)

dentry缓存

老馆长有个习惯:常被问到的书,他会把结果直接抄在一沓便签上贴在柜台。下次有人问,不用再跑卡柜翻抽屉,看一眼便签就报出编号。热点查询因此快得惊人。

内核里的「便签」就是 dentry 缓存。dentry 是目录项(directory entry)在内存中的缓存对象,key 是路径中的一个分量(如 home、user),value 指向对应的 inode,并按父子关系组成内存树。路径解析每走一层,都先在这棵树上查:命中就直接拿 inode,完全不用碰磁盘目录块;只有 miss 时才发起真正的文件系统 IO,并把结果补挂到缓存树上。

缓存不是无限的,内核用 LRU(最近最少使用)策略淘汰冷路径,让热目录长期驻留内存。这里有一个重要的工程权衡:缓存一致性。当其他进程或挂载操作修改目录时,缓存需要失效(invalidate),否则会读到过期映射。文件系统通过版本号和回写机制保证不会把脏数据误当真相太久。

dentry 缓存解释了无数「玄学」:第一次读数据集慢、第二次飞快;容器刚启动时进入目录卡顿,运行一会儿后如丝般顺滑。不是数据进了显存,而是内核的便签本帮你把路记住了。

Tlookup={O(1),dentry 命中O(scan),缓存未命中T_{\text{lookup}} =\begin{cases}O(1), & \text{dentry 命中}\\O(\text{scan}), & \text{缓存未命中}\end{cases}
命中便签就是 O(1) 报编号;没命中才需要跑一趟卡柜,逐张扫描卡片。
  1. 命中分支 O(1)dentry 缓存在内存哈希链中直接命中,免去磁盘 IO,这是路径解析的最快路径。
    Thit=O(1)T_{\text{hit}} = O(1)
  2. 未命中分支 O(scan)需要读目录数据块并扫描目录项,代价取决于目录大小和磁盘状态。
    Tmiss=O(目录项数量)T_{\text{miss}} = O(\text{目录项数量})
  3. 缓存的整体收益大部分热点路径稳定命中,系统整体路径解析近似常数时间。
    命中率↑⇒Tavg→O(1)\text{命中率} \uparrow \Rightarrow T_{\text{avg}} \to O(1)

硬链接与循环

图书馆有一种特殊操作:同一本书,管理员在多个分类柜里各放一张索引卡。读者从「操作系统」或「程序设计」的卡片都能借到同一本书。这就是硬链接——多张卡指向同一份档案。

硬链接(hard link)在某个目录中新增一条目录项,指向一个已存在的 inode,同时把该 inode 的链接计数 nlink 加一。此后无论从哪个名字进入,读写的是同一份数据。删除一个名字只让 nlink 减一;只有 nlink 归零,inode 和数据块才会被真正回收。这就是「删除文件」在硬链接语境下的正确理解:删的只是名字,不是数据。

危险也随之而来:如果允许给目录建硬链接,可能出现 /a/b/a/b/... 无限循环,任何递归遍历都会死循环。因此文件系统对目录实施三道防线:一是禁止用户为目录创建硬链接(系统保留的 . 和 .. 除外);二是保证目录 nlink 至少为 2——空目录的 nlink 也是 2,因为「自身」加「父目录里的 ..」各计一次;三是删除目录时递归检查子目录计数。防的就是「卡柜自己给自己加卡」。

多卡一书让文件的访问入口更灵活,但规则必须锁死「卡柜不能自己加卡」。这是图书馆安全的基石,也是文件系统数据一致性的底线——理解了 nlink,你就理解了为什么 rm 一个硬链接通常「删不掉」真正的内容。

释放 inode  ⟺  nlink=0\text{释放 inode} \iff \text{nlink} = 0
只有当指向这本书的卡片全部撕掉,书才可以下架销毁;只要还剩一张卡,内容就保留。
  1. nlink(链接计数)指向该 inode 的目录项总数,每新建一个硬链接就加一,每删除一个名字就减一。
    nlink=link_count\text{nlink} = \text{link\_count}
  2. 删除名字只移除目录项,不碰数据;只有当计数降到零才触发真正释放。
    unlink⇒nlink−=1\text{unlink} \Rightarrow \text{nlink} -= 1
  3. 归零判定inode 回收的条件是引用计数为零,这是文件系统防止数据提前释放的「最后一道锁」。
    nlink=0⇒回收数据块与 inode\text{nlink} = 0 \Rightarrow \text{回收数据块与 inode}

挂载点跨越

总馆建了分馆,两个馆区之间有一条不起眼的连廊。档案编号规则在两个馆区各自独立,但读者不需要知道:走到连廊尽头,自然进入新馆区的书架区。这条连廊,就是挂载点。

挂载(mount)把另一个文件系统的根目录,附着到当前目录树中某个已存在目录上,这个目录就叫挂载点(mount point)。路径解析走到挂载点时,VFS(虚拟文件系统层)会先查挂载表(mount table):如果该目录正好是某个挂载的入口,就切换到对应的 vfsmount 结构,把后续路径交给新文件系统的根继续解析。整个过程对调用者透明——你只是走进了连廊,并不需要知道换了馆区。

关键语义是覆盖而不是合并:挂载后,挂载点原本的目录项数据被「遮住」,你看到的是新馆区的根目录;执行 umount 后,原来的内容会原封不动地重新出现。这是 U 盘、ISO 镜像、网络盘能接入同一棵目录树的核心机制,也是 chroot 和容器文件系统实现隔离的基础。

连廊让多馆区共享同一套门牌体系。理解挂载点覆盖语义,你就明白为什么 Docker 里挂载一个卷到 /data 后,镜像原本的 /data 内容「消失」了——它们还在档案馆里,只是连廊入口暂时换了方向。

lookup(p)→{vfs_child(d),d 是挂载点fs_child(d),否则\text{lookup}(p) \rightarrow\begin{cases}\text{vfs\_child}(d), & d\, \text{是挂载点}\\\text{fs\_child}(d), & \text{否则}\end{cases}
走到连廊入口时,路标自动切换为新区的地图;否则就按当前馆区的规则继续走。
  1. d(当前目录)路径解析过程中正在处理的目录节点,每一步都要判断它是否为挂载点。
    d=当前目录 dentryd = \text{当前目录 dentry}
  2. 挂载点分支 vfs_child若 d 是挂载点,VFS 查挂载表,取出对应的 vfsmount,把后续解析交给新文件系统根。
    vfs_child(d)=mount 的新文件系统根\text{vfs\_child}(d) = \text{mount 的新文件系统根}
  3. 普通分支 fs_child否则调用该文件系统自己的 lookup 逻辑,在目录项中找下一级名字。
    fs_child(d)=目录项查找结果\text{fs\_child}(d) = \text{目录项查找结果}

AI 应用场景

容器镜像的层叠文件系统(OverlayFS)

阶段:训练环境构建与镜像启动

解决的问题:容器镜像由多层组成,每层都有大量目录项。若每次启动都全量扫描所有层,启动会极其缓慢。OverlayFS 利用目录项缓存与挂载语义,把多层「叠放」成单一视图,路径解析按需穿透各层,只在必要时才到低层查找。

具体案例:用 Docker 启动训练容器时,base 镜像里的 /usr、/lib 与挂载进来的代码卷 /workspace 看似一体。首次 exec 进入 /workspace 较慢,之后飞快——这正是 dentry 缓存与目录项查找共同作用的结果;排查「镜像里明明有文件,挂载后却看不到」,也要回到挂载覆盖语义来理解。

分布式元数据服务的目录分片

阶段:大规模数据集读取管线

解决的问题:千万级小文件集下,单机目录项查找成为瓶颈。借鉴 htree 的哈希索引思想,把元数据按目录哈希分片到多台服务器,让并行查找和横向扩容成为可能。

具体案例:对象存储网关或 JuiceFS 这类系统,在内存中维护目录项缓存并做 LRU 淘汰,命中时避免对后端的远程调用,未命中才回源扫描目录。AI 训练进程反复读取同一批 checkpoint 文件时,缓存命中率极高,训练启动时间大幅缩短。

训练数据加载器的文件打开优化

阶段:DataLoader 与数据预处理

解决的问题:PyTorch 每次迭代读取一张图片都涉及路径解析,小文件多、路径深时系统调用开销被放大,GPU 常常饿着等数据。

具体案例:把几万张小图打包成 TFRecord、WebDataset 或 LMDB,本质是把「大量目录项查找」变成「一次连续读」;也可以在文件监听层面感知目录变更,配合预读把热路径预热进 dentry 缓存。理解路径解析的代价,你就能从「改代码」升级到「改 IO 模式」。

Python 演示

python79 行
import os  class Inode:    """模拟 inode:保存元数据和目录项表/文件内容"""    _counter = 0     def __init__(self, is_dir=False):        Inode._counter += 1        self.ino = Inode._counter        self.is_dir = is_dir        self.nlink = 1        self.dirents = {} if is_dir else None        self.content = b""  class MiniFS:    """极简文件系统:目录数据块用 dict 模拟"""     def __init__(self):        self.root = Inode(is_dir=True)        self.root.nlink = 2  # 根目录的 . 和 ..     def _lookup_one(self, parent, name):        """单级查找:在目录项表中找名字"""        if not parent.is_dir:            raise NotADirectoryError(name)        if name not in parent.dirents:            raise FileNotFoundError(name)        return parent.dirents[name]     def resolve(self, path):        """逐级路径解析:/home/user/docs/doc"""        cur = self.root        if path == "/":            return cur        for piece in path.strip("/").split("/"):            cur = self._lookup_one(cur, piece)        return cur     def create(self, path, is_dir=False):        parent_path, _, name = path.rpartition("/")        parent = self.resolve(parent_path)        node = Inode(is_dir=is_dir)        parent.dirents[name] = node        if is_dir:            node.nlink = 2        return node  def main():    fs = MiniFS()    fs.create("/home", is_dir=True)    fs.create("/home/user", is_dir=True)    fs.create("/home/user/docs", is_dir=True)    fs.create("/home/user/docs/doc")     target = "/home/user/docs/doc"    print("路径解析过程:", target)    cur = fs.root    for piece in target.strip("/").split("/"):        cur = fs._lookup_one(cur, piece)        print(f"  查找 '{piece}' -> inode {cur.ino}  nlink={cur.nlink}")     print("最终对象 is_dir:", cur.is_dir)    print()     print("--- 硬链接模拟 ---")    doc_dir = fs.resolve("/home/user/docs")    doc_dir.dirents["doc_link"] = cur    cur.nlink += 1    print("新增硬链接后 inode", cur.ino, "nlink =", cur.nlink)    del doc_dir.dirents["doc_link"]    cur.nlink -= 1    print("删除一个名字后 inode", cur.ino, "nlink =", cur.nlink, "(未归零,数据保留)")  if __name__ == "__main__":    main()
目的:用几十行代码模拟目录项表和逐级路径解析,并演示硬链接 nlink 计数如何保护数据不被提前释放。
  1. self.dirents = {} if is_dir else None用字典模拟目录的数据块,key 是文件名、value 是 inode 对象,对应真实世界的「目录项表」。
  2. if name not in parent.dirents单级查找的核心判断:目标名字不在当前目录项表中就抛 FileNotFoundError。
  3. for piece in path.strip("/").split("/")把路径拆成分量后逐级查找,完美复刻内核路径解析的循环。
  4. fs.create("/home", is_dir=True)创建目录时也会在父目录的数据块中插入一条目录项,与普通文件的创建流程一致。
  5. doc_dir.dirents["doc_link"] = cur; cur.nlink += 1创建硬链接的本质:新增目录项 + 链接计数加一。
  6. del doc_dir.dirents["doc_link"]; cur.nlink -= 1删除硬链接只移除目录项并减计数,数据内容仍保留。

常见误区

误区我一直把目录想象成一个盒子,文件是被装进盒子里的东西。既然是盒子,它肯定和文件不是一类东西吧?文件夹嘛。

正解目录在文件系统里就是一个特殊文件,它的「内容」不是文件本身,而是一张张目录项记录(文件名 → inode)。“把文件放进目录”的本质是向这个文件追加一条记录,而不是把文件搬进某个空间。

为何常见:图形界面的「文件夹」隐喻太深入人心,用户和初学者都很难把目录和普通文件归为一类。

误区`/home/user/docs/doc` 这么长的路径,内核肯定能从根目录直接跳到 docs 找到 doc 吧?反正有缓存,应该可以一步到位。

正解无论有没有缓存,逻辑上路径解析都必须逐级进行:先找 home,再找 user,再找 docs,最后找 doc。dentry 缓存只是把每一级的结果记住,并没有改变「必须逐级问路」的语义;缓存 miss 时,这一级的磁盘读取代价依然存在。

为何常见:人眼扫路径时能整体感知,这种「一眼看到头」的体验误导了大家,以为内核也能跨级直达。

误区dentry 缓存就是把 inode 缓存一份放内存嘛,反正都是文件的元数据,名字不同而已。

正解dentry 是「路径分量 → inode」的映射缓存,解决的是“把名字翻译成编号”;inode 是文件真正的元数据本体,解决的是“这个文件长什么样”。多个路径分量(比如两个硬链接名)可能映射到同一个 inode,所以两者是不同抽象层,不能混为一谈。

为何常见:两者都以「文件信息」的形态存在于内存,且相互关联,初学者很容易把缓存对象和被缓存的本体搞混。

误区目录里删掉一个文件,磁盘空间就应该立刻释放。有硬链接时我删一个名字感觉文件还在,难道是系统 bug?

正解硬链接让多个目录项指向同一个 inode,删除名字只把 nlink 减一。只有 nlink 归零才回收 inode 与数据块。备份工具「硬链接去重」正是基于这个语义——多名字共享一份数据,任何名字删除都不影响内容。

为何常见:与「删一个文件少一个文件」的日常直觉冲突,缺乏引用计数意识。

误区把 U 盘挂载到 `/mnt/usb`,`/mnt/usb` 里原来的文件应该和新文件混在一起显示吧?就像把两个文件夹合并那样。

正解挂载的语义是覆盖:挂载后你只能看到新文件系统的根内容,挂载点原有目录项被隐藏;卸载后原内容重新出现。它不做合并,而是把目录树中的某个节点临时替换为另一棵树的根。

为何常见:日常「两个文件夹合并」的操作经验被错误迁移到了挂载语义上。

面试题

Q1. 目录在文件系统中的本质是什么?

单选题 · easy

  • A.一个存放其他文件的特殊容器
  • B.一种特殊类型的文件,数据块存目录项← 正确答案
  • C.一段独立于文件系统之外的管理区
  • D.与 inode 表完全相同的结构

答案:B。目录数据块里存的是「文件名 → inode」的目录项,是一种特殊文件,不是独立容器,也和管理区无关。选 B。

Q2. 目录项(dirent)的核心作用是什么?

单选题 · easy

  • A.存储文件正文内容
  • B.记录文件名到 inode 的映射← 正确答案
  • C.保存文件权限位
  • D.管理磁盘空闲块

答案:B。目录项就是索引卡,卡面是文件名、卡背是 inode 编号,完成名字到档案的映射。正文、权限、空闲块都由其他结构负责。

Q3. 路径解析的时间复杂度通常取决于什么?

单选题 · mid

  • A.文件总大小
  • B.路径深度和每级目录查找代价← 正确答案
  • C.磁盘总容量
  • D.文件创建时间

答案:B。路径解析是逐级查卡,总开销等于每一级查找代价之和,深度越深、目录越大越慢,与文件大小和磁盘容量无关。

Q4. 关于目录项哈希索引(htree),下列说法正确的是?

多选题 · mid

  • A.用哈希值快速定位目录项所在数据块← 正确答案
  • B.文件极多时可能退化为顺序扫描← 正确答案
  • C.哈希索引可以完全替代 dentry 缓存
  • D.哈希基于 inode 编号而非文件名

答案:AB。htree 用 hash(name) 定位数据块,减少全目录扫描;超大目录和哈希冲突会有退化风险。但它解决的是磁盘块定位,dentry 缓存解决的是内存名字映射,二者不可互相替代。D 错在哈希基于文件名。

Q5. dentry 缓存中存放的是什么?

单选题 · mid

  • A.完整目录的数据块副本
  • B.文件名到 inode 的内存映射← 正确答案
  • C.所有 inode 的元数据
  • D.文件正文内容

答案:B。dentry 是路径分量到 inode 的缓存对象,类似服务台的记忆便签。它不存完整目录块,也不存正文,更不是 inode 本身。

Q6. 哪些机制共同防止文件系统出现硬链接循环?

多选题 · hard

  • A.禁止对目录创建硬链接← 正确答案
  • B.目录 nlink 至少为 2(含 .. 引用)← 正确答案
  • C.删除目录时递归检查子目录计数← 正确答案
  • D.只允许符号链接跨目录

答案:ABC。禁止目录硬链接掐断循环的源头,nlink 至少为 2 保证目录计数完整,删除时递归校验兜底。符号链接可以跨目录,但这不是防循环的机制。

Q7. 把新文件系统挂载到某目录后,该目录原有的内容会怎样?

单选题 · mid

  • A.被复制到新文件系统
  • B.被隐藏,umount 后可再次看到← 正确答案
  • C.自动合并进新文件系统
  • D.立即删除

答案:B。挂载语义是覆盖而非合并,原目录项被遮住,卸载后原样重现。理解了这一点,容器挂载卷时的「文件消失」现象就不再神秘。

Q8. 路径解析中「挂载点跨越」发生在哪一层?

单选题 · hard

  • A.块设备层
  • B.VFS 层,查挂载表切换 vfsmount← 正确答案
  • C.具体文件系统驱动内部
  • D.磁盘控制器驱动

答案:B。挂载表由 VFS 维护,路径解析到挂载点时由 VFS 查表并把后续查找交给新文件系统。块设备和磁盘驱动只负责读写块,不做路径语义。

Q9. 关于绝对路径与相对路径,正确的是?

多选题 · easy

  • A.绝对路径从根目录 / 开始← 正确答案
  • B.相对路径从当前工作目录开始← 正确答案
  • C.两者解析过程都逐级查找← 正确答案
  • D.相对路径不需要经过任何目录项查找

答案:ABC。绝对路径起点是根 dentry,相对路径起点是当前工作目录 dentry,中间都要逐级查目录项。D 错在相对路径同样要查卡。

Q10. 删除一个硬链接文件时,文件数据会发生什么?

单选题 · easy

  • A.立即删除文件数据
  • B.nlink 减 1,归零后才删除数据← 正确答案
  • C.必须重启系统才生效
  • D.只删除目录项但不释放空间

答案:B。删除名字只让 nlink 减一,只有计数归零才回收 inode 和数据块。这是引用计数的核心语义,也解释了为什么硬链接文件「删不掉」。