OpenHarmony littlefs 文件系统存储结构与 IO 性能优化分析
引言
随着科技的发展和网络技术的进步,计算机存储空间愈加紧张,存储空间对文件系统的功能需求越来越大,大规模的数据增长为文件存储、非结构化数据存储提出了新的挑战。
对于许多物联网设备而言,拥有一个小型且具有弹性的文件系统至关重要,littlefs 文件系统应运而生。littlefs 文件系统在 2017 年由 Christopher Haster 开发,遵循 Apache 2.0 协议,被应用在 ARM 的 IoT 设备 Mbed 操作系统。littlefs 文件系统能够让嵌入式系统在 ROM 和 RAM 资源有限的情况下,还具备文件系统基本的掉电恢复、磨损均衡的功能。
littlefs 是一种极简的嵌入式文件系统,适配于 norflash,它所采用的文件系统结构与运行机制,使得文件系统的存储结构更加紧凑,运行中对 RAM 的消耗更小。它的设计策略采用了与传统“使用空间换时间”完全相反的“使用时间换空间”的策略,虽然它极大地压缩了文件系统存储空间,但是运行时也增加了 RAM 的消耗,不可避免地带来了随机读写时 IO 性能的降低。
目前,OpenAtom OpenHarmony(以下简称“OpenHarmony”) liteos_m 内核采用了 littlefs 作为默认的文件系统。本文着重介绍了 littlefs 文件系统的存储结构,并根据对读写过程的分析,解析引起 littlefs 文件系统随机读写 IO 性能瓶颈的根本原因,然后提出一些提升 littlefs 随机读写 IO 性能优化策略。
littlefs 文件系统结构
文件系统存储结构信息基本以 SuperBlock 为开端,然后寻找到文件系统根节点,再根据根节点,逐步拓展成一个文件系统树形结构体。littlefs 也与此类似,以 SuperBlock 和根目录为起点,构建了一个树形存储结构。不同的是 littlefs 的根("/")直接附加在 SuperBlock 之后,与其共享元数据对(metadata pair)。littlefs 中目录或者文件都是以该根节点为起点,构建了与其他文件系统类似的树形结构。
littlefs 文件系统树形存储结构如下:
图 1 littlefs 文件系统树形存储结构示意图
如图 1 所示,存储 littlefs 文件系统元数据的结构为元数据对,即两个相互轮转、互为表里的 Block。存储 SuperBlock 的元数据对固定存储在 block 0 和 block 1,并且文件系统根目录附加在 SuperBlock 的尾部,与 SuperBlock 共享元数据对。元数据的存储是以 tag 的格式存储在元数据对内,按照元数据的类型,将 Tag 分为标准文件、目录、用户数据、元数据对尾部指针等类型。littlefs 借助于这些不同类型 tag 信息,将 littlefs 文件系统组织成结构紧凑的树形存储结构体。例如 tail 类型的 tag 可以将比较大的目录结构使用多个元数据对存储,并且使用 tail 类型的 tag 将这些元数据对连接成一个单向的链表。而目录类型的 tag 则直接指向该目录的元数据对,例如"tag: dir_data"类型的 tag 指向目录"/data"的元数据对,而该元数据对中又可以包含子目录或者文件(Inline 类型或者 outline 类型)。
littlefs 目录存储结构
littlefs 目录的引用为其父目录元数据对(metadata pair)内的一个 dir 类型的 Tag,而其内容则占用一个或者多个元数据对。一个目录的元数据对内既可以包含子目录引用的 Tag,也可以包含属于该目录下文件的 Inline 类型的 Tag 或者指向该文件的 CTZ 跳表的 CTZ 类型 Tag 指针。最终 littlefs 通过一层层目录或者文件的索引,组成了文件系统的树形存储结构。
littlefs 文件存储方式
littlefs 文件系统为极简的文件系统,使用最小的存储开销,同时实现对小文件(Bytes 级别)和大文件(MB 级别)的支持,对小于一个 Block 八分之一长度的文件,采用 Inline 类型的方式存储,而大于或者等于 Block 八分之一长度的文件则采用 Outline 的方式存储(CTZ Skip-list)。
1.2.1 inline 文件存储方式
Inline 文件存储方式,如图 2 所示,即将文件内容与文件名称一同存储在其父目录的元数据对(metadata pair)内,一个 Tag 表示其名称,一个 Tag 表示其内容。
图 2 littlefs Inline 文件存储结构
1.2.2 outline 文件存储方式
Outline 文件存储方式,如图 3 所示,文件其父目录的元数据对(metadata pair)内,一个 Tag 表示文件名称,另一个 Tag 为 CTZ 类型,其指向存储文件内容的链表头。
图 3 littlefs Outline 文件存储结构
CTZ 跳表(CTZ skip-list)链表的特别之处是:
(1)CTZ 跳表的头部指向链表的结尾;
(2)CTZ 跳表内 Block 内包含一个以上的跳转指针。
若是使用常规链表,存储文件前一个数据块包含指向后一个数据块指针,那么在文件追加或者修改内容的时候,则需要存储文件起始块到目标块的所有内容拷贝到新块内,并且更新对后一个数据块的指针。而若是采用反向链表的方式,则在文件追加或者修改内容的时候,则只需要将存储文件目标块到链表结尾的块的所有内容拷贝到新块内,然后更新对后一个数据块内对前一个数据块指向的指针,这样对于文件追加模式可以减少修改量。另外,为了加快索引,采用了跳表的方式,Block 内包含一个以上的跳转指针,规则为:若一个数据块在 CTZ skip-list 链表内的索引值 N 能被 2^X 整除的数,那么他就存在指向 N – 2^X 的指针,指针的数目为 ctz(N)+1。如表 1,对于 block 2,包含了 2 个指针,分别指向 block 0 和 block 1,其它块也是采用相同的规则。
表 1 littlefs 块的 skip-list 链表计算样表
littlefs 文件读写流程
以上章节针对 littlefs 文件系统结构进行了分析,接下来开始探讨 littlefs 内部的运行机制,以读写流程为例,分析 littlefs 随机读写的 IO 性能瓶颈。
需要提前了解的是,littlefs 的文件只拥有一个缓存,写时作为写缓存使用,读时作为读缓存使用。
littlefs 文件读过程
以下图 4 是 littlefs 读文件的流程图,在读流程的开始先检测先前是否有对文件的写操作,即检测文件缓存是否作为写缓存。若是,则强制将缓存中的数据刷新到存储器,根据文件类型和访问位置,或者直接从文件所在的元数据对读取,或者从存储文件内容的 CTZ 跳表内的块内读取,再将数据拷贝到用户缓存冲,并从存储器预读取数据将文件缓冲区填满。具体过程如下:
图 4 littlefs 文件系统读过程流程图
littlefs 文件写过程
以下图 5 是 littlefs 写文件的流程图,在写流程的开始先检测先前是否有对文件的读操作,即检测文件缓存是否作为读缓存。若是,则清除缓存中的数据。若是 APPEND 类型的写操作,则直接减写位置定位到文件末尾。若写位置超过文件长度,说明文件结尾与写位置间存在空洞,则使用 0 填充文件中的空洞。对应 Inline 类型文件,若推测到写后,文件长度超过了阈值,则将文件转成 Outline 类型。对于 Outline 类型的文件,若是修改文件的内容,则需要申请新块,并将目标块内访问位置之前的所有内容都拷贝到新块,将 buffer 中的用户数据写到缓冲区或者刷新到存储器。
注意:写后并没有立刻更新 Inline 文件的 commit,或者更新 Outline 文件的 CTZ 跳表,这些操作被延迟在文件关闭或者缓冲区再次作为读缓存的时候强制文件刷新时更新。
图 5 littlefs 文件系统写过程流程图
littlefs 文件随机读写 IO 性能瓶颈分析
littlefs 文件只有一个缓冲区,为读写复用。根据 littlefs 运行机制,若是对文件先读后写,那么仅需要直接将缓冲区的数据清空,然后申请一个新块将目标块内访问位置直接的数据拷贝到新块中,然后写数据到新块。若是先写后读,那么需要将数据刷新到存储器,同时更新文件的 CTZ 跳表。在这个过程中,不仅涉及到刷新数据到存储器,而且涉及到分配新块替换目标块之后的所有块从而更新 CTZ 跳表,出现多次费时的擦除块动作。在随机读写的过程中,频繁发生读写切换,也就频繁地发生申请新块、擦除新块(非常费时)、数据搬移等等动作,严重地影响了 IO 性能。
littlefs 读写 IO 性能优化策略
由“2.3 littlefs 文件随机读写 IO 性能瓶颈分析”章节描述可知,影响 littlefs 文件随机读写 IO 性能的主要原因是文件只有一个的缓存且被读写复用,造成在读写切换的过程中频繁地发生文件刷新,申请新块,然后执行费时的块擦除,再将 CTZ 跳表上块内的 block 内容搬移到新块,进而更新 CTZ 跳表,这严重影响了随机读写 IO 的性能。
所以,在 RAM 空间允许的情况下,可以考虑“使用空间换时间”的策略,适当地增加文件缓存的数量,使一个文件拥有多个缓冲区,而这些缓冲区对应着一个 Block 的大小,在一定的条件下一次刷新一个 Block,从而避免过多的数据搬移。另外,littlefs 的策略是“使用时间换空间”,但是每个文件都拥有一个缓冲区明显浪费空间。因为在一段时间内,只会有一定数量的文件被执行读或者写,所以可以考虑建立一个拥有一定数量的缓存池,使缓存在文件间共享。
图 6 littlefs 优化策略
优化的策略如图 6 所示,littlefs 文件缓存池为一个双向链表 fc_pool,缓存池随着被打开文件的个数的增长而延长,直到用户设置的最大限制;缓存池随着文件的关闭而逐渐缩减。
每个缓存挂载在 fc_pool 缓存池双向链表上,当缓存被写或者被读时,则将缓存移到链表开头,那么缓存池链表末尾的缓存则为待老化的缓存,可以优先被选择回收。
在申请缓存时,优先从缓冲池链表末尾选择空闲缓存;若无空闲缓存,则考虑抢占读缓存;若缓存池既没有空闲缓存也没有读缓存,在缓存池长度没有达到优化限制的情况下,则创建新缓存,然后将新缓冲添加到链表头;若缓存池既没有空闲缓存也没有读缓存,并且缓存池长度已经达到用户限制,那么就需要从链表末尾抢占一个写缓存,并强制占有该缓存的文件执行刷新,进而完成抢占。
在文件被关闭或者刷新时,主动释放缓存到缓存池,挂载在双向链表的末尾。
使用上述策略对文件缓存进行优化,可以在一定程度上减少因更新文件内容而执行的存储器块擦除动作,从而增加随机读写的 IO 性能,也间接地延长了 NorFlash 的寿命。
总结
通过本文的讲解,相信大家对于 littlefs 文件系统有了较为全面的了解。总的来说,littlefs 是一种极简的文件系统,实现了文件系统基本的数据缓存、掉电恢复、磨损均衡等功能,在资源相对富裕的环境中,开发者们可以对其运行机制甚至存储结构进行“使用空间换时间”的优化策略,提升读写的 IO 性能。学会有效地利用文件系统往往能起到事半功倍的作用,希望开发者能够将所学知识有效应用到未来的开发工作中,从而提高开发工作的效率。
评论