linux系统复习:Ext 文件系统(一个文件在磁盘上到底长什么样)

上一篇结尾留了三个问题:inode 是什么?read 去磁盘哪个位置读?为什么 open 要"解析路径"? 这一篇从磁盘的物理结构一路推上来,每一步都是被上一步逼出来的选择: 磁盘怎么定位 → CHS 太笨 → LBA → 扇区太小 → 块 → 盘太大 → 分区 → 分区里要有秩序 → 文件系统 → inode / 目录 / 挂载。


一、先认识硬件:磁盘是什么样的

1.1 磁盘的身份

  • 机械磁盘是计算机中唯一的一个机械设备;
  • 磁盘是外设;
  • 特点:慢 、容量大 、便宜。

这三句话合起来,就是后面所有设计的出发点:又慢又大,所以要"成块地搬",还要"按位置随机访问"。

1.2 物理结构:盘片、磁头、磁道、柱面、扇区

复制代码
        主轴
         │
   ┌─────┴─────┐
   │  盘片(1)   │  ← 每一面一个磁头
   │  盘片(2)   │
   │  盘片(3)   │
   └───────────┘
    ▲
    └── 传动臂,所有磁头"共进退"

几个名词,课件给了定义:

名词 定义
磁头(head) 每个盘片一般有上下两面,分别对应 1 个磁头,共 2 个磁头
磁道(track) 从盘片外圈往内圈编号 0 磁道、1 磁道......靠近主轴的同心圆用于停靠磁头,不存储数据
柱面(cylinder) 磁道构成柱面,数量上等同于磁道个数
扇区(sector) 每个磁道都被切分成很多扇形区域,每道的扇区数量相同
圆盘(platter) 就是盘片的数量

容量公式:

复制代码
磁盘容量 = 磁头数 × 磁道(柱面)数 × 每道扇区数 × 每扇区字节数

📌 一个极其重要的细节 :传动臂上的磁头是共进退的。

什么意思?所有盘片的磁头装在同一根传动臂上,一起移动。所以:

当磁头移动到第 N 条磁道时,所有盘片的第 N 条磁道同时处于磁头下方。

这正是"柱面"这个概念的物理来源------同一柱面 = 所有盘面上相同半径的那一圈 。读写同一柱面时,不需要移动传动臂,只要切换"用哪个磁头",速度极快。

打个比方:把磁盘想成一摞唱片。

  • 一张唱片 = 一个盘片;
  • 唱片上的一圈纹路 = 一条磁道;
  • 一摞唱片上位置相同的那些圈 = 一个柱面(换层不用挪唱针,只要换唱针高度)。

1.3 扇区:存储数据的基本单位

扇区:是磁盘存储数据的基本单位,512 字节,块设备。

记住两个关键词:512 字节 、块设备。

("块设备"后面还会出现:意思是它按"块"随机读写,不是像磁带那样只能顺序读写。)

课件还有一句点睛:

文件 = 内容 + 属性,都是数据,无非就是占据那几个扇区的问题!

这句是整篇的钥匙 :不管属性还是内容,最终都要落在扇区上。所以"文件系统"要解决的,本质就是------如何用扇区这套物理资源,表达出"文件"这个逻辑概念。


二、第一个问题:怎么定位一个扇区

2.1 CHS 寻址:用三个坐标

既然磁盘是"盘片 × 磁道 × 扇区"的三维结构,那就用三个坐标定位:

  1. 先定位磁头(用哪个 Head)→ 决定用哪一面;
  2. 再确定磁头要访问哪一个柱面/磁道(Cylinder);
  3. 最后定位扇区(Sector)。

这就是 CHS 寻址。

CHS 寻址对早期的磁盘非常有效:知道用哪个磁头、读取哪个柱面上的第几扇区,就可以读到数据了。

打个比方 :就像去图书馆找书------"2 楼、第 5 排、第 12 本"。三个坐标,定位到一本。

2.2 CHS 撞到了天花板

但是:

CHS 模式支持的硬盘容量有限 。因为系统用 8 bit 存储磁头地址 、用 10 bit 存储柱面地址 、用 6 bit 存储扇区地址,而一个扇区共有 512 Byte。

算一下上限:

复制代码
磁头数上限 = 2^8  = 256
柱面数上限 = 2^10 = 1024
扇区数上限 = 2^6  = 64(实际常用 63)
每扇区      = 512 B

最大容量 = 256 × 1024 × 63 × 512 B
        = 8 064 MB
        ≈ 8 GB(按 1MB = 1048576B 算)
        ≈ 8.4 GB(按 1MB = 1000000B 算)

8 GB 就撑死了。而现在的磁盘是 TB 级------差了一千多倍。

所以 CHS 必须被淘汰。

2.3 换个思路:把磁盘"拉直"

课件给的比喻非常好:

磁带上面可以存储数据,我们可以把磁带"拉直 ",形成线性结构。 那么磁盘本质上虽然是硬质的,但是逻辑上 我们可以把磁盘想象成为卷在一起的磁带。

拉直之后:

复制代码
  物理上是三维的(盘片 × 磁道 × 扇区)
              ↓  逻辑上拉直
  变成一条线:
  [扇区0][扇区1][扇区2][扇区3] ...... [扇区N]

于是每个扇区都有了一个线性地址 ------其实就是数组下标。

这种地址叫做 LBA(Logical Block Address,逻辑块地址)。

这一步转换是整篇最关键的思想跳跃:

