EROFS compressed reading-path

EROFS 压缩读路径

发布于 2026年9月9日

阶段 4:压缩读路径

这是全套材料最难的一章,也是最有价值的一章。 压缩相关代码(zmap.c + zdata.c + zutil.c)占全树约 33%。

建议分三步走:先看 zmap(映射),再看 zdata(状态机), 最后看两者如何联动。不要试图一次读懂整个压缩路径。

本阶段目标

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

  1. 解释为什么压缩文件的”逻辑偏移 → 物理位置”不能靠算术,必须查索引
  2. 区分 lcluster(逻辑簇)与 pcluster(物理簇),说清它们为什么不是一一对应
  3. 说出压缩索引的三种格式与 HEAD / NONHEAD 两种索引项类型
  4. 读懂 z_erofs_map_blocks_fo() 的主流程
  5. 解释 pcluster 为什么必须有状态机(并发去重解压)
  6. 说清 in-place 解压与 cached 解压的差别,以及 in-place 为什么需要 margin
  7. 解释 m_pa 为什么是字节地址、为什么不保证块对齐

4.1 为什么压缩不能做算术

压缩 vs 非压缩

非压缩时,逻辑偏移到物理位置是个除法:

/* 阶段 3 的 erofs_map_blocks,data.c */
map->m_pa = erofs_pos(sb, vi->startblk) + map->m_la;

压缩后这个等式不成立,因为压缩后长度不定。

阶段 1 的实测数据说明了问题有多极端:

文件 原始大小 磁盘占用 比例
rep.txt 400000 字节 4096 字节 1.02%
big.txt 3000000 字节 12288 字节 0.41%

同一个文件,逻辑 8KB 对应的物理位置可能是 100 字节之后,也可能是 10000 字节之后。 没有算术解,只能查表。

那张表就是压缩索引,它存在 inode 后面(datalayout 1/3 时)。

4.2 lcluster 与 pcluster

这是压缩路径最基础的一对概念:

  lcluster(逻辑簇) pcluster(物理簇)
全称 logical cluster physical cluster
是什么 压缩前的固定长度块 一次解压的物理单位
大小 通常 4K 的倍数(z_lclusterbits) 不定(压缩后多长就多长)
谁定义 格式规定,固定 mkfs 决定
数量关系 一个 pcluster 解压后可能覆盖多个 lcluster  

关键认知:lcluster 与 pcluster 不是一一对应的。

逻辑视图(lcluster,等长):
  ┌────┬────┬────┬────┬────┐
  │ L0 │ L1 │ L2 │ L3 │ L4 │
  └────┴────┴────┴────┴────┘
    ↓    ↓              ↓
物理视图(pcluster,不等长):
  ┌─────────────┬──────────┐
  │   P0        │   P1     │      P0 解压后覆盖 L0~L3
  └─────────────┴──────────┘      P1 覆盖 L4

这个”一对多”的关系,是后面所有复杂性的根源:

  • 索引只需要给 pcluster 的头 记物理地址 (中间的 lcluster 不记,标记成 NONHEAD)
  • 从中间某个 lcluster 出发,要往回找它属于哪个 pcluster

4.3 压缩索引:三种格式,两类索引项

索引格式

三种格式

索引本身也占空间,所以有三种格式,mkfs 按需要选择 (用 z_advise 里的标志位告诉内核该用哪种):

格式 每项大小 优点 缺点
完整索引(non-compacted) 16 字节 直接、无限制 占空间
compacted_2b 约 2 字节(大 pcluster 时 4 字节) 省空间 解码复杂
compacted_1 更紧凑 最省 只支持小 pcluster

标志:Z_EROFS_ADVISE_COMPACTED_2B(erofs_fs.h)。 读取函数:z_erofs_load_full_lcluster(zmap.c)与 z_erofs_load_compact_lcluster(zmap.c)。

两类索引项

每个索引项有一个类型字段(advise 的低位,掩码 Z_EROFS_LI_LCLUSTER_TYPE_MASK, erofs_fs.h):

