EROFS back-end decompression

EROFS 后端解压

发布于 2026年9月11日

阶段 5:解压后端

本章聚焦解压后端的设计与实现:算法如何抽象、如何挂接, 同步与异步如何取舍,硬件加速如何接入。
读完你应该能说清”一个解压请求从发出到完成,中间经过了哪些设计决策”。

本阶段目标

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

  1. 说出 EROFS 的解压后端是怎么抽象和挂接的
  2. 区分四种压缩算法的特点与适用场景,知道各自的 Kconfig 开关
  3. 解释同步解压与异步解压的选择逻辑,以及 12288 这个阈值的来历
  4. 说清硬件加速(crypto accel)的接入方式与现实限制
  5. 说清后端实现的关键设计约定(错误返回、进展检测归属、请求结构), 以及为什么硬件加速只支持 DEFLATE

5.1 统一的后端接口

解压分派

EROFS 用一个结构体统一抽象所有解压算法(compress.h):

struct z_erofs_decompressor {
        int (*config)(struct super_block *sb, struct erofs_super_block *dsb,
                      void *data, int size);
        const char *(*decompress)(struct z_erofs_decompress_req *rq,
                                  struct page **pagepool);
        int (*init)(void);
        void (*exit)(void);
        char *name;
};

五个成员:

成员 作用
.config 读镜像里的算法参数(比如 LZ4 的滑动窗口大小)
.decompress 真正的解压函数,返回错误信息字符串或 NULL
.init / .exit 模块加载/卸载时调用(比如初始化算法所需的缓冲池)
.name 算法名

这种”函数指针表 + 每种算法一个文件”的组织方式, 是内核里支持多算法的标准做法。
加一个新算法只需: 写一个 decompressor_xxx.c,实现这几个函数,注册进去。

5.2 四种算法

算法 源文件 特点 Kconfig
LZ4 decompressor.c 默认、最快、支持 in-place 无独立开关(随 EROFS_FS_ZIP)
microLZMA decompressor_lzma.c 压缩率高,解压较慢 EROFS_FS_ZIP_LZMA
DEFLATE decompressor_deflate.c 兼容性好,唯一支持硬件加速 EROFS_FS_ZIP_DEFLATE
ZSTD decompressor_zstd.c 压缩率/速度平衡好(v6.10 合入) EROFS_FS_ZIP_ZSTD

几点需要注意:

LZ4 是默认的。绝大多数 EROFS 镜像用它, 因为解压速度是只读场景的第一优先级(尤其是手机启动场景)。

LZ4 有两种数据排布:SHIFTED 和 INTERLACED (阶段 4 的 zmap.c 选择)。
区别在于压缩数据在内存页里的摆放方式, INTERLACED 在特定情况下对多页 pcluster 更友好。

microLZMA 用的是 XZ 的 microLZMA 变体, 通过内核的 xz_dec_microlzma_run() 调用。
注意它属于流式(streaming)接口: 调用方要自己维护输入/输出缓冲并循环推进(见 5.5 的设计约定)。

算法是在 inode 上指定的,不是全局的 (vi->z_algorithmtype[0] / [1],每个文件可以不同)。
这也是为什么 z_erofs_map_blocks_fo() 要把算法格式填进 map。

5.3 ⭐ 同步解压 vs 异步解压

同步 vs 异步

解压可以在当前进程上下文完成(同步/前台), 也可以丢给工作队列后台完成(异步)。

选择逻辑在 z_erofs_runqueue()(zdata.c):

static int z_erofs_runqueue(struct z_erofs_frontend *f, unsigned int rabytes)
{
        ...
        int syncmode = sbi->sync_decompress;
        bool force_fg;

        force_fg = (syncmode == EROFS_SYNC_DECOMPRESS_AUTO && !rabytes) ||
                (syncmode == EROFS_SYNC_DECOMPRESS_FORCE_ON &&
                        (rabytes <= Z_EROFS_MAX_SYNC_DECOMPRESS_BYTES));
        ...
}