把"三维物理结构"抽象成"一维数组",从此访问磁盘 = 访问数组的某个下标。

课件还给了几何解释,帮助你理解"拉直"不改变什么:

  • 磁道:某一盘面的某一圈 → 展开是一维数组;
  • 柱面:所有盘面同一圈 → 展开像一个二维数组(每个磁道扇区数一样,正好是二维表);
  • 整盘:多张二维表 → 三维数组;
  • 而 C 语言里,多维数组在内存里也是一维的------所以"拉直"完全符合直觉。

2.4 CHS ↔ LBA 的换算

既然两者能互转,公式必须记住(考试常考):

CHS 转 LBA:

复制代码
单个柱面的扇区总数 = 磁头数 × 每磁道扇区数

LBA = 柱面号C × (磁头数 × 每磁道扇区数)
    + 磁头号H × 每磁道扇区数
    + 扇区号S - 1

为什么最后要 - 1?

扇区号通常是从 1 开始的,而在 LBA 中,地址是从 0 开始的。 柱面和磁道都是从 0 开始编号的。

LBA 转 CHS:

复制代码
柱面号 C = LBA // (磁头数 × 每磁道扇区数)
磁头号 H = (LBA % (磁头数 × 每磁道扇区数)) // 每磁道扇区数
扇区号 S = (LBA % 每磁道扇区数) + 1