/* z_erofs_load_full_lcluster,zmap.c */
advise = le16_to_cpu(di->di_advise);
m->type = advise & Z_EROFS_LI_LCLUSTER_TYPE_MASK;      /* 取类型 */
if (m->type == Z_EROFS_LCLUSTER_TYPE_NONHEAD) {
        /* NONHEAD:不记地址,只记"距离头多远" */
        m->clusterofs = 1 << vi->z_lclusterbits;
        m->delta[0] = le16_to_cpu(di->di_u.delta[0]);
        ...
        m->delta[1] = le16_to_cpu(di->di_u.delta[1]);
} else {
        /* HEAD:记真正的物理地址 */
        m->partialref = !!(advise & Z_EROFS_LI_PARTIAL_REF);
        m->clusterofs = le16_to_cpu(di->di_clusterofs);
        if (advise & Z_EROFS_LI_HOLE) {
                m->compressedblks = 0;
                m->pblk = EROFS_NULL_ADDR;                     /* 空洞 */
        } else {
                m->pblk = le32_to_cpu(di->di_u.blkaddr);       /* 物理块号 */
        }
}
  • HEAD:pcluster 的第一个 lcluster。记录真实的物理块地址。
    • 子类型:PLAIN(普通)、HEAD2(用第二种压缩算法)
    • 标志位:PARTIAL_REF(部分引用,去重产生,阶段 6)、HOLE(空洞)
  • NONHEAD:被同一个 pcluster 覆盖的后续 lcluster。 不记物理地址,只记 delta[0] / delta[1](距离头多远)。

这就是”一对多”关系在磁盘上的表达: 一个 pcluster 只需要一份物理地址,后续 lcluster 用 NONHEAD 占位。

4.4 映射主流程:z_erofs_map_blocks_fo()

入口是 z_erofs_map_blocks_iter()(zmap.c):

int z_erofs_map_blocks_iter(struct inode *inode, struct erofs_map_blocks *map,
                            int flags)
{
        ...
        if (map->m_la >= inode->i_size) {              /* 超出文件末尾 */
                ...
        } else {
                err = z_erofs_fill_inode(inode, map);   /* 首次访问时填元数据 */
                if (!err) {
                        if (vi->datalayout == EROFS_INODE_COMPRESSED_FULL &&
                            (vi->z_advise & Z_EROFS_ADVISE_EXTENTS))
                                err = z_erofs_map_blocks_ext(inode, map, flags);
                        else
                                err = z_erofs_map_blocks_fo(inode, map, flags);
                }
                if (!err)
                        err = z_erofs_map_sanity_check(inode, map);
        }
}

两条分支:

  • z_erofs_map_blocks_ext()(zmap.c):extents 格式(较新的格式)
  • z_erofs_map_blocks_fo()(zmap.c):lcluster 索引格式(经典格式)

本章讲后者,它更能体现”索引查找”的本质。

第一步:算逻辑簇号

/* zmap.c, :423-424 */
ofs = flags & EROFS_GET_BLOCKS_FINDTAIL ? inode->i_size - 1 : map->m_la;
...
initial_lcn = ofs >> lclusterbits;                    /* 逻辑簇号 */
endoff = ofs & ((1 << lclusterbits) - 1);             /* 簇内偏移 */

这一步和阶段 3 的”算块号”一样是算术—— 但注意,算出来的是逻辑簇号,还不是物理地址。

第二步:从磁盘读索引项

/* zmap.c */
err = z_erofs_load_lcluster_from_disk(&m, initial_lcn, false);

这个函数内部按格式分派到 full / compact 的读取函数。

第三步:判断是不是 HEAD(关键分支)

/* zmap.c */
if (m.type != Z_EROFS_LCLUSTER_TYPE_NONHEAD && endoff >= m.clusterofs) {
        /* 情况 A:这个 lcluster 就是某个 pcluster 的头 */
        m.headtype = m.type;
        map->m_la = (m.lcn << lclusterbits) | m.clusterofs;
        ...
} else {
        /* 情况 B:不是头,要往回找它所属 pcluster 的头 */
        if (m.type != Z_EROFS_LCLUSTER_TYPE_NONHEAD) {
                end = (m.lcn << lclusterbits) | m.clusterofs;
                map->m_flags &= ~EROFS_MAP_PARTIAL_MAPPED;
                m.delta[0] = 1;
        }
        err = z_erofs_extent_lookback(&m, m.delta[0]);          /* 往回找 */
        ...
}

