Linux内核分析之文件系统-01

This language version is unavailable; showing the other language.

34.1 Ext4 磁盘布局 —— 块组、inode 表与日志

ext4 的磁盘组织围绕"块组"展开:把整个设备切成若干自包含单元,每组内含自己的位图与 inode 表,使"一个文件的 inode 与数据邻近、并发分配互不干扰"。超级块与组描述符经 sparse_super/flex_bg 特性稀疏化与聚簇。本节解剖磁盘布局与挂载时的解析。


34.1.1 磁盘总览

ext4 磁盘布局 (1KB 块为例):

 偏移 1024:  超级块 (struct ext4_super_block)
 ┌────────────────────────────────────────────────────┐
 │ 块组 0: [超级块][组描述符...][保留GDT][数据位图]      │
 │         [inode位图][inode表][数据块...]              │
 ├────────────────────────────────────────────────────┤
 │ 块组 1: (sparse_super: 无超级块)                     │
 │         [数据位图][inode位图][inode表][数据块...]     │
 ├────────────────────────────────────────────────────┤
 │ 块组 N: ...                                        │
 └────────────────────────────────────────────────────┘
 flex_bg: 多个组的 位图+inode表 聚到组0 附近
          (元数据 locality → 现代默认)
 日志区:  独立 inode (inode 8) 或外部设备 (34.3 节)

 组大小 = blocks_per_group × block_size
   (默认 8 × block_size 个块/组 → 4KB 块 = 128MB/组)

34.1.2 超级块与内存态

// fs/ext4/ext4.h:1332(磁盘超级块, 节选字段)
struct ext4_super_block {
    ...
    __le32  s_inodes_count;     /* Inodes count */
    __le32  s_blocks_count_lo;  /* Blocks count */
    __le32  s_r_blocks_count_lo;    /* Reserved blocks count (root 保留 5%) */
    __le32  s_free_blocks_count_lo;
    __le32  s_free_inodes_count;
    __le32  s_first_data_block; /* First Data Block */
    __le32  s_log_block_size;   /* Block size = 1024 << 值 */    /* ext4.h:342 宏 */
    __le32  s_log_cluster_size; /* bigalloc 的分配簇 */
    ...
    __le32  s_blocks_per_group; /* # Blocks per group */    /* :459 宏 */
    __le32  s_inodes_per_group; /* # Inodes per group */    /* :462 宏 */
    ...
    __le32  s_journal_inum;     /* 日志 inode 号 (通常 8) */
    ...
    __u8    s_desc_size;        /* 组描述符大小 (64bit 特性) */
    __le32  s_desc_size_hi;     /* (64 位布局) */
    ...
};
// fs/ext4/ext4.h:1525(内存态 sb_info, 节选)
struct ext4_sb_info {
    ...
    unsigned long s_desc_size;      /* 组描述符字节数 */
    ext4_group_t s_groups_count;        /* 块组总数 */
    ...
    struct percpu_counter s_freeclusters_counter;   /* 空闲簇计数 */
    struct percpu_counter s_freeinodes_counter;
    struct percpu_counter s_dirtyclusters_counter;  /* 延迟分配预留 */
    ...
    struct ext4_group_info ** __rcu *s_group_info;  /* :1607 组信息二维表 */
    ...
    struct buffer_head *s_sbh;      /* 超级块缓冲 */
    ...
    struct journal_s *s_journal;        /* JBD2 日志 (34.3) */
    ...
};

磁盘态与内存态的分工是理解 ext4 代码的钥匙:ext4_super_block 是字节序严格的磁盘镜像(__le32),ext4_sb_info 是运行态(per-CPU 计数器、组信息表、日志指针)。挂载入口 ext4_fill_super()(super.c:5796)→ __ext4_fill_super()(:5326):get_tree_bdev 从 VFS 层拿到块设备后读超级块、校验特征集(INCOMPAT 不认识即拒绝挂载)、初始化 mb allocator 与 journal——33.2 节 fs_context 装配线的"填树"环节。

34.1.3 块组与组描述符