(// 表示整除)

这组公式的本质 :把一个三维坐标压成一个数,再还原回来------就像把 (楼层, 排, 号) 编码成一个连续编号。

2.5 谁来做换算

课件这句回答很关键:

LBA 地址转成 CHS 地址,CHS 如何转换成为 LBA 地址。谁做啊??磁盘自己来做!固件(硬件电路,伺服系统)

也就是说:

操作系统只使用 LBA。CHS 是磁盘内部的事,OS 根本不管。

于是得到这一节的终极结论:

从现在开始,磁盘就是一个"元素为扇区"的一维数组,数组的下标就是每一个扇区的 LBA 地址。OS 使用磁盘,就可以用一个数字访问磁盘扇区了。

这就是为什么我们能让 OS 写代码时不用管磁盘型号------它只跟"第 1000 号扇区"打交道,至于这个扇区在哪个盘片哪一圈,磁盘固件自己算。


三、第二个问题:扇区太小、磁盘太大

3.1 引入"块":扇区太小了

硬盘是典型的"块 "设备。操作系统读取硬盘数据的时候,其实是不会一个个扇区地读取 ------这样效率太低,而是一次性连续读取多个扇区 ,即一次性读取一个"块"(block)。

关键数字:

一个"块"的大小是格式化的时候确定的,并且不可以更改 ,最常见的是 4KB ,即连续八个扇区 组成一个"块"。 "块"是文件存取的最小单位。

概念 大小 谁在用
扇区 512 B 磁盘硬件
块(block) 通常 4 KB = 8 扇区 文件系统 / OS

为什么要块? 两个原因:

  1. 效率:读一个扇区要一次寻道 + 一次等待旋转,读 8 个相邻扇区几乎不多花时间。所以"成块读"是白赚的;
  2. 管理:如果按 512 字节管理 TB 级磁盘,需要的管理信息会大到无法承受(位图会很夸张)。

打个比方 :你去仓库取货,不会一包一包 拿(扇区),而是整箱整箱搬(块)。箱子大小是仓库规定的,你不能改。

课件还给了块和 LBA 的换算式:

复制代码
知道 LBA:块号 = LBA / 8
知道块号:LBA = 块号 × 8 + n    (n 是块内第几个扇区)

3.2 引入"分区":盘太大了

其实磁盘是可以被分成多个分区(partition)的。以 Windows 观点来看,你可能会有一块磁盘并且把它分区成 C、D、E 盘------那个 C、D、E 就是分区。

那 Linux 怎么分区?

柱面是分区的最小单位 ,我们可以利用参考柱面号码的方式来进行分区,其本质就是设置每个区的起始柱面和结束柱面号码。

注意两点:

  1. 分区的单位是柱面(因为同一柱面读写快);
  2. 只要知道"每个分区的起始和结束柱面号"+"每个柱面多少扇区",就能算出这个分区有多大、里面的 LBA 是多少。

打个比方 :一栋楼有 100 层,你把 1-30 层租给 A 公司、31-60 层租给 B 公司。只要记"从第几层到第几层",不用一房间一房间地划分。

3.3 引入 inode:属性和内容分家

现在有块了,也有分区了。那文件怎么存?

回到那个等式:

复制代码
文件 = 属性 + 内容

如果都塞进数据块里,会有什么问题?

想象一下:你要 ls -l 看目录里所有文件的属性。如果属性存在数据块里,你得把每个文件的内容全部读出来才能看到属性------这太蠢了。

所以:

文件数据都存储在"块"中,那么很显然,我们还必须找到一个地方储存文件的元信息(属性信息) ,比如文件的创建者、文件的创建日期、文件的大小等等。 这种储存文件元信息的区域就叫做 inode ,中文译名为"索引节点"。

课件强调:

  • Linux 下文件的存储是属性和内容分离存储的;
  • Linux 下,保存文件属性的集合叫做 inode,一个文件,一个 inode ,inode 内有一个唯一的标识符,叫做 inode 号。

这就是 stat 命令看到的那些信息从哪来:

复制代码
[root@localhost linux]# ls -l
总用量 12
-rwxr-xr-x. 1 root root 7438 "9月  13 14:56" a.out
-rw-r--r--. 1 root root  654 "9月  13 14:56" test.c

每行包含 7 列:模式、硬链接数、文件所有者、组、大小、最后修改时间、文件名。

而 stat 能看到更细的:

复制代码
[root@localhost linux]# 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 的内容 :Inode(inode 号)、Links(硬链接数)、Size、三个时间、权限、UID/GID。

3.4 inode 长什么样

复制代码
struct ext2_inode {
    __le16  i_mode;         /* File mode */            // 权限
    __le16  i_uid;          /* Low 16 bits of Owner Uid */
    __le32  i_size;         /* Size in bytes */         // 文件大小
    __le32  i_atime;        /* Access time */           // 最后访问时间
    __le32  i_ctime;        /* Creation time */         // 属性改变时间
    __le32  i_mtime;        /* Modification time */     // 内容修改时间
    __le32  i_dtime;        /* Deletion Time */
    __le16  i_gid;          /* Low 16 bits of Group Id */
    __le16  i_links_count;  /* Links count */           // 硬链接数 ★
    __le32  i_blocks;       /* Blocks count */
    __le32  i_flags;        /* File flags */
    // ...
    __le32  i_block[EXT2_N_BLOCKS];  /* Pointers to blocks */  // ★★ 数据块指针
    // ...
};

三个必须记住的点(课件标了"再次注意"):

  • 文件名属性并未纳入到 inode 数据结构内部(文件名存在目录里,不在 inode 里!);
  • inode 的大小一般是 128 字节或者 256 字节,后面统一 128 字节;
  • 任何文件的内容大小可以不同,但是属性大小一定是相同的。

最后这条特别妙:

  • 一个 1GB 的文件,inode 也是 128 字节;
  • 一个 0 字节的空文件,inode 也是 128 字节。

这解释了"0KB 的空文件为什么占磁盘空间" ------它的内容 0 字节,但属性(inode)占了 128 字节,还要占一个目录项。

3.5 但还有两个问题没答

课件在这里停下来问了两个问题,这两个问题直接逼出了"文件系统"这个概念:

  1. 我们知道硬盘是"块"设备,读取的基本单位是"块"。"块"又是硬盘的每个分区下的结构,难道"块"是随意在分区上排布的吗?那要怎么找到"块"呢?
  2. 还有上面提到的存储文件属性的 inode,又是如何放置的呢?

换句话说:

  • 一个分区里有几百万个块,哪些被占了、哪些空着,谁来记?
  • 一个分区里有几十万个 inode,哪个空着能用,谁来记?
  • 这些"管理信息"本身存在哪?

文件系统就是为了组织管理这些的!


四、ext2 文件系统:给分区建立秩序

4.1 宏观布局

我们想要在硬盘上存文件,必须先把硬盘格式化为某种格式的文件系统 ,才能存储文件。文件系统的目的就是组织和管理硬盘中的文件 。 Linux 中最常见的是 ext2 系列 (ext2 → ext3 → ext4)。ext3 和 ext4 虽然对 ext2 进行了增强,但是其核心设计并没有发生变化,我们仍以 ext2 作为演示对象。

布局:

复制代码
分区
├── 启动块(Boot Block / Sector)     ← 1KB,PC 标准规定
└── ext2 文件系统
    ├── 块组 0(Block Group 0)
    ├── 块组 1
    ├── ...
    └── 块组 N

关于启动块:

启动块的大小是确定的,为 1KB ,由 PC 标准规定,用来存储磁盘分区信息和启动信息 ,任何文件系统都不能修改启动块。启动块之后才是 ext2 文件系统的开始。

为什么要分块组?

ext2 文件系统将整个分区划分成若干个同样大小的块组(Block Group) 。只要能管理一个分区,就能管理所有分区,也就能管理所有磁盘文件。

这句话的意思是:块组是"可复制的模板"------每个块组内部结构一样,所以你只要搞懂一个块组,整个分区、整个磁盘就都懂了。

打个比方:一栋楼分成若干"单元",每个单元结构完全一样(电梯、楼梯、水电表位置都相同)。物业只要搞懂一个单元怎么管,整栋楼就都会管了。

4.2 块组内部:六个部分

复制代码
┌─────────────────────────────────────────────────────┐
│ Block Group                                          │
├─────────────────────────────────────────────────────┤
│ 1. Super Block(超级块)          ← 整个文件系统的总账 │
│ 2. GDT(块组描述符表)            ← 各组的位置索引     │
│ 3. Block Bitmap(块位图)         ← 哪些数据块被占了   │
│ 4. Inode Bitmap(inode 位图)     ← 哪些 inode 被占了  │
│ 5. Inode Table(i 节点表)        ← 所有 inode 的数组  │
│ 6. Data Blocks(数据区)          ← 文件内容           │
└─────────────────────────────────────────────────────┘

一个块组就像一个小区:

部分 类比 作用
Super Block 物业总台账 全小区的楼房数、户数、建成时间
GDT 楼栋索引表 每栋楼在哪、有几层
Block Bitmap 车位占用灯板 哪个车位空着
Inode Bitmap 户号分配表 哪个户号还没人用
Inode Table 业主档案柜 每户的姓名、面积、入住时间
Data Blocks 实际房屋 真正住人的地方

逐个讲。

4.3 超级块(Super Block)

存放文件系统本身的结构信息 ,描述整个分区的文件系统信息。 记录的信息主要有:block 和 inode 的总量 、未使用的 block 和 inode 的数量 、一个 block 和 inode 的大小、最近一次挂载的时间、最近一次写入数据的时间、最近一次检验磁盘的时间等。
Super Block 的信息被破坏,可以说整个文件系统结构就被破坏了。

为什么?因为它记的是"全局账目"------总量、空闲量、尺寸。账本没了,你连"这个分区有多少个 inode"都不知道,根本没法查。

备份机制:

超级块在每个块组的开头都有一份拷贝 (第一个块组必须有,后面的块组可以没有)。 为了保证文件系统在磁盘部分扇区出现物理问题的情况下还能正常工作,就必须保证 super block 信息在这种情况下也能正常访问。所以一个文件系统的 super block 会在多个 block group 中进行备份 ,这些 super block 区域的数据保持一致。

打个比方:公司账本复印好几份,分别锁在不同楼层的保险柜里。烧了一层楼,还能从别的楼层恢复。

4.4 GDT(Group Descriptor Table,块组描述符表)

描述块组属性信息 。整个分区分成多个块组,就对应有多少个块组描述符。 每个块组描述符存储一个块组的描述信息,如:在这个块组中从哪里开始是 inode Table,从哪里开始是 Data Blocks,空闲的 inode 和数据块还有多少个 等等。 块组描述符在每个块组的开头都有一份拷贝。

复制代码
struct ext2_group_desc
{
    __le32  bg_block_bitmap;        /* Blocks bitmap block */      // 块位图在哪
    __le32  bg_inode_bitmap;        /* Inodes bitmap */             // inode 位图在哪
    __le32  bg_inode_table;         /* Inodes table block*/         // inode 表在哪
    __le16  bg_free_blocks_count;   /* Free blocks count */         // 还有多少空闲块
    __le16  bg_free_inodes_count;   /* Free inodes count */         // 还有多少空闲 inode
    __le16  bg_used_dirs_count;     /* Directories count */         // 用了多少个目录
    __le16  bg_pad;
    __le32  bg_reserved[3];
};

注意这三个字段 :bg_block_bitmap、bg_inode_bitmap、bg_inode_table ------ 它们是"位置指针"。

也就是说:块组里每一块的起始位置,都是从 GDT 里查出来的。这正好回答了第 3.5 节的第一个问题:"怎么找到块?"

4.5 块位图(Block Bitmap)与 inode 位图(Inode Bitmap)

  • Block Bitmap 中记录着 Data Block 中哪个数据块已经被占用,哪个数据块没有被占用;
  • Inode Bitmap 中每个 bit 表示一个 inode 是否空闲可用。

这就是位图(bitmap)------用一个 bit 表示一个对象的状态。

算一下规模:如果一个块组有 8192 个数据块,那需要的位图就是:

复制代码
8192 bit = 1024 Byte = 1 KB

1KB 就能管理 8192 个块(32MB 空间)的占用情况------非常划算。

📌 这个思路在第 04 篇讲 MySQL 的 set 类型时也出现过(1,2,4,8),第 05 篇讲 open 的 flags 也用了。位图是"密集布尔状态"的通用解法。

打个比方:停车场的空位指示灯板------每个车位一个小灯,红/绿。你不用去现场看,看灯板就知道哪里有空位。

4.6 i 节点表(Inode Table)

  • 存放文件属性:如文件大小、所有者、最近修改时间等;
  • 当前分组所有 inode 属性的集合;
  • inode 编号以分区为单位,整体划分,不可跨分区。

关键 :inode 表本质上是一个数组,下标就是 inode 号(在同一分区内)。

所以"给定 inode 号 → 找到 inode"这个操作,就是数组下标访问:

复制代码
inode 号 → 算出属于哪个块组 → 在块组的 Inode Table 里取第几个元素

为什么 inode 号"不可跨分区" ?因为编号是每个分区自己从头开始编 的。分区 A 有 1 号 inode,分区 B 也有 1 号 inode,它们是两个不同的文件。

📌 这一点在第 4.8 节讲"挂载"时会变成核心问题:既然 inode 号不唯一,我怎么知道该去哪个分区找?

4.7 数据区(Data Blocks)

数据区:存放文件内容,也就是一个一个的 Block。根据不同的文件类型有以下几种情况:

  • 对于普通文件,文件的数据存储在数据块中;
  • 对于目录 ,该目录下的所有文件名和目录名存储在所在目录的数据块中 ,除了文件名外,ls -l 命令看到的其它信息保存在该文件的 inode 中;
  • Block 号按照分区划分,不可跨分区。

这里埋了一个巨大的伏笔:

目录也是文件 ,它的"内容"就是"文件名和 inode 号的映射"。

第 4.10 节会专门展开。

4.8 inode 和 block 怎么对应起来

这是"文件内容到底存在哪"的答案。

复制代码
#define EXT2_NDIR_BLOCKS        12
#define EXT2_IND_BLOCK          EXT2_NDIR_BLOCKS         // 12
#define EXT2_DIND_BLOCK         (EXT2_IND_BLOCK + 1)     // 13
#define EXT2_TIND_BLOCK         (EXT2_DIND_BLOCK + 1)    // 14
#define EXT2_N_BLOCKS           (EXT2_TIND_BLOCK + 1)    // 15

inode 里有 i_block[15]------15 个块号指针:

复制代码
i_block[0..11]  → 12 个"直接块号"          直接指向数据块
i_block[12]     → "一级间接块"              指向一个"装满块号的块"
i_block[13]     → "二级间接块"              指向"装满一级间接块号的块"
i_block[14]     → "三级间接块"              再套一层

为什么这么设计? 因为要兼顾小文件和大文件:

文件大小 用哪些指针 寻址次数
很小(≤ 12 块 = 48KB) 只用直接块 1 次(最快)
中等 用到一级间接 2 次
大 二级间接 3 次
超大 三级间接 4 次

这正是"索引"的精髓:小文件不浪费,大文件能扩展。

算一下 ext2 的单文件上限(块大小 4KB,块号 4 字节 → 一个块能装 1024 个块号):

复制代码
直接:12 块                    = 12 块
一级:1024 块                  = 1024 块
二级:1024 × 1024 块           = 1,048,576 块
三级:1024 × 1024 × 1024 块    = 1,073,741,824 块
─────────────────────────────────────────────
总计约 1,074,790,436 块 × 4KB ≈ 4 TB

4TB------这就是 ext2 单文件的理论上限。

打个比方 :i_block[15] 就像你家的"地址簿":

  • 前 12 格直接写"东西放在哪个抽屉"(直接块);
  • 第 13 格写"去看第二本地址簿"(一级间接);
  • 要是东西太多,第 14 格写"去看第二本地址簿的目录"(二级间接)......

东西少的时候,一本地址簿就够;东西多了,就层层加索引。

4.9 新建一个文件,内核做了 4 件事

课件用 touch abc 演示:

复制代码
[root@localhost linux]# touch abc
[root@localhost linux]# ls -i abc
263466 abc

然后说明四个步骤:

Step 1:存储属性

内核先找到一个空闲的 i 节点(这里是 263466)。内核把文件信息记录到其中。

(对应:Inode Bitmap 里找一个空的 bit,把 inode 表里那格填上属性。)

Step 2:存储数据

该文件需要存储在三个磁盘块,内核找到了三个空闲块:300、500、800。将内核缓冲区的第一块数据复制到 300,下一块复制到 500,以此类推。

(对应:Block Bitmap 里找三个空 bit。)

Step 3:记录分配情况

文件内容按顺序 300、500、800 存放。内核在 inode 上的磁盘分布区记录了上述块列表。

(对应:把这几个块号写进 i_block[]。)

Step 4:添加文件名到目录

新的文件名 abc。Linux 如何在当前的目录中记录这个文件?内核将入口(263466,abc)添加到目录文件 。 文件名和 inode 之间的对应关系,将文件名和文件的内容及属性连接起来。

注意 Step 4 的关键词 :"目录文件"。

这就引出下一个大问题。

4.10 目录,竟然也是文件

课件直接问:

  • 我们访问文件,都是用的文件名,没用过 inode 号啊?
  • 目录是文件吗?如何理解?

回答:

  • 目录也是文件 。但是磁盘上没有目录的概念,只有文件属性 + 文件内容的概念。
  • 目录的属性不用多说,内容保存的是:文件名和 Inode 号的映射关系。

这句话是理解整个 Linux 文件系统的关键:

复制代码
目录 ≠ 文件夹图标
目录 = 一个普通文件
       其内容是"名字 → inode 号"的表格

验证代码(readdir.c):

复制代码
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <dirent.h>
#include <sys/types.h>
#include <unistd.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;
}

