EROFS mount and metabuf

EROFS 挂载与元数据原语

发布于 2026年9月7日

阶段 2:挂载与元数据原语

本章最重要的内容是 erofs_buf 三件套。 它是 EROFS 取元数据的唯一方式——搞懂它,后面每个阶段的代码你都能读懂一大半。


本阶段目标

读完这一章,你应该能够:

  1. 说出挂载从 mount 命令到”能访问根目录”经过了哪几步
  2. 解释 superblock 校验时”拒绝不兼容特性”的必要性
  3. 说出 sbi 里 6~8 个关键字段各自”谁会用它”
  4. 解释 erofs_buf 为什么存在,以及三个函数各自干什么
  5. 能读懂任意一处 erofs_bread 调用,并判断什么时候该 put
  6. 知道 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 三件套

erofs_buf

它解决什么问题

元数据有三个特点:

  1. 经常跨页边界——一个 inode 可能横跨 4094~4100 字节
  2. 读取极其频繁——每次查找目录、解析 inode 都要读
  3. 读的大小都很小——几个字节到几十字节

如果每次都单独读一页,慢得无法接受。 所以需要一个小游标:读一页,用完不急着还,下次如果还在同一页就直接复用。

这就是 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);
}

流程是:

  1. 算页号:index = (buf->off + offset) >> PAGE_SHIFT
  2. 能复用吗:看上一页是不是就是这一页
    • 是 → 直接返回,省一次 IO
    • 否 → put 旧的,重新 read_mapping_folio()
  3. 返回指针: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

自测检查点

  1. 为什么找不到 erofs_mount() 这个函数?
  2. feature_incompat 里有不认识的位时,内核为什么宁可拒绝挂载也不硬读?
  3. erofs_init_metabuf() 有哪三种 mapping 选择?分别对应什么场景?
  4. erofs_bread() 的”复用”是怎么判断的?复用的收益是什么?
  5. erofs_bread() 返回 NULL 是错误吗?(提示:看 need_kmap 参数)
  6. 一个 erofs_buf 用完不 put 会怎样?
  7. 隐患① 的不对称具体在哪两行?fsoff 为 0 时会触发吗?
  8. 隐患② 为什么叫”跨 mapping 复用”?为什么 metabox 的引入让它成为可能?
  9. 为什么 EROFS 敢用”算”来定位 inode(erofs_iloc),而不维护索引结构?
  10. 读 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。

参考

linux-stable (93f51579e7df)