三种策略

sbi->sync_decompress(internal.h)可以取三个值:

值 行为
AUTO(默认) 非预读请求(同步读)→ 前台;预读 → 后台
FORCE_ON 请求大小 ≤ 12288 字节 → 前台;否则后台
FORCE_OFF 一律后台

阈值 12288 从哪来?

/* zdata.c */
#define Z_EROFS_MAX_SYNC_DECOMPRESS_BYTES       12288

12288 = 12 KB = 3 页。

为什么是这个数? 背后的权衡是:

异步解压的额外开销包括:提交工作项 → 唤醒工作线程 → 上下文切换 → 执行 → 回调。
对于只需解压几 KB 的请求,这套流程的开销可能超过解压本身。

所以设一个阈值,小于它就”小活自己干”。这是内核里常见的模式。

这个数不是理论推导出来的,是实测调优的结果。
遇到这类”魔法数字”时,合理的做法是:理解它权衡的是什么,而不是纠结为什么不是 12000。

可以亲手调

sysfs 接口(sysfs.c):

/sys/fs/erofs/<设备>/sync_decompress

读它看当前值,写入可切换策略。

5.4 硬件加速(crypto accel)

EROFS 可以借用内核 crypto 子系统的 acompress(异步压缩/解压)接口, 把解压卸载到硬件(如 Intel QAT)。

先澄清一个常见的困惑:这里的 “crypto” 是内核的密码学/压缩算法框架 (crypto/acompress),不是加密。EROFS 不做加密。

现实限制

/* decompressor_crypto.c */
static struct z_erofs_crypto_engine *z_erofs_crypto[Z_EROFS_COMPRESSION_MAX] = {
        [Z_EROFS_COMPRESSION_LZ4] = (struct z_erofs_crypto_engine[]) {
                {},                                          /* 空 */
        },
        [Z_EROFS_COMPRESSION_LZMA] = (struct z_erofs_crypto_engine[]) {
                {},                                          /* 空 */
        },
        [Z_EROFS_COMPRESSION_DEFLATE] = (struct z_erofs_crypto_engine[]) {
                { .crypto_name = "qat_deflate", },
                {},
        },
        [Z_EROFS_COMPRESSION_ZSTD] = (struct z_erofs_crypto_engine[]) {
                {},                                          /* 空 */
        },
};

这是一张静态白名单,不是自动发现机制。

  • LZ4 / LZMA / ZSTD 三档的引擎数组全空
  • 只有 DEFLATE 有一项:"qat_deflate"

IAA 能用于 EROFS 吗?不能,三层障碍:

  1. 白名单里没有 deflate-iaa
  2. IAA 是 4 KiB 滑窗,而 mkfs 默认 deflate dict_size 是 32K (erofs-utils/lib/compressor_deflate.c)
  3. iaa_comp_adecompress 要求 src/dst 各自 nr_sgs == 1, 而 EROFS 会跨多页建 sg

有意思的旁证:T4 那份 2024 年官方演讲大讲 IAA 加速, 但其数据来自 fsck.erofs + 用户态 QPL 库,
演讲自己也说 “In-kernel decompression support is still ongoing”。 演讲里的数字不等于内核路径的能力——读材料时要留意这一点。

一个附带的可用性缺陷

z_erofs_crypto_enable_engine()(decompressor_crypto.c) 在名字没匹配任何表项时,返回 0(成功):

        for (alg = 0; alg < Z_EROFS_COMPRESSION_MAX; ++alg) {
                for (e = z_erofs_crypto[alg]; e->crypto_name; ++e) {
                        if (!strncmp(name, e->crypto_name, len)) {
                                ...
                                tfm = crypto_alloc_acomp(e->crypto_name, 0, 0);
                                if (IS_ERR(tfm)) {
                                        up_write(&z_erofs_crypto_rwsem);
                                        return -EOPNOTSUPP;   /* 分配失败:有报错 */
                                }
                                ...
                        }
                }
        }
        up_write(&z_erofs_crypto_rwsem);
        return 0;                                             /* 名字没匹配:也返回 0 */