运行:

复制代码
whb@bite:~/code/test/test$ ./readdir /
Filename: mnt, Inode: 1048577
Filename: tmp, Inode: 1179650
Filename: sys, Inode: 917506
Filename: libx32, Inode: 17
Filename: srv, Inode: 786434
Filename: lib64, Inode: 16
Filename: sbin, Inode: 18
Filename: dev, Inode: 131073
Filename: swapfile, Inode: 12
Filename: run, Inode: 1048578
Filename: log.txt, Inode: 20
Filename: proc, Inode: 1179649
Filename: lost+found, Inode: 11
Filename: etc, Inode: 262145
Filename: lib, Inode: 14
Filename: opt, Inode: 917505
Filename: usr, Inode: 1179651
Filename: lib32, Inode: 15
Filename: boot, Inode: 655361
Filename: var, Inode: 655365
Filename: product-service-1.0-SNAPSHOT.jar, Inode: 19
Filename: bin, Inode: 13
Filename: media, Inode: 393217
Filename: home, Inode: 786433
Filename: root, Inode: 655362

对照 ls -li /:

复制代码
whb@bite:~/code/test/test$ ls -li /
     13 lrwxrwxrwx   1 root root         7 Sep 14  2020 bin -> usr/bin
 655361 drwxr-xr-x   3 root root      4096 May  6 14:34 boot
      2 drwxr-xr-x  17 root root      3880 Jul 17 10:39 dev
 262145 drwxr-xr-x  98 root root      4096 Oct 27 14:56 etc
 786433 drwxr-xr-x   6 root root      4096 Sep  4 14:56 home
     ...
     20 -rw-r--r--   1 root root       461 Jul 16 15:51 log.txt
     ...