情况 B 是理解压缩映射的关键:

你可能落在某个 pcluster 中间的某个 lcluster 上。 此时索引项(NONHEAD)里没有物理地址, 必须通过 z_erofs_extent_lookback()(zmap.c)往回走 delta[0] 步,找到头。

类比:你站在一列火车的第 3 节车厢,想知道车头在哪。 车厢里没写车头位置(NONHEAD 不记地址), 但写了”距车头 2 节”(delta),于是往前走 2 节就找到了。

第四步:得到物理地址

/* zmap.c */
map->m_pa = erofs_pos(sb, m.pblk);

m.pblk 是块号,经 erofs_pos() 转成字节地址。

记住:m_pa 是字节地址,不是块号。

而且(与阶段 3 的 flat 路径不同)它不保证块对齐—— 因为 pcluster 是连续存放的,第二个 pcluster 的起点取决于前一个有多长。

第五步:选择压缩算法格式

/* zmap.c */
if (m.headtype == Z_EROFS_LCLUSTER_TYPE_PLAIN) {
        if (vi->z_advise & Z_EROFS_ADVISE_INTERLACED_PCLUSTER)
                map->m_algorithmformat = Z_EROFS_COMPRESSION_INTERLACED;
        else
                map->m_algorithmformat = Z_EROFS_COMPRESSION_SHIFTED;
} else if (m.headtype == Z_EROFS_LCLUSTER_TYPE_HEAD2) {
        map->m_algorithmformat = vi->z_algorithmtype[1];
} else {
        map->m_algorithmformat = vi->z_algorithmtype[0];
}

SHIFTED 与 INTERLACED 是 LZ4 的两种数据排布方式, 区别是压缩数据在页里的摆放顺序不同(阶段 5 会看到它影响解压)。

收尾:注意是 unmap 不是 put

/* zmap.c */
erofs_unmap_metabuf(&m.map->buf);

这是有意为之(本项目专门核查过):

erofs_unmap_metabuf() 只解除内核映射,不释放 folio 引用。
因为这个 folio 很快还会被用到——下一个 lcluster 的索引很可能就 在 ALIGN(end, 8) + 16 处(仅隔 16 字节), 不释放就能命中 erofs_bread 的复用快路径(data.c),省一次元数据 IO。

⚠️ 千万不要”顺手”把它改成 erofs_put_metabuf()。 那会引入性能回退。正确做法是加注释说明意图。 (这是本项目核查后给出的结论。)

一个值得对照的细节

zmap.c:

if (fragment && vi->datalayout == EROFS_INODE_COMPRESSED_FULL)
        vi->z_fragmentoff |= (u64)m.pblk << 32;

看起来像”把块号当字节用的 bug”,其实不是—— m.pblk 这个槽位被 mkfs 复用了,存的是 fragmentoff 的高 32 位 (低 32 位来自 h_fragmentoff),合起来才是完整的 64 位偏移。 本项目核查后确认这是有意的位打包。

这个例子说明:在 EROFS 里看到”不合理”的位运算,先怀疑自己没理解字段复用, 而不是急着报 bug。 反过来也说明,这类复用值得加注释。

4.5 ⭐ pcluster 状态机

pcluster 状态机

这一节回答一个核心问题:同一个 pcluster 被多个并发读请求需要时,怎么避免重复解压?

先看问题

场景:10 个进程同时读同一个压缩文件的前 4KB。

没有协调:各读各的、各解各的 → 同一份数据解压 10 次 → CPU 浪费 9 倍
有协调  :第一个解压,其余 9 个等结果

这就是 pcluster 必须是一个有状态对象的原因。

三种状态