报错路径不对称:引擎存在但分配失败 → 报错; 名字根本没匹配 → 报成功。

后果:向 /sys/fs/erofs/accel 写错名(typo、或想用 deflate-iaa)时, store 返回成功,但什么都没发生。运维会误以为加速已启用。

这是一条很好的练手补丁(改动局限在一个函数内,无兼容性风险)。 阶段 7 的练手清单里有它。

5.5 后端设计要点回顾

把本章涉及的设计决策汇总一下——这些才是后端实现的主干。

算法怎么抽象:函数指针表 + 一个算法一个文件

struct z_erofs_decompressor(compress.h)用五个成员把算法差异收拢:

成员 作用
.config 读镜像里的算法参数(如 LZ4 滑窗大小)
.decompress 真正的解压函数,返回错误字符串或 NULL
.init / .exit 模块加载/卸载时调用
.name 算法名

好处是可插拔:加一种新算法只需写一个 decompressor_xxx.c 实现这几个函数并注册,调用方(zdata.c)完全不用改。

请求怎么描述:统一 req

所有后端共用 struct z_erofs_decompress_req(compress.h) 描述一次解压:
输入页数组 / 输出页数组、页内偏移、长度、 算法、以及若干控制标志。

⇒ 后端不需要知道调用方是同步还是异步、是预读还是同步读—— 它只看到”一份输入、一份输出”。

几个容易忽略的标志:

标志 含义
inplace_io 是否允许原地解压(省一次拷贝)
partial_decoding 只要部分输出即可
fillgaps 输出有空隙(未被整页认领的输出槽)时,是否分配临时页作为解压落点 / 后续拷贝源

算法怎么选:每 inode 指定,不是全局

算法存在 inode 里(vi->z_algorithmtype[0] / [1]), 所以同一个镜像里不同文件可以用不同算法。
映射阶段 z_erofs_map_blocks_fo() 把算法格式填进 map->m_algorithmformat, 后端据此分派。

同步还是异步:看请求特征

z_erofs_runqueue()(zdata.c)按”是否预读”与”请求大小”(阈值 12288 = 3 页)决定:
小请求 / 同步读 → 前台解压(降延迟); 预读 → 后台队列(不干扰前台)。

硬件加速:白名单 + 异步接口

通过内核 crypto 子系统的 acomp(异步压缩)接入, 但引擎来自静态白名单 z_erofs_crypto[](decompressor_crypto.c),
其中只有 DEFLATE 档填了 "qat_deflate",其余三档为空。

两条容易踩的约定(重点)

① 错误返回:decompress 返回”字符串或 NULL”

不是返回 int。NULL 表示成功,非 NULL 是错误信息字符串, 由上层统一上报。写新后端时别搞错返回类型。

② 进展检测:是调用方的责任(若库契约不保证报错)

这一点值得单独强调,因为它容易被忽略:

解压循环通常写成”反复调用库函数直到完成”, 循环退出条件依赖库函数主动报错。 但有些库的契约并不保证”无法推进时会返回错误”。

例如 include/linux/xz.h 明确说明: microLZMA 的 xz_dec_microlzma_run() 不会返回 XZ_BUF_ERROR,
因此”本轮既没消耗输入也没产出输出”这种情况, 需要调用方自己检测并退出,否则循环可能一直空转。

⇒ 写后端时的正确姿势:若所用库的契约不保证无进展会报错, 循环里必须自己比对输入/输出位置是否有变化。

📌 这不是某个后端的特例,而是一条通用设计原则: 循环的正确性不能只依赖被调函数”一定会报错”。

5.6 资源管理:临时页与页池

论文里 EROFS 主打的「内存高效解压」,落地就靠这一节讲的页池(pagepool)。
但它在文档里几乎是盲区——它在实测 trace 里却以 __erofs_allocpage 的身份反复出现。

