阶段 2:挂载与元数据原语
本章最重要的内容是
erofs_buf三件套。 它是 EROFS 取元数据的唯一方式——搞懂它,后面每个阶段的代码你都能读懂一大半。
本阶段目标
读完这一章,你应该能够:
- 说出挂载从
mount命令到”能访问根目录”经过了哪几步 - 解释 superblock 校验时”拒绝不兼容特性”的必要性
- 说出
sbi里 6~8 个关键字段各自”谁会用它” - 解释
erofs_buf为什么存在,以及三个函数各自干什么 - 能读懂任意一处
erofs_bread调用,并判断什么时候该put - 知道
erofs_buf的三个真实隐患(它们都是本项目分析中发现的实际问题)
2.1 挂载全景
第一个要注意的点:现代内核用 fs_context 机制挂载,
所以你去找 erofs_mount() 是找不到的。入口在这:
erofs_fs_type super.c ← 文件系统类型注册
└─ erofs_init_fs_context super.c
└─ erofs_context_ops super.c
├─ .parse_param → 解析挂载选项(device= / fsoffset= / dax= ...)
├─ .get_tree → 打开块设备,回调 fill_super
├─ .reconfigure → remount(-o remount,...)
└─ .free → 清理
真正干活的是 erofs_fc_fill_super(super.c),它内部的关键步骤:
| 步骤 | 函数 | 干什么 |
|---|---|---|
| ① | erofs_read_superblock(super.c) |
读 superblock 并校验 |
| ② | — | 填 sbi(EROFS 的运行时状态) |
| ③ | erofs_scan_devices(super.c) |
扫描多设备表(若有) |
| ④ | — | 取 root inode,建立根 dentry |
| ⑤ | — | 处理剩余选项、注册 sysfs |
2.2 superblock 读取与校验
erofs_read_superblock(super.c)做的事,一句话:读进来,然后拼命检查。
检查项里最重要的是这三类:
magic 不匹配 → 拒绝
不是 EROFS 镜像就直接拒绝。这是最基础的保护。
不认识的 incompat 特性 → 拒绝
feature_incompat 里任何一个本内核不认识的位,都导致挂载失败。
为什么这么严格? 因为”不兼容”意味着磁盘上有本内核读不懂的结构。 硬着头皮读,得到的可能是错误数据,严重的会触发内核异常。
宁可拒绝,不可猜。 这是文件系统设计的通则。
块大小越界 → 拒绝
super.c 那个检查:
if (sbi->blkszbits < 9 || sbi->blkszbits > PAGE_SHIFT) {
erofs_err(sb, "blkszbits %u isn't supported", sbi->blkszbits);
return -EINVAL;
}
块大小必须在 [512, 页大小] 之间。超出范围说明镜像损坏或格式不支持。
📌 一个值得记住的背景:EROFS 官方文档公开声称, 内核实现应当”能够承受任意磁盘损坏而不产生真正有害的行为” (原文:”bear any on-disk corruption by design”)。
换句话说,EROFS 把”抗恶意镜像”当成自己的设计承诺。 这个承诺在阶段 5 会遇到一个反例——一个由恶意镜像触发的无限自旋缺陷。 记住这个对照,到时候你会理解为什么那个缺陷值得上报。
superblock 的扩展:sb_extslots 与 16 字节槽
superblock 的固定头是 128 字节。后来不断加特性(xattr 前缀、ishare、metabox、
fragment……),128 字节根本装不下新字段——怎么办?
答案是 sb_extslots:在 128 字节之后,再接 sb_extslots 个 16 字节的扩展槽。
/* erofs_fs.h */
#define EROFS_SB_EXTSLOT_SIZE 16
__u8 sb_extslots; /* superblock size = 128 + sb_extslots * 16 */
/* super.c 挂载时 */
sbi->sb_size = 128 + dsb->sb_extslots * EROFS_SB_EXTSLOT_SIZE;
if (sbi->sb_size > PAGE_SIZE - EROFS_SUPER_OFFSET) {
erofs_err(sb, "invalid sb_extslots %u (more than a fs block)", sbi->sb_size);
goto out;
}
⇒ 这是一种向后兼容的扩展手法:老内核只认固定 128 字节,新内核通过 sb_extslots 知道”后面还跟着多少扩展字段”。
superblock 总大小不能超过一个块(PAGE_SIZE - EROFS_SUPER_OFFSET),否则报错。
扩展槽里现在放什么(super.c 挂载时读取):
| 字段 | 用途 |
|---|---|
xattr_prefix_start / xattr_prefix_count |
xattr 前缀表的位置与条数(12 专题) |
xattr_filter_reserved |
xattr filter 预留 |
ishare_xattr_prefix_id |
ishare 指纹用哪个前缀(11 专题) |
metabox_nid |
metabox 那个”假 inode”的 nid(18 专题;含 self-loop 检测) |
packed_nid |
fragment 的 packed inode 的 nid(14 专题) |
⇒ 记住一条:凡是不在固定 128 字节头里的 superblock 字段,基本都在扩展槽里。
看某个新特性(metabox、fragment、ishare)的挂载代码时,
它读的 dsb->xxx 大多来自 sb_extslots 之后。
怎么对上号:dump.erofs 的 -s 会显示 sb_extslots;
挂载代码里凡是先判断 erofs_sb_has_xxx() 再读 dsb->yyy 的,
通常就是在从扩展槽取该特性的字段。
2.3 sbi:EROFS 的运行时状态
sbi(struct erofs_sb_info,internal.h)是 EROFS 挂在 super_block 上的私有数据。
字段很多,现在只需要记住这几个:
| 字段 | 从哪来 | 谁会用它 |
|---|---|---|
blkszbits |
superblock | 到处都在用。块↔字节换算的基础 |
meta_blkaddr |
superblock | erofs_iloc() 算 inode 位置(阶段 1 讲过) |
islotbits |
super.c 由 inode 尺寸算出 |
erofs_iloc() 的移位量 |
packed_nid |
superblock | 找 packed inode(压缩数据/fragment 常放那里,阶段 6) |
dif0 |
主设备信息 | 含 fsoff——镜像在设备内的起始偏移 |
devs / extra_devices |
device table | 多设备支持(阶段 6) |
dax_dev(每设备) |
挂载选项 | FSDAX(阶段 6) |
其中 dif0.fsoff 需要特别留意,它马上就会在 erofs_buf 里反复出现。
fsoff 是”镜像在设备内的起始偏移”,由挂载选项 -o fsoffset=X 指定,
用于”镜像不放在设备开头”的场景(比如镜像嵌在某个分区的中间)。
绝大多数情况下它是 0。
2.4 ⭐ erofs_buf 三件套
它解决什么问题
元数据有三个特点:
- 经常跨页边界——一个 inode 可能横跨 4094~4100 字节
- 读取极其频繁——每次查找目录、解析 inode 都要读
- 读的大小都很小——几个字节到几十字节
如果每次都单独读一页,慢得无法接受。 所以需要一个小游标:读一页,用完不急着还,下次如果还在同一页就直接复用。
这就是 erofs_buf。它是 EROFS 取元数据的唯一方式——
整个 fs/erofs/ 里,凡是”从磁盘读一小段元数据”,都走它。
① erofs_init_metabuf()(data.c)
决定”去哪个 page cache 取”。
int erofs_init_metabuf(struct erofs_buf *buf, struct super_block *sb,
bool in_metabox)
{
struct erofs_sb_info *sbi = EROFS_SB(sb);
buf->file = NULL;
if (in_metabox) {
if (unlikely(!sbi->metabox_inode))
return -EFSCORRUPTED;
buf->mapping = sbi->metabox_inode->i_mapping;
return 0;
}
buf->off = sbi->dif0.fsoff; /* ← 注意这里 */
if (erofs_is_fileio_mode(sbi)) {
buf->file = sbi->dif0.file;
buf->mapping = buf->file->f_mapping;
} else
buf->mapping = sb->s_bdev->bd_mapping;
return 0;
}
三种 mapping 选择(对应阶段 0 讲的 address_space):
| 模式 | mapping 指向 |
说明 |
|---|---|---|
| 块设备挂载 | s_bdev->bd_mapping |
借块设备自己的 page cache |
| fileio 文件后端 | 后备文件的 f_mapping |
借后备文件系统的 page cache |
| metabox | metabox_inode->i_mapping |
阶段 6 才讲,先记住有这个分支 |
重点记住 buf->off = sbi->dif0.fsoff 这一行(data.c)。
它把”镜像在设备内的偏移”记在了 buf 里,后面会引发一个不对称问题。
② erofs_bread()(data.c)
取元数据,返回可直接读的指针。
void *erofs_bread(struct erofs_buf *buf, erofs_off_t offset, bool need_kmap)
{
pgoff_t index = (buf->off + offset) >> PAGE_SHIFT;
...
if (buf->page) {
folio = page_folio(buf->page);
if (folio_file_page(folio, index) != buf->page)
erofs_unmap_metabuf(buf);
}
if (!folio || !folio_contains(folio, index)) {
erofs_put_metabuf(buf);
folio = read_mapping_folio(buf->mapping, index, buf->file);
...
}
buf->page = folio_file_page(folio, index);
if (!need_kmap)
return NULL;
if (!buf->base)
buf->base = kmap_local_page(buf->page);
return buf->base + (offset & ~PAGE_MASK);
}
流程是:
- 算页号:
index = (buf->off + offset) >> PAGE_SHIFT - 能复用吗:看上一页是不是就是这一页
- 是 → 直接返回,省一次 IO
- 否 →
put旧的,重新read_mapping_folio()
- 返回指针:
buf->base + (offset & ~PAGE_MASK)
第 2 步就是”复用”的意义所在:连续读同一页内的多处元数据,只读一次磁盘。
③ erofs_put_metabuf()(data.c)
void erofs_put_metabuf(struct erofs_buf *buf)
{
if (!buf->page)
return;
erofs_unmap_metabuf(buf);
folio_put(page_folio(buf->page));
buf->page = NULL;
}
解除映射 + 释放 folio 引用。必须调用。
一次典型的用法
struct erofs_buf buf = {}; /* 通常声明在栈上 */
void *data;
erofs_init_metabuf(&buf, sb, false);
data = erofs_bread(&buf, erofs_iloc(inode), true);
if (IS_ERR(data)) { ... 错误处理 ... }
/* 现在 data 指向 inode 所在位置,可以直接读了 */
parse_inode(data);
erofs_put_metabuf(&buf); /* ← 别忘了这一步 */
这个模式你会在 EROFS 里看到几十次。 认熟它。
2.5 metabox 初瞥
erofs_init_metabuf() 的第一个分支是 metabox(data.c)。
它的作用是:nid 的最高位为 1 时,表示该 inode 的元数据存放在 metabox 里, 而不是常规的元数据区。此时:
erofs_iloc()退化成nid << islotbits(不加meta_blkaddr,阶段 1 提过)erofs_init_metabuf()用metabox_inode->i_mapping
为什么需要它? 服务于增量构建—— 往已有镜像里追加文件时,不用重排整个元数据区, 把新元数据放进 metabox 即可。
详细内容留到阶段 6。现在只需要知道:mapping 不止一个
术语速查
| 术语 | 含义 | 出处 |
|---|---|---|
| fs_context | 现代内核的统一挂载框架 | 2.1 |
erofs_context_ops |
EROFS 的 fs_context 操作集 | super.c |
erofs_fc_fill_super |
挂载的实际执行者 | super.c |
sbi |
EROFS 的运行时状态(struct erofs_sb_info) |
internal.h |
fsoff |
镜像在设备内的起始偏移(-o fsoffset=) |
data.c |
erofs_buf |
取元数据的游标(带单页缓存) | 2.4 |
| metabox | 存放增量构建元数据的特殊区域 | 2.6,详讲见阶段 6 |
自测检查点
- 为什么找不到
erofs_mount()这个函数? feature_incompat里有不认识的位时,内核为什么宁可拒绝挂载也不硬读?erofs_init_metabuf()有哪三种mapping选择?分别对应什么场景?erofs_bread()的”复用”是怎么判断的?复用的收益是什么?erofs_bread()返回NULL是错误吗?(提示:看need_kmap参数)- 一个
erofs_buf用完不put会怎样? - 隐患① 的不对称具体在哪两行?
fsoff为 0 时会触发吗? - 隐患② 为什么叫”跨 mapping 复用”?为什么 metabox 的引入让它成为可能?
- 为什么 EROFS 敢用”算”来定位 inode(
erofs_iloc),而不维护索引结构? - 读 EROFS 代码时,看到
erofs_bread应该立刻去找什么?
自测答案
点击展开答案
1. 为什么找不到 erofs_mount()?
现代内核用 fs_context 机制挂载,不再用老的 ->mount 回调。
入口是 erofs_fs_type(super.c)→ erofs_init_fs_context(super.c)
→ 安装 erofs_context_ops(super.c),
真正的执行者是 erofs_fc_fill_super(super.c)。
2. 为什么不硬读 incompat 特性?
“不兼容”意味着磁盘上有本内核读不懂的结构。
硬读会得到错误数据,严重时触发内核异常或安全漏洞。
文件系统的通则是:宁可拒绝,不可猜。
3. 三种 mapping 选择?
| 模式 | mapping |
|---|---|
| 块设备挂载 | sb->s_bdev->bd_mapping |
| fileio 文件后端 | 后备文件的 f_mapping |
| metabox | sbi->metabox_inode->i_mapping |
4. 复用怎么判断?收益?
判断依据(data.c):上一页的页号是否就是这次要的页号
(用 folio_file_page(folio, index) 和 folio_contains(folio, index))。
收益:连续读同一页内的多处元数据时只读一次磁盘。
考虑到读元数据是极高频操作,这个优化很重要。
5. 返回 NULL 是错误吗?
不是。 看 data.c:当 need_kmap = false 时,
函数直接 return NULL,表示”页已经备好了,但你没要求映射,所以不给你指针”。
错误是通过 ERR_PTR() 返回的,要用 IS_ERR() 判断,
不能用 !ptr 判断——这是读这段代码最容易犯的错。
6. 不 put 会怎样?
folio 的引用计数不降,页无法回收 → 内存泄漏。
erofs_buf 是栈上的普通结构体,没有析构函数兜底。
7. 隐患① 的不对称在哪?fsoff=0 会触发吗?
不对称在 data.c(index 加了 buf->off)与 data.c
(返回指针没加 buf->off)之间。
fsoff = 0 时不会触发,因为 +0 和 +0 一样。
只有 fsoff 非 0 且不是页大小整数倍时才显现。
(非页对齐的 fsoffset 还会被挂载期 superblock 自校验拦下一部分。)
8. 隐患② 为什么叫”跨 mapping 复用”?
因为复用判据(data.c)只比较页号,
从不检查 buf->mapping 是否还是同一个。
如果 buf A 用 mapping X 读了第 5 页, buf B 用 mapping Y 也要读第 5 页, 判据会认为”页号一样,可以复用”,于是返回 X 里的第 5 页——张冠李戴。
metabox 的引入让系统里出现了第二个 mapping(之前基本只有块设备一个),
才使这个隐患从”理论上存在”变成”实际可能触发”。
而 metabox 的 mapping 偏移从 0 起算,与块设备的低页号区间天然重叠,碰撞概率不为零。
9. 为什么敢”算”而不维护索引?
因为 EROFS 是只读的:所有布局在 mkfs 时定死,运行时不需要增删改。 不需要插入删除,就不需要 B 树、位图这类可变索引结构。 一次乘法就能定位,这是只读换来的红利。
10. 看到 erofs_bread 应立刻找什么?
找对应的 erofs_put_metabuf()。
这是 erofs_buf 的核心契约:取了就必须还,
而且没有语言机制帮你兜底(隐患③)。
读代码时养成这个习惯,你就能自己发现 EROFS 里曾经出现过的那类 bug。