// fs/ext4/ext4.h:403(组描述符, 节选)
struct ext4_group_desc
{
    __le16  bg_free_blocks_count_lo;    /* 空闲数据块低位 */
    __le16  bg_free_inodes_count_lo;
    __le16  bg_used_dirs_count_lo;
    ...
    __le32  bg_block_bitmap_lo;     /* 数据位图块号 */
    __le32  bg_inode_bitmap_lo;     /* inode 位图块号 */
    __le32  bg_inode_table_lo;      /* inode 表首块 */
    ...
    /* 64bit 特性: _hi 高位字段成对出现 */
};

每个块组的运行时账本在 struct ext4_group_info(ext4.h:3508)——MB allocator 的组级空闲度统计与" buddy 位图"缓存(34.2.4 节)。组的分工哲学:

分配的 locality 规则:

 新 inode → 尽量放进"父目录所在组"
   (Preallocation/Orlov 分配器: 目录相关联的文件聚集)
 新数据块 → 尽量与 inode 同组
   (目标: 磁盘时代减少寻道; SSD 时代价值转变为
    元数据局部性与锁分散)

 并发: 每组独立位图 → 不同组分配互不阻塞
   (多线程写入天然分散到不同块组)

34.1.4 关键特性集

INCOMPAT (不认识即拒绝挂载) 的代表:
  FILETYPE / EXTENTS (34.2) / 64BIT (组号>2^32)
  FLEX_BG (元数据聚簇) / METADATA_CSUM (校验和)
  BIGALLOC (簇=多块, 大文件省元数据)
RO_COMPAT (只读兼容) 的代表:
  SPARSE_SUPER (稀疏超级块) / PROJECT (项目配额)
特性行为: mkfs 时写入特征位, 内核挂载时逐位协商 —
  老内核挂新盘报 "unsupported feature" 即此机制

EXT4_MIN_BLOCK_SIZE = 1024(ext4.h:334):最小 1KB(向后兼容)、典型 4KB(s_log_block_size 的幂次,:342 宏 1024 << s_log_block_size)。s_first_data_block(0 或 1)区分 4KB+(超级块在组 0 的第 0 块)与 1KB(超级块在偏移 1024,组 0 首块让位)两种布局——读盘代码的第一处分支就在这里。


小结

ext4 磁盘布局以"块组"为自治单元(位图+inode 表+数据,元数据经 flex_bg 聚簇),超级块与组描述符经 sparse_super 稀疏分布;ext4_super_block(磁盘镜像)与 ext4_sb_info(运行态:per-CPU 计数、组信息表、日志指针)分工明确,ext4_fill_super 完成特征协商与子系统初始化。分配的 Orlov locality 与每组独立位图既保留机械盘的聚集智慧也适配 SSD 与多核并发。下一节深入 inode 本体——extent 树与块分配器。

34.2 Ext4 inode 与 extent 树

ext4 相对 ext3 的最大进化是 extent 树:用"一段逻辑块映到一段物理块"的区间描述取代 ext2/3 的逐块指针。1GB 文件在间接块体系下需要 256K 个指针,在 extent 体系下只需一个 12 字节的 extent——元数据开销降两个数量级。本节拆解磁盘 inode、extent 树结构与块分配器(mballoc)。


34.2.1 磁盘 inode 与兼容布局

// fs/ext4/ext4.h:795-830(节选)
struct ext4_inode {
    ...
    __le16  i_mode;
    __le16  i_uid;
    __le32  i_size_lo;      /* 小文件 (<2GB) 的大小 */
    ...
    __le32  i_atime;        /* atime/mtime/ctime 三时间戳 */
    __le32  i_mtime;
    ...
    __le16  i_links_count;      /* 硬链接数 (33.1.2 节 i_nlink 的磁盘源) */
    __le32  i_blocks_lo;        /* 512B 块计数 */
    ...
    __le32  i_block[EXT4_N_BLOCKS]; /* :818 15 个 32 位槽 */
    ...
    __le32  i_size_high;        /* 大小高位 (i_size_lo 组成 64 位) */
    ...
};
#define EXT4_NDIR_BLOCKS        12  /* :473 */
#define EXT4_IND_BLOCK          EXT4_NDIR_BLOCKS
#define EXT4_DIND_BLOCK         (EXT4_NDIR_BLOCKS + 1)
#define EXT4_TIND_BLOCK         (EXT4_NDIR_BLOCKS + 2)
#define EXT4_N_BLOCKS           (EXT4_TIND_BLOCK + 1)