逐行核对:

  • etc 在 readdir 里是 262145,ls -li 显示 262145 ✅
  • bin 在 readdir 里是 13,ls -li 显示 13 ✅
  • log.txt 在 readdir 里是 20,ls -li 显示 20 ✅

完全对上了------目录的内容就是"名字 → inode 号"。

由此推出两个重要结论(课件原话):

  • 所以,访问文件,必须打开当前目录,根据文件名,获得对应的 inode 号,然后进行文件访问;
  • 所以,访问文件必须要知道当前工作目录 ,本质是必须能打开当前工作目录文件,查看目录文件的内容!

注意第二句和第 01 篇的联系:

  • 第 01 篇:fopen("myfile") 用的是当前路径 ,/proc/[pid]/cwd 能看到;
  • 现在知道了:"当前路径"就是"当前工作目录这个文件",访问它就是一个普通文件访问。

4.11 路径解析:那当前目录的 inode 又从哪来

课件继续追问(这种追问方式值得学):

打开当前工作目录文件,查看当前工作目录文件的内容?当前工作目录不也是文件吗?我们访问当前工作目录不也是只知道当前工作目录的文件名 吗?要访问它,不也得知道当前工作目录的 inode 吗?

答案 1 :所以也要打开"当前工作目录的上级目录 "......但上级目录不也是目录吗?不还是同一个问题吗?