定义在 zdata.c:

enum z_erofs_pclustermode {
        /* 已被链接到另一个处理链 */
        Z_EROFS_PCLUSTER_INFLIGHT,
        /*
         * 弱化的 FOLLOWED:可能因 uptodated managed folios
         * 被分派到旁路队列,所以相关 folio 不能用于 in-place IO
         * (pcluster 可能在另一个队列里乱序解码)
         */
        Z_EROFS_PCLUSTER_FOLLOWED_NOINPLACE,
        /* 刚链接到当前处理链,相关 folio 可用于 in-place IO */
        Z_EROFS_PCLUSTER_FOLLOWED,
};

注意枚举顺序是有意义的:INFLIGHT(0) < NOINPLACE(1) < FOLLOWED(2), 所以判据可以写成一个比较(zdata.c):

if (fe->mode < Z_EROFS_PCLUSTER_FOLLOWED)    /* 不能 in-place */

核心代码

z_erofs_pcluster_begin()(zdata.c):

        /* ① 查:这个 pcluster 有人管吗?(zdata.c) */
        do {
                rcu_read_lock();
                pcl = xa_load(&EROFS_SB(sb)->managed_pslots, map->m_pa);
                needretry = pcl && !z_erofs_get_pcluster(pcl);
                rcu_read_unlock();
        } while (needretry);

        if (pcl) {
                fe->pcl = pcl;
                ret = -EEXIST;                        /* 已存在 */
        } else {
                ret = z_erofs_register_pcluster(fe);   /* 新建 */
        }

        /* ② 抢:原子地争抢"谁负责解压"(zdata.c) */
        if (ret == -EEXIST) {
                mutex_lock(&fe->pcl->lock);
                if (!cmpxchg(&fe->pcl->next, NULL, fe->head)) {
                        fe->head = fe->pcl;
                        fe->mode = Z_EROFS_PCLUSTER_FOLLOWED;   /* 我接管 */
                } else {
                        fe->mode = Z_EROFS_PCLUSTER_INFLIGHT;   /* 别人在处理 */
                }
        }

两个关键机制:

  1. managed_pslots:一个 xarray,按 map->m_pa(物理地址)索引所有在处理的 pcluster。 物理地址相同 = 同一个 pcluster,这是判重的关键。

  2. cmpxchg(&pcl->next, NULL, fe->head):原子比较并交换。

    • next 原本是 NULL → 我成功接管,返回旧值 NULL → FOLLOWED
    • next 已被别人设置 → 我失败 → INFLIGHT(别人在解压,我等结果)

这是典型的无锁并发模式:用一个原子操作决定”谁是负责人”。

后续流程

pcluster_begin(决定状态)
    ↓
读压缩数据(提交 bio)
    ↓
z_erofs_endio()          zdata.c   ← IO 完成回调
    ↓
z_erofs_decompress_queue()  zdata.c   ← 排队
    ↓
z_erofs_decompress_pcluster()  zdata.c   ← 真正解压
    ↓
z_erofs_pcluster_end()   zdata.c    ← 释放、唤醒等待者

META 特例:如果 m_flags 有 META(ztailpacking 内联数据), 不走 pcluster 机制,直接用 erofs_bread 读(zdata.c)。

页级同步:erofs_onlinefolio 三件套

pcluster 状态机管的是”这个压缩簇解到哪儿、谁来解”,但它没说清一件事:
多个 pcluster 的解压输出要往同一个 folio(读缓存页)的不同偏移写时,什么时候才能 folio_end_read() 告诉 VFS「这页好了」?——必须等最后一个写完。
erofs_onlinefolio 三件套就是用 folio->private 当引用计数来协调的 (data.c,不在 zdata.c,找的时候容易找错位置)。

void erofs_onlinefolio_init(struct folio *folio)
{
        union { atomic_t o; void *v; } u = { .o = ATOMIC_INIT(1) };
        folio->private = u.v;            /* 把 private 借用为原子计数器,置 1 */
}

