在 Linux 系统中,MAP_PRIVATE 是用于 mmap 系统调用的一个标志,它指定了创建的内存映射的特性. 当使用 MAP_PRIVATE 标志时,对映射的写入操作会创建一个该内存区域的私有拷贝,而不影响原始数据。这意味着所做的更改只有当前进程可以看到,对其他进程或者底层文件本身是不可见的。以下是 MAP_PRIVATE 的一些关键作用和特性:
- 写时复制(Copy-on-Write,COW): 使用 MAP_PRIVATE 创建的映射通常实现为写时复制。这意味着当进程试图写入映射的内存时,系统会创建该内存页的一个私有拷贝,而原始页面保持不变。这样进程的写入操作只会影响它自己的私有拷贝,不会影响原始文件或其他进程的映射
- 内存效率: MAP_PRIVATE 使得系统可以通过共享未修改的页面来节省内存。在多个进程映射相同文件的情况下,只有在写入时才会创建私有拷贝,未修改的页面则可以在所有进程间共享
- 文件映射: MAP_PRIVATE 通常与文件映射一起使用,尤其是需要读取文件内容但不打算修改原始文件时。它确保了对映射内存的任何修改都不会被写回文件
- 匿名映射: 虽然 MAP_PRIVATE 经常用于文件映射,但它也可以用于匿名映射(即不与任何文件关联的映射). 在这种情况下,MAP_PRIVATE 确保匿名区域的修改不会影响其他同样映射该匿名区域的进程
- 保护进程间隔离: MAP_PRIVATE 映射对于实现进程间的数据隔离非常有用,因为每个进程的修改都只限于它自己的地址空间内,不会影响到其他进程
- 应用场景: MAP_PRIVATE 在许多类型的应用程序中都非常有用,特别是那些需要读取大量数据进行处理,但又不想更改原始数据的应用. 它允许应用程序以一种非常内存效率的方式处理数据,特别是在处理大型文件或共享内存时
为了全面研究 MAP_PRIVATE 标志对内存的影响,文章将从文件映射和匿名映射的角度分别对 MAP_PRIVATE 标志进行详细分析。SYS_MMAP 系统调用的 ARG3 带有 MAP_PRIVATE 标志时即表示采用私有映射,私有映射的特点: 对映射的内存写入操作时会创建一个该内存区域的私有拷贝,而不影响原始数据. MAP_PRIVATE 私有映射和 MAP_SHARED 共享映射相对立的,因此两个标志不能同时出现,当 SYS_MMAP 系统调用的 ARG3 没有显示设置 MAP_PRIVATE/MAP_SHARED 标志时,系统调用默认使用 MAP_PRIVATE. 那么接下来分别从匿名映射和文件映射角度来分析 MAP_PRIVATE.
MAP_PRIVATE with 匿名映射
匿名映射是没有关联任何文件的映射方式,那么严格意义上的匿名映射只有匿名内存一种,因此匿名内存都是采用 MAP_PRIVATE 和 MAP_ANONYMOUS,其映射的物理页(匿名页)可以与其他进程共同读(这里与共享内存进行区域,不称为共享读), 但当进程对匿名内存发起写操作之后,内核会重新分配一个匿名页,然后将原先匿名页的内容拷贝到新的匿名页上,最后进程的写操作在新分配的匿名页上进行,以上便是一个最简单的 COW 场景. 因此 MAP_PRIVATE 在匿名映射/匿名内存里的作用就是让进程的私有数据不被其他进程共享.
当使用 SYS_MMAP 系统调用建立私有的匿名映射,即分配匿名内存,那么可以在 SYS_MMAP 的 ARG3 给定 MAP_PRIVATE 和 MAP_ANONYMOUS 参数,那么可以分配匿名内存,如果此时分配的虚拟内存大小超过 PMD_SIZE,并且系统启用了 THP,那么此时 SYS_MMAP 系统调用会分配匿名透明大页内存. 当分配了匿名内存之后,不同的操作会导致不同的内存行为出现,那么可能会出现以下几种:
- ZERO PAGE/ZERO HUGE PAGE: 当分配匿名内存之后,对匿名内存发起读操作,那么内核在支持 ZERO PAGE 的情况下,将虚拟内存映射到 ZERO PAGE 上,那么进程或者子进程都可以从这块虚拟内存上读到 0.
- 独占写(首次): 如果匿名内存只有当前进程在使用,那么当用户进程对匿名内存发起写操作,那么因为没有映射物理内存而触发缺页,缺页异常处理函数会分配新的物理页作为匿名页,然后进程在新的匿名页上进行读写操作.
- 非独占写(首次): 当匿名页被多个进程共同映射时,如果其中一个进程对匿名内存发起写操作,那么会因为权限异常导致缺页,缺页异常处理函数会新分配一个物理页,然后将原先匿名页内容拷贝到新的物理页上,然后将进程的虚拟内存映射到新物理页上,这样进行的写操作在新的物理页上,这样其他进程还是读到原先的数据,而该进程可以读写私有的数据.
综合来看,MAP_PRIVATE 标识的核心作用是保证数据的私有不被其他进程共享. 另外 MAP_PRIVATE 标志只有在多进程共同映射一个匿名页的场景才能最大限度发挥作用,另外对于匿名页,其也需要一定的逆向映射知道其被哪些进程共同映射,这样在处理类似 SWAP、MIGRATE 操作时才能更好的处理负责的映射问题.
MAP_PRIVATE(匿名映射) 创建
当用户进程调用 SYS_MMAP 系统调用分配匿名内存时,其使用 MAP_PRIVATE 和 MAP_ANONYMOUS 两个标志,SYS_MMAP 系统调用在处理这个场景时的代码流程如上图,其中 TRANS VMA FLAGS 处调用 calc_vm_prot_bits 函数将 MAP_PRIVATE 标志转换成 VMA FLAGS,然后在 REVER INDEX 处将虚拟地址存储在 VMA 的 vm_pgoff 里,用于后续的逆向映射使用, 接着在ACCONT ANON 处调用 accountable_mapping 函数识别处匿名映射,则进行相应的数据统计,最后在 SETUP ANON-MEMORY 处将新分配的 VMA 调用 vma_set_anonymous 函数标记为匿名内存. SYS_MMAP 系统调用对 MAP_PRIVATE 调用并没有太多特殊处理, 接下来查看具体的代码细节:
在 do_mmap 函数的 REVER INDEX 处存在如上逻辑,其判断映射不是文件映射情况下,并在 1517 行检查到用户进程传入了 MAP_PRIVATE 标志之后,进入 1518 行分支,并在 1521 行将新分配的虚拟地址的页号存储在 pgoff 变量里,该变量作为逆向映射的重要数据最终存储在 VMA 的 vm_pgoff 成员里. 另外对比 1508-1516 行的代码逻辑,用户进程传入 MAP_SHARED 的场景下,其会在 1515 行为 vm_flags 添加 VM_SHARED 标志,那么反向说明没有 VM_SHAED 的就是私有映射.
在 mmap_region 函数的 ACCONT ANON 处内核调用 accountable_mapping 函数检查虚拟区域是否支持私有写,如果是则在 1715 行将虚拟内存长度右移 PAGE_SHIFT 之后存储到 charged 里,然后在 1716 行调用 security_vm_enough_memory_mm 函数检查内核是否有足够的内存分配给匿名内存,如果没有则返回 ENOMEM,这是因为采用 SYS_MMAP 惰性分配的匿名内存只管分配足够的虚拟内存,其并不负责分配物理内存的分配,因此会出现物理内存不足的情况。检查完毕之后向 VMA FLAGS 添加 VM_ACCOUNT 标志,以此让内核对该虚拟内存进行内存消耗统计.
这里可以看一下 accountable_mapping 函数的实现逻辑,对于匿名内存那么 file 为空,那么匿名私有映射此时 vm_flags 里并不包含 VM_SHARED 标志,另外由于用户进程在创建匿名内存是使用了 PROT_WRITE,因此此时包含 VM_WRITE 标志, 因此最后 vm_flags 里只包含了 VM_WRITE,可以私有写内存就是这么识别出来的,至于 VM_NORESERVE 则是不希望内核去检查是否有充足内存,由用户进程调用 SYS_MMAP 时的 MAP_NORESERVE 标志进行设置.
在 mmap_region 函数的 SETUP ANON-MEMORY 处检测到非文件映射,且不包含 VM_SHARED,那么进入 1795 行调用 vma_set_anonymous 函数,将 VMA 标记为匿名内存,因为对于匿名映射来说 MAP_PRIVATE 标志代表非 VM_SHARED. 当 VMA 被标记为匿名内存之后,那么在内核的其他内存行为里,内核就会针对匿名内存进行指定的处理. 从这里再次验证 SYS_MMAP 系统调用就是一个分流的作用,虽然都是分配虚拟内存,但因不同的 MAP FLAGS 导致虚拟内存被划分成不同类型的虚拟内存.
MAP_PRIVATE(匿名映射) 缺页
当用户进程首次访问匿名内存时(此时 SYS_MMAP 系统调用已经将私有匿名映射的内存识别为匿名内存),由于其并未映射具体的物理内存,那么会触发缺页异常,在缺页异常处理函数里,匿名内存的处理逻辑如上图,在 DETECT ANON-MEMORY 处调用 vma_is_anonymous 函数识别出缺页的虚拟内存是匿名内存,然后进入 do_anonymous_page 进行处理,对于 MAP_PRIVATE 相关比较大的是在 SETUP RMAP 处调用 page_add_new_anon_rmap 函数为匿名页建立逆向映射, 其余动作就是将虚拟内存建立页表映射到物理页上.
在 handle_pte_fault 函数的 DETECT ANON-MEMORY 处,函数在 4913 行判断 PTE 页表不存在,那么虚拟内存是首次缺页,于是进入 4914 行进行处理,此时函数调用 vma_is_anonymous 函数判断虚拟内存是否为匿名内存,因为在 SYS_MMAP 系统调用时,其将 MAP_PRIVATE 和 MAP_ANONYMOUS 映射的虚拟内存标记为匿名内存,那么该条件符合并进入 4915 行分支调用 do_anonymous_page 函数为匿名内存建立页表映射.
在 do_anonymous_page 函数里,函数首先在 4039 行判断匿名内存的 VMA FLAGS 里是否包含 VM_SHARED 标志,如果包含则返回 VM_FAULT_SIGBUS 报错,这是因为 MAP_PRIVATE 标志最终会转换成不包含 VM_SHARED 的 VMA,因此匿名内存不包含 VM_SHARED 标志.
do_anonymous_page 函数负责为匿名内存分配匿名页,并填充相应的页表将虚拟内存映射到匿名页上,映射完毕之后在 SETUP RMAP 处调用 page_add_new_anon_rmap 函数为匿名页建立逆向映射,函数的核心是在 56 行调用 __page_set_anon_rmap 函数,可以看到其内部函数实现,先在 11 行判断物理页是否为匿名页,因为这里是首次分配的物理页,因此物理页还没有被标记为匿名页,于是没有跳转到 out 处,接着 19 行的 exculusive 为 true,因此 VMA 使用自己的 anon_vma, 函数的 28-30 行建立逆向映射,其在 29 行将 STRUCT page 的 mapping 指向了 AV,然后将 index 成员设置为 linear_page_index 对应的偏移,其实这里就是 SYS_MMAP 系统调用里针对 MAP_PRIVATE 设置的 vm_offset,最后函数在 33 行调用 SetPageAnonExclusive 函数将物理页标记为独占匿名页,因为该场景下面,只有一个进程映射了物理页,并且是 MAP_PRIVATE 的,因此同时会将物理页标记成匿名页,这里话句话来说之后的内核只要调用 PageAnon 函数或者 PageAnonExclusive 函数可以判断出物理页是匿名页或者独享匿名页. 分析到这里,匿名内存已经和匿名页映射完毕,那么 MAP_PRIVATE 如何实现数据私有化呢? 内核很多场景都会面临数据私有化问题,这里介绍最经典的 FORK + COW 场景:
MAP_PRIVATE(匿名映射) FORK
SYS_FORK 系统调用是最能体现 MAP_PRIVATE 的重要性,承接上面的分析,MAP_PRIVATE 和 MAP_ANONYMOUS 分配的虚拟内存被设置成匿名内存,并通过缺页将虚拟内存映射到物理页上,并且这个物理页被标记为匿名页(或者独享匿名页),那么此时如何进程调用 SYS_FORK 系统调用创建子进程时,子进程拷贝父进程的这段匿名内存,但这里的拷贝并不是直接将父进程匿名内存的内容拷贝到子进程的虚拟地址空间里,这样做会导致性能极低,因此 SYS_FORK 做了一个优化,只是将子进程新建虚拟内存的页表映射到父进程匿名内存的匿名页上,然后将父子进程的最后一级页表修改为写保护,那么这个就有意思了,父子进程如果发起读操作,那么都可以从匿名页里读到相同的数据, 这也符合逻辑,毕竟 FORK 是拷贝父进程的内存。但是只要父进程或者子进程对匿名内存发起写操作,那么内核会新分配一个物理页作,然后将原先匿名页的内容拷贝到新的物理页里,并将进程的匿名内存映射到新的物理页上,那么接下来的写操作就在新的物理页上进行,这样的逻辑即确保了父子进程之间的数据独立性,也节省了内存开销,这就是大名鼎鼎的 COW 机制. 回到本小节的重点,这里将研究 MAP_PRIVATE 对 SYS_FORK 的影响,具体如下:
在 SYS_FORK 里 MAP_PRIVATE 标志的影响应该分匿名映射和文件映射两种大场景,那么针对匿名映射场景,MAP_PRIVATE 和 MAP_ANONYMOUS 标志已经被 SYS_MMAP 标记为匿名内存,那么这里实则研究 SYS_FORK 对匿名内存的处理,其处理逻辑如上,核心的步骤是在 CREATE CHILD-VMA 处调用 vm_area_dup 函数复制父进程的匿名内存 VMA,然后在 COPY RMAP 处复制匿名页的逆向映射,接着在 COPY PAGE-TABLE 处调用 copy_page_range 拷贝页表,并在 SETUP WP 处将父子进程的页表都设置成写保护.
anon_vma_fork 函数用于复制父进程的逆向映射之外,也用于设置自己的逆向映射,其在 350 行调用完 anon_vma_clone 函数复制父进程的逆向映射,然后在 359 行调用 anon_vma_alloc 函数新分配一个 AV,以及 363 行调用 anon_vma_chain_alloc 函数分配一个新的 AVC,并在 371-372 行设置了 AV 与父进程 AV 的关系,最后在 382 行调用 anon_vma_chain_link 函数将 AVC 与 AVC 和 VMA 进行绑定,那么经过这些处理之后,子进程处理完自己和父进程之间的逆向映射,以及自己的逆向映射关系.
anon_vma_clone 函数用于处理父子进程之间的逆向映射关系,其大致逻辑就是父进程的逆向映射子进程需要全盘继承,那么函数在 284 行调用 list_for_each_entry_reverse 函数遍历父进程的 anon_vma_chain 链表,以此知道父进程与哪些它的父进程共享 VMA,每次遍历过程中,函数在 287 行新分配一个 AVC,然后在 297 行调用 anon_vma_chain_link 函数将 AVC 插入到遍历到的 AV 红黑树里,然后将 AVC 加入到自己的 anon_vma_chain 链表里. 遍历完毕之后,子进程的 AVC 就插入到所有父进程和父父进程的 AV 红黑树里,这是开发者就是这道为什么 AV 的红黑树就增加了把,另外子进程的 anon_vma_chain 链表里也为了很多 AVC,通过遍历这个 anon_vma_chain 链表也可以知道子进程与哪些父进程共同映射匿名页.
在 SYS_FORK 系统调用的 SETUP WP 处位于 copy_present_pte 函数里,函数在 952 行调用 PageAnon 函数检查到物理页存在,且物理页为匿名页,那么函数进入到 960 行调用 page_try_dup_anon_rmap 函数,该函数的作用是将独占匿名页修改为非独占匿名页,并添加物理页的映射引用,这个符合 MAP_PRIVATE 标志的设定,当多个进程的 VMA 共同映射到同一个匿名页时,匿名页自己也需要快速辨识出其是由多个 VMA 映射,因此会将独占匿名页标志移除。函数接着在 977 行调用 is_cow_mapping 和 pte_write 函数检查到匿名内存具有写权限,然后属于 COW 映射,那么函数进入 978 行分支调用 ptep_set_wrprotect 和 pte_wrprotect 函数,将父子进程的页表修改为写保护,那么此时父子进程共同读匿名页是没有任何问题的,但是只要发起写操作就会触发缺页异常,缺页接下来就执行 COW.
is_cow_mapping 函数判断 COW 映射的条件是虚拟内存有写权限而没有 VM_SHARED 标志,通过之前的分析可以知道 MAP_PRIVATE 会让虚拟内存没有 VM_SHARED 标志,因此这里匿名内存就属于 COW. 通过上面的分析,可知 SYS_FORK 系统调用针对 MAP_PRIVATE 做了 COW 和 RMAP 的设置. 接下来继续分析 MAP_PRIVATE 在 COW 和 RMAP 的影响.
MAP_PRIVATE(匿名映射) COW
MAP_PRIVATE 标志的最核心作用就是当多个进程共同映射一个匿名页时,只要其中一个进程对匿名页进行写操作时,那么就需要保证各进程之间的数据独立性,内核提供了 COW 机制来进行保证,所谓 COW 机制就是多个进程共同映射一个匿名页时,多个进程可以共同读取匿名页的内容,因为没有进程修改,读到的内容都是一致的,从进程角度来看,它并不知道其与其他进程共同映射同一个匿名页,只要保证读到的数据正常即可,那么当进程需要对匿名内存写操作时,这个时候内核会从新分配一个物理页,然后将原先匿名页内容拷贝到新的物理页上,并将进程的虚拟内存映射到新的物理页上,进程接下来的写操作写到匿名页上,从进程角度来说,其写如的数据只有进程自己看到,其他进程还在共同读取原先的匿名页,这样就实现了数据的独立,也节省了内存开销,是内核重要的运行机制.
正如上面提到的,当进程对多个进程共同映射的匿名页发起写操作时,MMU 会检查权限异常(FORK 将页表修改为写保护),那么触发缺页处理函数先在 DETECT WP 处识别出并不是虚拟内存的首次缺页,另外缺页的原因是写引起的,并且页表没有写权限,因此缺页异常处理函数判定是一次写保护引起的缺页,那么调用 do_wp_page 函数处理缺页,接着函数识别出发生 WP 缺页的是匿名内存,那么根据匿名页是否独占来进行处理,如果匿名页不独占那么调用 wp_page_copy 函数进行 COW 操作,如果匿名页是独占,那么调用 wp_page_reuse 函数处理缺页. 缺页异常处理完毕之后,进程将数据写入新的匿名页里(如果是独占匿名页则写入到自己的匿名页里),这样保证里 MAP_PRIVATE 标志设置的数据私有化的请求.
在缺页异常处理 DETECT WP 处,函数首先在 4913 行检查 vmf->pte 不为空,说明匿名内存不是首次发生缺页,然后在 4933 行检查到 VMF FLAGS 里只包含了 FAULT_FLAG_WRITE 或者 FAULT_FLAG_UNSHYARE 标志,既可以进入 4934 分支进行处理,此时函数在 4934 行调用 pte_write 函数检查到页表里没有写权限,即之前的页表是写保护的,那么直接进入 4935 行的 do_wp_page 函数,这里也符合 FORK 系统调用时对页表的设置.
在 do_wp_page 函数里检查到物理页是匿名页时,函数进入 3412 行分支,如果在 3418 行调用 PageAnonExclusive 函数检测是独占匿名页,那么直接跳转到 reuse 处,此时由于匿名页只有一个进程在使用,那么原则上只需将页表修改为可读可写即可,那么进程的读写操作都是私有化的,此时调用 wp_page_reuse. 如果此时匿名页不是独占的,那么函数通过物理页的引用计数判断,在 3428 行判断匿名页是 KSM 或者引用计数大于 3,那么跳转到 copy 处执行 COW,另外还有几种场景也可以判断的是多个进程共同映射了匿名页,因此需要执行 COW,此时调用 wp_page_copy 函数.
在 wp_page_copy 函数里,函数在 3112 行调用 alloc_page_vma 函数分配一个新的物理页,然后在 3117 行调用 __wp_page_copy_user 行将匿名页的内容拷贝到新分配的物理页上,此时完成了 COW 机制的 COPY 动作.
wp_page_copy 函数在分配到新的物理页之后,在 3149 分支建立页表映射到新的物理页上,值得注意的是 3167 行为页表添加写权限,并在 3178 行调用 page_add_new_anon_rmap 函数设置新物理页的逆向映射,最后将物理页标记为独享的匿名页. 那么接下来进程的读写操作都在新的独享匿名页里完成, 实现了 MAP_PRIVATE 的数据私有化的请求.
对于只有一个进程映射匿名页发生 COW 时,其在 do_wp_page 函数里的处理逻辑与多个进程映射的匿名页发生 COW 有所不同,函数在 3411 行检测到 PageAnon 为真,那么在 3418 行调用 PageAnonExclusive 检测发现此时匿名页并不是独占匿名页,那么继续 3420 行之后的检查,发现所有条件并不满足,于是在 3451 行调用 page_move_anon_rmap 函数处理独占匿名页的场景,函数会将匿名页标记为独占匿名页,然后调用 wp_page_reuse 函数处理独占匿名页的场景.
对于只有一个进程映射匿名页发生 COW 时,其调用 wp_page_reuse 函数进行处理,此时缺页处理函数不需要为进程分配新的物理页,而是继续使用已经映射的物理页,此时只需要将页表修改为可读可写即可,即函数在 3066 行将页表修改为可读可写,由于此时匿名页已经是独享匿名页,因此无需其他操作. 那么进程的读写操作则是在原来的匿名页上进行,只不过此时匿名页变成了独享匿名页. 通过上面的分析已经将 MAP_PRIVATE 标志的影响进行了详细说明,那么接下来通过一个实践案例实践上面的场景,实践案例在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS
make menuconfig
[*] Package --->
[*] Memory Mapping Mechanism --->
[*] Memory Mapping: FLAGS MAP_PRIVATE --->
# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-MEMORY-MMAP-MAP-PRIVATE-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
BiscuitOS-MEMORY-MMAP-MAP-PRIVATE-default Source Code on Gitee
实践案例由一个应用程序构成,程序在 22 行调用 mmap 函数分配一块虚拟内存,并在 24 行使用 MAP_PRIVATE 和 MAP_ANONYMOUS 标志,那么分配的虚拟内存是匿名内存,然后在 31 行对匿名内存首次发起写操作,此时会触发缺页将虚拟内存映射到新的独享匿名页上,那么进程的写操作在数据写入到该独享匿名页上. 函数接着在 35 行使用 fork 系统调用,那么此时独享匿名页会被两个进程共享而变成非独享匿名页,接着在 39 行子进程首先对匿名内存发起写操作,此时会触发缺页异常,并且此时匿名页被两个进程共同映射,因此缺页异常处理函数发起了 COW 操作,那么将分配一个新的物理页,将原先匿名页内容拷贝到新的物理页,并将虚拟内存映射到新的物理页上,最后将物理页标记为独享匿名页。另外父进程睡眠 1s 之后对匿名页发起了读操作,由于此时权限正常因此不会触发缺页异常,那么父进程继续在 46 行执行写操作,此时由于权限异常而触发缺页,由于此时只有一个进程应用该匿名页,因此将匿名页标记为独享匿名页,然后将页表修改为可写,那么父进程将数据写入到该匿名页上. 以上便是完整的实践过程,开发者可以使用内存流动工具查看内存在 COW 机制里的流动,在 39 行前后加上 BS_DEBUG 开关:
接着在匿名内存 COW 缺页流程必经之路上任意位置加上 BS_DEBUG 函数,以此观察内存在某个函数里的流动,例如上图在 wp_page_copy 函数的 3101 行加上 bs_debug 打印,以此确认内存是向哪个函数流动,接下来执行如下命令进行实践:
# 编译应用程序
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-MEMORY-MMAP-MAP-PRIVATE-default/
# 编译内核
make kernel
# 运行实践案例
make build
当 BiscuitOS 运行之后,直接运行 RunBiscuitOS.sh 脚本可以看到运行实践案例,可以先打印匿名内存刚写入的字符 ‘B’, 然后打印了子进程写入的 ‘D’ 字符,最后是父进程先读取匿名内存的字符是原先匿名页的内容,最后读取的数据是新写入的数据,从数据角度来看父子进程的数据并没有相互干扰,符合 FORK 系统调用和匿名内存在的内存行为。最后看到内存确实流动到了 wp_page_copy 函数,开发者可以使用内存流动工具将 MAP_PRIVATE 和匿名映射的内容通过实践跟踪出来,加深对相关知识的学习理解.
MAP_PRIVATE with 文件映射
文件映射是关联文件的映射方式,通俗的讲,只要符合这个特点的内存类型很多,例如文件映射内存、共享内存、HugeTLB 内存、DAX 映射内存等,这类型内存的特点是内核会将虚拟内存与真实的文件或者内核伪文件进行关联. MAP_PRIVATE 标志应用在这些类型的内存时,可以确保这些内存数据私有化得到保障(共享内存除外)。当多个进程将文件映射到各自地址空间时,如果采用 MAP_PRIVATE 标志,那么当多个进程发起读操作可以读到相同的内容,当其中某个进程发起写操作,那么会因为权限异常而发生缺页,缺页异常处理函数会新分配一个匿名页,然后将文件的内容(PAGECACHE 的内容)拷贝到新的匿名页里,并将进程的虚拟内存重映射到匿名页上,那么进程接下来的读写操作都在新的匿名页上进行,而其他进程则继续读到原始的数据,因此保证了进程之间的数据独立性.
当使用 SYS_MMAP 系统调用建立私有的文件映射,那么可以在 SYS_MMAP 的 ARG3 给定 MAP_PRIVATE 参数(不要添加 MAP_ANONYMOUS 标志),然后将 ARG4 填充为打开文件的描述符,那么就可以分配私有的文件映射内存,此时如果打开的文件是 HugeTLBFS 文件,那么这里映射的则是私有 HugeTLB 大页. 当分配了私有文件映射内存之后,不同的操作会导致不同的内存行为,那么可能会出现以下几种:
- 独占读写(首次): 如果文件映射内存只有当前进程在使用,那么当用户进程对匿名内存发起读写操作,那么因为没有映射物理内存而触发缺页,缺页异常处理函数会分配新的物理页作为 PAGECACHE,然后从文件里读取数据读到 PAGECACHE,然后进程在新的 PAGECACHE 上进行读写操作.
- 非独占写(首次): 当 PAGECACHE 被多个进程共同映射时,如果其中一个进程对文件映射内存发起写操作,那么会因为权限异常导致缺页,缺页异常处理函数会新分配一个物理页,然后将原先 PAGECACHE 内容拷贝到新的物理页上,然后将进程的虚拟内存映射到新物理页上,该物理页也是匿名页,这样进行的写操作在新的匿名页上,那么其他进程还是读到原先的数据,而该进程可以读写私有的数据.
综合来看,MAP_PRIVATE 标识的核心作用是保证数据的私有不被其他进程共享. 另外 MAP_PRIVATE 标志只有在多进程共同映射一个 PAGECACHE 的场景才能最大限度发挥作用,另外对于 PAGECACHE,其也需要一定的逆向映射知道其被哪些进程共同映射,这样在处理类似 SWAP、MIGRATE 操作时才能更好的处理负责的映射问题.
MAP_PRIVATE(文件映射) 创建
当用户进程调用 SYS_MMAP 系统调用分配文件映射内存时,其使用 MAP_PRIVATE 标志,SYS_MMAP 系统调用在处理这个场景时的代码流程如上图,其中 TRANS VMA FLAGS 处调用 calc_vm_prot_bits 函数将 MAP_PRIVATE 标志转换成 VMA FLAGS,然后在 FLAGS CHECK 处检查文件打开的权限是否与 SYS_MMAP 提供的权限一致,接着在ACCONT ANON 处调用 accountable_mapping 函数识别处私有写映射,则进行相应的数据统计,最后在 SETUP FS-SPECIAL 处会根据文件来自的文件系统提供的 MMAP 处理,此处需要结合对应的文件系统,处理完毕之后在 ADD-RMAP 处添加逆向映射信息, 接下来查看具体的代码细节:
在 do_mmap 函数的 FLAGS CHECK 处存在如上逻辑,其判断映射是文件映射情况下,并在 1488 行检查到用户进程传入了 MAP_PRIVATE 标志之后,进入 1489 行分支,内核在 1489-1500 行检测 SYS_MMAP 映射的权限是否满足文件打开时设置的权限,如果不满足则直接返回错误, 因为不能存在文件以只读打开,而 SYS_MMAP 映射可读可写的文件映射内存.
在 mmap_region 函数的 ACCONT ANON 处内核调用 accountable_mapping 函数检查虚拟区域是否支持私有写,如果是则在 1715 行将虚拟内存长度右移 PAGE_SHIFT 之后存储到 charged 里,然后在 1716 行调用 security_vm_enough_memory_mm 函数检查内核是否有足够的内存分配给匿名内存,如果没有则返回 ENOMEM,这是因为采用 SYS_MMAP 惰性分配的文件映射内存只管分配足够的虚拟内存,其并不负责分配物理内存的分配,因此会出现物理内存不足的情况。检查完毕之后向 VMA FLAGS 添加 VM_ACCOUNT 标志,以此让内核对该虚拟内存进行内存消耗统计.
这里可以看一下 accountable_mapping 函数的实现逻辑,对于文件映射内存那么 file 为不为空,但由于不是 HugeTLBFS 文件映射,那么条件不满足,那么文件私有映射此时 vm_flags 里并不包含 VM_SHARED 标志,另外由于用户进程在创建文件映射内存是使用了 PROT_WRITE,因此此时包含 VM_WRITE 标志, 因此最后 vm_flags 里只包含了 VM_WRITE,可以私有写内存就是这么识别出来的,至于 VM_NORESERVE 则是不希望内核去检查是否有充足内存,由用户进程调用 SYS_MMAP 时的 MAP_NORESERVE 标志进行设置.
在 mmap_region 函数的 SETUP FS-SPECIAL 处调用特定文件系统提供的 mmap 接口对文件映射的虚拟内存进行处理,这里开发者可以根据文件所在的文件系统进行详细分析,另外文件系统处理完毕之后,SYS_MMAP 系统调用在对文件系统处理的结果进行检查,这里也包括 VMA 的合并. 以上便是 MAP_PRIVATE 文件映射虚拟内存在 SYS_MMAP 系统调用里的处理逻辑. 对于文件映射内存,首先通过 VMA 的 VMA_FILE 成员可以判断其属于文件映射,然后根据 VMA FLAGS 是否包含 VM_SHARED 判断为私有映射还是共享映射,这里 MAP_PRIVATE 的文件映射是不包括 VM_SHARED 标志的.
MAP_PRIVATE(文件映射) 缺页
当用户进程首次访问 MAP_PRIVATE 的文件映射内存时(此时内核可以识别出虚拟内存是文件私有映射内存),由于其并未映射具体的物理内存,那么会触发缺页异常,在缺页异常处理函数里,私有文件映射内存的处理逻辑如上图,在 DETECT FILE-MAPPING 处识别处文件映射虚拟内存缺页,然后在 DETECT PRIVATE 识别出私有文件映射虚拟内存,那么调用 do_cow_fault 函数进行缺页处理. 在 PREPARE AV 处准备 AV,这是因为私有映射需要数据独立,因此会在 ALLOC NEW-PAGE 处分配一个新的物理页,然后在 ALLOC PAGECACHE 处分配一个 PAGECACHE,然后将文件内容加载到 PAGECACHE 里,那么此时缺页是写异常,所以不能将数据写入到 PAGECACHE 里,只能写入到新分配的物理页里,此时在 COPY PAGECACHE 处将 PAGECAHCE 内容拷贝到新的物理页里,然后在 ADD RMAP-ANON 处将建立页表将虚拟内存更新映射到新的物理页上,此时物理页不映射文件,所有将物理页标记为匿名页,并建立了匿名页的逆向映射,这时知道 AV 的作用了吧. 缺页异常处理完毕之后,进程可以继续执行写操作,那么此时将数据写入到匿名页里,并且接下来读取的数据也是从文件里拷贝出来的数据,从进程角度来看,这块虚拟内存确实不会被其他进程写入,同时也可以读到文件里的内容,唯一的缺陷就是当进程释放该虚拟内存之后,再次映射文件时看不到之前被写入的数据,正式由于这样的特性,共享库有了更多的应用场景. 接下来详细看一下代码逻辑:
在 handle_pte_fault 函数的 DETECT FILE-MAPPING 处,函数在 4913 处检查最后一级页表是否存在,对于首次访问那么最后一级页表一定不存在,然后在 4914 行调用 vma_is_anonymous 函数识别到不是匿名映射,那么就可以先识别处文件映射虚拟内存发生缺页, 于是进入 do_fault 函数进行处理缺页.
在 do_fault 函数的 DETECT PRIVATE 处,函数在 4652 行检查 VMF FLAGS 里是否包含 FAULT_FLAG_WRITE, 该标志只要缺页时发生写操作就会包含, 那么 4652 行逻辑不符合要求,那么在 4654 行检测 VMA FLAGS 是否不包含 VM_SHARED 标志,通过 SYS_MMAP 系统调用的分析,可以知道对应私有映射,VMA 不会包含 VM_SHARED 标志,因此进入 4655 行分支调用 do_cow_fault 函数处理缺页。到这里缺页异常处理函数已经识别出私有文件映射内存.
在识别出私有文件映射之后,并结合 COW 机制的原理,那么理解上图代码逻辑就比较简单,函数首先在 543 行调用 alloc_page_vma 函数分配一个新的物理页,然后在 554 行调用 __do_fault 函数分配一个 PAGECACHE,然后从磁盘里将文件数据读取到 PAGECACHE 里,接着在 560 行调用 copy_user_highpage 函数将 PAGECACHE 的内容拷贝到新分配的物理页上,最后在 563 行调用 finish_fault 函数建立页表将虚拟内存映射到新分配的物理页上.
由于新分配的物理页没有关联文件,因此需要将物理页标记为匿名页,以及建立匿名页的逆向映射. 在缺页流程的 ADD RMAP-ANON 处调用 do_set_pte 函数,其在 4311 行判断出缺页原因包含 FAULT_FLAG_WRITE,那么 write 变量为真,其次 VMA FLAGS 不包含 VM_SHARED, 那么识别是私有文件映射出来,此时在 4313 行调用 page_add_new_anon_rmap 函数将新分配的物理页标记为匿名页,由于此时只有一个进程映射匿名页,因此也将匿名页标记为独享匿名页,最后就是建立逆向映射. 以上便是私有文件映射写操作引起的缺页过程,该过程发生了 COW 操作,导致文件映射虚拟内存映射了匿名页,但 VMA 却没有被标记为匿名内存. 除了这个场景内核对私有文件映射的场景是 FORK 多进程共同映射 PAGECACHE 的场景:
MAP_PRIVATE(文件映射) FORK
对于文件映射,其与匿名映射不同的点是其不止可以通过正向映射知道虚拟内存与物理内存的映射关系,其还可以通过 STRUCT address_space 知道虚拟内存与物理内存的正向映射关系,以及物理内存与虚拟内存的逆向映射关系. 那么对于私有文件映射的 FORK 场景就变得没有匿名映射那么复杂,对于私有文件映射其在 FORK 时都不需要复制父进程的页表和 PAGECACHE 里的内容,子进程只需真正写私有文件映射时,通过 STRUCT address_space 找到虚拟内存和 PAGECACHE 的映射关系即可,接下来就是 COW 操作.
上图是私有文件映射发生 FORK 系统调用的代码逻辑,对于私有文件映射,其在 CREATE CHILD-VMA 处调用 vm_area_dup 函数创建子进程的 VMA,然后在 ADD FILE-RMAP 处调用 vma_interval_tree_insert_after 函数将子进程 VMA 添加到逆向映射区间树里,以此让 PAGECACHE 知道哪些 VMA 映射了它。接着在 SKIP COPY 处 FORK 系统调用直接跳过了私有文件映射的页表拷贝,以上便是私有文件映射的 FORK 处理逻辑,那么接下来看代码细节:
在 dup_mmap 函数的 ADD FILE-RMAP 处,函数在 667 其判断出虚拟内存是文件映射之后,进入 668 分支从 STRUCT file 的 f_mapping 里获得 STRUCT address_space, 然后在 676 行调用 vma_interval_tree_insert_after 函数将子进程的 VMA 添加到逆向映射区间树里.
在 copy_page_range 函数的 SKIP COPY 处调用 vma_needs_copy 函数识别虚拟内存是否需要拷贝页表,只要不符合上面三个条件就不需要拷贝页表,对于私有文件映射内存,其不满足上面三个条件,因此不需要拷贝页表。但指的注意的是,如果父进程的私有文件映射已经被写入过,其映射的物理内存不再是 PAGECACHE,而是匿名页,那么 1259 行的条件就满足,那么此时子进程就要拷贝页表. 通过上面的分析之后,FORK 系统调用执行完毕之后,子进程的私有文件映射并没有与具体的物理页进行映射,那么此时子进程对私有文件读操作会读到 PAGECACHE 的内容,如果写操作则发生前面所说的私有文件映射写操作缺页. 为了更好的让开发者深入了解上述所讲的内容,那么接下来通过一个实践案例配合内存流动工具进行讲解, 实践案例在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS
make menuconfig
[*] Package --->
[*] Memory Mapping Mechanism --->
[*] Memory Mapping: FLAGS MAP_PRIVATE on FILE --->
# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-MEMORY-MMAP-MAP-PRIVATE-FILE-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
BiscuitOS-MEMORY-MMAP-MAP-PRIVATE-FILE-default Source Code on Gitee
实践案例由一个应用程序构成,程序在 25 行调用 open 函数打开文件 “/mnt/BiscuitOS.txt”, 然后在 30 行调用 mmap 函数分配一块虚拟内存映射文件,并在 32 行使用 MAP_PRIVATE 标志,那么分配的虚拟内存是私有文件映射内存,接着函数在 40 行对虚拟内存进行读操作,此时会触发缺页分配一块物理内存,将磁盘文件的内容拷贝到物理内存上,那么进程可以从物理内存里读到文件的数据. 函数接着在 44 行使用 fork 系统调用,那么此时新创建的子进程会与父进程共同映射物理内存. 接着子进程在 48 行对私有文件映射的虚拟内存进行写操作,那么会触发缺页,缺页异常处理会新分配一个物理页,然后将原先文件的内容拷贝到新的物理页,并且此时新的物理页并没有关联文件,因此新的物理页变成了匿名页,那么接下来子进程的读写操作都在匿名页上进行. 此时父进程则先睡眠 1s,之后先从私有文件映射的虚拟内存里读取数据,以此确保数据与子进程是独立的,然后再次对虚拟内存执行写操作,此时的内核行为与子进程一样,通过以上的处理原始文件的内容并没有被改动,父子进程的读写操作都在各自的匿名页上进行,这样很好的保护的文件数据的干净. 以上便是完整的实践过程,开发者可以使用内存流动工具查看内存在 COW 机制里的流动,在 48 行前后加上 BS_DEBUG 开关:
接着在私有文件映射内存 COW 缺页流程必经之路上任意位置加上 BS_DEBUG 函数,以此观察内存在某个函数里的流动,例如上图在 do_cow_fault 函数的 4540 行加上 bs_debug 打印,以此确认内存是向哪个函数流动,接下来执行如下命令进行实践:
# 编译应用程序
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-MEMORY-MMAP-MAP-PRIVATE-FILE-default/
# 编译内核
make kernel
# 运行实践案例
make build
当 BiscuitOS 运行之后,直接运行 RunBiscuitOS.sh 脚本可以看到运行实践案例,可以看到进程先从文件映射内存里读到数据 ‘H’, 然后子进程写入 ‘D’ 之后读取到该字符,接着父进程从文件映射里读到的字符还是 ‘H’, 那么说明此时子进程的写操作不会影响父进程数据的独立性,最后父进程读到了刚写入的字符 ‘E’. 另外看到内存流动工具打印了子进程发生缺页时确实执行了之前分析的 do_cow_fault 逻辑. 以上便是 MAP_PRIVATE 标志在文件映射内存里的使用逻辑,更多的使用场景开发者可以基于上面的分析进行发散.