问题:解压要临时页,但”用完就 free”太浪费

解压器需要临时页(scratch buffer):
in-place 解压的双缓冲、z_erofs_stream_switch_bufs() 里为 deduped 空隙分配的页、以及 z_erofs_bvset_flip 翻页时腾出来的旧页。
如果每次用完都 __free_pages(),高峰时内存抖动会很大。

解法:标记”短命页”,归还到页池循环复用

/* 分配临时页时,给它打上短命标记 */
set_page_private(nextpage, Z_EROFS_SHORTLIVED_PAGE);

/* 用完不归还给伙伴系统,而是还回页池,供下一次解压复用 */
z_erofs_put_shortlivedpage(be->pagepool, old_bvpage);

两个配套的标记(folio->private 上的值):

标记 含义
Z_EROFS_SHORTLIVED_PAGE 临时页:用完即弃,进页池等复用
Z_EROFS_PREALLOCATED_PAGE 预分配页:pagepool 里常备的页,取来就用

为什么这样省内存

pagepool 是个临时页的回收站——解压高峰时需要的一批临时页,在这里转一圈又出来, 不用每次都向伙伴系统要。它把”临时页的生命周期”从 alloc → 用 → free 改成了 alloc → 用 → 还池 → 复用 → …,把峰值内存和分配开销都压住了。

怎么对 trace 对上号

抓 __x64_sys_read 时,__erofs_allocpage() 会出现(它是 erofs_allocpage() 这个 static inline 的底层真函数,erofs_allocpage 本身被内联、抓不到)。 但要注意它的调用栈——从 z_erofs_scan_folio() 进来的是常规输出页分配, 只有从解压后端(LZ4 的 plain 后端、z_erofs_stream_switch_bufs())进来的 才是临时页。别把所有 __erofs_allocpage 都当成页池的临时页。

这也是为什么 05 章讲「内存高效解压」时,最好顺带看一次 pagepool—— 它不是一句口号,是实打实的临时页复用。

术语速查

术语 含义 出处
z_erofs_decompressor 解压后端的统一接口 compress.h
SHIFTED / INTERLACED LZ4 的两种数据排布 zmap.c
rabytes 本次预读的字节数(非预读时为 0) zdata.c
force_fg 是否在前台(当前上下文)解压 zdata.c
Z_EROFS_MAX_SYNC_DECOMPRESS_BYTES 同步解压阈值 = 12288(12KB) zdata.c
sync_decompress 同步策略(sysfs 可调) internal.h、sysfs.c
acompress 内核 crypto 子系统的异步压缩接口 5.4
z_erofs_crypto[] 硬件引擎静态白名单 decompressor_crypto.c
XZ_BUF_ERROR microLZMA 不会返回它(契约) include/linux/xz.h

自测检查点

  1. struct z_erofs_decompressor 有几个成员?各干什么?
  2. 四种算法里,哪个是默认的?哪个是唯一支持硬件加速的?
  3. 压缩算法是全局配置还是每个文件可以不同?
  4. force_fg 在 AUTO 模式下什么情况为真?
  5. 阈值 12288 字节等于几 KB / 几页?为什么小请求适合同步解压?
  6. sync_decompress 可以从哪里调整?
  7. 硬件加速为什么只支持 DEFLATE?白名单里有什么?
  8. IAA 为什么用不了?列出至少两个障碍。
  9. 向 /sys/fs/erofs/accel 写一个不存在的引擎名,会发生什么?为什么这是问题?
  10. z_erofs_decompress_req 描述一次解压,其中 inplace_io / partial_decoding / fillgaps 各管什么?
  11. 加一种新压缩算法,需要改哪些文件?调用方(zdata.c)要改吗?为什么?
  12. 写解压循环时,为什么”循环退出条件不能只依赖被调函数报错”?以 microLZMA 的契约为例说明。

自测答案

点击展开答案

1. 接口成员?

