F2FS(Flash-Friendly File System) 是一种专为闪存存储设备(如固态硬盘和闪存卡)而设计的文件系统,最初由三星电子开发。F2FS 旨在充分利用闪存设备的特性,并提供高性能、高效的文件系统操作,同时减小对闪存设备的磨损。以下是 F2FS 文件系统的一些主要特点和概述:
- 闪存优化: F2FS 专门为闪存设备而设计,以最大程度地减小对闪存设备的写入和磨损。它采用了诸如写入放大(Write Amplification)减小、块分配和垃圾回收等技术来延长闪存设备的寿命
- 高性能: F2FS 在闪存设备上表现出色,特别是对于读取操作和写入小文件的操作。它具有低延迟和高吞吐量,适用于闪存驱动的高性能应用
- TRIM支持: F2FS 支持 TRIM 命令,该命令可以通知闪存设备哪些数据块不再需要,从而帮助提高性能和延长设备寿命
- 日志功能: F2FS 支持日志功能,通过记录文件系统更改来提供数据一致性和可恢复性。这有助于防止数据损坏,并允许更快速地恢复文件系统到一致状态
- 支持大容量存储: F2FS 支持大容量闪存设备,并能够有效地处理大文件
- 多平台支持: 虽然最初是为 Android 设备而设计的,但 F2FS 现在也可以在许多 Linux 发行版上使用,以及其他一些操作系统上运行
- 适用于嵌入式系统: F2FS 通常用于嵌入式系统和移动设备,例如智能手机和平板电脑,以及一些嵌入式控制器
F2FS 是一种闪存优化的文件系统,具有高性能、高效率和闪存设备寿命延长等优点。它特别适用于使用闪存存储的系统和设备,但也可以在一些通用计算机上使用。如果您在使用闪存设备的 Linux 系统上寻找高性能的文件系统选项,那么 F2FS 可能是一个很好的选择, 在 Linux 中使用 F2FS 需要打开内核宏 CONFIG_F2FS_FS.
F2FS 文件系统提供映射文件的 mmap 接口使用了 f2fs_file_mmap 函数,f2fs_file_mmap 函数为文件映射的 VMA 提供的 vm_ops 接口为 f2fs_file_vm_ops,该数据接口实现了 fault 接口 f2fs_filemap_fault,那么文件映射 VMA 发生缺页时 f2fs_filemap_fault 函数会被调用.
在 F2FS 文件系统里,文件映射(File-Mapped) 的数据架构如上图,用户进程使用文件映射将文件映射到进程地址空间之后,进程使用 VMA 描述映射之后的虚拟内存区域,F2FS 文件系统会为 VMA 提供相应 generic_file_vm_ops,另外 vm_file 指向映射的文件(struct file), 其又指向唯一的 STRUCT inode, 其 mapping 成员用于指向 STRUCT address_space, 该数据结构用于维护文件与 PAGE CACHE 和 VMA 的映射关系,其中 i_mmap 成员指向一颗区间树(RB-TREE), 该区间树维护了映射到该文件的 VMA. 另外 i_pages 指向 XARRAY 数组,该数组维护了文件映射的 PAGE CACHE,每个 PAGE CACHE 对应一个 STRUCT page 数据结构,STRUCT page 的 mapping 成员反过来指向 STRUCT address_space, 那么可以知道 PAGE CACHE 被哪些 VMA 映射,因此形成了一个闭环. 当进程首次访问 VMA 虚拟内存区域时,会触发缺页异常构造这些逻辑。那么接下来通过一个实践案例了解这种异常场景,实践案例在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS
make menuconfig
[*] DIY BiscuitOS/Broiler Hardware --->
[*] Virtio-BLK: F2FS Filesystem Disk --->
[*] Package --->
[*] Paging Mechanism --->
[*] Page Fault with File-Mapped F2FS --->
# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-FILE-F2FS-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
实践案例由一个应用程序构成,进程在 23 行在 “/mnt/f2fs/” 目录下打开文件 BiscuitOS.txt 文件,该目录已经挂载为 F2FS 文件系统,进程接着在 29 行调用 mmap 函数将文件映射到进程的地址空间,并在 40 行对文件对应的虚拟内存进行写操作,然后在 42 行对虚拟内存进行读操作,操作完毕之后就是释放虚拟内存和关闭文件. 以上便是一个最基础的实践案例,可以知道 40 行读操作就会触发缺页,为了可以看到内存在缺页异常里的流动,在 40 行前后加上 BS_DEBUG 开关:
接着在 F2FS 文件映射内存缺页流程必经之路上任意位置加上 BS_DEBUG 函数,以此观察内存在某个函数里的流动,例如上图在 f2fs_filemap_fault 函数的 44 行加上 bs_debug 打印,以此确认内存流动到这里,接下来执行如下命令进行实践(需要提前打开内核宏: CONFIG_F2FS_FS):
# 编译应用程序
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-FILE-F2FS-default/
# 编译内核
make kernel
# 编译实践案例
make build
当 BiscuitOS 启动之后,直接运行 RunBiscuitOS.sh 脚本,脚本里包括实践所需的命令,可以看到进程执行之后对虚拟内存的访问引起了缺页异常,并且该案例的缺页异常处理流程打印了字符串 “F2FS PF on filemap_fault 0x6000000000” 两次, 那么说明实践案例分配了 PAGECACHE,同时也可以看到 PAGECACHE 按着之前分析的代码路径流动。最后开发者可以在该路径上的任何地方使用 bs_debug 查看 PAGECACHE 在缺页异常处理流程里的流动.
对于 F2FS 文件系统映射文件到地址空间之后,进程访问该虚拟内存时,由于 MMU 发现对于的物理内存不存在,那么触发缺页异常。在缺页异常处理函数里,其主要做三个事情,首先是分配 PAGECACHE,如上图调用 filemap_alloc_folio 函数进行分配; 当分配 PAGECACHE 之后 F2FS 文件系统向 BIO 层发送请求从磁盘里读取读取多个页表内容到 PAGECACHE, 由于磁盘 I/O 延迟无法立即获得文件内存,因此缺页异常会通过 VM_FAULT_RETRY 再次进行缺页处理,以确保文件内容已经更新到 PAGECACHE 里; 最后一个任务就是更新页表指向新的 PAGECACHE,以及更新页表标记为脏页等. 完成三个任务之后缺页异常处理函数就返回,那么进程可以正常访问虚拟内存. 因为 VM_FAULT_RETRY 的缘故,handle_mm_fault 会被执行两次,这么做的原因有如下:
- 磁盘 I/O 延迟: 当页面不在物理内存中,需要从磁盘加载时,可能由于磁盘 I/O 操作的延迟,内核无法立即获取所需页面。此时,内核可能会将页面标记为需要重新尝试,并等待磁盘 I/O 完成
- 页面锁定: 在某些情况下,内核可能会锁定某些页面,以确保它们在内存中不会被交换出去或释放。如果页面被锁定,内核可能会等待解锁页面后才能重新加载
- 其他临时情况: 还可能存在其他临时情况,导致页面无法立即加载到内存中。在这种情况下,内核可能会标记页面并尝试重新加载
VM_FAULT_RETRY 并不是 Linux 文件映射的特定特性,而是内核内存管理的一般机制。它在多种上下文中都有用,不仅限于文件映射。具体的行为可能因 Linux 内核版本、硬件配置和文件系统类型而异,因此要根据具体情况考虑如何处理 VM_FAULT_RETRY 错误码.
do_shared_fault 函数作为 F2FS 文件系统映射内存写操作导致缺页的核心处理函数,函数调用 __do_fault 函数分配物理内存,其细节涉及 XARRY 等映射这里不放开讲。当分配物理内存之后,文件映射内存没有提供 VMA 对应的 page_mkwrite 接口,于是直接调用 finish_fault 函数进行页表设置,最后如果页变成脏页,则调用 fault_dirty_shared_page 函数进行标脏,以上便是可读可写共享内存的缺页过程。对于读操作导致的缺页,其核心调用 do_read_fault 函数,对于共享内存来说,其逻辑与 do_shared_fault 函数无异. 另外 F2FS 文件系统提供的 VMA 缺页处理函数是 filemap_fault,该函数负责分配 PAGECACHE 和发起 BIO 请求读取文件内存.
F2FS 文件系统使用 ext4_readahead 函数从磁盘上读取文件内容,其核心通过 F2FS 文件系统提供的 ext4_mpage_readpages 执行实际的读取操作. 在读取的时候, F2FS 文件系统不是一个页一个页的读,而是一次性读取 ra->ra_pages 个页,另外 F2FS 文件向 BIO 层发送请求时不是按 PAGECACHE 的粒度,而是按 Buffer Head 的粒度发送请求.
F2FS with FAULT AROUND
在 Linux 中,FAULT AROUND 是一种缺页处理(Page Fault Handling) 的优化策略,旨在改善内存访问性能,特别是在具有局部性的内存访问模式下。当一个进程访问文件映射的内存中的某个地址时,如果所需的页面不在物理内存中,就会发生缺页(Page Fault),这需要将缺失的页面从磁盘加载到物理内存中,然后允许进程继续执行。FAULT AROUND 策略的目标是减少未来可能的缺页次数,通过在页面访问周围预取(Prefetch) 附近的页面来实现这一目标. 在许多情况下,应用程序访问的数据在空间上具有局部性,即近期访问的数据很可能在不久的将来再次被访问。因此预取附近的页面有助于利用局部性,减少未来的缺页. 接下来通过一个实践案例了解该场景,实践案例在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS
make menuconfig
[*] DIY BiscuitOS/Broiler Hardware --->
[*] Virtio-BLK: F2FS Filesystem Disk --->
[*] Package --->
[*] Paging Mechanism --->
[*] Page Fault with File-Mapped F2FS FAULT AROUND --->
# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-FILE-F2FS-FR-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
BiscuitOS-PAGING-PF-FILE-F2FS-FR-default Source Code on Gitee
测试用例由一个用户程序构成,程序首先在 23 行调用 open 函数打开 F2FS 文件系统目录下的 BiscuitOS.txt 文件,然后在 29 行调用 mmap 函数将文件映射到进程的地址空间,接下来在 40 行对文件映射的虚拟内存进行读操作,此时会发送 FAULT AROUND 将附近的虚拟内存页表一共建立,接下来在 46 行对下一个页的虚拟内存进行读操作,由于页表已经建立,因此此时可以直接访问文件映射的虚拟内存,最后就是释放内存和关闭文件. 以上便是一个最基础的实践案例,可以知道 40 行读操作就会触发缺页,为了可以看到内存在缺页异常里的流动,在 40 行前后加上 BS_DEBUG 开关:
接着在 F2FS 文件映射内存缺页流程必经之路上任意位置加上 BS_DEBUG 函数,以此观察内存在某个函数里的流动,例如上图在 do_read_fault 函数的 4517 行加上 bs_debug 打印,以此确认内存流动到这里,接下来执行如下命令进行实践(需要提前打开内核宏: CONFIG_F2FS_FS):
# 编译应用程序
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-FILE-F2FS-FR-default/
# 编译内核
make kernel
# 编译实践案例
make build
当 BiscuitOS 启动之后,直接运行 RunBiscuitOS.sh 脚本,脚本里包括实践所需的命令,可以看到进程执行之后对虚拟内存的访问引起了缺页异常,并且该案例的缺页异常处理流程打印了字符串 “F2FS PF on do_read_fault 0x6000000000” 两次, 那么说明实践案例分配了 PAGECACHE,同时也可以看到 PAGECACHE 按着之前分析的代码路径流动。最后开发者可以在该路径上的任何地方使用 bs_debug 查看 PAGECACHE 在缺页异常处理流程里的流动.
对于 F2FS 文件系统映射文件到地址空间之后,进程对虚拟内存发起读请求时,由于 MMU 发现对于的物理内存不存在,那么触发缺页异常。在缺页异常处理函数里,其主要做三个事情,首先是分配 PAGECACHE,如上图调用 filemap_alloc_folio 函数进行分配,F2FS 文件系统支持一次性从磁盘读取多个文件内容到 PAGECACHE,因此这里会分配多个 PAGECACHE; 当分配 PAGECACHE 之后 F2FS 文件系统向 BIO 层发送请求从磁盘里读取读取多个页表内容到 PAGECACHE, 由于磁盘 I/O 延迟无法立即获得文件内存,因此缺页异常会通过 VM_FAULT_RETRY 再次进行缺页处理,以确保文件内容已经更新到 PAGECACHE 里; 最后一个任务就是 F2FS 文件系统会执行 FAULT AROUND 操作,将此时缺页相邻的虚拟内存一同建立页表,那么进程接下来对相邻虚拟内存的访问不会引起缺页,因此调用 do_fault_around 函数完成 FAULT AROUND.
do_read_fault 函数作为 F2FS 文件系统映射内存读操作导致缺页的核心处理函数,与 do_shared_fault 写缺页不同的是,其在 4522-4526 行提供了 do_fault_around 函数,该函数在文件映射的内存发生缺页时用于一种优化策略,其作用是预取(prefetch)附近的页面,以减少未来可能的缺页。这个优化策略基于以下假设:
- 文件访问通常具有局部性(locality): 即如果一个页面被访问,那么附近的页面可能在不久的将来也会被访问。这是因为文件通常以较小的块进行读取,而不是逐个字节或逐个页面
- 文件映射的内存通常是按顺序或近似顺序访问的
F2FS 文件系统对于 FAULT-AROUND 机制提供了 filemap_map_pages(vmf->vma->v-m_ops->map_pages) 函数来预读文件, 该函数是通用的文件预读操作,其主要任务就是未相邻的虚拟内存提前建立映射到相应 PAGECACHE 的页表,注意这里不是从磁盘读取内容,而是建立页表. FAULT AROUND 直接返回 VM_FAULT_NOPAGE,那么缺页异常函数可以直接完成. 接下来进程访问相邻的虚拟内存不会发生缺页.
F2FS with COW
COW(Copy-on-Write)机制是一种内存管理和数据复制策略,通常用于优化资源的利用和提高性能。COW 机制的核心思想是只有在需要修改资源时才进行复制,而在读取或共享资源时,可以共享原始资源,从而节省了内存和计算资源。在 Linux 中,COW 机制主要应用于以下几个方面:
- 进程复制: 当一个进程通过 fork 系统调用创建子进程时,子进程会继承父进程的内存映像,包括虚拟内存地址空间和内存页面。在这种情况下,COW 机制确保子进程和父进程共享相同的物理内存页面,只有当其中一个进程尝试修改页面中的内容时,才会复制页面并使其成为独立的,从而保持进程间的内存隔离
- 共享库和可执行文件: 多个进程可以共享相同的共享库和可执行文件的内存映像,而不是为每个进程都复制一份相同的代码和数据。只有在某个进程尝试修改共享库或可执行文件中的内容时,才会使用 COW 机制来创建一个副本
- 写时复制文件系统: 一些文件系统,如 ZFS 和 Btrfs,使用 COW 机制来实现文件系统快照和版本控制。当文件被修改时,文件系统不会立即在磁盘上复制整个文件,而是创建一个新的数据块并将修改写入新块中。这减少了存储空间的浪费并提高了效率
- 虚拟机管理: 虚拟化环境中,COW 机制通常用于创建虚拟机的快照。初始时,快照与原始虚拟机共享相同的虚拟硬盘,但当虚拟机修改数据时,只有已修改的数据块才会被复制到新的快照中,从而节省存储空间
F2FS 文件系统也支持 COW 机制,进程在使用 mmap 将文件映射到进程地址空间时,使用 MAP_PRIVATE 标志即可启用 COW 机制。在 F2FS 文件系统里,当对 COW 的文件映射内存发起写操作时,MMU 检查到进程对 COW 内存没有写权限,于是触发缺页异常,缺页异常会为 VMA 新分配一个 COPY PAGE 作为副本,此时 COPY PAGE 为匿名页,接着将 VMA 对应的 PAGE CACHE 内容拷贝到 COPY PAGE 里,并将页表更新到 COPY PAGE,当缺页异常返回之后,进程对 COW 内存的读写操作都落到 COPY PAGE 里,但 COPY PAGE 的内容不会回写到磁盘文件上. 那么接下来通过一个实践案例了解该场景,实践案例在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS
make menuconfig
[*] DIY BiscuitOS/Broiler Hardware --->
[*] Virtio-BLK: F2FS Filesystem Disk --->
[*] Package --->
[*] Paging Mechanism --->
[*] Page Fault with File-Mapped F2FS COW --->
# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-FILE-F2FS-COW-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
BiscuitOS-PAGING-PF-FILE-F2FS-COW-default Source Code on Gitee
测试用例由一个用户程序构成,程序首先在 23 行调用 open 函数打开 F2FS 文件系统目录下的 BiscuitOS.txt 文件,然后在 29 行调用 mmap 函数将文件映射到进程的地址空间, 此时使用了 MAP_PRIVATE 标志,接下来在 40 行对文件映射的虚拟内存进行读操作,此时会发送 FAULT AROUND 将附近的虚拟内存页表一共建立,接下来在 45 行对虚拟内存进行写操作,此时会触发 COW,并在 45 行打印读到的数据,最后就是释放内存和关闭文件. 以上便是一个最基础的实践案例,可以知道 40 行读操作和 43 行写操作就会触发缺页,为了可以看到内存在缺页异常里的流动,在 43 行前后加上 BS_DEBUG 开关:
接着在 F2FS 文件映射内存缺页流程必经之路上任意位置加上 BS_DEBUG 函数,以此观察内存在某个函数里的流动,例如上图在 wp_page_copy 函数的 3102 行加上 bs_debug 打印,以此确认内存流动到这里,接下来执行如下命令进行实践(需要提前打开内核宏: CONFIG_F2FS_FS):
# 编译应用程序
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-FILE-F2FS-COW-default/
# 编译内核
make kernel
# 编译实践案例
make build
当 BiscuitOS 启动之后,直接运行 RunBiscuitOS.sh 脚本,脚本里包括实践所需的命令,可以看到进程执行之后对虚拟内存的访问引起了缺页异常,并且该案例的缺页异常处理流程打印了字符串 “F2FS COW on wp_page_copy 0x6000000000”, 那么说明实践案例分配了 PAGECACHE,同时也可以看到 PAGECACHE 按着之前分析的代码路径流动。最后开发者可以在该路径上的任何地方使用 bs_debug 查看 PAGECACHE 在缺页异常处理流程里的流动.
当进程首先对 COW 的文件映射内存执行了读操作,那么会触发相应的缺页异常,缺页异常处理函数会分配 PAGECACHE,并从磁盘加载文件内容到 PAGECACHE,最后建立页表映射到 PAGECACHE。在这种场景下,进程对该内存执行写操作,MMU 检查到没有写权限于是触发缺页异常,此时缺页异常处理函数的处理逻辑如上,由于此时页表已经存在,只是因为写保护触发的缺页,那么 do_wp_page 函数进行逻辑处理,由于此时 PAGECACHE 不是匿名内存,那么调用 wp_page_copy 函数,该函数首先分配一个匿名页,然后调用 __wp_page_copy_user 函数将 PAGECACHE 内容拷贝到匿名页,此时匿名页就是 PAGECACHE 的一个副本,缺页异常将页表映射到匿名页上,那么后续进程的读写操作都在该副本匿名页上.
当进程首先对 COW 的文件映射内存执行写操作,那么会触发上图逻辑,由于页表不存在,那么缺页异常处理函数会调用 do_cow_fault 函数,该函数首先分配一个匿名页,然后调用 __do_fault 函数分配 PAGECACHE,并将磁盘文件内容加载到 PAGECACHE 里,接着调用 copy_user_highpage 将 PAGECACHE 内容拷贝到匿名内存里,此时匿名内存就是 PAGECACHE 的副本,缺页异常将页表映射到匿名内存上,那么接下来进程对 COW 内存的读写都落到匿名内存上.
do_cow_fault 函数的逻辑如上,F2FS 文件系统没有特除的处理逻辑,此处可以看到 COW 过后,虽然虚拟内存是文件映射,但虚拟内存映射的匿名内存,因此如果进程再发生 fork 动作,那么就要按匿名内存 COW 场景进行处理,此时该虚拟内存还是会被设置为写保护。