void erofs_onlinefolio_split(struct folio *folio)
{
        atomic_inc((atomic_t *)&folio->private);   /* 又来一个写入者,计数 +1 */
}

void erofs_onlinefolio_end(struct folio *folio, int err, bool dirty)
{
        do {
                orig = atomic_read(...);
                v = dirty << EROFS_ONLINEFOLIO_DIRTY;
                v |= (orig - 1) | (!!err << EROFS_ONLINEFOLIO_EIO);
        } while (atomic_cmpxchg(...) != orig);             /* 原子减 1 */

        if (v & (BIT(EROFS_ONLINEFOLIO_DIRTY) - 1))
                return;                                    /* 不是最后一个,直接走 */
        folio->private = 0;
        if (v & BIT(EROFS_ONLINEFOLIO_DIRTY))
                flush_dcache_folio(folio);
        folio_end_read(folio, !(v & BIT(EROFS_ONLINEFOLIO_EIO)));  /* 最后一个:解锁 */
}

三个要点:

  1. folio->private 被借用为 atomic_t + 两个标志位(DIRTY / EIO)—— 内核里用 private 存引用计数的经典手法
  2. init 置 1 → 每来一个写入者 split +1 → 每写完一个 end −1
  3. 计数归零的那一个才真正 folio_end_read()(!(v & EIO) 表示「没出错才标记成功」)

怎么对 trace 对上号:抓 __x64_sys_read 的 function_graph,压缩读路径里会看到

z_erofs_scan_folio() {
  erofs_onlinefolio_init();
  erofs_onlinefolio_split();
  erofs_onlinefolio_end();
}

这一串就是上面三个函数——z_erofs_scan_folio 每处理一个 folio,就 「登记一个写入者(split)→ 写完(end)」。如果没这套引用计数, 先写完的 pcluster 会过早解锁那页,后面的写入者就往已解锁的页里写,数据会乱。

4.6 in-place 解压 vs cached 解压

in-place vs cached

解压需要一块目标内存放结果。这块内存从哪来?

  in-place cached
目标内存 文件自己的 page cache 页 EROFS 自管的临时页
额外拷贝 无 多一次
结果缓存 不缓存(下次读还要解压) 留在 managed cache,可复用
约束 尾部需要 margin 无
触发条件 mode == FOLLOWED 且 folio 可用 其余情况

managed cache 是一个专用于此的假 inode(internal.h 的 managed_cache, 通过 MNGD_MAPPING(sbi) 访问,定义在 internal.h)。

为什么 in-place 需要 margin?

这是本节最需要理解的一点。

LZ4 这类算法解压时会向前回看已解压的数据(LZ4 回看窗口最大 64KB)。 如果目标内存就是源数据所在的那一页,就会出问题:

目标页(同时也是源数据所在):
  ┌────────────┬──────────────┐
  │ 已解压覆盖  │  还未覆盖     │
  │            │ (源数据在这)│
  └────────────┴──────────────┘
                ↑            ↑
             读指针        写指针

  写指针不断前进,可能追上读指针,
  于是"回看"读到的是自己刚写进去的错误数据。

解决办法:目标缓冲区尾部多留一段空间(margin), 保证回看窗口始终能读到未被覆盖的原始压缩数据。

这个 margin 的大小由解压算法决定, LZ4 对应 LZ4_DECOMPRESS_INPLACE_MARGIN 等常量(阶段 5 详讲)。

in-place 解压是 EROFS 最早宣传的核心特性之一 (2019 年第一份官方演讲就专门有一页讲 “Decompression In-place”)。 它的价值是省一次内存拷贝——在手机这种内存带宽紧张的设备上很关键。

避免自盖:z_erofs_bvset_flip

上一节讲 in-place 解压要 margin,但它没说具体怎么不把自己的输入盖掉。 答案在 zdata.c 的 z_erofs_bvset_flip()——压缩数据的 bvec(小块描述符) 存在两个页里,读完一页就翻页:

static struct page *z_erofs_bvset_flip(struct z_erofs_bvec_iter *iter)
{
        /* have to access nextpage in advance, otherwise it will be unmapped */
        struct page *nextpage = iter->bvset->nextpage;   /* ← 必须先取下一页地址! */
        struct page *oldpage;