答案 2 :所以类似"递归 ",需要把路径中所有的目录全部解析,出口是 / 根目录。

最终答案:

任何文件,都有路径。访问目标文件,比如 /home/whb/code/test/test/test.c,都要从根目录开始,依次打开每一个目录 ,根据目录名,依次访问每个目录下指定的目录,直到访问到 test.c。 这个过程叫做 Linux 路径解析。

退出的关键:

根目录固定文件名、inode 号,无需查找,系统开机之后就必须知道。

这是整个递归的"出口"------没有它,递归会无限下去。

那路径又是谁提供的?

  • 你访问文件,都是指令/工具访问,本质是进程 访问,进程有 CWD!进程提供路径。
  • 你 open 文件,提供了路径。

那最开始的路径从哪来?

  • 所以 Linux 为什么要有根目录?根目录下为什么要有那么多缺省目录?
  • 你为什么要有家目录?你自己可以新建目录?
  • 上面所有行为:本质就是在磁盘文件系统中,新建目录文件 。而你新建的任何文件,都在你或系统指定的目录下新建,这不就是天然就有路径了嘛!
  • 系统 + 用户共同构建 Linux 路径结构。

这段话说得很漂亮 :路径不是凭空来的,是"目录套目录"地建出来的。系统建了 /,建了 /home、/etc、/usr;你在 /home/xxx 下建了自己的目录------每建一个目录,就多了一层路径。

4.12 路径缓存:每次都从 / 开始太慢了

课件继续问:

问题 1 :Linux 磁盘中,存在真正的目录吗? 答案 :不存在,只有文件。只保存文件属性 + 文件内容。
问题 2 :访问任何文件,都要从 / 目录开始进行路径解析? 答案 :原则上是,但是这样太慢 ,所以 Linux 会缓存历史路径结构。
问题 3 :Linux 目录的概念,怎么产生的? 答案 :打开的文件是目录的话,由 OS 自己在内存中进行路径维护。

具体结构:

Linux 中,在内核中维护树状路径结构的内核结构体叫做:struct dentry。

复制代码
struct dentry {
    atomic_t d_count;
    unsigned int d_flags;
    spinlock_t d_lock;
    struct inode *d_inode;        /* Where the name belongs to - NULL is negative */
    struct hlist_node d_hash;     /* lookup hash list */      // 哈希表
    struct dentry *d_parent;      /* parent directory */      // 父节点
    struct qstr d_name;                                       // 名字
    struct list_head d_lru;       /* LRU list */              // 淘汰链表
    union {
        struct list_head d_child;
        struct rcu_head d_rcu;
    } d_u;
    struct list_head d_subdirs;   /* our children */          // 子节点
    struct list_head d_alias;     /* inode alias list */
    unsigned long d_time;
    struct dentry_operations *d_op;
    struct super_block *d_sb;     /* The root of the dentry tree */
    void *d_fsdata;
    int d_mounted;                                            // 是否挂载点
    unsigned char d_iname[DNAME_INLINE_LEN_MIN];
};

课件总结了四点:

  • 每个文件其实都要有对应的 dentry 结构,包括普通文件。这样所有被打开的文件,就可以在内存中形成整个树形结构;
  • 整个树形节点也同时会隶属于 LRU (Least Recently Used,最近最少使用)结构中,进行节点淘汰;
  • 整个树形节点也同时会隶属于 Hash ,方便快速查找;
  • 更重要的是,这个树形结构,整体构成了 Linux 的路径缓存结构 。打开访问任何文件,都先在这棵树下根据路径进行查找,找到就返回属性 inode 和内容;没找到就从磁盘加载路径,添加 dentry 结构,缓存新路径。

这就是"缓存 + 淘汰"的经典设计:

结构 作用
树(d_parent / d_subdirs) 表达"目录层级"
Hash(d_hash) 按名字 O(1) 查
LRU(d_lru) 内存不够时,淘汰好久没用的

打个比方:

  • 你去图书馆找书,理论上每次都要从"总目录 → 分区 → 书架"查一遍(路径解析);
  • 但管理员会把常查的路线记在本子上(dentry 缓存);
  • 本子写满了,就把最久没用的那几条擦掉(LRU);
  • 本子上没记的,才老老实实从总目录查起。

4.13 挂载:inode 不能跨分区,那我在哪个分区

又到了课件的经典追问:

