很多人第一次接触"文件"这个概念,是多亏了 ls -l 那一排输出:文件名、权限、所有者、大小、修改时间。但如果你以为自己用 ls 看到的那个"文件"就是磁盘上真实存放的一整块数据,那就把故事想简单了。真相是:你在屏幕上看到的那个 test.c,其实只是磁盘浩如烟海的扇区里,被一套精密的"账本"串起来的一组编号而已。扯开这套账本的封皮,你就走进了操作系统最迷人的车间之一——文件系统。
这一篇我们只讲一件事:磁盘上到底住着一个什么样的世界,Linux 又是靠什么把"一个名字"翻译成"一堆扇区"的。我们会从一张铁皮盘上的扇区讲起,一路讲到块、分区、inode、块组,再到软硬链接,最后落到几个在实际项目中经常坑人的边界问题。
在动笔之前,我需要先跟你约定一个视角:现在 Linux 上最主流的 ext4 其实内核早就换成了"extents + 日志"这套更现代的机制,但它的"骨架"——inode、块组、位图这套设计——从 ext2 时代就没变过。所以本篇文章以结构最干净的 ext2 为讲解对象,ext3/ext4 只是在这个骨架上做增强。地基稳了,上面住几层楼都行。
扇区与磁头:磁盘上最小的一格
机械硬盘(HDD)里最核心的部件是一堆同轴的圆盘,我们叫它盘片(platter)。一张盘片的上下两面都覆盖着一层磁性材料,就像你小时候玩过的磁粉,只是更精细。每个盘面上方悬着一个磁头(head),它负责在盘面上"读"或者"写"那层磁粉的方向——磁粉极性朝一个方向记为 0,朝另一个方向记为 1,于是数据和"磁铁"挂上了钩。这就是为什么前辈老师爱说"磁盘就是一块磁铁"。
关键来了:磁头并不是一个孤零零的单元格,它是一根传动臂上的"串串"。传动臂往内一伸,所有盘面上的磁头是一起进退的——也就是说,左右两个盘面上的两个磁头永远停在"同一个半径"上。这个"同半径"在物理上怎么称呼?我们把所有盘面上、距离圆心"一样远"的那些磁道合起来,叫一个柱面(cylinder)。柱面是个逻辑概念,但它特别有用:因为磁头共进退,所以在一次定位之后,磁头其实能接触到每个盘面上同一个半径的磁道。
一条磁道再继续切分,就成了一个扇形区域,我们叫扇区(sector)。扇区是磁盘读写的最小单位,经典值是 512 字节。所谓"最小单位",意思是你要读数据可以覆盖一个扇区,但要写就只能整块地写,不能只改里面的半个字节,剩下的数据你得先读出来拼好再整块写回去。
于是在传统视角下,要精确定位一个扇区,需要三个坐标:柱面号 C、磁头号 H、扇区号 S。这套定位方式就叫 CHS 寻址。它的容量天花板很低:早期 BIOS 用 8 bit 表示磁头数、10 bit 表示柱面数、6 bit 表示每道扇区数,最大能表达的容量约是 256×1024×63×512 字节,也就是大约 8.4 GB 上下。这点良心容量根本喂不饱现代的大容量硬盘。
于是有了更聪明的做法:既然一片磁盘能看成无数个扇区堆叠,那我们干脆给每个扇区编一个从 0 开始递增的"序号",用这个序号直接找扇区,这就是 LBA(Logical Block Address,逻辑块地址)。你可以把整个磁盘想象成一个元素为扇区的一维数组,数组下标就是 LBA。
我的看法是,LBA 的价值不在于"新",而在于"把复杂藏起来"。CHS 和 LBA 怎么互相换算,操作系统根本不关心——那是磁盘自己的固件(伺服系统、硬件电路)在内部完成的。OS 想读某块数据,只要说"我要第 N 个扇区",磁盘自己知道 N 该去哪个柱面哪个磁道哪个扇区取。这就像你叫快递不用知道中转站车次,只要你报对门牌号。
如果你好奇换算公式,这里给你一张"速查卡",// 表示整除:
CHS -> LBA:
LBA = C * (每柱面扇区数) + H * 每磁道扇区数 + (S - 1)
其中:每柱面扇区数 = 磁头数 * 每磁道扇区数
LBA -> CHS:
C = LBA // (磁头数 * 每磁道扇区数)
H = (LBA % (磁头数 * 每磁道扇区数)) // 每磁道扇区数
S = (LBA % 每磁道扇区数) + 1
要注意:扇区号在 CHS 里从 1 数起,而 LBA 从 0 数起,所以上面有那个 +1 和 -1。这套换算不用背,稍有印象就行——真正的重点是那个"下标":从此以后,OS 眼里磁盘就是一个可以按数字访问的扇区大数组。
块:文件的"最小居住格"
如果每个文件都按扇区(512 字节)为单位跟磁盘打交道,那效率会低得可怜。夸张点说,为了读一个 300 字节的小文件,你要发起一次磁盘定位、读取、传输,一个扇区只装了一半数据。所以现代操作系统读写磁盘,习惯"多攒几个扇区一起干",于是有了**块(block)**的概念。
块是格式化时定死的,格式化之后就不能改,最常见的块大小是 4 KB,也就是连续 8 个扇区拼在一起。块是文件存取的最小单位——注意,这句话份量很重:哪怕你写一个只有 1 个字符的文件,它也要占掉整整一个 4 KB 的块。这点后面讲"删文件还占空间"时还会回来坑你。
块和扇区、LBA 之间也是简单的换算关系:
知道 LBA,块号 = LBA // 8 (假设 8 扇区一块)
知道块号,LBA = 块号 * 8 + n (n 是块内第几个扇区)
磁盘从"三维数组"被我们看成一个"一维数组"之后,块就是在这个数组上又划出的一格一格的"房子"。接下来的问题就是:这些房子不是凭运气铺的,得有人管理。
分区:把磁盘切成管理单元
一块大磁盘可以直接用吗?能,但不好用。Windows 下你习惯把一块硬盘分成 C、D、E 盘,Linux 里这块磁盘同样可以被切成多个分区(partition)。
分区怎么切?我们前面讲过磁头共进退,柱面天然是一个"整体"。于是分区的最小单位通常就是柱面:你指定一个分区的起始柱面和结束柱面,这片区域就是一个分区。把一个个分区在逻辑上"平铺"开,它们就像一块大平面上的几个小区间。
这样想就清楚了:因为每个柱面上扇区数一致,所以只要知道一个分区的起始、结束柱面号,以及每个柱面有多少扇区,这个分区占多少 LBA、多大容量,就都算得出来了。这也解释了为什么后面很多文件系统概念都带"在指定分区内"的前提——分区是一切的边界。
inode:文件属性和内容的"分家"
现在我们把目光放回 ls -l 的输出:
total 12
-rwxr-xr-x. 1 root root 7438 Sep 13 14:56 a.out
-rw-r--r--. 1 root root 654 Sep 13 14:56 test.c
每一行有 7 列:模式、硬链接数、所有者、组、大小、最后修改时间、文件名。这些信息一大堆,到底存在哪?ls -l 只是把它们从文件系统里"读"出来再显示给你看罢了。
想看到更完整的信息,请出 stat:
$ stat test.c
File: test.c
Size: 654 Blocks: 8 IO Block: 4096 普通文件
Device: 802h/2050d Inode: 263715 Links: 1
Access: (0644/-rw-r--r--) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2017-09-13 14:56:57.059012947 +0800
Modify: 2017-09-13 14:56:40.067012944 +0800
Change: 2017-09-13 14:56:40.069012948 +0800
看见 Inode: 263715 这几个字没有?这就是我们要等的"主角"。一个文件的内容和它的属性是分开存储的:内容放在那些块里,属性(所有者、大小、时间、权限……)则放在一个叫做 inode 的结构里。inode 中文译作"索引节点",每个文件都有、并且只有一个 inode;每个 inode 里有一个全文件系统唯一的编号,就叫 inode 号。
一句话:文件 = 内容 + 属性,内容在数据块里,属性在 inode 里,两者靠 inode 号对上。
注意一个反直觉的点:文件名并不存放在 inode 里。 inode 里装着的是模式的标志位、uid/gid、文件大小、三个时间戳、链接数,还有最重要的指向数据块的指针数组。我们现在的任务,就是弄清 inode 里那片指针到底怎么把"文件"的两半拼起来,而这就得先看看 inode 住在"哪套房子里"——也就是文件系统的整体布局。
文件系统登场:一个分区的标准布局
一块分区随便怎么摆都行吗?不行。磁盘不像内存,不是上电就"自动有结构"。我们得先给它"格式化",也就是往里面写一套管理用的数据结构,这套结构的总称就是文件系统。在 Linux 上最常见的是 ext2 系列(后来的 ext3、ext4 是其增强版)。
ext2 会把整个分区划分成若干个块组(Block Group),每个块组内部结构几乎一模一样。先把整幅地图画出来:
+------------------------------------------------------------------+
| Boot Block(启动块,1KB,不归文件系统管) |
+------------------------------------------------------------------+
| Block Group 0 |
| Super Block(超级块) |
| GDT(块组描述符表) |
| Block Bitmap(块位图) |
| Inode Bitmap(inode 位图) |
| Inode Table(inode 表) |
| Data Blocks(数据块区) |
+------------------------------------------------------------------+
| Block Group 1 |
| Super Block(备份)/ GDT/ 位图/ inode 表/ 数据块 |
+------------------------------------------------------------------+
| Block Group 2 |
| ... |
+------------------------------------------------------------------+
你要做的第一件事,是把**启动块(Boot Block)**从文件系统里"摘"出去。它固定在分区最开头,大小约定为 1 KB,由 PC 标准规定,用来存放分区信息和开机引导代码,任何文件系统都不能修改它。启动块之后,ext2 的文件系统才正式开始。
下面把块组里每一格都过一遍。
超级块(Super Block)
块组开头的超级块是整个文件系统的"户口本",记录的是描述整个分区的总体信息:block 和 inode 的总量、已用和空闲数量、每个 block 和 inode 的大小、最近挂载时间、最近写入时间、最近一次检错时间等等。超级块一旦损坏,整个文件系统结构就"失忆"了,后果接近外科手术级别的灾难。
稳妥起见,超级块在每个块组的开头都留了一份拷贝;第 0 个块组的超级块是"正本",后面的块组里的叫备份,它们的数据保持一致。为什么敢这么铺张?因为磁盘扇区可能出物理坏道,多留几份,万一某一块坏了,系统还能从备份里把家底捞回来。
GDT:块组描述符表
每个块组都有自己的"小名片",这份名片集中放在块组的**块组描述符表(GDT,Group Descriptor Table)**里。整个分区有多少个块组,GDT 里就有多少个描述符。每个描述符记着这个块组的一个关键信息:Inode Table 从哪个块开始、Data Blocks 从哪个块开始、空闲 inode 和数据块还有多少、这个块组的性 bitmap 在哪个块……相当于"你所在块组的目录页"。
同样出于抗坏道考虑,GDT 在每个块组也都有备份。
块位图(Block Bitmap)与 inode 位图(Inode Bitmap)
这是两张极其朴素的"清单",却极其关键。块位图一次管理整个块组的数据块:每一个 bit 对应一个数据块,1 表示"已被占用",0 表示"空闲可用"。inode 位图同理,每个 bit 对应一个 inode,标出哪些 inode 空闲、哪些已被分配。
位图的价值在于"查找空闲格子"变得非常快:内核想给新文件找个空块或空 inode,只需要扫一遍位图就能定位,而不是去逐个试。这也为后面"磁盘明明没满却写不进去"埋下了伏笔——注意:块满了是一种满法,inode 用尽又是另一种满法,这两张位图各自独立记账!
inode 表(Inode Table)
inode 表就是存放 inode 的地方,里面是"当前块组所有 inode 的集合"。inode 的大小常见为 128 字节或 256 字节。每个 inode 相比于别的 inode 大小一样,但每个 inode 里"塞的文件内容"可大可小——大小不同只在数据块上体现,不在 inode 上体现。
这里必须先立下一个重要的边界:inode 编号以分区为单位整体划分,不可跨分区。 也就是说 inode 号只在"一个分区(一个文件系统)"内有意义,两个不同分区的 inode 号各自独立、互不通用。这一点后面讲"硬链接不能跨文件系统"时会回来用上。
数据块区(Data Blocks)
最后一大片区域就是数据块区,真正存放文件内容的地方。普通文件的内容在这里;但目录的"内容"也是存在这里的——目录的数据块里存的是"它下面的文件名 + 对应 inode 号"这种映射关系,这个等会儿单独讲。
按同样的道理,块号也是按分区划分的,不可跨分区。
到这里,你可能已经发现 ext2 的结构其实非常对称:一个块组 = "超级块 + 描述表 + 两张位图 + inode 表 + 数据块区"。它本质上是一套"位图管块、inode 管属性、数据块管内容、名字靠目录映射"的四层记账体系。
inode 怎么找到数据块:直接块与间接块
光有布局还不够,最硬核的问题是:**给定一个 inode,我怎么顺着它读到文件的内容?**答案藏在 inode 里的指针数组 i_block[15] 中。
struct ext2_inode {
...
__u32 i_block[EXT2_N_BLOCKS]; /* Pointers to blocks */
...
};
#define EXT2_NDIR_BLOCKS 12
#define EXT2_IND_BLOCK 12
#define EXT2_DIND_BLOCK 13
#define EXT2_TIND_BLOCK 14
#define EXT2_N_BLOCKS 15
i_block 有 15 个槽位,把 15 个指针大体分成四档:
i_block[0] ──┐
i_block[1] ──┤
i_block[2] ──┤ ...
i_block[11] ──┴── 前 12 个是"直接块指针":直接指向一个数据块
i_block[12] ──── 一级间接:指向一个"块索引块",该块里存的是指向数据块的指针
i_block[13] ──── 二级间接:指向"块索引块",它又指向一层"块索引块",最后一层才指向数据块
i_block[14] ──── 三级间接:再多套一层
- 直接块(direct block):
i_block[0..11],一共 12 个,谁里面直接存的就是数据块的块号。适合小文件,一次到位,最快。 - 一级间接(single indirect):
i_block[12]存的是一个"索引块"的块号,而这个索引块本身是一个普通块,里面塞满了 1024 个指针(对 4KB 块、每个指针 4 字节而言),每个指针各自指向真正的数据块。 - 二级间接(double indirect):
i_block[13],先指向一个"索引块",这个索引块里的每个指针又指向下一层索引块,到了最深那一层才指向数据块。 - 三级间接(triple indirect):
i_block[14],套三层。
这套设计的精髓是"小文件好用、大文件能长大"。拿默认 4 KB 块、每个索引块能装 1024 个指针来算:
| 档位 | 能表示的容量 |
|---|---|
| 12 个直接块 | 12 × 4 KB = 48 KB |
| 一级间接(1024 个指针) | 1024 × 4 KB = 4 MB |
| 二级间接(1024 × 1024 个指针) | 约 4 GB |
| 三级间接(1024 × 1024 × 1024 个指针) | 约 4 TB |
把四档加起来,ext2 在 4 KB 块下支持的最大单文件大小大约在 4 TB 附近。对照线上 stat 输出里那行 Blocks: 8——它表示该文件占用了 8 个 512 字节的"逻辑扇区块"(注意这里 stat 的 Blocks 单位是 512 字节大小,不是 4KB 的块),而 IO Block: 4096 才是数据块(4KB)。这就是两类"块"容易让人犯迷糊的地方,我在结尾的思考题里会再出一道。
**为了一个几字节的小文件,内核干嘛要盖这么多层楼?**答案是:直接块使小文件只花"一次间接都省"的成本,非常快;而间接块让文件能突破"固定指针数量"的天花板,理论上想多大就多大。这种"兼得"其实是非常经典的工程设计。
顺带说明一点:ext4 早已改用"extent(区段)"机制来表达大文件(一个 extent 可以描述一大段连续块,极大减少指针数量),从而把上限推到 16 TB 甚至更高。但"多级索引 + 指针数组"这套旧心智模型,能帮你把 inode 的本质——"记录数据块位置的账本"——理解得透透的。
用 inode 号做增删改查:新建一个文件看看
现在把整套机制串起来。假设我们 touch abc 然后 ls -i abc:
$ touch abc
$ ls -i abc
263466 abc
编号 263466 就是 abc 的 inode 号。整个过程内核干了四件事:
- 分配 inode 存属性:扫 inode 位图,找到一个空闲 inode(这里是 263466),把文件的模式、时间戳等属性写进去。
- 分配数据块存内容:假设这个文件的内容需要 3 个数据块,内核从块位图里翻出 3 个空闲块,编号假设是 300、500、800,把内容分别拷进去。别误会,文件内容通常是顺序写的,但块号并不一定连续——这没关系,inode 会记下它们的顺序。
- 在 inode 里记分配情况:把
300, 500, 800这个"块列表"登记到 inode 的i_block指针区。 - 把"名字 → inode"写进目录:在当前目录的数据块里,加一条记录
(263466, abc),把文件名和 inode 号拴在一起。
于是"文件名 abc"和"内容 + 属性"就通过这条目录记录 + inode 连接起来了。这也顺便回答了一个宏观问题:所谓"格式化",本质上就是用 mkfs 工具把分区划分成若干块组,在每个块组里写好超级块、GDT、块位图、inode 位图这些管理信息。格式化的产物,那个"管理信息的总和",就叫文件系统。
目录:一张"名字→inode"的表
问题来了:我平时访问文件都打文件名,从来没打过 inode 号啊,这个 inode 到底凭什么跟名字对上?答案就是:目录本身也是一个文件,磁盘上并没有"目录"这种神秘实体,只有"文件属性 + 文件内容"两件事;目录的文件属性平平无奇,真正特别的是它的内容——里面存着一堆"文件名 → inode 号"的映射。
目录的内容(本质就是很多个小记录):
+------------------+---------------+
| 文件名 | inode 号 |
+------------------+---------------+
| . | 263466 |
| .. | 9601 |
| abc | 263466 |
| test.c | 263715 |
| ... | ... |
+------------------+---------------+
你可以写一个简单的 C 程序,用 readdir 把任意目录的内容"抖"出来看,验证目录里存的真的是"名字+inode":
// readdir.c —— 打印指定目录下的文件名与 inode 号
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <dirent.h>
#include <sys/types.h>
int main(int argc, char *argv[])
{
if (argc != 2) {
fprintf(stderr, "Usage: %s <directory>\n", argv[0]);
exit(EXIT_FAILURE);
}
DIR *dir = opendir(argv[1]);
if (!dir) {
perror("opendir");
exit(EXIT_FAILURE);
}
struct dirent *entry;
while ((entry = readdir(dir)) != NULL) {
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0)
continue;
printf("Filename: %s, Inode: %lu\n",
entry->d_name, (unsigned long)entry->d_ino);
}
closedir(dir);
return 0;
}把这个程序放到根目录跑一遍,你会看到 / 下每个子项都有一个 inode 号;再用 ls -li / 对照,两边的 inode 号完全一致。这就足够证明:访问任何文件,第一步永远是"打开它的上级目录文件,根据文件名查出 inode 号",再拿着 inode 号去读属性、顺藤摸到数据块。 换句话说,没有目录这张"姓名簿",光有 inode,你是找不到"人"(文件)的。
注意文件夹第一排那两个特别的项 . 和 ..:. 指当前目录自身的 inode,.. 指上级目录的 inode。它们不是普通影子,而是目录自带的两个"硬链接",这点等讲了硬链接你会有更深的体会。
路径解析与 dentry 缓存
好,现在你知道了"访问文件要先打开上级目录"。可上级目录自己不也是个文件吗?访问它,我还得打开它的上级……这不就死循环了?
对,这就是路径存在的意义。访问 /home/whb/code/test/test/test.c,内核得从根目录 / 开始,一级一级地开门:先打开 / 找到 home 的 inode,再打开 home 找 code,再打开 code 找 test……一路叫门叫到 test.c。这个过程就叫 Linux 路径解析。根目录 / 是天然的"出口"——它的文件名和 inode 号是固定的、系统开机时必须知道的,不需要再往上去找。
那么问题又来了:既然"要访问文件必须能打开上级目录",那"我是谁、我在哪"这句话,本质上就是"我当前的工作目录是谁"?没错。每个进程都有一个 CWD(Current Working Directory,当前工作目录),你用 pwd 看到的那个路径,就是当前进程打开着的那个"上级目录"。你 open 一个相对路径时,系统自然会拿跟 CWD 拼接出全路径。
还有一个性能问题:难道每次访问一个文件,都要从 / 一层层把目录翻到底吗?那样每次读文件都得做几十次目录巡检,太慢了。内核的答案是做缓存:它会在内存里维护一棵"路径树",每个被打开过的路径节点用一个叫 dentry 的内核结构体表示。dentry 里挂着它对应的 inode、它的父目录、它的名字等。所有的 dentry 同时挂在两张结构上——一张 Hash 表方便快速按名字查找,一张 LRU 链表用于淘汰冷门节点。访问文件时,内核先在这棵内存树里按路径找,找到就直接返回属性(inode)和内容;找不到,才去磁盘把这条路径加载出来、补丁式地挂进缓存树。这样"热路径"跑在内存里,速度自然快得多。
一句话总结 Linux 的目录观:**磁盘上没有"目录"这个实物,只有一群"文件名→inode 映射"的文件;"目录树"这个形态,是操作系统在内存里用 dentry 维护出来的投影。**这也回答了那三个经典连环问:磁盘上有没有真正的目录?没有,只有文件。访问任何文件都从根开始解析?原则上是,但缓存让绝大多数解析落在内存里。目录的概念哪来的?由 OS 在内存里基于 dentry 树构建。
挂载:让一个文件系统住进一棵目录树
现在还差最后一块拼图。前面强调过 inode 号和块号都"不跨分区",那么问题来了:Linux 明明可以有很多个分区,我怎么知道"我现在访问的这个路径是在哪个分区"?这就是**挂载(mount)**要解决的。
先做一个小实验(建议你在虚拟机或容器里体验,别在重要机器上乱动)。用 dd 生成一个 5MB 的"假磁盘文件",对它格式化,再挂载:
$ dd if=/dev/zero of=./disk.img bs=1M count=5
$ mkfs.ext4 disk.img
$ mkdir /mnt/mydisk
$ df -h # 看挂载前的分区
...
$ sudo mount -t ext4 ./disk.img /mnt/mydisk/
$ df -h
...
/dev/loop0 4.9M 24K 4.5M 1% /mnt/mydisk
$ sudo umount /mnt/mydisk
解读一下:disk.img 只是当前目录下一个普通文件,但它装满了一整套 ext4 的块组结构,于是可以当"一块虚拟分区"用。mount 把它接到 /mnt/mydisk 这个目录上之后,df 就把它列成一个独立的分区——你进入 /mnt/mydisk,看到的其实是 disk.img 内部的另一个文件系统。那个 /dev/loop0 是"循环设备(loop device)",一种伪设备,专门让你把文件当块设备用。
这一步给我们的威力是巨大的:"我在哪个分区"是可以通过"路径前缀"判断的。 内核从根 / 往下解析路径时,一旦发现某个目录"压着"一个挂载的分区,就自动切换到那个分区去继续解析。挂载点就像一扇隐形传送门:跨过它,inode 号换了一套独立的编号体系,但用法完全一样。
结论三句话:
- 分区写好文件系统后不能直接用,必须挂载到某个目录上才能用;
- 能否访问一个文件,取决于从根到它的整个"路径前缀"是否都可用;
- 走进一个挂载点 = 走进另一个独立的 inode 世界。
硬链接:多个名字,同一个 inode
好,整个文件系统的骨架我们已经走完了。现在进入"文件系统里最常被问到头大"的一对概念:硬链接和软链接。
看一个现象。Windows 里一个文件通常就是一个"名字+内容"的绑定,你若想同一个内容出现两个名字,要么复制(那是两份数据),要么建快捷方式。但 Linux 打开了一扇神奇的门——硬链接(hard link):让多个文件名对应同一个 inode:
$ touch abc
$ ln abc def # 给 abc 创建一个硬链接 def
$ ls -li abc def
263466 abc
263466 def
注意看,abc 和 def 的 inode 号完全相同,都是 263466。它们不是"副本",而是同一个文件的两张"门牌"。前面 ls -l 里第二列那个数字就是硬链接数,现在 inode 263466 的链接数从 1 变成了 2。
硬链接的本质,就是在目录里多加了一条"名字 → 同一个 inode 号"的记录:
目录内容(两个名字指向同一 inode):
+--------+-----------+
| 文件名 | inode 号 |
+--------+-----------+
| abc | 263466 |
| def | 263466 | <-- 硬链接
+--------+-----------+
当你删除一个文件时,内核实际上干了两件事:第一,在它所属的目录里删掉"名字→inode"那条记录;第二,把该 inode 的硬链接数减 1。只有链接数减到 0 时,内核才真正释放这个 inode 和它占用的数据块——因为那一刻才说明"再没人叫它了,这块地可以回收"。这能推理出一个很有意思的结论:"删除文件"删除的从来不是文件,而是"一个名字";只要链接数还没到 0,内容就一直活着。
你还记得根目录下有几个"土著"硬链接吗?——对,. 和 ..。其实每个非根目录因为自带 .(指向自己)和父目录里那条记录,天生就有 2 个链接数;每增加一个子目录,子目录自身带的 .. 又会往上指,于是又 +1。所以目录的链接数 ≈ 2 + 子目录个数。这是我们第一个待解的思考题,文末揭晓。
软链接:用"名字"去引用另一个文件
硬链接虽然帅,但有它的局限。于是有了软链接(symbolic link,也叫符号链接),一个更像"快捷方式"的存在:它不跟原文件共享 inode,而是自带一个全新的 inode,内容里存的是"目标文件的名字"。
$ ln -s abc abc.s # 对 abc 创建一个软链接 abc.s
$ ls -li
263563 -rw-r--r--. 2 root root 0 Sep 15 17:45 abc
261678 lrwxrwxrwx. 1 root root 3 Sep 15 17:53 abc.s -> abc
263563 -rw-r--r--. 2 root root 0 Sep 15 17:45 def
注意三件事:
abc.s的文件类型是l(link),权限列是lrwxrwxrwx——它对权限"无所谓",因为真正起权限作用的是它指向的目标abc。abc.s的 inode 号(261678)和abc(263563)完全不同,它是个独立的文件;它的文件内容就是abc这几个字符。ls -l里那行abc.s -> abc的->就是"软链接指向的名字"。
于是两个概念的形象差异就很清楚了:
硬链接:helloworld.c 和 hello 是同一栋房子的两块门牌
(同一个 inode,链接数 +1,内容一样,删任何一个不影响另一个的存亡)
软链接:shortcut 是一张写着"office 在 3 号楼 201"的小纸条
(独立的 inode,内容存目标名字;目标没了,纸条还在,但成了"悬空链接")
- 软链接是独立文件:占一个 inode、占一点内容空间(就那串目标名字)、可以跨文件系统、也常用来链目录。
- 硬链接不是新文件:它只是"名字→已有 inode"的映射,不占新 inode,因而不能跨文件系统、不能链接目录。
为什么硬链接不能链接目录?因为目录允许被硬链接的话,.. 和某些反向引用会形成"目录环",路径解析就会自杀式地无限循环(你在一个目录的子孙里又往回指父目录)。所以内核硬性拒绝为目录建硬链接(./.. 是内核自己造的例外,不属于你手动 ln 的范畴)。为什么不能跨文件系统?因为我们前面反复强调:inode 号只在所属分区内有意义,两个分区的 inode 263466 是两款完全不同的东西,不可能靠一个裸号去指另一个分区的文件。
软硬链接四大对比表
| 对比维度 | 硬链接 | 软链接(符号链接) |
|---|---|---|
| 是否独立文件 | 不是,只是"名字→inode"映射 | 是,有独立 inode 和内容 |
| inode 号 | 和原文件相同 | 和原文件不同 |
| 链接数变化 | 原 inode 的链接数 +1 | 不影响原 inode 的链接数(自己独立链接数=1) |
| 命令 | ln 源 目标 | ln -s 源 目标 |
| 能否跨文件系统 | 不能 | 能 |
| 能否链接目录 | 不能(内核禁止) | 能 |
| 原文件删除后 | 链接仍可正常访问原内容 | 变成悬空链接(dangling),访问报错 |
| 类比 | 同一栋楼的几块门牌 | 写着一行地址的小纸条 |
用途上:硬链接常用来做"文件备份/多入口"(改了任一入口,内容同步变,且只占一份空间)、以及理解 ./..;软链接则到处被当作"快捷方式"——/bin、/lib、/sbin 其实很多都是指向 /usr/bin、/usr/lib 的软链接,方便老脚本继续按老路径找命令。
三个时间戳:atime / mtime / ctime
讲 stat 时你看到它给出了三个时间,初学者容易混,这里一次性说清:
- Access 时间(atime):最后访问时间,指这个文件被读过的时间。
- Modify 时间(mtime):内容最后修改时间,指文件内容被动过的时间。
- Change 时间(ctime):属性最后修改时间,指 inode 里那些元信息(权限、所有者、链接数、内容大小等)被改的时间。
一句话记忆锚点:改内容会动 mtime 和 ctime;只改权限/链接数会动 ctime;读会动 atime。ctime 是最"敏感"的那个——它连"内容变化"也会连带更新。
磁盘爆满的隐性浪费与坑
现在到了本文最有工程价值的部分。你以为"用 df -h 看到还剩几个 G,就一定能写进文件"?错,藏着好几个坑。
坑一:块太小,小文件在"漏"
前面说过块是文件存取的最小单位。一个文件无论如何至少要占一整个块。你创建一百万个小文件,哪怕每个只有 1 字节,每个也吃掉 4 KB,实际物理占用是"内容大小"的几千倍。体现在数据上的怪相是:ls -l 显示文件只有 1 字节,du 却告诉你它占了 4 KB。为什么我们常说"小文件很费空间",根因就在这里——每个小文件都拖着一个甩不掉的整块。
坑二:删了文件,空间却没回来(删除仍占空间的坑)
这是一种很隐蔽的情况:你用 rm 删了一个很大的日志文件,df 却显示空间一点没多。为什么?因为"删除"本质是把那个名字的链接数减 1、当链接数归零才释放 inode 和数据块。如果这个文件此刻正被某个进程 open 着,内核不会立刻释放它——文件还挂在进程的文件表上,得等所有进程都 close 掉它,那块空间才会真正归还。
这就带来一个经典排障场景:日志打成死循环、磁盘被写爆,管理员 rm 掉日志文件空间却不变。对策是先查是哪个进程还抱着它:lsof | grep deleted,然后把那个进程重启或优雅关闭,空间才会真正释放。这也是为什么运维圈常讲"删文件没用,得干掉那个打开着它的进程"。
坑三:明明有空间却报 Disk full——元数据已满
这是本篇文章最容易被忽视、却最真实的一个坑。回顾块组结构:数据块和 inode 是两套各自独立的记账体系。想象你拿一个 1 GB 的分区,疯狂地创建几百万个 1 字节的小文件——每个文件都要耗掉一个 inode。很快,情况就变成:数据块可能还剩一大堆空闲,但 inode 已经全部被分配光了。此时你 touch 一个空文件都会失败,报错 No space left on device(设备上没有剩余空间)。
查证方法:df -h 看的是块的使用率,而 df -i(-i 表示 inode)看的是 inode 使用率。看 df -i 的 IUse% 是不是已经 100%:
$ df -h
$ df -i
如果 inode 用满了,df -h 还显示有大把空闲,这绝不是系统 bug,而是"块没满但 inode 满了"。
坑四:保留块(reserved blocks)
ext 系列还有一招"藏起来的空间":默认会把分区约 5% 的块保留给超级用户(root),s_r_blocks_count 这个字段干的就是这个。这些块普通用户是写不进去的(即使还有未被占用的空间)。这也是"明明有空间却写不进"的又一种可能,通常发生在分区使用率冲到 95% 左右、你想给普通用户目录写入的时候。
你可以用 tune2fs 查看和调整这个保留比例(谨慎操作,别乱调):
$ tune2fs -l /dev/sda1 | grep -E "Block count|Reserved block count|Free blocks"
$ tune2fs -m 5 /dev/sda1 # 把保留比例设为 5%(示例,需要 root 且谨慎)
dumpe2fs 和 tune2fs -l 都能像开卷考试一样,把文件系统每一层的家底给你看(超级块字段、各块组的位图/表位置、Free blocks 等),是排查这几类"容量之谜"的第一手工具:
$ dumpe2fs -h /dev/sda1
$ dumpe2fs /dev/sda1 | less # 不带 -h 会逐个块组打印,量大,建议 less / 直接看块组 0
文件系统修复:e2fsck
超级块损坏、位图与实际分配不一致、断电导致 inode 表不干净……这些都是文件系统"得病"的典型。ext 系列从 ext2 起就带了一套一致性检查工具(e2fsck),名字里的 fsck 就是 file system consistency check(文件系统一致性检查)。
为什么断电会损坏?因为很多操作是"先写数据再记账",写到一半断电,位图/链接数就可能和真实占用对不上。e2fsck 的职责就是扫描整个文件系统,检查每一块的分配记录和 inode 引用是否自洽,找出那些"被位图标记为已占用、却没有任何 inode 引用"或反过来"inode 仍指向已标记释放块"之类的矛盾,然后尽量修复、把孤立的数据链到 lost+found 目录里。
修复的原则只有一个,必须牢记:被挂载着的文件系统不要直接跑 fsck;要么先 umount,要么用只读模式、要么在系统启动阶段(此时分区未挂载、或处于只读状态)做检查。 命令示例:
$ sudo umount /dev/sda2
$ sudo e2fsck -f /dev/sda2 # -f 强制检查(即使看着"干净")
由于超级块有备份,也有一招"超级块坏了,用备份救命"的思路:用 dumpe2fs 或 mke2fs -n 找到备份超级块的位置,再用 e2fsck -b <备份超级块号> 去尝试恢复。这一招平时用不上,但关键时候能救命。
思考题(附详解答案)
思考题 1:为什么目录的链接数约等于"2 + 子目录个数"?请拆解这三份功劳。
答案要点:目录文件的链接数,等于"指向它的合法名字"的数量。
- 第 1 份:父目录里有一条"目录名 → 目录 inode"的记录,这是它最早的一个名字;
- 第 2 份:它自己内容里有
.这一条,指向自己(它也计入链接数); - 其余每份:它每拥有一个子目录,那个子目录内部的
..都会指回它,于是链接数再 +1。 所以:目录链接数 = 1(父目录里的名字)+ 1(自身的 .)= 2 + 子目录个数。 这正好解释为什么"一个空目录刚建出来链接数是 2",以及为什么/根目录的链接数也不是 1。
思考题 2:用 ls -i 看到某文件 inode 号为 263466,请你用自己的话描述:在这个分区内的 inode 号,怎样一步步变成文件内容。
答案要点:inode 号是按分区整体划分的,因此拿到 inode 号,先算出它属于哪个块组(inode 号 ÷ 每块组 inode 数 大致定组,实际由文件系统内部换算),再定位到该块组的 inode 表,在那里按下标取到 inode 结构体;inode 里的 i_block[0..11] 等指针,一级级解引用(直接块跳过,间接块顺藤摸瓜),最终得到数据块的块号,再转成 LBA、落到扇区读出内容。与此同时 inode 里的模式、uid/gid、大小、时间戳就是"属性"。一句话:inode 号 → 块组(inode 表) → inode(属性 + 指针) → 数据块(内容)。
思考题 3:stat 输出里 Blocks: 8 和 IO Block: 4096 都叫"块",为什么数字对不上?
答案要点:两者单位不同。stat 的 Blocks 字段固定以 512 字节为单位统计该文件实际占用(它沿袭了 POSIX 的约定),IO Block 才是文件系统的数据块大小(这里是 4096)。所以 Blocks: 8 × 512 = 4096,正好等于 1 个数据块。这正是"1 字节内容占 1 块"的那个隐性浪费在数字上的体现。
思考题 4:对一个文件执行 ln a b 之后,链接数从 1 变 2。此时再用 rm a,请问 b 还能不能用?inode 和数据块会不会被释放?
答案要点:能,且不会释放。rm a 只是把"a → inode"这条目录记录删掉、链接数从 2 减到 1。因为链接数不是 0,inode 和数据块都不释放,b 依然完整可读可写。硬链接"备份"的价值就在这里:只要还有一个名字,数据就还在。只有当最后一条名字也删掉、链接数归零,inode 和数据块才被真正回收。
思考题 5:文件被 rm 之后空间为何有时不释放?和"链接数减到 0"是矛盾的吗?
答案要点:不矛盾。rm 先把名字删掉、链接数 -1。如果这时没有进程打开它,链接数归零就立即释放;但如果还有一个或多个进程正 open 着它,即使链接数已为 0,进程仍可通过自己手里的"文件描述符"继续读写这块数据,所以盘上空间暂时不归还,直到所有进程 close 它。也就是说"链接数归零"触发的是"回收请求",而"正在被进程打开"会推迟到文件描述符全部关闭才真正落地。查 lsof | grep deleted 就能找到那些"已删除但仍占用"的漏网之鱼。
思考题 6:为什么"inode 几乎用尽"时,明明 df -h 还有剩余却能报 No space?
答案要点:inode 和数据块是分区的两套独立资源,各有各的位图、各有各的数量上限。创建新文件既需要一个空闲块(存内容和目录记录等),也需要一个空闲 inode(存属性)。当所有 inode 都被占满——哪怕数据块还剩很多——新文件连 inode 都申请不到,于是 open/creat 返回 ENOSPC。这时要看 df -i(inode 使用率),而不是 df -h(块使用率)。
到这,我们就把 Ext 系列文件系统这块"最硬的骨头"啃得差不多了。你回头望一眼走过的路:从磁头共进退的 CHS,到线性下标 LBA,再到格式化后定死的块;走进块组看超级块、GDT、两张位图、inode 表和数据块各司其职;搞懂 inode 里那 15 个槽位如何用"直接 + 多级间接"把大小文件通吃;领悟目录不过是一张"名字→inode"的映射表,而那条"从根一路开门"的路径解析又被 dentry 缓存养在内存里;最后挂载让每个分区的 inode 世界各安其位,软硬链接则把"名字、inode、内容"三者的关系玩到极致。
要我说,文件系系统这一课最值钱的心智模型就三个:文件 = 内容 + 属性且分开放;inode 是属性+指针的账本,inode 号只在分区内有效;删除的本质是减链接数,链接数归零才真正释放。 把这三句话刻进脑子里,你以后遇到"删文件空间没回来""以为没满却写不进""硬链接和软链接到底差在哪"这类问题,心里就有底了,知道该看 df -h、df -i、stat、ls -li 还是 dumpe2fs。
更妙的是,这套设计从 ext2 一路沿用至今,ext3 加了日志、ext4 把间接块升级成 extent,但块组、inode、位图这套骨架子一直没有变。搞懂 ext2 等于拿到了解读整个 ext 家族的总钥匙,也为你以后去读日志文件系统、CoW 文件系统(比如 btrfs、ZFS)打下了参照系。下次面试官再问"Linux 到底怎么把文件名变成磁盘上的字节",你就知道从哪一句讲起,也讲得比别人深一层了。
还没有评论 — 第一条由你来留。