五个(compress.h):

  • .config:读镜像里的算法参数
  • .decompress:真正的解压函数
  • .init / .exit:模块加载/卸载
  • .name:算法名

2. 默认算法?唯一支持硬件加速的?

  • 默认:LZ4(无独立 Kconfig,随 EROFS_FS_ZIP 启用)
  • 唯一支持硬件加速:DEFLATE (白名单 z_erofs_crypto[] 里只有 "qat_deflate" 一项)

3. 全局还是每文件?

每个文件可以不同。算法存在 inode 的 z_algorithmtype[0] / [1] 里, 映射时填进 map->m_algorithmformat。

4. AUTO 模式下 force_fg 何时为真?

看 zdata.c:

force_fg = (syncmode == EROFS_SYNC_DECOMPRESS_AUTO && !rabytes)

即 rabytes == 0(不是预读请求)时为真。

直觉:同步读(应用正在等数据)→ 就地解压,降低延迟; 预读(后台猜测性读取)→ 丢到后台,不干扰前台。

5. 12288 字节?

12288 = 12 KB = 3 × 4096 = 3 页

小请求适合同步,是因为异步的流程开销 (提交工作项 → 唤醒线程 → 上下文切换 → 执行 → 回调) 可能超过解压本身的开销。

6. 从哪调整?

sysfs:/sys/fs/erofs/<设备>/sync_decompress(sysfs.c)。 可读可写。

7. 为什么只支持 DEFLATE?

因为硬件引擎是靠静态白名单 z_erofs_crypto[] 提供的 (decompressor_crypto.c), 而白名单里只有 DEFLATE 档填了 "qat_deflate", LZ4 / LZMA / ZSTD 三档的数组都是空的 {}。

8. IAA 为什么用不了?

三层障碍(答出两条即可):

  1. 白名单里没有 deflate-iaa
  2. IAA 是 4 KiB 滑窗,而 mkfs 默认 deflate dict_size 是 32K
  3. iaa_comp_adecompress 要求 src/dst 各自 nr_sgs == 1, 而 EROFS 会跨多页建 sg

9. 写不存在的引擎名会怎样?

store 返回成功(0),但什么都没发生。

因为 z_erofs_crypto_enable_engine()(decompressor_crypto.c) 在名字没匹配任何表项时走完两层循环后 return 0。

这是问题,因为报错路径不对称: 引擎存在但 crypto_alloc_acomp 失败时返回 -EOPNOTSUPP(用户能看到), 名字根本没匹配时却返回 0。 运维会误以为硬件加速已启用。

10. z_erofs_decompress_req 的三个标志?

(compress.h)

标志 含义
inplace_io 是否允许原地解压(可省一次拷贝与额外页)
partial_decoding 只解出部分输出即可,不必全部
fillgaps 输出存在”空隙”(未被整页认领的输出槽)时,是否分配临时页作为解压落点 / 后续拷贝源(去重场景会用到)

11. 加一种新算法要改什么?

  • 新增一个 decompressor_xxx.c,实现 .config / .decompress / .init / .exit / .name
  • 在 Kconfig 里加对应开关,并把它注册进算法表
  • 调用方(zdata.c)不用改——因为它只依赖 struct z_erofs_decompressor 这个函数指针表, 算法差异被接口吃掉了。这正是”可插拔”的意义。

12. 为什么循环退出不能只依赖被调函数报错?

因为不是所有库都保证”无法推进时会返回错误”。

典型例子:include/linux/xz.h 明确说明 microLZMA 的 xz_dec_microlzma_run() 不会返回 XZ_BUF_ERROR。 也就是说,当”本轮既没消耗输入、也没产出输出”时, 它仍可能返回 XZ_OK 而不报错。

⇒ 此时若循环只靠”库返回错误”来退出,就会一直空转。

正确做法:循环里记录上一轮的输入/输出位置, 本轮结束后比对,若两者都没变 → 判定无进展 → 报错退出。 进展检测的责任在调用方。

参考

linux-stable (93f51579e7df)