在 Linux 内存管理中,MAP_ANONYMOUS 是传递给 mmap 系统调用的一个标志,它指示创建的内存映射不是基于任何文件的, 但不代表其创建的内存不与文件关联。使用 MAP_ANONYMOUS 标志的内存映射具有以下特点和用途:
- 匿名映射: 与通常通过 mmap 关联特定文件以映射文件内容到内存的方式不同,MAP_ANONYMOUS 创建的是匿名映射, 这意味着映射的内存区域不与任何文件系统中的文件直接关联
- 内存分配: MAP_ANONYMOUS 常用于分配一块新的内存区域,类似于其他语言中的堆分配。这种方式分配的内存通常用于进程内部的数据存储或进程间通信
- 初始化为零: 使用 MAP_ANONYMOUS 标志分配的内存默认被初始化为零,这提供了一块干净的内存空间,保证了安全性和一致性
- 不需要文件描述符: 由于不基于文件映射,调用 mmap 时,通常将文件描述符参数设置为 -1,并使用 MAP_ANONYMOUS 标志
- 进程间通信(IPC): 虽然 MAP_ANONYMOUS 本身并不隐含共享行为,但它经常与 MAP_SHARED 标志结合使用,特别是在父进程和子进程间共享内存时。这种组合提供了一种无需文件支持的 IPC 方式
- 快速且灵活: 对于需要快速分配和释放大量内存的应用程序,使用 MAP_ANONYMOUS 通常比传统的文件映射更为高效和灵活
使用 MAP_ANONYMOUS 标志表示采用匿名映射方式,但这里的匿名映射并不等同于匿名内存,这还要看其与其他 MMAP FLAGS 的配合情况: 如果与 MAP_PRIVATE 标志配合,那么表示分配的内存不与任何文件关联, 也就是匿名内存; 如果与 MAP_SHARED 标志配合,只表示内存的创建基于文件,但内核还是会为虚拟内存创建伪文件并进行关联.
MAP_ANONYMOUS 标志是虚拟内存可以不基于文件进行创建,但最终是否与文件关联取决于 MAP_PRIVATE 和 MAP_SHARED 标志,两个标志将逆向映射分成了两大类,最终会分成两类内存: 匿名内存和匿名文件映射内存。匿名内存是采用匿名映射,不与文件关联并映射到匿名页的内存; 匿名文件映射内存是创建是不基于文件,但最终还是与文件关联的内存,常见的内存包括匿名映射共享内存、匿名映射 HugeTLB 大页等. 两种类型匿名映射内存都可以使用 SYS_MMAP 系统调用进行分配,对于匿名内存,其 ARG3 必须包含 MAP_PRIVATE 和 MAP_ANONYMOUS 标志,而对于匿名文件映射内存,其 ARG3 必须使用 MAP_SHARED 和 MAP_ANONYMOUS 标志. 两种内存的 ARG4 必须设置为 -1. 接下来分别从匿名内存和匿名文件映射内存角度来分析 MAP_ANONYMOUS 对内存行为的影响.
MAP_ANONYMOUS 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_ANONYMOUS(匿名内存) 创建
当用户进程调用 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_ANONYMOUS(匿名内存) 缺页
当用户进程首次访问匿名内存时(此时 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_ANONYMOUS(匿名内存) 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_ANONYMOUS(匿名内存) 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_ANONYMOUS 和匿名映射的内容通过实践跟踪出来,加深对相关知识的学习理解.
MAP_ANONYMOUS with 匿名文件映射
匿名文件映射是不基于文件创建,但最终与文件关联的文件映射内存,这么说会有很大的疑惑,但我门拉长创建的时间线,其在调用 SYS_MMAP 系统调用时,不需要提供映射的文件,只需提供 MAP_SHARED 和 MAP_ANONYMOUS 标志即可,那么内核会为虚拟内存创建文件并与之关联,该类型内存在内核里的逻辑架构与文件映射的内存无异,因此也可以将其当做文件映射内存来看待. 接下来从 MAP_ANONYMOUS 的视角来看该类型内存的内存行为:
MAP_SHARED 和 MAP_ANONYMOUS 标志配合使用可以分配共享内存,并且内核为其分配一个伪文件,那么这段虚拟内存在内核里的数据架构就和文件映射的一致,那么回到之前我为什么说使用 MAP_ANONYMOUS 匿名映射的虚拟内存只有配合 MAP_PRIVATE 标志时才是真正的匿名映射,其余的虚拟内存都是文件映射内存.
当使用 SYS_MMAP 系统调用建立匿名共享内存时,那么可以在 SYS_MMAP 的 ARG3 给定 MAP_SHARED 和 MAP_ANONYMOUS 参数,那么可以分配共享内存. 当分配了共享内存之后,不同的操作会导致不同的内存行为出现,那么可能会出现以下几种:
- 首次读/写操作: 当分配共享内存之后,由于只有分配了虚拟内存部分,那么用户进程对虚拟内存首次发起读或者写操作时,MMU 会因为没有映射物理页而触发缺页,缺页异常处理会为共享内存建立文件映射架构,并分配 SHM 里分配物理内存,最后建立页表使虚拟内存映射到新分配的物理内存上,那么进程可以正常读写共享内存.
- FORK 多进程共享: 当进程分配一段匿名的共享内存之后,其使用 FORK 创建子进程之后,子进程会与父进程共享这段内存,那么任一进程的写操作都会被其他共享的进程看到.
综合来看,MAP_SHARED 标识的核心作用是保证数据在多个进程之间共享. 另外 MAP_SHARED 标志只有在多进程共享的场景才能最大限度发挥作用,另外对于共享内存,其也需要一定的逆向映射知道其被哪些进程共享,这样在处理类似 SWAP、MIGRATE 操作时才能更好的处理复杂的映射问题.
MAP_ANONYMOUS(匿名文件映射内存) 创建
当用户进程调用 SYS_MMAP 系统调用分配共享内存时,其使用 MAP_SHARED 和 MAP_ANONYMOUS 两个标志,SYS_MMAP 系统调用在处理这个场景时的代码流程如上图,其中 VM-SHARED FLAGS 处根据 MAP_SHARED 标志为 VMA 添加 VM_SHARED 和 VM_MAYSHARE 标志,然后在 ALLOC PSEUDO-FILE 处根据 MAP_SHARED 标志为虚拟内存分配并关联伪文件, 最后在 REVERSE-TREE 处建立共享内存的逆向映射. SYS_MMAP 系统调用对 MAP_SHARED 调用并没有太多特殊处理, 接下来查看具体的代码细节:
在 do_mmap 函数的 VM-SHARED FLAGS 处存在如上逻辑,其判断映射不是文件映射情况下,并在 1508 行检查到用户进程传入了 MAP_SHARED 标志之后,进入 1509 行分支,此时如果检查到 VMA FLAGS 里包含了 VM_GROWSDOWN 或者 VM_GROWSUP 标志,那么说明共享内存不允许具有堆栈向上或者向下的属性,否则直接返回 EINVAL. 函数在 1514 行将 pgoff 设置为 0,因此推测 vm_pgoff 标志在共享内存里并不重要. 函数在 1515 行向 VMA FLAGS 里添加了 VM_SHARED 和 VM_MAYSHARE 标志.
在 mmap_region 函数的 ALLOC PSEUDO-FILE 处识别到 VMA 没有关联文件,但 VMA 包含 VM_SHARED 标志,那么此时识别出虚拟内存为共享内存,因此调用 shmem_zero_setup 函数为其创建一个伪文件. 函数首先在 4229 行计算出文件映射虚拟内存大大小,接着调用 shmem_kernel_file_setup 函数从 TMPFS 文件系统里创建一个名为 “dev/zero” 的伪文件,这里值得注意是文件名不是 “/dev/zero”, 以免混淆. 文件创建完毕与 VMA 进行关联,然后将 VMA 的 vm_ops 设置为 shmem_vm_ops, 里面重点提供了 fault 的实现,即虚拟内存发生缺页时,缺页异常处理函数最终会调用 fault 对应的回调函数.
在 mmap_region 函数里创建完 VMA 之后,接下来将 VMA 加入到进程的地址空间区间树和双向链表里,以及在 REVERSE-TREE 处调用 __vma_link_file 函数设置共享内存的逆向映射,函数在 633 行调用 vma_interval_tree_insert 函数将 VMA 添加到 vm_file 对应的 ADDRESS_SPACE 逆向映射区间树. 以上便是 SYS_MMAP 对标记为 MAP_SHARED 的逆向映射虚拟内存的处理,可以看到 SYS_MMAP 系统调用将其识别为共享内存,并以文件映射的方式架构该虚拟内存在内核里的逻辑.
MAP_ANONYMOUS(匿名文件映射内存) 缺页
当用户进程首次访问匿名共享内存时(此时 SYS_MMAP 系统调用已经将匿名共享内存识别为共享内存),由于其并未映射具体的物理内存,那么会触发缺页异常,在缺页异常处理函数里,匿名共享内存的处理逻辑如上图,在 DETECT ANON-MEMORY 处调用 vma_is_anonymous 函数识别出缺页的虚拟内存不是匿名内存,然后进入 do_fault 进行处理,此时通过 VM_SHARED 标志识别出是匿名共享内存,那么进入 do_shared_fault 函数进行处理. 此时虚拟内存类型已经识别出来,那么内核接下来只需完成两个事情,即分配物理内存和建立页表,于是缺页异常处理函数在 ALLOC-PAGE 处调用 shmem_fault 函数从系统共享内存池子里分配共享内存,然后在 SET-PAGETABLE 处调用 finish_fault 函数完成页表的填充构建和映射操作,那么处理完毕之后,进程可以正常读写共享内存.
在 do_fault 函数时已经识别处虚拟内存不是匿名内存,那么函数在 4652 行通过 VMF flags 里包含了 FAULT_FLAG_WRITE,因此此时是因为写发生的缺页,因此这里条件不满足,那么在 4654 行由于 VMA 在 SYS_MMAP 系统调用里已经包含了 VM_SHARED 标志,因此该条件也不满足,最后匿名共享内存进入 do_shared_fault 行分支进行处理.
在 __do_fault 函数里,函数的主要作用是分配物理内存,那么函数在 4173 行调用虚拟内存关联文件提供的 fault 回调函数,在 SYS_MMAP 系统调用里,内核为匿名共享内存提供了 shmem_vm_ops VMOPS,其中 fault 回调对应的是 shmem_fault,该函数从系统共享内存池子里分配一个物理页,并将 vmf->page 指向新分配的物理页.
当分配好物理页之后,最后的任务就是建立页表映射,在 SET-PAGETABLE 处调用 do_set_pte 函数建立页表,函数在 4311-4318 行对 MAP_SHARED 标志的内存进行区别处理,在 4311 行检测到 write 为真且 VMA FLAGS 里不包含 VM_SHARED, 那么说明是私有映射的情况,这里显然不满足,因为 VMA FLAGS 里早已经包含了 VM_SHARED, 其来自 MAP_SHARED, 因此函数进入 4316 行分支,此时调用 page_add_file_rmap 函数建立文件映射的逆向映射,因此可以再次证明 MAP_SHARED 和 MAP_ANONYMOUS 组合请求最终分配的是文件映射的匿名映射共享内存,这句话是不是很绕口,但可以知道 MAP_SHARED 标志分配的内存都是文件映射的,并且可以在多个进程之间共享,那么接下来继续分析如何实现多进程之间的共享.
MAP_ANONYMOUS(匿名文件映射内存) FORK
SYS_FORK 系统调用是最能体现 MAP_SHARED 的重要性,承接上面的分析,MAP_SHARED 和 MAP_ANONYMOUS 分配的虚拟内存被设置为匿名共享内存,既然是共享内存那么就可以在多个进程之间共享. 由于此时共享内存是匿名映射的,无法直接通过常用的进程间通行手段进行共享,但可以使用 FORK 系统调用实现父子进程之间的共享,FORK 系统调用在创建子进程时,会将父进程的虚拟内存拷贝到子进程地址空间里,对于父进程的共享内存,那么父子进程可以共同映射,实现数据的共享.
SYS_FORK 的实现逻辑如上图,其在 CREATE CHILD-VMA 处创建子进程的 VMA,然后在 ADD FILE-RMAP 处识别出需要复制的父进程 VMA 是共享内存,那么其属于文件映射的一种,因此在 FORK 的时候需要将子进程的 VMA 添加到文件对应的逆向映射区间树里,这样才好管理文件映射物理页的逆向映射关系. 由于文件映射有自己的管理逻辑,其不可以在页表映射之外维护一套正向映射的关系,因此子进程的 VMA 无需复制父进程的页表以及虚拟内存的内容,这样可以大大加快 FORK 的速度,然后子进程在真正访问该共享内存时才真正建立页表映射, 映射到父进程的共享内存上,实现父子进程之间的数据共享. 回到本小节的重点,这里将研究 MAP_PRIVATE 对 SYS_FORK 的影响,具体如下:
在 dup_mmap 函数里,函数在 661 行获得 VMA 关联的文件,此时对于匿名映射的共享内存来说文件是存在的,因此进入 663 行分支进行处理,并且在 671 行调用 vma_interval_tree_insert_after 函数将子进程的 VMA 添加到文件关联的逆向映射区间树里. 做了以上操作之后,FORK 系统调用没有做更多的操作,连页表都没有复制,那么对于子进程的共享内存来说,其虚拟内存的页表是空的,因此只能通过子进程访问共享内存引发缺页实现,然后缺页异常处理函数会根据文件映射找到父进程的共享内存,缺页部分的逻辑和前面分析的是一致的,只是少了分配物理页的环节,其余均一致. 处理完毕之后,父子进程将页表都映射到同一个物理页上了,那么接下来就是父子进程之间的数据共享。为了更好的说明上述过程,接下来将通过一个实践案例分析介绍匿名映射共享内存的使用,以及与其他进程之间共享内存的过程, 实践案例在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS
make menuconfig
[*] Package --->
[*] Memory Mapping Mechanism --->
[*] Memory Mapping: FLAGS MAP_SHARED --->
# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-MEMORY-MMAP-MAP-SHARED-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
BiscuitOS-MEMORY-MMAP-MAP-SHARED-default Source Code on Gitee
实践案例由一个应用程序构成,程序在 22 行调用 mmap 函数分配一块虚拟内存,并在 24 行使用 MAP_SHARED 和 MAP_ANONYMOUS 标志,那么分配的虚拟内存是匿名的共享内存,然后在 31 行对匿名共享内存首次发起写操作,此时会触发缺页将虚拟内存映射到新的物理页上,那么进程的写操作在数据写入到共享内存上. 函数接着在 35 行使用 fork 系统调用,那么此时父子进程会共享共享内存,接着在 39 行子进程首先对共享内存发起写操作,此时由于会触发缺页异常,缺页异常处理函数会通过虚拟内存关联的文件找到父进程的共享内存,并将页表也映射到共享内存上,那么父子进程之间可以共享数据,因此 39 行的写入到父进程的共享内存里. 父进程在睡眠 1s 之后在 46 行对共享内存发起读操作,此时读到的数据是子进程刚刚写入的数据,那么可以证明父子进程之间实现了数据共享. 以上便是完整的实践过程,开发者可以使用内存流动工具查看整个流程,流入查看子进程虚拟内存建立页表的过程,在 39 >行前后加上 BS_DEBUG 开关:
接着在匿名共享内存缺页流程必经之路上任意位置加上 BS_DEBUG 函数,以此观察内存在某个函数里的流动,例如上图在 do_shared_fault 函数的 4579 行加上 bs_debug 打印,以此确认内存是向哪个函数流动,接下来执行如下命令进行实践:
# 编译应用程序
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-MEMORY-MMAP-MAP-SHARED-default/
# 编译内核
make kernel
# 运行实践案例
make build
当 BiscuitOS 运行之后,直接运行 RunBiscuitOS.sh 脚本可以看到运行实践案例,可以先打印匿名共享内存刚写入的字符 ‘B’, 当子进程写入 ‘D’ 之后,父进程读到 ‘D’ 字符,那么证明了父子进程之间实现了数据共享. 以上便是 MAP_ANONYMOUS 对内存行为的影响,其还在很多方面有影响,开发者可以更加之前的分析方法在更多的场景加深对 MAP_ANONYMOUS 标志的理解和学习.