Linux内核分析之文件系统-01
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 调度。