i_block[15] 是 ext2/3/4 的兼容接口:传统模式下 12 个直接指针 + 间接/二级/三级间接(12 块后 1+256+256²+256³ 的逐块树);extent 模式下整个 i_block 区域(60 字节)被重解释为 extent 树的根节点。一个 inode 4KB 大小的 i_size_high 与时间戳扩展(extra 字段)保证 64 位大小与纳秒时间戳——33.1.2 节 VFS inode 的 i_size/i_atime_sec+nsec 的磁盘对应物。

34.2.2 extent 树结构

// fs/ext4/ext4_extents.h:56-97
struct ext4_extent {
    __le32  ee_block;   /* first logical block extent covers */
    __le16  ee_len;     /* number of blocks covered by extent */
    __le16  ee_start_hi;    /* high 16 bits of physical block */
    __le32  ee_start_lo;    /* low 32 bits of physical block */
};

struct ext4_extent_idx {
    __le32  ei_block;   /* index covers logical blocks from 'block' */
    __le32  ei_leaf_lo; /* pointer to the physical block of next level */
    __le16  ei_leaf_hi;
    __u16   ei_unused;
};

struct ext4_extent_header {
    __le16  eh_magic;   /* 0xf30a */
    __le16  eh_entries; /* number of valid entries */
    __le16  eh_max;     /* capacity of store in entries */
    __le16  eh_depth;   /* has tree real underlying blocks? */
    __le32  eh_generation;
};
#define EXT4_MAX_EXTENT_DEPTH 5     /* :92 */
extent 树形态 (以 inode 内为根):

 inode.i_block[0..14] = header + 4 个 extent (深度 0)
   depth=0: 叶子 = ext4_extent[] 直接映射
   depth>0: 索引 = ext4_extent_idx[] 指向下级块

 12 字节/extent: 逻辑起点 + 长度(≤128MB, 最高位=未初始化标志)
 逻辑块 1000..1999 → 物理块 50000..50999 一个 extent 搞定

 查找: 二分 (按 ee_block 有序) + 沿 ei_leaf 下钻
 插入: 邻接合并 (顺序写自然产生长 extent) / 块满分裂
 深度上限 5, 4KB 块下理论容量 ~16TB×2^32 (实际受 64bit 特性)

顺序写生成天然长 extent是 ext4 性能的核心红利:dd 一个 1GB 文件通常只要 8 个 extent;随机写会把树打碎(e4defrag 的修复对象,34.4 节)。ee_len 的最高位是 uninitialized extent 标志(预分配但未写零的区域——读返回零、写时才真正占用),配合延迟分配实现"先占位后供给"。

34.2.3 映射分发:ext4_map_blocks

// fs/ext4/inode.c:563-565(分发点)
    retval = ext4_ext_map_blocks(handle, inode, map, flags);    /* extent 模式 */
    ...
    retval = ext4_ind_map_blocks(handle, inode, map, flags);    /* 间接模式 */

ext4_map_blocks() 是"逻辑块→物理块"的统一入口,按 inode 挂载时的模式(ext4_test_inode_flag(inode, EXT4_INODE_EXTENTS))分发。flags 承载请求意图:EXT4_GET_BLOCKS_CREATE(允许分配新块)、_UNWRIT_EXTENT(建未初始化 extent)、_IO_NO_* 系列(延迟分配探测)。延迟分配(delalloc)的两段式在此接合:

delalloc 的分配时点:

 write() 时: 只写页缓存 + 标 DELALLOC + 计入
   s_dirtyclusters_counter (34.1.2 节 per-CPU 计数)
   — 不分配物理块! 数据位置未知, 无需日志事务
 回写时 (36.3 节 flusher):
   ext4_da_write_begin → ext4_map_blocks(CREATE|UNWRIT)
   → 此时才知道物理位置 → 元数据变更进 JBD2 事务
   → 按"多文件聚集"批量分配 (多块一次 ioctl 大小请求)

 收益: 大 IO、连续 extent、事务数最小化
 代价: ENOSPC 延迟暴露 (空间在 write 时只是"预计")