        oldpage = z_erofs_bvec_iter_end(iter);           /* 当前这页结束 */
        iter->bvpage = nextpage;                          /* 切到下一页 */
        iter->bvset = kmap_local_page(nextpage);
        iter->nr = (PAGE_SIZE - base) / sizeof(struct z_erofs_bvec);
        iter->cur = 0;
        return oldpage;                                   /* 旧页放回页池复用 */
}

那行注释是灵魂:

have to access nextpage in advance, otherwise it will be unmapped

翻页前必须先把下一页的地址存下来——因为旧页一旦 flip 就会失效(会被解映射)。
这就是 in-place 解压避免「输出盖掉还没读完的输入」的具体手法:一边解压一边往前翻页,写完的旧页立刻腾出来。

怎么和 trace 对上号:
抓压缩读路径时,z_erofs_scan_folio() 内部会出现z_erofs_bvset_flip()——出现它,说明这一簇是 in-place 解压(数据要解压回原位),
而且压缩数据跨了 bvec 页边界、正在翻页。出现得越频繁,说明 pcluster 越大、bvec 越多。

解压后要不要缓存:cache_strategy

上一节讲 cached 解压,但没说要不要把解压好的物理簇数据留下来复用——这由挂载选项 cache_strategy 决定。
它影响的是:解压过一次的数据,下次再读到相邻部分时,要不要重新解压一遍。

super.c 里的三档(默认 readaround):

static const struct constant_table erofs_param_cache_strategy[] = {
        {"disabled",   EROFS_ZIP_CACHE_DISABLED},
        {"readahead",  EROFS_ZIP_CACHE_READAHEAD},
        {"readaround", EROFS_ZIP_CACHE_READAROUND},   /* 默认 */
};

/* 挂载默认 */
sbi->opt.cache_strategy = EROFS_ZIP_CACHE_READAROUND;

落到代码里是 zdata.c 的 z_erofs_should_alloc_cache()—— 每个解压请求都要问一次”这次要不要把物理簇数据存进 managed cache”:

static bool z_erofs_should_alloc_cache(struct z_erofs_frontend *fe)
{
        unsigned int cachestrategy = EROFS_I_SB(fe->inode)->opt.cache_strategy;

        if (cachestrategy <= EROFS_ZIP_CACHE_DISABLED)
                return false;                          /* disabled:一律不缓存 */

        if (fe->map.m_flags & EROFS_MAP_PARTIAL_MAPPED)
                return true;                           /* 部分映射(fragment/dedupe 引用)→ 缓存 */

        if (cachestrategy >= EROFS_ZIP_CACHE_READAROUND &&
            fe->map.m_la < fe->headoffset)
                return true;                           /* readaround:往回读(m_la < headoffset)→ 缓存 */

        return false;
}

三档的实际差别:

档位 缓存什么 适合
disabled 什么都不缓存(每次重解压) 只读一遍的场景,省内存
readahead 只缓存 partial_mapped(被引用的部分,如 dedupe/fragment) 顺序读为主
readaround(默认) partial + 往回读(m_la < headoffset,读到文件前部) 通用,局部性好的场景

⇒ 名字里的 “around” 指的是向后(往回)——读到了文件靠前的部分,很可能很快又会再读那一带,所以把解压结果留下来。
“ahead” 则只管向前(被引用的部分)。

怎么对上号:mount -o cache_strategy=readaround(可改);
挂载后 /sys/fs/erofs/<dev>/cache_strategy 能看到当前值(super.c 的 seq_printf)。
抓 z_erofs_scan_folio 的 trace,看它后面跟不跟 z_erofs_bind_cache()(把解压结果绑进 managed cache)——跟了说明这次走了缓存。

注意它和 erofs_buf 的区别:erofs_buf 是元数据的读取缓冲,
cache_strategy 管的是解压后的物理簇数据要不要复用——两个是不同的缓存。

4.7 常见误解

