Linux文件系统底层原理全解析:从磁盘抽象到软硬链接
本章节的核心目标只有一个:把“文件没有被打开时是怎么躺在磁盘上的”这件事彻底讲清楚。当你真正理解 inode、块组、路径缓存和链接计数后,再看 ls、find、ln,甚至误删恢复,都会有完全不同的认知。
一、从物理磁盘到逻辑抽象:一切源于“解耦”
1.1 磁盘的物理世界
在拆开文件系统这个“软件层”之前,老师先用机械磁盘的物理结构帮我们建立了直觉:
- 盘片、磁头、传动臂:盘片高速旋转,磁头来回摆动。
- 扇区(Sector):磁盘的基本存储单位,512 字节。物理寻址使用 CHS(柱面 C、磁头 H、扇区 S)。
- 磁性原理:盘片表面是无数微小的磁性区域,本质上可以理解为“512 个小磁铁”构成一个扇区,用南北极表示 0 和 1。
1.2 逻辑抽象:三维 → 一维
操作系统并不想在代码里维护一套复杂的三维坐标。于是磁盘被逻辑抽象成一维数组:
- 所有相同半径的磁道连起来,形成一个二维平面;所有柱面再堆叠,变成三维结构。
- 最终在操作系统看来,整个磁盘就是一个大的一维数组,每个元素是一个扇区。
- 从此寻址不再用 CHS,而改用 LBA(逻辑块地址)——一个单纯的数组下标。
- 磁盘内部会自动完成 LBA 与 CHS 的相互转换,对上层完全透明。
1.3 为什么是 4KB 的“块(Block)”?
这是一个经常被忽略、但极其关键的设计。老师强调了两个原因:
💡 零碎知识点:4KB 这个数字不是拍脑袋定的,而是大量实验得出的平衡值。后续讲内存管理时还会提到,内存也是以 4KB(页)为单位管理,磁盘 Block 与此对应,做好了“随时载入内存”的准备。
二、分区、格式化与 Ext2 块组结构
2.1 分区:分而治之
一个大容量磁盘(比如 800GB)无法直接管理。操作系统把它切成多个分区(比如 300GB、200GB)。每个分区可以独立格式化,使用不同的文件系统(如 Ext2、Ext3、Ext4、XFS(一种高性能的日志式文件系统))。
分区的本质只是在磁盘开头记录每个分区的 Start 和 End LBA,类似于当年虚拟地址空间的划分思想。
2.2 格式化的真相:不是清空,而是“画格子”
很多人以为格式化就是擦除数据。实际上格式化根本不碰你的数据内容,它只是向分区写入文件系统的管理信息,包括:
- 超级块(Super Block)
- 组描述符表(GDT)
- Inode 位图 / Block 位图(清零,标记全部为空闲)
- 然后按规则划分出 块组(Block Group)
💡 零碎知识点:删除文件之所以“秒删”,就是因为系统只需要把位图对应比特位清零,数据块本身连碰都不碰。这也解释了为什么误删后只要不再写入新文件,数据是可以恢复的——那些位图只是被标记为“可用”,原数据还在那里。
2.3 一个块组的解剖图(Ext2)
老师反复强调:文件系统以分区为载体(文件系统依赖于分区作为其物理载体,分区提供了一块独立的存储空间,文件系统则在此之上构建逻辑结构以管理数据),研究透分区内一个块组,就研究透了整个分区。一个块组包含以下核心结构:
| Super Block | 描述整个文件系统的宏观信息:inode 总数、block 总数、块大小、最近写入时间等。注意:为了防单点故障,超级块会在多个块组中做备份,内容完全相同。一旦某份损坏,可以用其他的恢复。 |
| GDT(Group Descriptor Table) | 描述当前块组的详细信息:inode 表从哪里开始、data block 从哪里开始、空闲 inode 数、空闲数据块数等。 |
| Block Bitmap | 记录数据块占用情况。1 个比特位 对应 1 个 4KB 数据块。 |
| Inode Bitmap | 记录 inode 节点占用情况。 |
| Inode Table | 存放文件属性(权限、UID/GID、大小、时间戳、数据块指针等)。文件名不在这里! |
| Data Blocks | 存放文件实际内容,以 4KB 为单位。 |

