EROFS压缩文件格式和读取流程分析
EROFS在快速迭代中出现了许多新特性,而文档并不丰富。本文试图基于erofs-utils的1.3版本摸清压缩文件在EROFS中的on-disk存储格式和用户态文件读取的关键路径,并根据commit信息看懂后续版本的更新。
本文主要关注读取(解压)的流程,基本不涉及压缩侧的特性(如dedupe)。
压缩文件on-disk索引
EROFS将文件未压缩的原始数据划分为若干个lcluster(Logical
Cluster),固定输出大小的压缩数据单元则称为pcluster(Physical
Cluster)。其中lcluster的大小是固定的,等于块大小(4K),pcluster则可以由若干个连续块组成。文件索引的任务就是将给定的逻辑地址(区间)翻译成磁盘上的物理地址(区间)。对于压缩文件,EROFS支持Legacy和Compacted两种索引格式,根据dinode中i_format
字段嵌入的datalayout加以区分。
Legacy index
Legacy索引的结构如图所示。每个inode在可选的xattr区域之后存储压缩索引,其中黄色部分struct z_erofs_map_header
是per-inode的元数据,占据8个字节,后面跟着8字节的padding。它记录了inode层面开启的压缩特性(h_advice
),使用的压缩算法类型(h_algorithmtype
)和lcluster的大小(h_clusterbits
,目前实现中为常数)。
紧随其后的是若干个8字节结构体struct z_erofs_vle_decompressed_index
,它是一个4KB
lcluster的索引,定义如下: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17struct z_erofs_vle_decompressed_index {
__le16 di_advise;
/* where to decompress in the head cluster */
__le16 di_clusterofs;
union {
/* for the head cluster */
__le32 blkaddr;
/*
* for the rest clusters
* eg. for 4k page-sized cluster, maximum 4K*64k = 256M)
* [0] - pointing to the head cluster
* [1] - pointing to the tail cluster
*/
__le16 delta[2];
} di_u;
};di_advice
记录了lcluster的种类。PLAIN
类型的lcluster未被压缩,以原始数据的格式映射到一个长度为1个block的pcluster中去;HEAD
类型的lcluster是一段extent的起始lcluster,它代表这个extent中的一系列lcluster映射到了某个pcluster,并存储了pcluster的物理地址(blk_addr
)。该extent中剩余的lcluster则为NONHEAD
lcluster,它记录了与前一个HEAD lcluster和后一个HEAD
lcluster的距离(delta[0]
和delta[1]
)。
需要注意的是,一段extent并不一定对齐到lcluster的边界。这意味着某个lcluster的前一部分和后一部分可能从属于不同的extent。在这种情况下,这个lcluster的类型是HEAD,并且di_clusterofs
是非0值,记录了extent起始位置在该lcluster中的偏移量。
上图给出了一个例子。图中lcluster0,
lcluster1以及lcluster2的前1024字节组成了第一个extent,映射到物理地址为0x233000;lcluster2的剩余部分属于第二个extent,映射到物理地址为0x234000的pcluster。两个pcluster的长度均为1个块大小。因此lcluster0和lcluster2都是HEAD
lcluster,且di_clusterofs
分别为0和1024。lcluster1是NONHEAD
lcluster,并且与前后HEAD lcluster的距离都是1。
Big pcluster
在map_block的时候,作为压缩文件系统,EROFS要同时给出extent的llen和plen。早期版本的EROFS要求pcluster的大小等于块大小(4KB),因此可以假设plen的长度就是4KB。Linux v5.13引入了big pcluster特性,它允许pcluster的大小为块大小的倍数。于是,我们需要在文件索引中记录这个大小。
EROFS选择将pcluster大小记录在组成extent的第一个NONHEAD
lcluster中。这个lcluster被称为CBLKCNT
lcluster,它的delta[0]
一定是1,因此可以复用delta[0]
的空间。这种情况下,delta[0]
中的Z_EROFS_VLE_DI_D0_CBLKCNT
被设为1。如果某个extent没有NONHEAD
lcluster,说明它对应的pcluster长度一定只能是1个块大小。
如图所示,由于第一个pcluster的大小是8192(2个块大小),因此对应extent中的第一个NONHEAD
lcluster的delta[0]
记录了0x2 | 1 << (Z_EROFS_VLE_DI_D0_CBLKCNT)
。
Compacted Index
Legacy Index的一大缺点是对于每个4KB大小的lcluster,都要记录一个8字节的索引,导致了不必要的空间占用。Compacted Index的核心思想就是压缩文件索引所占用的空间。EROFS把相邻若干个lcluster的索引编码在一起,称为pack。每个pack需要预留4字节来存放某一个extent对应的起始pcluster地址,由于单个文件的pcluster是连续的,其他extent的物理地址可以根据该地址推算出来。
具体来说,EROFS支持compacted_2B和compacted_4B两种索引格式,归约后每个lcluster索引分别需要占用2字节和4字节的存储空间:
- 2B: 每个lcluster索引包含14个位,每16个索引为一个pack,pack大小为\((14 * 16 + 32) / 8 = 32\)字节。
- 4B: 每个lcluster索引包含16个位,每2个索引为一个pack,pack大小为\((16 * 2 + 32) / 8 = 8\)字节。
可见,2B和4B的索引格式分别需要32字节和8字节对齐,以保证它们不会跨越块边界。因此EROFS使用2B索引作为compacted index的主体部分,使用4B索引作为前后填充来满足32字节的对齐要求。
14位lcluster索引的使用:一共有3种不同类型的lcluster,因此需要2个位来存储其类型。对于HEAD和PLAIN
lcluster,剩余12个位用来存储clusterofs
;对于NONHEAD
lcluster,存在3种情况:
- CBLKCNT lcluster:存储pcluster大小。
- 恰好位于一个pack的最后:存储
delta[1]
。 - 其他情况:存储
delta[0]
。
每个pack中只会存一个pcluster的地址:
- 当不开启big pcluster特性时,该地址为pack中第一个HEAD/PLAIN lcluster所对应的pcluster地址减去一个块大小。
- 当开启big pcluster特性时,该地址为pack中第一个新开启的extent所对应的pcluster地址。对于一般extent,新开启即HEAD/PLAIN lcluster落在该pack中;对于映射到big pcluster的extent,新开启即CBLKCNT lcluster(而不是HEAD lcluster)落在该pack中。
v1.4
fiemap
在一般的解压流程中,如果read()
读取的最后一个extent是不完整的,EROFS可以部分解压pcluster来满足读取请求,而不需要计算该extent的完整长度。如图所示,黄色部分标识了第一次返回的map->m_la
和map->llen
,它并不是一个完整的extent。
然而,存在一个ioctl选项fiemap
,它从用户态获取文件的extent。于是EROFS引入了z_erofs_get_extent_decompressedlen()
函数,用于在这种情况下计算完整的extent长度。它在带有EROFS_GET_BLOCKS_FIEMAP
flag的z_erofs_do_map_blocks()
中被调用。
对于legacy索引,extent长度可以简单从存储的delta[1]
中获取,不需引入任何额外逻辑。然而对于compacted索引,只有每个pack的最后一个lcluster才会存储delta[1]
,因此这个过程需要向后遍历每个lcluster的索引,直到找到一个HEAD/PLAIN
lcluster为止。遇到有效的delta[1]
可以加速遍历过程。
v1.5
ztailpacking
对于未压缩文件,EROFS允许将文件末尾填不满一个块的数据内联在inode中,避免块对齐导致的空洞,让磁盘布局更加紧凑,而ztailpacking特性允许压缩文件的末尾数据也被内联。内联部分的数据可能是压缩的,也可能是未被压缩的。
实现上,per
inode的struct z_erofs_map_header
的h_advice
会记录ztalipacking是否开启。此外,该结构体还维护了一个h_idata_size
变量,存储内联部分数据的压缩后大小。
内联部分的起始位置在最后一个lcluster索引之后,可以使用带EROFS_GET_BLOCKS_FINDTAIL
flag的z_erofs_do_map_blocks()
计算。
启动ztailpacking特性时的镜像打包过程可以参见[2]。
v1.6
interlace pcluster
TBD
fragment
EROFS允许将若干个文件的一部分打包在一个packed_file中一起压缩,以此提高压缩率。这些部分称为fragment。一般来说,文件的tail部分会作为fragment,也可以将一个完整的文件作为fragment。
实现上,per
inode的struct z_erofs_map_header
的h_advice
会记录该inode是否包含fragment。如果有的话,fragment的文件偏移量被记录在h_fragmentoff
中。一个文件不可能同时包含fragment和开启ztailpacking特性,因此h_fragmentoff
和h_idata_size
位于同一个union中。
z_erofs_do_map_blocks()
中,如果发现当前读取的extent是一个fragment,会设置EROFS_MAP_FRAGMENT
标记。z_erofs_read_one_data()
看到该标记后,会去packed_inode中根据记录的fragmentoff
读取数据。