我们已经能够根据 inode 号在指定分区找文件了,也已经能根据目录文件内容找指定的 inode 了,在指定的分区内,我们可以为所欲为了。可是:inode 不是不能跨分区吗?Linux 不是可以有多个分区吗?我怎么知道我在哪一个分区???

答案:挂载(mount)。

实验(课件给的):

复制代码
# 1. 造一个 5MB 的文件,当做一个"分区"
$ dd if=/dev/zero of=./disk.img bs=1M count=5

# 2. 格式化成 ext4 文件系统
$ mkfs.ext4 disk.img

# 3. 建一个空目录作为"挂载点"
$ mkdir /mnt/mydisk

# 4. 看看当前有哪些分区
$ df -h
Filesystem      Size  Used Avail Use% Mounted on
udev            956M     0  956M   0% /dev
tmpfs           198M  724K  197M   1% /run
/dev/vda1        50G   20G   28G  42% /
tmpfs           986M     0  986M   0% /dev/shm
...

# 5. 挂载
$ sudo mount -t ext4 ./disk.img /mnt/mydisk/

# 6. 再查一次,多了一行
$ df -h
...
/dev/loop0      4.9M   24K  4.5M   1% /mnt/mydisk

# 7. 卸载
$ sudo umount /mnt/mydisk

关于 /dev/loop0(课件专门解释了):

/dev/loop0 在 Linux 系统中代表第一个循环设备 (loop device)。循环设备,也被称为回环设备 或者 loopback 设备 ,是一种伪设备 (pseudo-device),它允许将文件作为块设备 来使用。这种机制使得可以将文件(比如 ISO 镜像文件)挂载为文件系统,就像它们是物理硬盘分区或者外部存储设备一样。

结论(课件原话):

  • 分区写入文件系统,无法直接使用 ,需要和指定的目录关联,进行挂载才能使用。
  • 所以,可以根据访问目标文件的"路径前缀"准确判断我在哪一个分区。(理解到这个层面)

"路径前缀决定分区"这句话是关键:

复制代码
/home/whb/a.txt   → 前缀 /home  → 挂载点在哪,就去哪个分区
/mnt/mydisk/b.txt → 前缀 /mnt/mydisk → 那个 disk.img 分区

打个比方:

  • 一个总图书馆(/)有很多房间;
  • 现在你买了一批新书(disk.img),但它们不在总馆的索引体系里(inode 号是各自的);
  • 你做的操作是:把新书搬进总馆的一个房间,门口挂个牌子 /mnt/mydisk;
  • 从此你按"总馆路径"就能找到它们------门口的牌子,就是"分区边界"。

这就解释了"inode 号不唯一"为什么不是问题 :因为路径已经暗含了分区 。看到 /mnt/mydisk/xxx,内核知道"哦,这要去 loop0 这个分区找 inode"。


五、软链接和硬链接:从 inode 视角看

现在你有了 inode 的视角,这两个概念会变得非常自然。

5.1 硬链接

真正找到磁盘上文件的不是文件名,而是 inode 。其实在 Linux 中可以让多个文件名对应于同一个 inode。

复制代码
[root@localhost linux]# touch abc
[root@localhost linux]# ln abc def
[root@localhost linux]# ls -li abc def
263466 abc
263466 def

两个名字,同一个 inode 号(263466)。

abc 和 def 的链接状态完全相同,他们被称为指向文件的硬链接 。内核记录了这个连接数,inode 263466 的硬链接数为 2。

删除文件时发生了什么?

我们在删除文件时干了两件事情:

  1. 在目录中将对应的记录删除;
  2. 将硬链接数 - 1 ,如果为 0,则将对应的磁盘释放。

这是硬链接的核心:

  • 删掉 abc 时,只是从目录里删掉"abc → 263466"这一行;
  • inode 的 i_links_count 从 2 变 1;
  • 文件内容一点没动 (因为还有 def 指着它)。

打个比方 :一个房间有两把钥匙 (两个文件名),房间(inode)只有一个。你扔掉一把钥匙,房间还在;只有两把都扔了,房间才会被回收。

这个机制还解释了一个常见困惑:

为什么删了文件,磁盘空间没释放?

因为还有别的硬链接(或者还有进程打开着它)。

5.2 软链接

硬链接是通过 inode 引用另外一个文件;软链接是通过 名字 引用另外一个文件,但实际上,新的文件和被引用的文件的 inode 不同 。应用场景上可以想象成一个快捷方式。

复制代码
[root@localhost linux]# ln -s abc.s abc
[root@localhost linux]# ls -li
263563 -rw-r--r--. 2 root root 0 9月  15 17:45 abc
261678 lrwxrwxrwx. 1 root root 3 9月  15 17:53 abc.s -> abc
263563 -rw-r--r--. 2 root root 0 9月  15 17:45 def

注意:

  • abc.s 的权限是 lrwxrwxrwx(l = link);
  • 它的 inode 是 261678 ,和 abc(263563)不同 ------它是独立的文件;
  • 它的大小是 3 (因为存的是字符串 abc,3 个字节);
  • 后面有 -> abc,表示它指向谁。

5.3 对比表

对比项 硬链接 软链接
本质 多个文件名 → 同一个 inode 一个独立文件,内容是"目标路径字符串"
inode 相同 不同
能不能跨分区 不能(inode 不跨分区) 能(只是个路径字符串)
能不能指向目录 一般不能(会破坏树结构) 能
目标被删了会怎样 文件还在(links_count > 0) 变成"断链"(指向不存在的路径)
相当于 同一个房间的第二把钥匙 快捷方式