关于 Inode 的几个零碎细节:
- 固定大小:在 Ext2/3/4 中,一个 inode 通常是 128 字节(不排除未来新文件系统会变化)。
- 局部性原理:Inode Table 也是以 4KB 为单位存储的。一个块能装下 4096 / 128 = 32 个 inode。文件系统一次性把这 32 个 inode 读进内存,因为短时间内创建的文件往往具有关联性,后续访问很可能命中缓存。
- 全局唯一:inode 编号和 data block 编号是在分区内全局唯一的,可以跨块组,但绝对不能跨分区。不同分区可以各自从 0 开始编号。(分区:国家;块组:省市;国家对国家,管理方式是不通的,国家内对省市的管理是相通的)
- 定位公式:给定一个 inode 编号,通过除法 + 取模即可算出它在哪个块组、在块组内的哪个位置。数据块同理。
三、目录的本质与路径解析:文件名去哪了?
3.1 目录也是“普通文件”
这是本章节最具颠覆性的认知之一:
在磁盘层面,根本没有“目录”这个概念。磁盘上只有 inode 和 data block 两种东西。
目录与普通文件的存储方式完全一样:
- 目录有自己的 inode(里面存权限、类型等属性)。
- 目录有自己的 data block。
唯一不同的是内容:目录的数据块里存储的不是普通文本或二进制,而是一张**“文件名 → inode 编号”的映射表**。
这就是为什么:
- 文件名不保存在文件的 inode 中(因为文件名要用来当索引)!文件名保存在它所属目录的数据块里。
- 在同一个目录下,文件名不能重复(映射表的 key 必须唯一)(这也就是为什么你在相同目录下,新建文件,系统会给出“新建文件”、“新建文件1”、“新建文件2”…)。
3.2 找文件必须从根目录“逐级解析”
既然文件名散落在各级目录里,那访问文件就必须从根目录开始“层层揭开”:
下面我们把第 4 步拆开,用 /home/whb/lesson21/code.c 这个路径,一步步看操作系统到底做了什么:
整个过程可以用下面这张图直观表示:
#mermaid-svg-BArhiz9fJ538vBc1{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-BArhiz9fJ538vBc1 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-BArhiz9fJ538vBc1 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-BArhiz9fJ538vBc1 .error-icon{fill:#552222;}#mermaid-svg-BArhiz9fJ538vBc1 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-BArhiz9fJ538vBc1 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-BArhiz9fJ538vBc1 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-BArhiz9fJ538vBc1 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-BArhiz9fJ538vBc1 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-BArhiz9fJ538vBc1 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-BArhiz9fJ538vBc1 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-BArhiz9fJ538vBc1 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-BArhiz9fJ538vBc1 .marker.cross{stroke:#333333;}#mermaid-svg-BArhiz9fJ538vBc1 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-BArhiz9fJ538vBc1 p{margin:0;}#mermaid-svg-BArhiz9fJ538vBc1 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-BArhiz9fJ538vBc1 .cluster-label text{fill:#333;}#mermaid-svg-BArhiz9fJ538vBc1 .cluster-label span{color:#333;}#mermaid-svg-BArhiz9fJ538vBc1 .cluster-label span p{background-color:transparent;}#mermaid-svg-BArhiz9fJ538vBc1 .label text,#mermaid-svg-BArhiz9fJ538vBc1 span{fill:#333;color:#333;}#mermaid-svg-BArhiz9fJ538vBc1 .node rect,#mermaid-svg-BArhiz9fJ538vBc1 .node circle,#mermaid-svg-BArhiz9fJ538vBc1 .node ellipse,#mermaid-svg-BArhiz9fJ538vBc1 .node polygon,#mermaid-svg-BArhiz9fJ538vBc1 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-BArhiz9fJ538vBc1 .rough-node .label text,#mermaid-svg-BArhiz9fJ538vBc1 .node .label text,#mermaid-svg-BArhiz9fJ538vBc1 .image-shape .label,#mermaid-svg-BArhiz9fJ538vBc1 .icon-shape .label{text-anchor:middle;}#mermaid-svg-BArhiz9fJ538vBc1 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-BArhiz9fJ538vBc1 .rough-node .label,#mermaid-svg-BArhiz9fJ538vBc1 .node .label,#mermaid-svg-BArhiz9fJ538vBc1 .image-shape .label,#mermaid-svg-BArhiz9fJ538vBc1 .icon-shape .label{text-align:center;}#mermaid-svg-BArhiz9fJ538vBc1 .node.clickable{cursor:pointer;}#mermaid-svg-BArhiz9fJ538vBc1 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-BArhiz9fJ538vBc1 .arrowheadPath{fill:#333333;}#mermaid-svg-BArhiz9fJ538vBc1 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-BArhiz9fJ538vBc1 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-BArhiz9fJ538vBc1 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BArhiz9fJ538vBc1 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-BArhiz9fJ538vBc1 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BArhiz9fJ538vBc1 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-BArhiz9fJ538vBc1 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-BArhiz9fJ538vBc1 .cluster text{fill:#333;}#mermaid-svg-BArhiz9fJ538vBc1 .cluster span{color:#333;}#mermaid-svg-BArhiz9fJ538vBc1 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-BArhiz9fJ538vBc1 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-BArhiz9fJ538vBc1 rect.text{fill:none;stroke-width:0;}#mermaid-svg-BArhiz9fJ538vBc1 .icon-shape,#mermaid-svg-BArhiz9fJ538vBc1 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BArhiz9fJ538vBc1 .icon-shape p,#mermaid-svg-BArhiz9fJ538vBc1 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-BArhiz9fJ538vBc1 .icon-shape .label rect,#mermaid-svg-BArhiz9fJ538vBc1 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BArhiz9fJ538vBc1 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-BArhiz9fJ538vBc1 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-BArhiz9fJ538vBc1 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
根目录 / 的 inode(编号 2)
读取 / 的 data block
映射表:home → inode 128
读取 home 的 inode(128)
读取 home 的 data block
映射表:whb → inode 256
读取 whb 的 inode(256)
读取 whb 的 data block
映射表:lesson21 → inode 384
读取 lesson21 的 inode(384)
读取 lesson21 的 data block
映射表:code.c → inode 512
读取 code.c 的 inode(512)
读取 code.c 的 data block,得到文件内容
💡 零碎知识点:ls 命令打印的每一行,其实就是把目录 data block 里的文件名,与该文件 inode 里的属性(权限、大小、时间)拼接后展示给你。用 ls -li 就能看到每个文件对应的 inode 编号
💡 零碎知识点:每一步“读目录的 data block”都是一次磁盘 IO(除非命中了 Dentry 缓存)。所以路径越深,逐级解析的磁盘 IO 就越多——这正是后面第四部分要讲的 Dentry 缓存要解决的问题。
3.3 分区定位:挂载与路径前缀
路径解析还解决了一个关键问题:如何知道文件在哪个分区?
- 分区必须**挂载(mount)**到某个目录下才能被访问。
- 挂载是操作系统中将存储设备(如硬盘、U盘、光盘等)或文件系统与目录树连接的过程。通过挂载,系统可以访问存储设备中的数据。
- 系统通过路径前缀匹配挂载点(当执行挂载操作时,指定的目录即为挂载点) 来确定文件属于哪个分区,遵循就近原则。
老师现场做了实验:
# 1. 用 dd 伪造一个 5MB 的“磁盘镜像”
dd if=/dev/zero of=deepseek.img bs=1M count=5
# 2. 格式化为 ext4 文件系统(本质是写入 super block、GDT、位图)
mkfs.ext4 deepseek.img
# 3. 挂载到某个目录(需要 root)
mount -t ext4 -o loop deepseek.img ./mnt
# 4. 此时进入 ./mnt 就相当于进入了这个分区
touch ./mnt/test.txt
# 5. 查看挂载状态
df -h
# 6. 卸载
umount ./mnt
当你以后访问 /home/whb/lesson21/code.c 时,系统看到路径前缀匹配了某个挂载点,就能确定你要操作的是哪个分区,然后在该分区内用 inode 编号定位。(该目录与该分区绑定,在目录层面,访问到这个目录,相当于进入你这个分区,可以开始逻辑管理了)
四、Dentry 缓存:内核里的“多叉树”
如果每次访问文件都要从根目录开始做磁盘 IO,效率太低了。Linux 的解决方案是 Dentry(目录项)缓存。
4.1 内存里的路径多叉树
内核在内存中维护了一棵动态增长的 struct dentry 多叉树:
- 根节点是根目录。
- 中间节点是目录。
- 叶子节点是普通文件或空目录。
- 每个 dentry 结点代表文件系统中一个路径节点,例如目录或文件。
当你第一次访问某个路径时,内核从磁盘逐级加载目录节点,在内存中构建这棵树的对应分支。下次再访问相同路径时,直接在内存树中“命中”,无需磁盘 IO。
4.2 实验:find 的“第一次慢,第二次快”
老师课上现场演示了:
find / -name "code.c"
# 第一次执行:明显卡顿,因为路径树不完善
find / -name "code.c"
# 第二次执行:秒出结果,因为目录结构已缓存在内存
4.3 struct dentry 的三重身份
struct dentry 在内存中非常精妙,它同时属于三个数据结构:
💡 概念:LRU 是 Least Recently Used 的缩写,中文意为“最近最少使用”。它是一种常用的缓存淘汰策略,用于在缓存空间有限的情况下决定哪些数据应该被保留,哪些应该被移除。
原理:当缓存达到容量上限时,LRU 策略会优先淘汰最长时间未被访问的数据。也就是说,如果某个数据项在最近一段时间内没有被读取或写入,它就更有可能被移除。
💡 零碎知识点:老师提示,Linux 中“目录树”的概念本质上是内存级的。磁盘上并没有一棵树,只有一堆 inode 和 data block。
五、从 inode 到数据块:超大文件如何映射?
找到 inode 后,如何拿到文件内容?inode 里有一个数据块指针数组(早期内核版本 Ext2 里是 15 个)。
5.1 直接索引与间接索引
- 前 12 个指针:直接指向 data block,适合小文件。
- 第 13 个指针:一级间接索引。它指向的块不存文件内容,而是存下一级 data block 的地址(4KB 块可以存 1024 个 4 字节地址)。凭空多出 1024 个数据块。
- 第 14 个指针:二级间接索引。多一层跳转,多出 1024 × 1024 个块。
- 第 15 个指针:三级间接索引。再多一层,容量达到 1024 × 1024 × 1024 × 4KB,文件上限极其庞大。