34.2.4 MB allocator:多块分配器

// fs/ext4/ext4.h:3508(组级状态, 节选要点)
struct ext4_group_info { ... /* 空闲度分类 + buddy 位图缓存 */ };

// fs/ext4/mballoc.c:1831
static int ext4_mb_load_buddy(struct super_block *sb, ext4_group_t group, ...)

块分配器(multi-block allocator)以"组 + 阶"二维索引空闲块:每组的 buddy 位图按 2 的幂分类空闲连续区(与 18.3 节伙伴系统同构,但目标是磁盘连续性),ext4_mb_load_buddy() 把组位图加载/构建进内存 buddy 缓存。分配请求带"目标"提示(同组/同 flex_bg/物理邻近已有 extent),以 locality 优先级在组间挑选——这就是 34.1.3 节 locality 规则的执行层。每组的 ext4_group_info 按空闲度分槽(1/2/4...块级),使"找一段 ≥N 块"在组内 O(1) 定位。


小结

ext4 的 i_block[15] 槽位是新旧两种映射的复用点:传统间接树兼容 ext2/3,extent 模式(默认)以"区间对区间"的 B 树式结构(深度≤5、逻辑块有序二分)把大文件元数据压缩两个数量级;未初始化 extent 与延迟分配协作实现"先占位后供给",分配时点推迟到回写期以聚集大 IO。MB allocator 以组级 buddy 位图承接"多块+局部性"的分配请求。下一节看元数据变更的一致性保障——JBD2 事务。

34.3 Ext4 日志 (JBD2)

JBD2(Journaling Block Device 2)是 ext4 的事务层:把"一组必须同时生效或同时不生效的块写"打包成事务,崩溃后重放已提交事务恢复一致。它对上层暴露极小的接口(journal_start/stop 之间的一切元数据修改都属于事务),内部以"运行事务 → 提交 → 检查点"的三段流水线消化性能成本。本节解剖这套协议。


34.3.1 数据结构:journal、transaction、handle

// include/linux/jbd2.h:1025-1048(journal 的四个序号, 契约核心)
    tid_t           j_tail_sequence;        /* :1025 最旧未检查点 */
    ...
    tid_t           j_transaction_sequence; /* :1032 下一个事务号 */
    ...
    tid_t           j_commit_sequence;  /* :1040 已提交到哪 */
    ...
    tid_t           j_commit_request;   /* :1048 请求提交到哪 */

// include/linux/jbd2.h:453-488(句柄与事务)
 * @h_transaction: Which compound transaction is this update a part of?
 * @h_ref: Reference count on this handle.
    struct {
        transaction_t   *h_transaction;     /* :479 */
        ...
    };
    int         h_ref;          /* :488 */

    atomic_t        t_updates;      /* :655 事务在途更新数 */
三层数据结构:

 journal_t (每文件系统一个, ext4_sb_info->s_journal)
   ├─ 日志区域: 循环使用的日志块序列
   ├─ j_tail_sequence ────── 检查点推进的旧端
   ├─ j_commit_sequence ──── 已提交端 (崩溃恢复点!)
   └─ j_transaction_sequence 新事务分配端

 transaction_t (运行中/提交中的一轮)
   ├─ t_updates: 在途 handle 计数 (:655)
   ├─ t_buffers: 记账的元数据块链
   └─ t_state: 运行→刷新→提交→完成 状态机

 handle_t (每次 journal_start 得到的凭据)
   └─ h_ref/h_transaction: "本次修改属于哪轮事务"

四个序号的不变式是崩溃恢复的全部依据:日志中 [j_tail, j_commit] 区间的重放推进 j_tail;j_commit_sequence 之前的元数据写保证生效,之后的一切视为未发生。

34.3.2 事务生命周期