误解一:以为一个 lcluster 对应一个 pcluster

不是。一个 pcluster 解压后可能覆盖多个 lcluster(4.2 节)。 所以才有 HEAD / NONHEAD 的区分,才有 extent_lookback 往回找。

误解二:以为 m_pa 是块号

不是,是字节地址(zmap.c 用 erofs_pos() 转过)。 而且不保证块对齐——pcluster 连续存放,起点取决于前面有多长。

这个不对齐正是 pageofs_in 存在的原因,也是解压路径复杂的一个来源。

误解三:看到 m.pblk << 32 就以为是 bug

zmap.c 的这行是有意的位打包(4.4 节末尾讲过)。 在 EROFS 里遇到奇怪的位运算,先查 mkfs 侧这个字段是怎么写的。

误解四:以为 erofs_unmap_metabuf 是漏了 put

不是。zmap.c 特意只 unmap 不 put,为的是让下一个索引项命中复用快路径。 (但这也意味着调用方要清楚后续谁负责 put —— 又是一处人工契约。)

误解五:以为压缩文件读一次只解压一次

不一定。如果同一次读涉及多个 pcluster,每个 pcluster 都要单独解压。 反过来,多个 pcluster 也可能被合并成一次批量提交。

术语速查

术语 含义 出处
lcluster 逻辑簇,压缩前的固定长度块 4.2
pcluster 物理簇,一次解压的物理单位 4.2
HEAD / NONHEAD 索引项类型:pcluster 的头 / 被覆盖的后续簇 zmap.c
delta[0]/delta[1] NONHEAD 记录”距头多远” zmap.c
extent_lookback 从 NONHEAD 往回找 HEAD zmap.c
managed_pslots 按物理地址索引在处理的 pcluster(xarray) zdata.c
cmpxchg 原子争抢”谁负责解压” zdata.c
FOLLOWED / INFLIGHT 我接管 / 别人在处理 zdata.c
managed cache EROFS 自管的临时页(cached 解压用) internal.h
margin in-place 解压时在目标尾部留的安全余量 4.6
SHIFTED / INTERLACED LZ4 的两种数据排布 zmap.c

自测检查点

  1. 为什么压缩文件不能像非压缩那样用算术定位?
  2. lcluster 和 pcluster 的区别?为什么一个 pcluster 能覆盖多个 lcluster?
  3. 索引项分 HEAD 和 NONHEAD,NONHEAD 为什么不记物理地址?
  4. 如果你要读的 lcluster 是 NONHEAD,代码怎么找到物理地址?
  5. zmap.c 那个分支判断在判断什么?endoff >= m.clusterofs 意味着什么?
  6. pcluster 为什么必须有状态?没有状态机会发生什么?
  7. cmpxchg(&pcl->next, NULL, fe->head) 成功和失败分别意味着什么?
  8. 三种状态的枚举顺序为什么是有意的?
  9. in-place 解压为什么需要 margin?具体是什么风险?
  10. m_pa 是块号还是字节地址?它保证块对齐吗?为什么?
  11. zmap.c 为什么用 erofs_unmap_metabuf 而不是 erofs_put_metabuf?
  12. 看到 zmap.c 的 (u64)m.pblk << 32,你的第一反应应该是什么?

自测答案

点击展开答案

1. 为什么不能算术定位?

压缩后长度不定,逻辑偏移与物理位置之间没有固定比例。 阶段 1 实测:400000 字节可压到 4096 字节(1.02%), 也可能随机数据压不动(100%)。所以只能查索引表。

2. lcluster 与 pcluster?

  • lcluster:压缩前的固定长度块(通常 4K 的倍数)
  • pcluster:一次解压的物理单位,压缩后多长就是多长

一个 pcluster 解压后得到的数据可能跨越多个 lcluster—— 因为压缩是以”一大块”为单位做的,而逻辑视图是等分的。

3. NONHEAD 为什么不记物理地址?

因为同一个 pcluster 覆盖的所有 lcluster 共享同一份物理地址(都在那个 pcluster 里)。 只在 HEAD 记一次,后续用 NONHEAD 占位, 靠 delta 表达”距头多远”,可以省下大量索引空间。

