在 Linux 中,文件是计算机存储数据的基本单元,它是计算机系统中用于存储、组织和管理信息的主要方式之一. 文件在 Linux 和其他操作系统中都扮演着重要的角色,文件用于存储各种类型的数据,包括文本、图像、音频、视频、程序代码等。文件是计算机上保存和检索信息的主要手段, 文件可用于组织和结构化数据。例如,文本文件可用于存储文档、配置文件和日志,而目录(文件夹)可用于组织文件和其他目录. 在硬件拓扑结构上,文件是存储在磁盘的,系统要访问文件需要经过以下步骤:
- 应用程序请求: 流程的开始是由应用程序触发的,应用程序通过文件系统接口(如 open、read、write 等) 发起对文件的读写请求
- 文件描述符: 当应用程序请求打开文件时,内核为该文件分配一个文件描述符。文件描述符是一个整数,用于标识打开的文件
- 文件操作:
- 读取文件: 当应用程序请求读取文件时,内核会检查文件是否在 Page Cache 中,如果在缓存中,则直接从缓存中返回数据。如果不在缓存中,则内核会从磁盘读取数据,并将数据复制到内存中,并可能将其放入 Page Cache 中以备将来使用
- 写入文件: 当应用程序请求写入文件时,内核会将数据写入内存中的缓冲区。然后内核根据文件系统的策略将数据写入 Page Cache 中,并在适当的时候将数据刷新到磁盘上的文件中
- 同步和缓存管理:
- 同步(Sync): 对于写操作,内核通常会将数据写入 Page Cache中,并不立即写入磁盘,以提高性能。然而,应用程序可以通过调用 fsync 或 fdatasync 等函数来强制刷新 Page Cache 中的数据到磁盘,以确保数据持久性
- Page Cache 管理: 内核会自动管理 Page Cache,根据内存压力和策略来选择哪些数据块保留在缓存中,哪些数据块被丢弃
- 文件关闭: 当应用程序不再需要文件时,通常会使用 close 系统调用关闭文件描述符。这将释放相关的资源,并可能导致数据刷新到磁盘
在 Linux 里,访问普通文件(Regular File) 的方式通常有: Buffered I/O、File-Mapped、Direct I/O 以及 DAX(Direct Access), 通常文件访问使用 Buffered I/O 方式,它通过使用缓冲区(buffer)来管理文件数据,通常在用户空间和内核之间进行数据传输, 并提供了 POSIX 接口(Read/Write/OPEN/CLOSE) 用于文件访问。Linux 根据不同的场景,也会使用其他方式进行文件访问,这些方式直接的差异如下:
- Buffered I/O: Buffered I/O 使用缓冲区来管理文件数据,它是默认的文件 I/O 方式。数据首先从磁盘读取到内核的 Page Cache 中,然后从 Page Cache 复制到用户空间的缓冲区。类似地,在写入文件时,数据首先被写入用户空间的缓冲区,然后从缓冲区复制到内核的 Page Cache,并最终刷新到磁盘。Buffered I/O 通过缓存来优化磁盘 I/O 操作,减少磁盘访问次数,提高文件访问速度
- Page Cache: Page Cache 是内核中的一个缓存机制,用于存储文件数据的页。它是 Buffered I/O 的一部分,用于缓存文件数据块以加速文件读取和写入操作。Page Cache 的数据存储在内存中,并在文件 I/O 操作期间自动加载和卸载. Page Cache 的主要作用是减少磁盘 I/O,提高文件访问性能。当应用程序请求读取文件时,内核首先检查 Page Cache 中是否已经有所需的数据,如果有,就直接从内存中返回数据,而不需要从磁盘读取
- Direct I/O: Direct I/O 是一种绕过 Page Cache 的文件 I/O 方式。它不使用内核的 Page Cache 来缓存文件数据,而是直接在用户空间和磁盘之间传输数据。这意味着数据不会在内核中缓存,而是直接读取或写入磁盘. Direct I/O 可以提供更精确的 I/O 控制,避免了Page Cache 可能引入的不确定性。它通常用于需要高度可预测性的 I/O 操作,如数据库管理系统
- DAX(Direct Access): DAX 是一种特殊的 Direct I/O,用于直接在用户空间和存储设备之间进行数据访问,绕过了操作系统的文件系统和 Page Cache。DAX 允许应用程序将数据直接映射到存储设备上,以实现低延迟、高性能的 I/O 操作. DAX 适用于需要极低延迟和高性能的应用程序,如高速数据库系统和大规模数据分析
在 Linux 中,文件映射方式分配内存是一种通过将文件内容映射到进程的地址空间来分配内存的方法。这通常使用 mmap 系统调用实现,可以将文件的一部分或整个文件映射到进程的内存中,使得进程可以直接读取和写入文件内容,就好像它们在内存中一样。这与匿名映射有以下区别:
- 源数据: 使用文件映射时,内存区域的内容来自磁盘上的文件。进程可以访问文件的内容,对文件所做的更改也会反映回文件; 匿名映射是一种不与文件相关的内存映射,它没有关联的文件内容。通常匿名映射用于进程之间共享数据或分配零初始化的内存块
- 数据可持久性: 文件映射通过文件映射分配的内存区域是持久性的,即进程终止后,文件内容仍然存在于磁盘上。这使得文件映射适合需要持久性数据存储和共享的情况; 匿名映射分配的内存区域在进程终止后被释放,其内容不会保留在磁盘上。这适用于临时数据存储和进程间通信,但不保留数据
- 文件描述符: 与文件映射关联的内存区域通常需要文件描述符,这样进程可以通过文件描述符来操作文件内容; 匿名映射不需要文件描述符,它通常用于分配内存块,而不是操作文件
- 初始化: 文件映射通常不需要显式初始化,因为它们的内容来自文件; 匿名映射通常需要显式初始化,因为它们没有关联的文件内容,可以使用 mmap 标志中的 MAP_ANONYMOUS 来分配零初始化的内存
在 Linux 里可以使用 mmap 系统调用分配文件映射的虚拟内存,mmap 提供了很多参数可以灵活设置文件映射内存属性,具体如下:
- ARG0: MAP_VADDR 可以指定文件映射内存的虚拟地址,该值为 NULL 是代表分配一个随机的虚拟地址,当该值为指定值时代表从指定的地址分配虚拟内存,该地址可能已经被分配出去,使用时调用者需要确保虚拟地址被覆盖.
- ARG1: MAP_SIZE 指明了需要分配文件映射内存的大小,长度为 PAGE_SIZE 的倍数,不能为 0.
- ARG2: 指明了文件映射内存的访问权限,其值可以是这些标志的合集: PROT_READ(可读)、PROT_WRITE(可写)、PROT_EXEC(可执行) 和 PROT_NONE(不可访问)
- ARG3: 指明映射类型,该字段可以选择 MAP_SHARED(共享映射) 和 MAP_PRIVATE(私有映射),当不能包含 MAP_ANONYMOUS 标志,否则变成匿名内存.
- ARG4: 映射文件描述符,该这段必须是需要映射文件的描述符.
- ARG5: 指明映射文件相对文件开头的偏移.
在 Linux 里,文件映射(File-Mapped) 的数据架构如上图,用户进程使用文件映射将文件映射到进程地址空间之后,进程使用 VMA 描述映射之后的虚拟内存区域,不同的文件系统会为 VMA 提供相应的 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 映射,因此形成了一个闭环.
Linux 支持多种文件系统,每个文件系统都会为其下创建的文件(Regular File)提供文件操作接口和文件映射虚拟内存操作接口,重点看一下与文件映射有关的接口,STRUCT file_operations 提供的 mmap 函数用于将该文件系统的文件映射到进程的地址空间,get_unmapped_area 接口用于将该文件系统映射到进程地址空间指定位置或者对齐方式. STRUCT vm_operation_struct 提供了该文件系统文件映射到虚拟内存区域之后,虚拟内存区域的操作接口,其中 fault 接口用于进程访问该虚拟内存发生缺页的处理方法, huge_fault 接口用于在支持文件大页映射的虚拟内存发生缺页时的处理方法.
File-Mapped with Shared
在 Linux 里,多个进程可以同时打开同一个文件,并且可以将该文件映射到进程的地址空间,那么多个进程实现文件内容共享。例如多个进程读取的内容都是一致的,另外当有一个进程对文件写操作之后,多个进程读到是进程写入的新值。该机制可以使用 mmap 映射内存时使用 MAP_SHARED 标志. 共享文件一般是只读的,这样的好处时系统只需维护一套 PAGE CACHE 就可以实现多个进程对文件的读请求,这样大大减少了内存消耗。共享方式的文件映射通常用来只读的共享库场景,每个进程都会加载很多相同的只读共享库,那么可以使用这种方法减少大量的内存消耗.
File-Mapped with Private/COW
在 Linux 里,进程可以私有化打开一个文件,即只有该进程可以对该文件有独占写(Exclusive Write), 而进程如果通过 fork 创建了子进程,那么子进程可以和父进程共享读(Shared Read) 文件的内容,直到子进程后者父进程向文件发起写操作时,那么 Linux 会执行 COW(Copy-On-Write) 机制, 该机制会在写的时候分配一个新的匿名页,并将原先 PAGE CACHE 的内容拷贝到新的匿名页里,那么写操作写到新的匿名页里,这样将会大大降低内存消耗.
在 File-Mapped 里,COW 场景不止发生在 FORK 操作,另外当一个进程使用 mmap 映射文件时采用了 MAP_PRIVATE 标志,那么文件映射的这段内存就会发生 COW。在这个场景下,当进程首先对文件映射的内存进行读操作时,此时内存是 MAY_SHARED 的属性,虽然此时会触发缺页异常,但缺页异常处理函数主要是分配 PAGE CACHE,并将文件内容从磁盘加载到 PAGE CACHE 里,最后建立虚拟内存到 PAGE CACHE 的页表,此后只要进程只对文件映射的内存执行读操作,那么 COW 不会发生。一旦进程开始发起写操作,那么再次触发缺页异常,缺页异常处理函数会重新分配一个新的匿名页,并将 PAGE CACHE 的内容拷贝到匿名页上,最后建立虚拟内存到匿名页的页表,那么接下来文件的读写操作都会落到这个匿名页上,换句话说就是写操作不会落盘.
对于文件映射的内存,同样都是 COW 场景,但由于对匿名内存的读写顺序导致缺页流程存在差异。当进程对文件映射内存先读后写,那么缺页异常处理函数先建立虚拟内存到 PAGECACHE 的映射,待再次写时缺页异常处理函数再次建立虚拟内存到匿名页的映射; 反之进程对文件映射先写后读,那么缺页异常处理函数直接建立虚拟内存到匿名页的映射. 文件映射的 COW 应用场景包括:
- 进程复制: 当一个进程使用 fork 系统调用创建一个子进程时,子进程通常会继承父进程的虚拟内存映射。为了节省内存和资源,内核可以使用 COW 来处理这些共享映射。当父进程或子进程尝试修改共享映射中的数据时,COW 会创建副本,以确保各自的修改不会影响其他进程
- 虚拟内存快照: 在某些情况下,应用程序可能需要创建虚拟内存的快照,以便在以后的某个时候回滚到先前的状态。COW 允许应用程序创建虚拟内存的快照,其中包括共享的文件映射。这可以用于实现撤销操作或在应用程序的不同状态之间切换
- 共享库加载: 当多个进程使用相同的共享库时,它们可以共享相同的文件映射,而不是为每个进程创建单独的副本。COW 确保在进程修改库中的数据时不会影响其他进程对该库的访问
- 内存映射文件的编辑器: 编辑器(如文本编辑器或图像编辑器)可以使用内存映射文件来加载大文件,以便快速访问和编辑。COW 可用于确保多个编辑器实例可以共享相同的文件映射,并在编辑时创建副本以避免相互干扰
- 虚拟机内存快照: 虚拟机管理器(如 VMware 或 VirtualBox)可以使用 COW 技术来创建虚拟机内存快照。这允许管理员捕获虚拟机的当前状态,随时回滚到以前的状态,而无需复制整个虚拟内存
File-Mapped with BIO Request
在 Linux 里,进程将文件映射到进程的地址空间之后,文件系统会在虚拟内存和磁盘之间使用物理页来缓存从块设备读取的数据,这个物理页称为 PAGE CACHE(页缓存). 另外文件系统与磁盘之间使用 BUFFER-HEAD 管理缓冲区,以便进行读取和写入磁盘之间的数据传输。总的来说,文件系统想向磁盘上的文件写入或读取数据时,都会通过 BUFFER-HEAD 将其打包成 BIO 请求,然后下发到系统的 BLOCK IO 层,待请求完成时,文件系统可以在页缓存上看到最新的数据。
BUFFER-HEAD 与 PAGECACHE 之间的关系如上图,当文件系统需要向磁盘文件读取或写入文件时,文件系统按 STRUCT buffer_head 描述的区域作为请求发送给 BIO,BIO 完成硬件上的磁盘数据读写,待请求完成之后磁盘可以通过中断等方式通知文件系统,此时 STRUCT buffer_head 指向的区域为最新的数据.
BiscuitOS 针对 File-Mapped 缺页专题提供了多个 Virtio-BLK 的磁盘,每个磁盘均采用不同的文件系统,当 VFS 向磁盘写入或读取文件时,其向 BIO 层发送请求,BIO 层最终将请求转换成 Virtio-BLK Kick-OFF 请求,待 Virtio-BLK 处理完成请求之后通过 MSIX 中断进行通知,BIO 收到通知之后将请求完成信息继续上报到 VFS 层,这样就完成了磁盘文件的读写.
上图是文件系统发起磁盘文件读操作的代码流程图,以 EXT4 文件和 Virtio-BLK 磁盘为例子进行讲解:
- A: VFS 层 READ 操作处理
- B: EXT4 文件系统的 READ 处理
- C: 将 READ 操作打包成为 BIO 请求发送到 BIO 层
- D: BIO 层将 BIO 请求通过同步或者异步队列最后下发到 Virtio-BLK 磁盘
- E: Virtio-BLK 磁盘处理完读请求将读取的内容放到指定位置并发送 MSIX 中断通知 BIO 层
- F: BIO 层将请求完成信息向上传递给文件系统
- G: 文件系统完成 BIO 请求向上传递给 VFS 层
- H: VFS 层完成最终的 COPY TO USER 操作
采用文件映射的虚拟内存发生缺页是,缺页异常处理函数在特定代码路径会执两次, 即 VM_FAULT_RETRY 导致的重试. VM_FAULT_RETRY 的缘故,handle_mm_fault 会被执行两次,这么做的原因有如下:
- 磁盘 I/O 延迟: 当页面不在物理内存中,需要从磁盘加载时,可能由于磁盘 I/O 操作的延迟,内核无法立即获取所需页面。此时,内核可能会将页面标记为需要重新尝试,并等待磁盘 I/O 完成
- 页面锁定: 在某些情况下,内核可能会锁定某些页面,以确保它们在内存中不会被交换出去或释放。如果页面被锁定,内核可能会等待解锁页面后才能重新加载
- 其他临时情况: 还可能存在其他临时情况,导致页面无法立即加载到内存中。在这种情况下,内核可能会标记页面并尝试重新加载
VM_FAULT_RETRY 并不是 Linux 文件映射的特定特性,而是内核内存管理的一般机制。它在多种上下文中都有用,不仅限于文件映射。具体的行为可能因 Linux 内核版本、硬件配置和文件系统类型而异,因此要根据具体情况考虑如何处理 VM_FAULT_RETRY 错误码. 在 Linux 里文件映射内存使用场景很广,这里总结了几种常见的场景:
PreALLOC 方式分配文件映射内存
PreALLOC 也支持对文件映射内存的分配,文件映射内存在用户进程里使用很广,例如共享库、普通文件等,PreALLOC 的特点可以让进程更快的访问虚拟内存。那么接下来通过一个实践案例介绍如何使用 PreALLOC 分配文件映射内存,实践案例在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS
make menuconfig
[*] Package --->
[*] Paging Mechanism --->
[*] USER PREALLOC: FILE-MAPPING Memory --->
# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-VM-PREALLOC-FILE-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
BiscuitOS-PAGING-VM-PREALLOC-FILE-default Source Code on Gitee
实践案例由一个应用程序构成,程序首先在 24 行调用 open 系统调用打开指定的文件,然后在 29 行调用 mmap 函数分配虚拟内存将文件映射到进程的地址空间,另外还使用了 MAP_POPULATE 标志,那么意味着在分配虚拟内存的同时也会分配物理内存,这里的物理内存作为 PageCACHE 使用,且 PageCACHE 里面已经加载了磁盘文件的内容,并建立相应的页表映射. 函数接着在 39 行对虚拟内存进行写操作,但由于页表已经建立,因此此时不会触发缺页,最后测试完毕之后将资源进行回收,以上便是一个最简单的实践案案例,为了验证 39 行处没有发生缺页,可以在 39 行处添加 BS_DEBUG 开关:
接着在内核内存缺页流程必经之路上任意位置加上 BS_DEBUG 函数,以此观察内存在某个函数里的流动,例如上图在 exc_page_fault 函数的 1506 行加上 bs_debug 打印,以此确认内存是向哪个函数流动,接下来执行如下命令进行实践:
# 编译应用程序
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-VM-PREALLOC-FILE-default/
# 编译内核
make kernel
# 编程程序
make build
BiscuitOS 启动之后,直接运行 RunBiscuitOS.sh 脚本,脚本里包含了运行实践案例所需的全部命令,可以看到应用程序运行之后,系统并没有打印缺页相关的信息,为了更好证明实践案例的可靠性,开发者可以将 mmap 函数的 MAP_POPULATE 标志去掉之后再实践:
通过对比实践可以看出,当 mmap 里去掉 MAP_POPULATE 标志之后,访问虚拟内存缺失引起了缺页异常,因此再次证明实践案例确实在分配虚拟内存的同时也分配了物理内存,并建立页表映射. 以上便是 PreALLOC 的一种使用场景.
LazyALLOC 方式分配文件映射内存
LazyALLOC 也支持对文件映射内存的分配,文件映射内存在用户进程里使用很广,例如共享库、普通文件等,LazyALLOC 的特点是当进程访问虚拟内存时才会去分配物理内存。那么接下来通过一个实践案例介绍如何使用 LazyALLOC 分配文件映射内存,实践案例在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS
make menuconfig
[*] Package --->
[*] Paging Mechanism --->
[*] USER LAZYALLOC: FILE-MAPPING Memory --->
# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-VM-LAZYALLOC-FILE-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
BiscuitOS-PAGING-VM-LAZYALLOC-FILE-default Source Code on Gitee
实践案例由一个应用程序构成,程序首先在 24 行调用 open 系统调用打开指定的文件,然后在 29 行调用 mmap 函数分配虚拟内存将文件映射到进程的地址空间,此时仅仅分配了虚拟内存. 函数接着在 38 行对虚拟内存进行写操作,但由于页表没有建立,因此此时不触发缺页,最后测试完毕之后将资源进行回收,以上便是一个最简单的实践案案例,为了验证 39 行处发生缺页,可以在 39 行处添加 BS_DEBUG 开关:
接着在内核内存缺页流程必经之路上任意位置加上 BS_DEBUG 函数,以此观察内存在某个函数里的流动,例如上图在 exc_page_fault 函数的 1506 行加上 bs_debug 打印,以此确认内存是向哪个函数流动,接下来执行如下命令进行实践:
# 编译应用程序
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-VM-LAZYALLOC-FILE-default/
# 编译内核
make kernel
# 编程程序
make build
BiscuitOS 启动之后,直接运行 RunBiscuitOS.sh 脚本,脚本里包含了运行实践案例所需的全部命令,可以看到应用程序运行之后,系统并打印缺页相关的信息,说明进程在分配虚拟内存的时候仅仅分配了虚拟内存. 以上便是 LazyAlloc 的一种使用场景.