5.2 跨块组存储
如果文件特别大,超过了本块组的 data block 数量怎么办?
- 没关系,数据块可以跨块组存储。(都是中国省市,可以跨省运作)
- 只要 inode 编号和 block 编号在分区内全局唯一,系统就能通过全局编号找到它们。
六、软硬链接:看似相似,本质迥异
6.1 软链接(Symbolic Link):独立的“快捷方式”
创建命令:ln -s source(源文件或目录) target(目标路径)
- 独立文件:拥有独立的 inode。
- 内容:data block 里保存的是目标文件的路径字符串。
- 跨分区:可以跨分区链接。
- 特性:删除源文件后,软链接变成“悬空(Dangling)”状态。
老师用 Windows 快捷方式做了类比:软链接就像桌面的 Chrome 快捷方式,双击时系统去读它指向的目标路径,再去开真正的程序。
6.2 硬链接(Hard Link):新增一条映射记录
创建命令:ln source(源文件) target(目标文件)
- 非独立文件:没有独立的 inode,而是目录里新增了一条“文件名 → 目标 inode”的映射。
- 共享 inode:硬链接与源文件共用同一个 inode 编号。
- 不可跨分区:因为 inode 不能跨分区。
- 引用计数:inode 里有一个 link count(链接计数)。新建一个硬链接,计数 +1;删除一个,计数 -1。只有当计数归零时,文件数据才会真正被释放。
目录的硬链接:. 与 .. 的奥秘
这是课上最巧妙的知识点之一:
- 每个目录下默认都有 .(当前目录)和 ..(上级目录)。
- . 是当前目录自身的硬链接,所以新建一个空目录,它的链接数是 2(一个是自己的目录名映射,一个是 .)。
- .. 是父目录的硬链接。你在父目录下每新建一个子目录,父目录的链接数就会 +1。
💡 应用:你可以通过 ls -ld 目录名 看到链接数,链接数 – 2 = 该目录下真正的子目录数量(不包括 . 和 ..)。老师举例:根目录的链接数是 18,说明根目录下有 16 个一级子目录。
硬链接的实际用途:秒级备份
老师举了一个“防误删”的场景:
ln code.c code.c.bak
# 现在 code.c 和 code.c.bak 指向同一个 inode
# 别人执行 rm code.c,你的 code.c.bak 依然能访问内容
# 因为 inode 的 link count 还没归零
不需要复制文件内容,不到一秒完成备份,这就是硬链接的工程价值。
七、课堂代码实证:不只是理论
7.1 观察 inode 与目录映射
ls -li
输出示例(课上数据):
1321114 -rw-rw-r– 1 whb whb 0 Jan 15 15:01 code.c
1321115 drwxrwxr-x 2 whb whb 4096 Jan 15 15:02 dir
左边第一列就是 inode 编号。code.c 的 inode 是 1321114,dir 的是 1321115。
7.2 用代码直接读取目录内容
老师准备了一段 readdir.c,核心逻辑是用 opendir() 打开目录,再用 readdir() 遍历:
// 读取目录时,实际上读的是该目录 data block 里的
// "文件名 – inode 编号" 映射表
while ((entry = readdir(dir)) != NULL) {
printf("文件名: %s, inode: %lu\\n", entry->d_name, entry->d_ino);
}
运行后你会看到目录里每个条目及其 inode,直观验证了目录的存储本质。
opendir:opendir 是一个在编程中用于打开目录的函数,常见于 C 语言和 PHP 等语言中。它主要用于访问文件系统中的目录内容,以便读取其中的文件或子目录。
readdir:readdir 是一个在操作系统中用于读取目录内容的函数,常见于 C 语言标准库以及类 Unix 系统(如 Linux、macOS)的文件系统编程中。它主要用于遍历目录中的文件和子目录。
7.3 软硬链接对比实验
ln -s code.c code-soft # 软链接
ln code.c code-hard # 硬链接
ls -li
观察结果:
- code-soft 会有一个全新的 inode 编号(如 1321116)。
- code-hard 的 inode 编号与 code.c完全相同(如都是 1321114)。
- code.c 的硬链接数会从 1 变成 2。
总结:文件系统的设计哲学
整节课下来,老师始终在强调一个核心思想——“先描述,再组织”:
当你下一次在 Linux 下执行 ls、find、rm、ln 时,你的脑海中浮现的不再只是一行行命令,而是一整幅清晰的画卷:磁头在 LBA 上寻址、块组里的位图在翻转、dentry 树在内存中生长、inode 的链接计数在悄悄变化。
希望这篇基于课堂的博客,能帮你把 Linux 文件系统的底层脉络彻底打通。如果有任何疑问,欢迎在评论区继续探讨!
网硕互联帮助中心


评论前必须登录!
注册