课件总结得非常精炼:

  • 软连接是独立文件;
  • 硬链接只是文件名和目标文件 inode 的映射关系。

5.4 用途

硬链接:

  • . 和 .. 就是硬链接(. 指向自己,.. 指向父目录);
  • 文件备份(比如 mysqldump 里常用的"快照"手法)。

软链接:

  • 类似快捷方式(比如 /usr/bin/python → /usr/bin/python3.9)。
    📌 . 和 .. 是硬链接这件事很能说明问题:它们是目录里的两个特殊项,. 指向自己这个目录的 inode,.. 指向父目录的 inode------完全符合"目录内容是名字→inode"这个模型。

5.5 顺带理解三个时间

复制代码
[root@localhost linux]# ls -li
...

三个时间:

  • Access:最后访问时间
  • Modify :文件内容最后修改时间
  • Change :属性最后修改时间

对应 inode 里的 i_atime / i_mtime / i_ctime。

注意 mtime 和 ctime 的区别:

  • echo x >> file → mtime 和 ctime 都变(内容变了,大小也变了,所以属性也变);
  • chmod 644 file → 只有 ctime 变(内容没动,只是属性动了)。

这正好呼应第 01 篇的"文件 = 属性 + 内容":两个时间,就是"内容时间"和"属性时间"。


六、本篇小结:把整条链收一遍

从物理到逻辑,这条路我们走完了:

  1. 磁盘 是"盘片 × 磁道 × 扇区"的三维结构,最小读写单位是 512 字节的扇区;
  2. CHS 用三个坐标定位扇区,但受限于 8+10+6 位,最大只能 8 GB;
  3. 于是把磁盘拉直 ,得到一维数组,下标就是 LBA ;OS 只用 LBA,CHS 由磁盘固件自己换算;
  4. 扇区太小 → 块 (通常 4KB = 8 扇区),块是文件存取的最小单位;
  5. 盘太大 → 分区 ,分区以柱面为单位(记录起止柱面号);
  6. 文件 = 属性 + 内容 ,所以拆分:属性存 inode (一个文件一个 inode,固定 128 字节),内容存数据块;
  7. 但块和 inode 不能乱放,必须组织 → 文件系统;
  8. ext2 = 启动块(1KB,PC 标准,不可改)+ 若干块组,块组之间结构相同("管一个就等于管全部");
  9. 块组内 6 部分:Super Block(总账,多份备份)、GDT(各组位置索引)、Block Bitmap(块占用)、Inode Bitmap(inode 占用)、Inode Table(属性数组)、Data Blocks(内容);
  10. inode 的 i_block[15] = 12 直接 + 1 一级 + 1 二级 + 1 三级间接 → 小文件快、大文件能扩;
  11. 新建文件 4 步 :找空 inode → 找空数据块 → 把块号写进 inode → 把(文件名, inode号)写进目录;
  12. 目录也是文件 ,内容是"名字 → inode"的表格(readdir 和 ls -li 对得上);
  13. 访问文件要路径 ,路径解析从 / 开始递归,/ 的 inode 是开机就知道的(递归出口);
  14. 每次从 / 开始太慢 → dentry 缓存(树 + Hash + LRU);
  15. inode 不跨分区 → 用挂载 把分区接到某个目录下,所以路径前缀决定分区;
  16. 硬链接 = 多个名字共享一个 inode(删一个减一次 links_count),软链接 = 独立文件存路径字符串。

七、还差最后一环

现在磁盘这一侧清楚了,程序这一侧也清楚了(第 01 篇)。但两边还没接上:

  • 程序里 write(fd, buf, n) 的 fd,怎么找到 struct file?
  • struct file 里的 f_inode,怎么对应到磁盘上的 inode?
  • 中间那个**内核缓冲区(page cache)**到底做了什么?
  • 一个字节从"你按下回车"到"落到磁盘",完整路径是什么样?

下一篇 :03 全景串联 ------ 跟着一个字节走完全程。

相关推荐
吹什么轩1 小时前
linux系统复习:全景串联
linux·运维·服务器
m0_547486661 小时前
《Ubuntu Linux操作系统与实验教程》全套PPT课件2026
linux·ubuntu·操作系统
Elastic 中国社区官方博客1 小时前
5 分钟内将 56 页扫描文档转化为带引用的答案:面向搜索无法读取的文档的检索增强生成流水线
大数据·运维·人工智能·elasticsearch·搜索引擎·信息可视化·全文检索
John_Hui2 小时前
第 1 篇:VMware 安装 Ubuntu 24.04.3 Server
linux·运维·ubuntu
kyle~2 小时前
C++ 原子操作、内存模型与无锁并发编程
linux·开发语言·c++·操作系统
2401_869769592 小时前
gdb_进程概念
linux
小猪佩奇TONY2 小时前
Linux 内核学习(19) --- linux swap 机制
linux·学习·swap·kswapd
长不大的杨瑞杰2 小时前
OpenCode v2 团队治理实战:多仓库 Policy、灰度发布与故障回滚
运维·人工智能·安全·软件工程·devops
开开心心就好3 小时前
本地文件搜索工具,双击Ctrl就能呼出搜索
java·服务器·前端·支持向量机·ffmpeg·音视频·模拟退火算法