图 1:本章导图——Ext2 是 Linux 的” native “文件系统,Ext3 是它的日志化进化版
这一篇在干嘛?
前面讲了 VFS 这个抽象层(
/linux内核/lk12),本章下沉到一个具体文件系统:Ext2——Linux 原生、曾经无处不在的文件系统。我们会看它在磁盘上怎么布局(块组、超级块、位图、inode 表)、在内存里怎么缓存、怎么分配 inode 和数据块、怎么处理文件洞,最后看 Ext3 如何用”日志”解决 Ext2 最痛的崩溃恢复问题。
Ext2 的一般特性 | 磁盘数据结构 | 内存数据结构 | 创建 Ext2 文件系统 | Ext2 的 VFS 方法 | 磁盘空间管理 | Ext3 与日志
Ext2 的一般特性
Unix 类操作系统支持多种文件系统,它们都满足 stat() 等少量 POSIX API 所需的公共属性子集,但内部实现各不相同。Linux 的第一个版本基于 MINIX 文件系统;随后推出扩展文件系统(Ext FS),加了不少重要特性但性能不佳;1994 年第二代扩展文件系统(Ext2)登场,高效、健壮,与它的后代 Ext3 一起成为使用最广的 Linux 文件系统。
Ext2 的效率来自这些设计:
- 块大小可选(1,024~4,096 字节),按预期平均文件长度取舍。平均文件只有几 KB 时选小块,内部碎片(文件长度与所占用磁盘空间的错配,类比
/linux内核/lk08动态内存的内部碎片)更少;文件普遍较大时选大块,磁盘传输次数更少、开销更低。 - inode 数量可选:创建时按预期文件数决定每分区分配多少 inode,最大化可用磁盘空间。
- 块组(block group):把磁盘块分组,每组的数据块和 inode 存放在相邻磁道上。同一块组里的文件平均寻道时间更短。
- 数据块预分配:给普通文件提前预留物理上相邻的块,文件增长时不用东一块西一块,减少碎片。
- 快速符号链接:不超过 60 字符的链接路径直接存在 inode 里,解析时连数据块都不用读(见
/linux内核/lk01硬链接与软链接)。
健壮性和灵活性方面:
- 谨慎的文件更新顺序,把系统崩溃的影响降到最低。例:为文件建新硬链接时,先增加磁盘 inode 的硬链接计数,再把新名字写进目录。若在两者之间断电,目录仍是一致的(只是 inode 计数偏大),删除文件不会酿成灾难,顶多数据块无法自动回收。反过来先改目录,同样的故障就会造成危险的不一致:删掉原硬链接会释放数据块,而新目录项还指向一个已不存在的 inode——这个 inode 号日后被别的文件复用时,写旧目录项会损坏新文件。
- 开机自动一致性检查:由外部程序 e2fsck 执行,系统崩溃后、预定的挂载次数到点后(每次挂载计数器加一)、或距上次检查超过预定时间后触发。
- 支持不可变文件(不能修改、删除、改名)和只追加文件(只能在尾部加数据)。
- 兼容 SVR4 和 BSD 两种新文件属组语义(SVR4:新文件继承创建进程的属组;BSD:继承所在目录的属组),由挂载选项选择。
Ext2 后来还想加一些特性:块碎片化(多个小文件共享一个块的不同片段)、透明压缩/加密、逻辑删除(undelete)、日志(journaling)。实际上一条也没正式进入 Ext2——可以说 Ext2 成了自身成功的受害者:几百万用户每天依赖它,没人敢轻易换掉它。需求最迫切的日志没有塞进 Ext2,而是催生了一个完全兼容 Ext2 的新文件系统——Ext3;不需要日志的用户继续用 Ext2,需要的大多迁到了 Ext3。
磁盘数据结构
Ext2 分区的第一个块永远不由文件系统管理——它保留给分区引导扇区。其余部分被切分成块组,布局见图 2。所有块组大小相同、顺序存放,所以内核只凭整数下标就能算出任意块组在磁盘上的位置。
图 2:Ext2 分区与单个块组的布局
块组的意义是减少文件碎片:内核尽量把同一文件的数据块放进同一个块组。每个块组里装的块有六种用途:超级块副本、块组描述符副本、数据块位图、inode 位图、inode 表、文件数据块。不装任何有意义信息的块就是空闲块。
超级块和块组描述符在每个块组里都有副本,但只有块组 0 里的那份被内核使用,其余副本内核碰都不碰。e2fsck 做一致性检查时以块组 0 的为准,然后把它们重新拷贝到所有其他块组。若块组 0 的主超级块损坏,管理员可让 e2fsck 改用其他块组里的旧副本——冗余副本通常足以把分区恢复到一致状态。
块组有多少个?约束是:数据块位图必须装进一个块。所以每个块组最多 8×b 个块(b 为块大小字节数),块组总数约为 s/(8×b)(s 为分区总块数)。例:32 GB 分区、4 KB 块,一个位图块描述 32K 个块 = 128 MB,最多需要 256 个块组。块越小,块组越多。
超级块
磁盘超级块是 ext2_super_block 结构,关键字段:
| 字段 | 含义 |
|---|---|
| s_inodes_count / s_blocks_count | inode 总数 / 文件系统总块数 |
| s_r_blocks_count | 保留块数 |
| s_free_blocks_count / s_free_inodes_count | 空闲块/空闲 inode 计数 |
| s_first_data_block | 第一个有效块号 |
| s_log_block_size | 块大小(以 2 的幂、1024 字节为单位:0=1KB,1=2KB…) |
| s_blocks_per_group / s_inodes_per_group | 每块组的块数 / inode 数 |
| s_mnt_count / s_max_mnt_count | 已挂载次数 / 触发检查的挂载次数阈值 |
| s_state | 状态:0 = 已挂载或未干净卸载,1 = 干净卸载,2 = 有错误 |
| s_lastcheck / s_checkinterval | 上次检查时间 / 检查间隔 |
| s_def_resuid / s_def_resgid | 保留块的默认 UID / GID |
__le16/__le32 等类型显式指定了磁盘上字节序(小端/大端)。若干磁盘块预留给超级用户(或 s_def_resuid/s_def_resgid 指定的用户/组)——普通用户的空闲块耗尽时,管理员仍能登录使用文件系统救急。
s_mnt_count、s_max_mnt_count、s_lastcheck、s_checkinterval 四个字段共同设定开机自动检查策略:挂载次数到点或时间到点(两者可并用)都触发 e2fsck;非干净卸载或内核发现错误时也强制检查。
组描述符与位图
每个块组有自己的组描述符 ext2_group_desc:
| 字段 | 含义 |
|---|---|
| bg_block_bitmap | 本组数据块位图所在的块号 |
| bg_inode_bitmap | 本组 inode 位图所在的块号 |
| bg_inode_table | 本组 inode 表第一个块号 |
| bg_free_blocks_count / bg_free_inodes_count | 组内空闲块/空闲 inode 数 |
| bg_used_dirs_count | 组内目录数 |
分配新 inode 和数据块时,内核依据这三个计数挑选”最合适”的块组。位图是比特序列:0 = 空闲,1 = 已占用。一张位图必须装进一个块,所以分别描述 8,192 / 16,384 / 32,768 个块(对应三种块大小)。
inode 表
inode 表是一串连续的块,起始块号存于组描述符的 bg_inode_table 字段。所有磁盘 inode 都是 128 字节:1 KB 块装 8 个,4 KB 块装 32 个;inode 表占多少块 = 每组 inode 总数(s_inodes_per_group)÷ 每块 inode 数。
ext2_inode 结构的关键字段:
| 字段 | 含义 |
|---|---|
| i_mode | 文件类型与访问权限 |
| i_uid / i_gid | 属主 / 属组 |
| i_size | 文件有效长度(字节) |
| i_atime / i_ctime / i_mtime / i_dtime | 最近访问 / inode 变更 / 内容变更 / 删除时间 |
| i_links_count | 硬链接计数 |
| i_blocks | 已分配数据块数(以 512 字节为单位!) |
| i_flags | 文件标志 |
| i_block[EXT2_N_BLOCKS] | 指向数据块的指针数组(通常 15 项) |
| i_generation | 文件版本(网络文件系统访问时使用) |
| i_file_acl / i_dir_acl | 文件/目录访问控制列表(扩展属性块指针) |
注意 i_size 与 i_blocks 不必有对应关系:文件总是占整数个块,所以非空文件的 i_size 可以小于 512×i_blocks(内部碎片);反之文件含洞时 i_size 可以大于 512×i_blocks(见后文”文件洞”)。i_size 是 32 位,本把普通文件限制在 2 GB 内,但 Ext2 玩了个小把戏:普通文件用不到的 i_dir_acl 字段充当 i_size 的高 32 位扩展,文件大小以 64 位整数存进 inode。64 位版与 32 位版双向兼容;在 32 位架构上访问大文件需用 O_LARGEFILE 标志打开(/linux内核/lk12)。
VFS 要求每个文件有唯一 inode 号。Ext2 不需要在磁盘上存”inode 号 → 块号”的映射表,因为它可以直接算出来:inode 号 ÷ 每组 inode 数 = 组号,余数就是组内 inode 表中的位置。例如每组 4,096 个 inode 时,13,021 号 inode 属于第 3 个块组的 inode 表第 733 项。inode 号本质上就是一把快速定位磁盘 inode 描述符的钥匙。
inode 的扩展属性
128 字节的 inode 像一件紧身衣:inode 长度必须是 2 的幂(避免 inode 表内部碎片),如今 128 字节几乎塞满,想加字段没地方;扩到 256 字节又太浪费,还引入不同 inode 长度的 Ext2 之间的兼容问题。
扩展属性(extended attributes)由此而生:属性存在 inode 之外单独分配的磁盘块里,inode 的 i_file_acl 字段指向该块;拥有相同属性集合的不同 inode 可以共享同一个块。块的布局见图 3:每个属性分两半——ext2_xattr_entry 描述符和属性名放在块开头(按名字排序),属性值放在块结尾(位置由分配顺序决定,固定不动)。
图 3:存放扩展属性的块的布局——名字区在块头、值区在块尾
配套 12 个系统调用,按”符号链接是否被解引用、文件用路径名还是文件描述符指定”分为三组前缀:setxattr/lsetxattr/fsetxattr 设置属性,getxattr/lgetxattr/fgetxattr 读属性,listxattr/llistxattr/flistxattr 列出所有属性,removexattr/lremovexattr/fremovexattr 删除属性。
访问控制列表(ACL)
Unix 传统把文件用户分成三类——属主、属组、其他人,粒度太粗。ACL 允许为每个文件指定具体用户(或用户组)及其权限。Linux 2.6 借助 inode 扩展属性完整支持 ACL——事实上扩展属性主要就是为 ACL 引入的。操作 ACL 的库函数 chacl()/setfacl()/getfacl() 底层正是 setxattr()/getxattr() 系统调用。由于定义 POSIX 1003.1 安全扩展的工作组的成果从未正式成为标准,各系统的 ACL 实现存在细微差异。
各种文件类型如何使用数据块
| 文件类型 | 说明 |
|---|---|
| 0 | 未知 |
| 1 | 普通文件 |
| 2 | 目录 |
| 3 | 字符设备 |
| 4 | 块设备 |
| 5 | 命名管道 |
| 6 | 套接字 |
| 7 | 符号链接 |
普通文件只有开始有数据时才需要数据块;刚创建时是空的,也可以被 truncate() 或 open() 清空(shell 里 >filename 就干这事)。
目录是一种特殊文件,其数据块存”文件名 + 对应 inode 号”,即 ext2_dir_entry_2 结构:
| 字段 | 含义 |
|---|---|
| inode | inode 号 |
| rec_len | 目录项长度(跳到下一有效项的偏移) |
| name_len | 文件名实际长度 |
| file_type | 文件类型(上表取值) |
| name | 文件名(变长数组,至多 255 字符) |
目录项变长(name 是变长数组),但为了效率其总长必须是 4 的倍数,不足处用 \0 补齐。删除目录项的技巧:把它的 inode 字段置 0,并把前一个有效目录项的 rec_len 加上它的长度——相当于把两个目录项”缝合”起来。图 4 的例子里,usr 项的 rec_len 是 12+16(usr 与 oldfile 两项长度之和),说明 oldfile 已被删除。
图 4:Ext2 目录示例——usr 的 rec_len 跨越了已被删除的 oldfile 项
符号链接:路径不超过 60 字符的是”快速符号链接”,路径直接存进 i_block 数组(15 个 4 字节整数),不需要数据块;更长的需要恰好一个数据块。
设备文件、命名管道、套接字:完全不需要数据块,所有信息都在 inode 里。
内存数据结构
为了效率,挂载文件系统时大部分磁盘数据结构会被拷进 RAM。想想哪些数据变多勤:创建新文件要改超级块的 s_free_inodes_count 和组描述符的 bg_free_inodes_count;文件追加数据要改 s_free_blocks_count 和 bg_free_blocks_count;甚至仅仅重写文件的一部分都要更新超级块的 s_wtime。所有 Ext2 磁盘数据结构都存放在分区的块里,内核用页缓存保持它们最新(/linux内核/lk15)。
三种缓存模式:
| 数据类型 | 磁盘结构 | 内存结构 | 缓存模式 |
|---|---|---|---|
| 超级块 | ext2_super_block | ext2_sb_info | 永久缓存 |
| 组描述符 | ext2_group_desc | ext2_group_desc | 永久缓存 |
| 数据块位图 | 块中的位数组 | 缓冲中的位数组 | 动态 |
| inode 位图 | 块中的位数组 | 缓冲中的位数组 | 动态 |
| inode | ext2_inode | ext2_inode_info | 动态 |
| 数据块 | 字节数组 | VFS 缓冲 | 动态 |
| 空闲 inode | ext2_inode | 无 | 永不缓存 |
| 空闲块 | 字节数组 | 无 | 永不缓存 |
- 永不缓存:空闲 inode/块没有有意义的信息,不值得占内存。
- 永久缓存:一直驻留 RAM,挂载期间无需重读(但需定期写回磁盘)。实现手法是让对应页的使用计数始终大于 0,页框回收算法(
/linux内核/lk17)就动不了它们。 - 动态缓存:对象在被使用期间缓存,文件关闭或数据块删除后可被回收。位图虽然”动态”,但页缓存会留下最近使用过的块,省掉大量磁盘读。
ext2_sb_info:超级块的内存映像
VFS 超级块对象的 s_fs_info 字段指向 Ext2 专属的 ext2_sb_info,内容包括:磁盘超级块的大部分字段、指向超级块缓冲区首部的 s_sbh、指向超级块缓冲区本身的 s_es、每块可装下的组描述符数 s_desc_per_block、指向组描述符缓冲区首部数组的 s_group_desc(通常一项就够)、以及挂载状态和挂载选项等。
图 5:ext2_sb_info 与超级块、组描述符缓冲区之间的关系
挂载 Ext2 时内核调 ext2_fill_super() 完成初始化,核心动作:分配 ext2_sb_info 存入 s_fs_info → __bread() 读超级块进缓冲(若页缓存里已有最新副本则不读盘)→ 为每组分配一个字节的 s_debts 数组(后文”债务”机制用)→ 逐块 __bread() 读入所有组描述符 → 为根目录分配 inode 和 dentry 对象。这些结构在卸载前一直驻留。内核要改超级块字段时,直接写缓冲再标记为脏即可。
ext2_inode_info:inode 的内存映像
打开文件触发路径名查找,对 dentry 缓存中不存在的每个分量都会新建 dentry 和 inode 对象(/linux内核/lk12)。VFS 访问 Ext2 磁盘 inode 时创建 ext2_inode_info 描述符,内含:整个 VFS inode 对象(嵌在 vfs_inode 字段)、磁盘 inode 中未被 VFS inode 覆盖的大部分字段、inode 所属块组下标 i_block_group、最近分配块的逻辑/物理块号 i_next_alloc_block 和 i_next_alloc_goal、预分配相关的 i_prealloc_block / i_prealloc_count、允许扩展属性与文件数据并发读的读写信号量 xattr_sem、指向文件 ACL 的 i_acl / i_default_acl。
超级块方法 alloc_inode 由 ext2_alloc_inode() 实现:从 ext2_inode_cachep slab 缓存(/linux内核/lk08)领取一个 ext2_inode_info,返回其中内嵌的 inode 对象地址。
创建 Ext2 文件系统
在磁盘上建立文件系统分两步:格式化(让磁盘驱动器能在盘上读写块——现代硬盘出厂已格式化,软盘用 superformat/fdformat)和创建文件系统(建立前文讲的所有数据结构)。
Ext2 文件系统由 mke2fs 工具创建,默认选项:块大小 1,024 字节(小文件系统默认值)、片段大小 = 块大小(片段未实现)、每 8,192 字节分配 1 个 inode、保留块比例 5%。它做的事:初始化超级块和组描述符 → 可选地扫描坏块并列出 → 为每个块组保留超级块、组描述符、inode 表、两张位图所需的块 → 位图清零 → 初始化 inode 表 → 创建根目录 → 创建 lost+found 目录(e2fsck 用来挂失而复得的块)→ 更新位图 → 把坏块归组到 lost+found。
以一张 1.44 MB 软盘为例,挂载后对 VFS 呈现为 1,412 个 1,024 字节的块。用 dd if=/dev/fd0 bs=1k count=1440 | od -tx1 -Ax 可得十六进制转储。可以看到容量太小,一个组描述符就够;保留块 72 个(1,440 的 5%);inode 表按默认每 8,192 字节一个 inode,共 184 个、占 23 块:
| 块 | 内容 |
|---|---|
| 0 | 引导块 |
| 1 | 超级块 |
| 2 | 唯一的组描述符 |
| 3 | 数据块位图 |
| 4 | inode 位图 |
| 5–27 | inode 表:inode 1 |
| 28 | 根目录(含 .、..、lost+found) |
| 29 | lost+found 目录(含 .、..) |
| 30–40 | 为 lost+found 预分配的保留块 |
| 41–1439 | 空闲块 |
Ext2 的 VFS 方法
/linux内核/lk12 描述的多数 VFS 方法在 Ext2 里都有专属实现,全部展开能写一本书,这里只做总览——理解了磁盘与内存数据结构,读 Ext2 的实现代码并不难。
超级块方法:alloc_inode、destroy_inode、read_inode、write_inode、delete_inode、put_super、write_super、statfs、remount_fs、clear_inode 等都有 Ext2 版本。
inode 方法随文件类型而异。普通文件的 create/lookup/link 等目录专属方法为 NULL(方法未定义时 VFS 调通用函数或什么都不做);目录则有 ext2_create()、ext2_lookup()、ext2_link()、ext2_unlink()、ext2_symlink()、ext2_mkdir()、ext2_rmdir()、ext2_mknod()、ext2_rename() 等;两者共享 ext2_permission()、ext2_setattr()。符号链接分两套:快速符号链接(路径在 inode 里)与普通符号链接(路径在数据块里),分别对应 ext2_fast_symlink_inode_operations 和 ext2_symlink_inode_operations 表——前者的 follow_link 是 ext2_follow_link(),后者是 page_follow_link_light() 配 page_put_link()。字符设备、块设备、命名管道的 inode 方法与文件系统无关,分别用 chrdev/blkdev/fifo 专用表。
文件方法大量复用通用函数(这些通用函数已在 /linux内核/lk16 详述):read/write 用 generic_file_read()/generic_file_write(),llseek/mmap/open/aio 系列全用 generic_ 前缀版本;Ext2 专属的只有 ext2_ioctl()、ext2_release_file() 和 ext2_sync_file()(fsync)。
磁盘空间管理
文件在磁盘上的存储与程序员眼中的文件有两点差异:块可能散落各处(文件系统尽力让它们连续);文件在程序员看来可能”虚胖”——lseek() 可以制造文件洞(hole)。空间管理要解决两大问题:
- 避免文件碎片:文件的物理存储散布在非相邻块上会拖慢顺序读(磁头频繁重新定位),类似
/linux内核/lk08讨论过的 RAM 外部碎片。 - 时间高效:内核要能从文件偏移快速算出逻辑块号,并且尽量减少对磁盘上寻址表的访问——每多一次中间访问,平均访问时间就显著上升。
创建与删除 inode
ext2_new_inode() 创建 Ext2 磁盘 inode,参数是父目录 inode 对象地址 dir 和类型 mode(可能还带 MS_SYNCHRONOUS 挂载标志,要求进程挂起等待 inode 分配完成)。它精心挑选新 inode 落在哪个块组:顶层目录分散到各组;普通文件尽量放进与父目录相同的组。为平衡组内目录与普通文件的数量,引入**“债务”(debt)**参数——s_debts 数组为每组记一笔账:新增目录加一,新增普通文件减一。主要步骤:
new_inode()分配 VFS inode 对象,初始化 i_sb,挂进使用中 inode 链表和超级块链表。- 新 inode 是目录:调
find_group_orlov()选组,启发式是——根目录的直接子目录应分散到所有块组(找空闲 inode 和空闲块都高于平均值的组);嵌套目录应放进父目录的组,条件是该组目录不太多、空闲 inode 足够、“债务”不大;都不满足时退而求其次,从父目录所在组起找第一个空闲 inode 高于平均值的组。 - 新 inode 是普通文件:调
find_group_other(),从父目录所在组出发越走越远——先做对数搜索(考察 i, i+1, i+1+2, i+1+2+4 … mod n 共 log(n) 个组),失败再做从父组开始的穷举线性搜索。 read_inode_bitmap()读所选组的 inode 位图,找到第一个 0 比特,得到第一个空闲磁盘 inode 的号。- 占用该 inode:位图置 1、位图缓冲标脏;MS_SYNCHRONOUS 挂载时
sync_dirty_buffer()同步写盘等待完成。 - 组描述符的 bg_free_inodes_count 减一(目录则 bg_used_dirs_count 加一),缓冲标脏。
- 按普通文件/目录增减 s_debts 中该组的债务。
- ext2_sb_info 的 s_freeinodes_counter 减一(目录则 s_dirs_counter 加一)。
- 磁盘超级块 s_dirt 置 1、缓冲标脏;VFS 超级块对象 s_dirt 置 1。
- 初始化 inode 对象:设 i_no、把当前时间填进 i_atime/i_mtime/i_ctime、装入块组下标。
- 初始化 ACL。
- 插入 inode_hashtable,
mark_inode_dirty()挂进超级块的脏 inode 链表。 ext2_preread_inode()把含该 inode 的块预读进页缓存——新建的 inode 很快就会被写回,提前读划算。- 返回新 inode 对象地址。
ext2_free_inode() 删除磁盘 inode,前提是善后工作已做完:inode 已从哈希表移除、最后一个硬链接已从目录删除、文件已截断为 0 以回收数据块。步骤:clear_inode() 清理——摘除脏的”间接”缓冲(挂在 inode->i_data 的 private_list 上)、若有缓冲正在 I/O 则等待完成、设备文件则从设备 inode 链表移除、置 I_CLEAR 状态 → 由 inode 号算出所属块组 → 读 inode 位图 → 组描述符计数回增(目录则减 bg_used_dirs_count)并标脏 → 位图对应比特清零、标脏(MS_SYNCHRONOUS 时同步写)。
数据块寻址
文件偏移 f 到数据块要走两步:先由 f 算出文件块号(f ÷ 块大小向下取整,Unix 文件没有控制字符,这一步是纯除法);再把文件块号翻译成逻辑块号——难就难在这,因为 Ext2 文件的数据块在盘上不一定相邻。
Ext2 的映射方案继承自早期 AT&T Unix:部分存进 inode 的 i_block 数组(EXT2_N_BLOCKS 通常为 15),不够用的部分靠专门的间接块扩展,结构见图 6。15 个数组元素分四类:
- 下标 0~11(直接映射):存文件块号 0~11 的逻辑块号。
- 下标 12(一次间接):存一个间接块的块号,该间接块本身是一个逻辑块号数组,覆盖文件块号 12 ~ b/4+11(b 为块大小字节数,每个逻辑块号占 4 字节)。
- 下标 13(二次间接):指向的间接块存”指向第三层数组”的指针,覆盖 b/4+12 ~ (b/4)²+(b/4)+11。
- 下标 14(三次间接):覆盖 (b/4)²+(b/4)+12 ~ (b/4)³+(b/4)²+(b/4)+11。
图 6:数据块寻址的数据结构——i_block 数组 + 一/二/三次间接块
这套机制明显偏向小文件:不超过 12 个数据块的文件,读任意数据只需两次磁盘访问(一次读 inode 的 i_block 分量,一次读数据块);大文件最坏要三四次连续访问。好在实践中 dentry、inode、页缓存大幅减少了真正的磁盘访问。
块大小直接影响寻址能力上限(一个间接块能装 b/4 个逻辑块号):
| 块大小 | 直接 | 一次间接 | 二次间接 | 三次间接 |
|---|---|---|---|---|
| 1,024 | 12 KB | 268 KB | 64.26 MB | 16.06 GB |
| 2,048 | 24 KB | 1.02 MB | 513.02 MB | 256.5 GB |
| 4,096 | 48 KB | 4.04 MB | 4 GB | ~ 4 TB |
文件洞
文件洞是普通文件中一段不含数据、不占任何磁盘块的”空区”。Unix 命令 echo -n "X" | dd of=/tmp/hole bs=1024 seek=6 创建的文件逻辑上有 6,145 字符(6,144 个空字符 + 一个 X),但只占一个数据块。
文件洞为的是不浪费磁盘空间,数据库应用和一切对文件做哈希的应用大量使用它。Ext2 的实现靠动态数据块分配:只有进程真正要往某块写数据时才分配块。i_size 记录”程序眼中的文件大小”(含洞),i_blocks 记录”实际分配的块数”(512 字节单位)。上例若块大小 4,096:i_size = 6,145,i_blocks = 8(一个 4 KB 块 = 八个 512 字节块);i_block 数组只有第 1 号元素(文件块号 1 对应)非空,其余全空。
图 7:带文件洞的文件——只有文件块号 1 分配了数据块
分配数据块
内核处理 Ext2 普通文件时调 ext2_get_block() 定位数据块;块不存在就自动分配(包括按需分配间接块)。它在每次对 Ext2 普通文件的读写时都可能被调(/linux内核/lk16),当然只在块不在页缓存里时才执行。
为减少碎片,分配新块时优先靠近该文件上一个已分配的块,退而在文件 inode 所在块组里找,最后才跨组取块。此外 Ext2 使用预分配:不只给请求的那一块,而是一次给至多 8 个相邻块。ext2_inode_info 的 i_prealloc_count 记录尚未用掉的预分配块数,i_prealloc_block 记录下一个预分配块的逻辑块号。文件关闭、截断或发生非顺序写时,剩余预分配块全部释放。
ext2_alloc_block() 接收 inode、目标块号 goal 和错误码变量地址。goal 由 ext2_get_block() 按启发式确定:本次块号与上次连续 → goal = 上次块的逻辑块号 + 1;不连续但文件已有块 → goal = 文件中前一个已分配块的逻辑块号;再不行 → goal = inode 所在块组的第一个块的逻辑块号。函数先看 goal 是不是文件的预分配块(是则直接用并返回),否则丢弃剩余预分配块、调 ext2_new_block() 真正找块。
ext2_new_block() 的搜索策略:goal 空闲就直接用;goal 被占就看它后面紧邻的块;附近都没有就从 goal 所在组开始扫所有块组——每组先找至少 8 个连续空闲块,再退而找单个空闲块。找到后顺手预分配最多 8 个相邻空闲块,更新 i_prealloc_block 和 i_prealloc_count。
释放数据块
删除文件或截断为 0 时,ext2_truncate() 扫描 i_block 数组定位所有数据块和间接块,反复调 ext2_free_blocks() 释放。后者释放一组相邻块(也被用于丢弃预分配块),参数为 inode、首块逻辑块号、相邻块数。对每个要释放的块:读位图 → 位图比特清零、标脏 → 组描述符 bg_free_blocks_count 加一、标脏 → 磁盘超级块 s_free_blocks_count 加一、标脏、s_dirt 置 1 → MS_SYNCHRONOUS 挂载时同步等待位图写盘完成。
常见坑:目录项删除是"缝合"而非"清空"
Ext2 删除目录项时只把 inode 字段置 0,并把前一项的 rec_len 延长覆盖它——被删项的字节还在原地。所以用十六进制工具翻看目录块时看到”尸体”很正常;反过来,别以为 rec_len 短于 4 的倍数或 name_len 与实际不符是损坏,它可能是被缝合过的删除痕迹。另外记住 i_blocks 的单位是 512 字节扇区而非文件系统块,别算错一倍。
Ext3 与日志
Ext3 从 Ext2 进化而来,设计目标只有两条:成为日志文件系统;尽可能与 Ext2 兼容。两条都实现得很好:磁盘数据结构与 Ext2 基本相同——干净卸载的 Ext3 可以直接按 Ext2 挂载;反过来给 Ext2 补个日志、按 Ext3 挂载也只需一步快速操作。前文对 Ext2 的描述几乎全部适用于 Ext3,所以本节聚焦新特性:日志。
为什么要日志
随着磁盘变大,传统 Unix 文件系统(如 Ext2)的一个设计选择变得不合时宜:文件系统块的更新可能在内存里滞留很久才写盘(/linux内核/lk14),断电或崩溃会把文件系统留在不一致状态。传统对策是挂载前全面检查:e2fsck 读超级块的 s_state 字段,不等于 EXT2_VALID_FS(没干净卸载)就穷举检查并修复所有磁盘数据结构。检查时间主要取决于文件和目录数量,也就是磁盘大小——如今几百 GB 的文件系统检查一次要几个小时,对生产环境和高可用服务器是不可接受的停机。
日志文件系统的思路:不查全盘,只查一个叫**日志(journal)**的特殊磁盘区域——它记录最近的写操作。故障后重新挂载只需几秒钟。
Ext3 的日志思想
每次对文件系统的高层改动分两步:先把要写的块拷贝进日志,I/O 完成(提交到日志)后再把块写进文件系统,文件系统写入完成(提交到文件系统)后丢弃日志里的副本。故障恢复时 e2fsck 区分两种情况:
- 崩溃发生在提交到日志之前:日志里要么缺副本要么不完整——直接忽略。高层改动丢了,但文件系统状态仍一致。
- 崩溃发生在提交到日志之后:副本有效,e2fsck 把它们写进文件系统,补完所有没来得及完成的 I/O。
别对日志抱过高期望:它只在系统调用层面保证一致性。比如用多次 write() 拷一个大文件时崩溃,拷贝会中断,副本比原件短——这不是不一致,日志管不了。
日志通常也不会拷贝所有块。文件系统的块分两类:元数据和普通数据。Ext2/Ext3 的元数据有六种:超级块、组描述符、inode、间接块、数据位图块、inode 位图块。SGI 的 XFS 和 IBM 的 JFS 只记元数据——足以恢复磁盘结构的一致性,但文件内容仍可能被崩溃损坏。Ext3 可以配置成元数据 + 数据都记,代价是性能;于是提供三种日志模式,由管理员选:
- journal:数据和元数据全记。最安全最慢——建新文件时连它的数据块都要复制成日志记录。
- ordered:只记元数据,但把数据块和对应元数据编组,保证数据块先于元数据写盘。文件内容损坏的概率被压低;默认模式。
- writeback:只记元数据,数据不管。与其他日志文件系统相同,最快。
挂载命令示例:mount -t ext3 -o data=writeback /dev/sda2 /jdisk。
日志块设备层(JBD)
Ext3 的日志通常存在文件系统根目录下一个隐藏文件 .journal 里。Ext3 并不亲自管理日志,而是使用通用的内核层 JBD(Journaling Block Device)——目前只有 Ext3 用它,但其他文件系统将来也可以用。
JBD 也不轻松:它自己也要在同一块盘上写日志,同样怕崩溃——JBD 必须保护自己日志的完整性。Ext3 与 JBD 的交互围绕三个基本单位:
- 日志记录(log record):描述对文件系统某个磁盘块的一次更新。有的日志系统只记被修改的字节区间,JBD 却记录整个缓冲——浪费日志空间(改一个位图比特也要记整个块),但快得多,因为 JBD 可以直接操作缓冲和缓冲区首部。每条记录在日志里就是一个普通数据/元数据块,附带一个
journal_block_tag_t小标签,存块在文件系统里的逻辑块号和几个状态标志。凡被 JBD 接管的缓冲(作为日志记录,或在 ordered 模式下需要先于元数据落盘的数据块),缓冲区首部会挂上一个journal_head结构:b_private 字段存其地址,BH_JBD 标志置位(/linux内核/lk15)。 - 原子操作句柄(atomic operation handle):对应文件系统的一次高层改动,通常一个修改文件系统的系统调用产生一个句柄。
- 事务(transaction):包含若干句柄,其日志记录会在同一时刻被 e2fsck 认可为”有效”。
原子操作句柄与事务
一个系统调用通常拆成一串低层操作。例如”给文件追加一个数据块”要:找文件最后一块 → 找空闲块 → 更新数据块位图 → 把逻辑块号写进 inode 或间接块 → 写块内容 → 更新 inode 若干字段。若崩溃发生在追加操作半途——一半低层操作执行了、一半没有——数据就坏了。更糟的高层操作还可能跨文件(比如移动文件)。所以 Ext3 必须保证每个系统调用的效果是原子的:要么全部生效,要么一个低层操作都不发生。
每个原子操作句柄由 handle_t 描述符表示。开始原子操作时 Ext3 调 JBD 的 journal_start():必要时分配新句柄并挂进当前事务。因为低层操作随时可能挂起进程,活动句柄的地址存在进程描述符的 journal_info 字段里(这样句柄跟着进程走)。结束时调 journal_stop()。
出于效率,JBD 把多个句柄的日志记录打包进一个事务(同一句柄的所有记录必须在同一事务里)。事务的所有日志记录存在日志的连续块中,JBD 整体对待它——只有全部记录提交到文件系统后才回收其占用的块。事务创建后开始接收新句柄,直到两种情况之一让它关门:经过固定时间(典型 5 秒),或日志里没有空闲块可分给新句柄了。
事务由 transaction_t 描述符表示,最重要的字段 t_state 描述状态:
| 状态 | 含义 |
|---|---|
| T_RUNNING | 仍在接收新句柄 |
| T_LOCKED | 不再收新句柄,但已有句柄未完 |
| T_FLUSH | 所有句柄已完,但部分日志记录还没写进日志 |
| T_COMMIT | 所有日志记录已写盘,但事务尚未在日志里被标记为完成 |
| T_FINISHED | 完整(complete):所有记录已物理写入日志 |
“完整”事务在故障恢复时被 e2fsck 信任并回放进文件系统;“不完整”事务(T_RUNNING~T_COMMIT 之一)的日志镜像可能不新,e2fsck 直接跳过。任一时刻日志可有多个事务,但只有一个是 T_RUNNING 的活动事务;若干事务可能同时处于不完整状态;完整事务在 JBD 确认其所有缓冲都已成功写入文件系统后才从日志删除。
一次写操作的完整旅程
以”Ext3 收到写普通文件若干数据块的请求”为例,看两个层如何协作:
- write() 服务例程触发文件对象的 write 方法——Ext3 用的是 generic_file_write()(
/linux内核/lk16)。 - generic_file_write() 对涉及的每一页调 address_space 的 prepare_write 方法——Ext3 实现为 ext3_prepare_write()。
- ext3_prepare_write() 调 JBD 的
journal_start()开启原子操作句柄,句柄加入活动事务(句柄只在第一次调用时创建;后续调用看到进程描述符 journal_info 已设,直接复用)。 - ext3_prepare_write() 调第 16 章讲过的
block_prepare_write(),把 ext3_get_block() 传给它准备页的缓冲和缓冲区首部。 - 内核确定块逻辑号时执行
ext3_get_block()——与 ext2_get_block() 类似,但关键差异是它调 JBD 函数确保低层操作被记录:对元数据块发写操作前调journal_get_write_access()(把元数据缓冲加进活动事务的列表;若该元数据还出现在某个更老的不完整事务里,就复制一份缓冲,保证老事务按旧内容提交);更新元数据缓冲后调journal_dirty_metadata()(把缓冲挪进活动事务的脏列表并写日志记录)。注意:JBD 接管的元数据缓冲不在 inode 的脏缓冲列表里,因此不会被第 15 章讲的常规刷盘机制写出——写盘的时机完全由 JBD 掌控。 - 若以 journal 模式挂载,ext3_prepare_write() 还对写操作碰到的每个数据缓冲调 journal_get_write_access()。
- 控制回到 generic_file_write(),把用户态数据拷进页后调 address_space 的 commit_write 方法——三种模式三种实现:
- journal 模式:ext3_journalled_commit_write() 对页里每个数据(非元数据)缓冲调 journal_dirty_metadata()——缓冲进活动事务的脏列表而非 inode 的脏列表,日志记录写入日志;最后 journal_stop() 关闭句柄。
- ordered 模式:ext3_ordered_commit_write() 对每个数据缓冲调
journal_dirty_data()把它插进活动事务的专用列表——JBD 保证该列表里的缓冲先于事务的元数据缓冲落盘,但不写日志记录;随后照常调 generic_commit_write() 把数据缓冲挂进 inode 脏列表;最后 journal_stop()。 - writeback 模式:ext3_writeback_commit_write() 只调 generic_commit_write()(数据缓冲进 inode 脏列表),然后 journal_stop()——数据完全不管。
- write() 服务例程到此结束,但 JBD 的活还没完。最终事务变完整(所有日志记录物理写入日志)后,执行
journal_commit_transaction()。 - ordered 模式下,journal_commit_transaction() 先激活事务列表中所有数据缓冲的 I/O 传输并等待全部完成——这就是”数据先于元数据”的保证来源。
- 激活事务中所有元数据缓冲的 I/O 传输(journal 模式下还包括数据缓冲)。
- 内核定期对每个完整事务激活**检查点(checkpoint)**活动:核实 journal_commit_transaction() 触发的 I/O 是否成功完成;成功则该事务可从日志删除。
日志里的记录在平时毫无用处——只有系统重启时,e2fsck 才扫描日志,把完整事务的日志记录所描述的写操作重新调度执行。
通关标准
不看书答出:Ext2 的块组里放了哪六种东西?为什么只有块组 0 的超级块被内核使用?inode 号怎么换算成磁盘位置?i_block 数组四类元素各覆盖哪些文件块号?文件洞为什么能让 6 KB 的文件只占一个块?Ext3 三种日志模式各记什么、性能与安全如何取舍?JBD 的记录/句柄/事务三层各对应什么粒度?——全部答出,本章过关。
为什么 Ext2 建硬链接时要先更新 inode 计数、再写目录?反过来会怎样?
先改 inode,崩溃发生时目录仍一致,顶多 inode 的硬链接计数偏大,删除文件不会出事(只是数据块无法自动回收)。反过来先写目录:崩溃后新目录项指向的 inode 已不存在,若该 inode 号日后被新文件复用,通过旧目录项写入就会损坏新文件的数据——这是真正的数据损坏,比”计数不对”严重得多。
Ext2 的 i_size 和 i_blocks 为什么可能不一致?两种不一致方向各对应什么场景?
i_blocks 单位是 512 字节。方向一:非空文件至少占一个块(无片段机制),i_size < 512×i_blocks,这是内部碎片;方向二:文件含洞(lseek 制造、不占任何数据块),i_size > 512×i_blocks,如 6,145 字节的文件只占一个 4 KB 块(i_blocks=8)。洞被数据库和文件哈希应用大量使用以节省磁盘空间。
ext2_new_inode() 为目录和普通文件选块组的策略有什么不同?"债务"参数起什么作用?
目录用 find_group_orlov():根目录的直接子目录分散到空闲 inode 和空闲块都高于平均值的组;嵌套目录优先放父目录所在组(条件:组内目录不太多、空闲 inode 够、债务小);不行再退化搜索。普通文件用 find_group_other():从父目录所在组开始对数搜索(步长 1,2,4…共 log(n) 个组),失败再线性穷举。“债务”(s_debts 数组,每组一字节)每加一个目录加一、每加一个普通文件减一,用于衡量组内目录与文件的平衡,防止某组被目录塞满。
为什么 JBD 的日志记录要存"整个缓冲"而不是被修改的字节区间?这样做牺牲了什么、换来了什么?
牺牲:日志空间——哪怕只改位图里的一个比特,也要把整个块记进日志。换来:速度与简单——JBD 可以直接操作缓冲和缓冲区首部,不必解析块内部结构去提取修改区间;整个缓冲原样回放也天然保证一致性。
Ext3 的 ordered 模式如何保证文件数据不因崩溃而损坏?它记日志吗?
ordered 模式的日志里只记元数据,不记数据块。但它在提交时用 journal_dirty_data() 把数据缓冲放进活动事务的专用列表,journal_commit_transaction() 会先激活这些数据缓冲的 I/O 并等待全部完成,然后才激活元数据缓冲的 I/O。“数据先于元数据落盘”保证了诸如”扩大文件”的写访问被完整保护:就算崩溃,元数据描述的文件长度所覆盖的数据要么已在盘上,要么元数据根本没写进去——文件内容不会指向垃圾块。