// fs/jbd2/transaction.c:466(开事务)与 :1836(收事务)
handle_t *jbd2__journal_start(journal_t *journal, int nblocks, int rsv_blocks, ...)
int jbd2_journal_stop(handle_t *handle)
// fs/jbd2/journal.c:570
int jbd2_journal_start_commit(journal_t *journal, tid_t *ptid)
一次元数据修改的完整协议:

 ext4_map_blocks 要改位图/inode (34.2.3 节回写期):
   handle = jbd2__journal_start(journal, nblocks)   [预留 n 块信用]
     jbd2_journal_get_write_access(bh)   把块记入当前事务
     ...实际修改 buffer_head...
     jbd2_journal_dirty_metadata(bh)     标"由日志管理"
   jbd2_journal_stop(handle)            归还信用, t_updates--

 运行事务 (T_running):
   多个 handle 的修改累积到同一 transaction_t
   (commit 周期默认 5 秒: /proc/sys/fs/ext4/*/commit_interval)

 提交 (jbd2_journal_start_commit 触发, :570):
   阶段1 [描述符]: 日志写"本轮涉及哪些块"清单
   阶段2 [数据]:   元数据块拷贝进日志区
   阶段3 [提交记录]: 写 commit block + **fsync 日志区**
   → 此刻 j_commit_sequence = T: 崩溃重放覆盖到这
   阶段4 [检查点]: 空闲时把元数据真正写回原位,
      推进 j_tail, 日志空间可复用

 data=ordered 模式 (默认):
   数据块先于同事务元数据落盘 — 防止"日志说文件
   有效而数据是垃圾"

为什么 write() 通常不进日志:默认模式数据走页缓存直写,只有元数据(位图/inode/目录项)进事务——34.2.3 节 delalloc 正是把"数据位置未知"的阶段保持在日志之外,回写期分配一旦发生,元数据与数据以 ordered 顺序保证一致。

34.3.3 崩溃恢复与 fastcommit

挂载时的恢复 (jbd2_journal_load → journal_recover):

 1. 扫日志找最后一个有效 commit block → j_commit_sequence
 2. 重放 [j_tail, commit] 区间的所有块到原位
    (已检查点的不重放 — 原位已是新版)
 3. 结果: 全部事务要么完整生效要么完全消失
    (这就是"原子性"的物理实现)

 fastcommit (JBD2 FC 特性):
   普通提交要拷贝全部元数据块 → fs 延迟敏感场景贵
   FC 模式只记"变更的精简描述流", 恢复时由 ext4
   重放回调 (j_fc_replay_callback, jbd2.h:1269 的
   注册钩子族) 语义级重放 — 提交更小更快

34.3.4 与上层的一致性契约

应用可见的三档保证:

 fsync(fd)          → 强制提交含该文件数据+元数据的事务
                      (34.4/36.3 的落盘责任终点)
 O_SYNC write       → 每次 write 隐含 fsync (33.4 节)
 默认 (无同步)      → 5 秒内元数据原子生效; 数据有序;
                      崩溃可能丢最后 ≤5s 的新文件,
                      但文件系统结构永不损坏

 错误传播: 日志/设备 IO 错误经 s_errno 记入超级块,
   ext4 转为只读 (remount-ro) — "宁可停也不带病运行"

小结

JBD2 以"四个序号 + 三层结构"实现块级事务:handle_t 的 start/stop 圈定修改归属,运行事务累积多笔修改,提交按描述符→数据→commit block→检查点四阶段推进,j_commit_sequence 之前的写获得崩溃原子性;data=ordered 让数据先于元数据落盘,delalloc 把分配推迟到这一时点以最小化事务。fastcommit 以语义级重放换取更小提交。元数据永不损坏、应用数据按同步等级取舍——这是 ext4 崩溃一致性的完整承诺。下一节看运行时的另一面:在线扩容与碎片整理。

34.4 Ext4 在线调整与碎片整理

文件系统不再是"建好即定型":ext4 支持运行中扩容(resize2fs 在线路径)、缩小(离线)、以及把文件碎片重排的 EXT4_IOC_MOVE_EXT 交换接口。本节分析在线扩容的块组追加协议与碎片整理的"物理块交换"实现。


34.4.1 在线扩容:追加块组

// fs/ext4/resize.c:1700(入口)
int ext4_group_add(struct super_block *sb, struct ext4_new_group_data *input)
// fs/ext4/resize.c:1535(装载一个 flex group 的核心)
static int ext4_flex_group_add(struct super_block *sb,
                   struct inode *trim_inode,
                   struct flex_groups *flex_gd)
// fs/ext4/resize.c:1338
static int ext4_setup_new_descs(handle_t *handle, struct super_block *sb, ...)
resize2fs 在线路径 (resize2fs 检测到挂载即走在线):

 用户空间 resize2fs:
   1. 在现有空间里准备好新块组的 位图/inode表
      (用"食人鱼"预留块或直接占尾部分配的块)
   2. ioctl EXT4_IOC_RESIZE_FS(新块数)
 内核 (resize.c):
   ext4_group_add → ext4_flex_group_add:
     [1] ext4_setup_new_descs (:1338) 构造新组描述符
         (事务内! 元数据变更, 34.3 节协议)
     [2] 更新超级块 blocks_count/groups_count
     [3] 新组并入 mb allocator (s_group_info 表扩容,
         34.1.2 节) 与统计
     [4] 一切在 JBD2 事务保护下 — 崩溃后要么
         新组生效要么磁盘回到旧大小

 扩容上限: GDT (组描述符表) 的预留项 — mkfs 时
 resize_inode inode 7 预留了 GDT 增长空间,
 用尽即"在线扩容失败, 需离线"

缩容无在线路径:把"已经分配了数据的块组"移除需要迁移其上所有数据,内核不做——resize2fs -M 需先卸载,由用户态把数据搬向头部再裁剪。

34.4.2 碎片整理:EXT4_IOC_MOVE_EXT

// fs/ext4/move_extent.c:562-574(要点)
 * ext4_move_extents - Exchange the specified range of a file
int ext4_move_extents(struct file *o_filp, struct file *d_filp,
              __u64 orig_blk, __u64 donor_blk,
              __u64 len, __u64 *moved_len)

ext4 的在线碎片整理不"搬数据"而是交换物理块:

e4defrag 的工作协议:

 1. e4defrag 创建临时文件 donor (mmap/alloc 目标连续区)
 2. ioctl(目标文件, EXT4_IOC_MOVE_EXT, {donor, 起止, 长度})
 3. 内核 ext4_move_extents:
     校验: 两文件均为 regular、同 fs、区间可锁
     逐段: 交换两文件的 extent 物理块指针
       (元数据操作! 数据页不动 — 立即完成, 无拷贝)
       页缓存失效 + JBD2 事务保护
 4. donor 文件吸收旧碎片位置后删除

 与 btrfs 的 reflink/swapext 同思想:
 "重排的是映射不是数据" — 大文件整理秒级完成
 限制: 需要约等于文件大小的临时空间;
 挂载了 ext4 以外叠加层 (加密/envelope) 时受限

34.4.3 运维观测面

观测工具链 (内核提供的口径):

 /proc/fs/ext4/<dev>/mb_groups   每 MB 组的空闲度/buddy 分布
   — 碎片化的直接体检表 (连续大块余量)
 e2freefrag                      用户态的空闲碎片报告
 dumpe2fs -h                     超级块/特性/块组摘要
 /sys/fs/ext4/<dev>/             延迟分配、警告率等运行参数
 ioctls: EXT4_IOC_GETSTATE/ESIM
 tune2fs -O +feature             在线开关兼容特性
   (如 metadata_csum 的在线启用路径)

 典型故障的内核口径:
 ENOSPC 但 df 有余量 → inode 耗尽或 簇碎片
   (mb_groups 里看连续性; delalloc 预留计入
    s_dirtyclusters_counter, 34.2.3 节)

小结

ext4 的运行时弹性来自三个设计:在线扩容把"新块组装配"变成 JBD2 事务内的元数据追加(GDT 预留是硬上限,缩容必须离线);EXT4_IOC_MOVE_EXT 以"交换 extent 物理指针"实现零数据拷贝的碎片重排(e4defrag 的内核支点);观测面从 mb_groups 的 buddy 分布到 ENOSPC 的双因诊断(inode 耗尽/簇碎片)构成完整运维闭环。ext4 四节至此完成——从磁盘布局、extent 映射、JBD2 事务到运行时调整;下一章离开磁盘文件系统,进入块层与 I/O 调度。