阶段 4:压缩读路径
这是全套材料最难的一章,也是最有价值的一章。 压缩相关代码(
zmap.c+zdata.c+zutil.c)占全树约 33%。建议分三步走:先看 zmap(映射),再看 zdata(状态机), 最后看两者如何联动。不要试图一次读懂整个压缩路径。
本阶段目标
读完这一章,你应该能够:
- 解释为什么压缩文件的”逻辑偏移 → 物理位置”不能靠算术,必须查索引
- 区分 lcluster(逻辑簇)与 pcluster(物理簇),说清它们为什么不是一一对应
- 说出压缩索引的三种格式与 HEAD / NONHEAD 两种索引项类型
- 读懂
z_erofs_map_blocks_fo()的主流程 - 解释 pcluster 为什么必须有状态机(并发去重解压)
- 说清 in-place 解压与 cached 解压的差别,以及 in-place 为什么需要 margin
- 解释
m_pa为什么是字节地址、为什么不保证块对齐
4.1 为什么压缩不能做算术
非压缩时,逻辑偏移到物理位置是个除法:
/* 阶段 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 被多个并发读请求需要时,怎么避免重复解压?
先看问题
场景: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; /* 别人在处理 */
}
}
两个关键机制:
-
managed_pslots:一个 xarray,按map->m_pa(物理地址)索引所有在处理的 pcluster。 物理地址相同 = 同一个 pcluster,这是判重的关键。 -
cmpxchg(&pcl->next, NULL, fe->head):原子比较并交换。next原本是NULL→ 我成功接管,返回旧值 NULL →FOLLOWEDnext已被别人设置 → 我失败 →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))); /* 最后一个:解锁 */
}
三个要点:
folio->private被借用为atomic_t+ 两个标志位(DIRTY/EIO)—— 内核里用 private 存引用计数的经典手法init置 1 → 每来一个写入者split+1 → 每写完一个end−1- 计数归零的那一个才真正
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 | 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 |
自测检查点
- 为什么压缩文件不能像非压缩那样用算术定位?
- lcluster 和 pcluster 的区别?为什么一个 pcluster 能覆盖多个 lcluster?
- 索引项分 HEAD 和 NONHEAD,NONHEAD 为什么不记物理地址?
- 如果你要读的 lcluster 是 NONHEAD,代码怎么找到物理地址?
zmap.c那个分支判断在判断什么?endoff >= m.clusterofs意味着什么?- pcluster 为什么必须有状态?没有状态机会发生什么?
cmpxchg(&pcl->next, NULL, fe->head)成功和失败分别意味着什么?- 三种状态的枚举顺序为什么是有意的?
- in-place 解压为什么需要 margin?具体是什么风险?
m_pa是块号还是字节地址?它保证块对齐吗?为什么?zmap.c为什么用erofs_unmap_metabuf而不是erofs_put_metabuf?- 看到
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 侧查这个字段是怎么写进去的。