4. NONHEAD 怎么找到物理地址?

调 z_erofs_extent_lookback()(zmap.c) 往回走 delta[0] 步,找到对应的 HEAD,再从 HEAD 取物理地址。

(类比:站在第 3 节车厢,看”距车头 2 节”,往前走 2 节。)

5. zmap.c 在判断什么?

if (m.type != Z_EROFS_LCLUSTER_TYPE_NONHEAD && endoff >= m.clusterofs)

判断”当前这个 lcluster 是不是就是某个 pcluster 的头“:

  • m.type != NONHEAD:它至少是个 HEAD 候选
  • endoff >= m.clusterofs:要读的偏移不早于这个头覆盖的范围起点

两者都成立 → 情况 A,直接用这个头的信息。 否则 → 情况 B,要往回找(extent_lookback)。

6. pcluster 为什么必须有状态?

因为同一个 pcluster 可能被多个并发读请求同时需要。 没有状态就无法判断”是不是有人在解压了”, 结果就是每个请求各解各的,同一份数据重复解压,浪费 CPU。

状态还决定了能不能用 in-place(见第 8 题)。

7. cmpxchg 成功/失败的含义?

  • 成功(next 原本是 NULL)→ 我是第一个接管者, mode = FOLLOWED,由我负责解压。
  • 失败(next 已被设置)→ 别人先接管了, mode = INFLIGHT,我挂到它的链上等结果,不重复解压。

8. 枚举顺序为什么有意为之?

因为顺序表达了”能力递增”: INFLIGHT(0) < FOLLOWED_NOINPLACE(1) < FOLLOWED(2)。

于是”能不能 in-place”可以写成一次比较(zdata.c):

if (fe->mode < Z_EROFS_PCLUSTER_FOLLOWED)   /* 不能 in-place */

这是内核里常见的”用枚举顺序编码能力层级”的手法。

9. in-place 为什么需要 margin?

风险是写指针追上读指针:

LZ4 解压时会向前回看已解压数据(窗口最大 64KB)。 如果目标内存就是源数据所在的页,解压写入会逐步覆盖源数据。 当写入追上回看位置时,解压器读到的就是自己刚写进去的错误数据, 而不是原始压缩数据——解压结果错误。

margin 就是在目标缓冲区尾部多留一段空间, 保证回看窗口始终落在未被覆盖的原始压缩数据上。

10. m_pa 是块号还是字节地址?保证对齐吗?

是字节地址(zmap.c 用 erofs_pos() 从块号转来)。

不保证块对齐。因为 pcluster 是连续紧凑存放的, 第二个 pcluster 的起点取决于第一个压缩后有多长—— 几乎不可能刚好落在块边界上。

这个不对齐是 pageofs_in 存在的原因, 也是解压路径比非压缩路径复杂的重要原因。

11. 为什么用 unmap 而不是 put?

有意为之的性能优化。

erofs_unmap_metabuf() 只解除内核映射,不释放 folio 引用。 因为下一个 lcluster 的索引很可能就在附近 (ALIGN(end, 8) + 16,仅隔 16 字节), 保留引用就能命中 erofs_bread 的复用快路径(data.c),省一次元数据 IO。

⚠️ 不要”顺手修正”成 put——那是性能回退。 本项目核查后的建议是加注释说明意图。

12. 看到 (u64)m.pblk << 32 的第一反应?

先怀疑字段被复用了,而不是急着报 bug。

这一行(zmap.c)里 m.pblk 存的其实是 fragmentoff 的高 32 位, 低 32 位来自 h_fragmentoff,合起来才是完整的 64 位偏移。 mkfs 侧(erofs-utils/lib/compress.c)复用了这个原本存块地址的槽位。

本项目核查后确认:不是 bug,是有意的位打包。

通用方法论:在 EROFS 里遇到”不合理”的位运算, 先去 mkfs 侧查这个字段是怎么写进去的。

参考

linux-stable (93f51579e7df)