目录
匿名内存(Anonymous) With PageFault
在 Linux 用户空间,虚拟内存可以通过两种映射方式与物理内存建立映射关系,第一种是 File-Mapping 文件映射,其可以将文件内容映射到用户空间,虚拟内存和磁盘文件中间通过 Page CACHE 进行数据中转,因此可以像普通虚拟内存一样访问文件; 另外一种是 Anonymous-Mapping 匿名映射, 用于将用户空间虚拟内存映射到物理内存上,以满足进程对内存的需求,例如堆(Heap)内存、堆栈(Stack)内存、以及 MMAP 内存等。两种映射方式的不同点在于是否有后端文件,匿名内存 可以通过 malloc/brk/mmap 进行分配
另外 Linux 还支持共享内存,其可以通过 mmap 函数进行分配,分配时采用分配标志: MAP_SHARED 与 MAP_ANONYMOUS. 而匿名内存同样可以使用 mmap 进行分配,其分配标志是: MAP_PRIVATE 与 MAP_ANONYMOUS。虽然两者都采用了 MAP_ANONYMOUS, 但是两者的处理逻辑是不同的,虽然共享内存没有明确指明后端文件,当内核会为每个共享文件关联一个 tmpfs 文件系统的文件,而匿名内存 Linux 是不会关联任何文件的,因此从这个角度来看,采用 MAP_PRIVATE 与 MAP_ANONYMOUS 的内存才能称为匿名内存.
在匿名内存架构里,VMA 通过页表正向映射到匿名页上,并且每个 VMA 维护一个 STRUCT anon_vma 的数据结构(简称为 AV),该数据结构里存在一颗红黑树,该红黑树的节点为 STRUCT anon_vma_chain(简称为 AVC), 每个 AVC 都指向另外一个 VMA,那么每个 AVC 可以表示映射到该匿名页的 VMA,最后将匿名页的 mapping 指向 AV。有了上面的关系之后,如>果获得一个匿名页之后,想通过逆向映射知道哪些 VMA 映射了该匿名页,那么可以通过匿名页的 mapping 获得 AV,然后遍历 AV 对应的红黑树获得每一个 AVC,在通过 AVC 获得 VMA 即可.
在 Linux 系统里,匿名内存的数量可以通过 ‘/proc/meminfo’ 文件提供的信息获得,其中 AnonPages 字段表示匿名内存的大小; 同理 ‘/proc/vmstat’ 文件也提供了信息,其中 nr_anon_pages 字段表示匿名页的数量.
匿名映射内存发生缺页最常见的场景就是: 当 CPU 访问了未建立页表的匿名内存,MMU 触发缺页异常. 但在 Linux 里还有很多场景也会导致匿名内存发生缺页,总结包括如下场景:
- 可读可写匿名内存场景: 进程访问了一块未与物理内存建立映射的匿名内存,MMU 检查到该场景并触发缺页异常,经过缺页异常处理函数分配物理内存建立页表之后,进程可以正常访问匿名内存.
- 只读匿名内存场景: 进程对一块未与物理内存建立映射的匿名内存执行读操作,MMU 检查到该场景并触发缺页异常,经过缺页异常处理函数将虚拟内存映射到 ZERO Page 之后,进程可以对虚拟内存进行读操作. 但继续对只读虚拟内存执行写操作,MMU 检查该该场景再次触发异常,并因权限检查异常发送 SIG_BUS 导致进程 SegmentFault.
- 写保护匿名内存场景: 匿名内存已经与物理内存建立页表,但匿名内存被设置为写保护(Write-Protection), 当进程对该匿名内存进行写操作,MMU 检查到该场景再次触发缺页异常,缺页异常处理函数会按 WP 处理逻辑给页表添加 _PAGE_RW 写权限,接下来进行可以对该匿名内存进行写操作.
- 写时拷贝场景: 匿名内存已经与物理内存建立页表,父进程可以对匿名内存进行读写操作,父进程调用 fork() 函数创建子进程,fork() 将匿名内存设置为写保护,那么子进程可以对匿名内存读操作,但子/父进程对匿名内存进行写操作,MMU 会检查到没有写权限触发缺页异常,缺页异常处理函数可能会将匿名内存的内容拷贝到一个新的物理页上,然后将页表映射到新的物理页上,并添加 _PAGE_RW 写权限,那么进程可以对匿名内存进行读写操作.
- SWAP 场景: 匿名内存已经与物理内存建立页表,进程可以正常访问匿名内存,但是由于系统内存紧缺,于是系统将部分匿名内存交换到 SWAP Space,那么当进程再次访问匿名内存时,MMU 检查到对应的物理内存不存在,那么触发缺页异常,缺页异常处理函数发现物理内存被交换到 SWAP Space,于是 SWAP In 到新的物理页上,并将页表更新到新的物理页上,那么进程可以正常访问匿名内存.
- 内存压缩场景: 匿名内存已经与物理内存建立页表,进程可以正常访问匿名内存,但是由于系统内存紧缺,于是系统将部分匿名内存进行压缩,那么进程再次访问匿名内存时,MMU 检查到对应的物理内存不存在,那么触发缺页异常。缺页异常处理函数发现物理内存被压缩,于是解压缩到新的物理页上,并将页表更新到新的物理页上,那么进程可以正常访问匿名内存.
- KSM 场景: 匿名内存已经与物理内存建立页表,进程可以正常访问匿名内存,但是由于 KSM 机制,KSM 将匿名内存与另外一块匿名内存进行合并,然后将匿名内存标记为写保护,那么进程可以正常对匿名内存进行写操作,但当进程对匿名内存进行写操作时,MMU 检查到写保护的存在,于是触发缺页异常,缺页异常处理函数将 KSM 页内容拷贝到一个新的物理页上,并将更新页表到新的物理页上,那么进程可以对匿名内存执行写操作.
- NUMA Balancing 场景: 进程分配了跨 NUMA NODE 的匿名内存,进程可以对匿名内存进行读写操作,但 NUMA Balancing 机制会定期轮询进程的内存,将跨 NUMA NODE 的内存重新迁移到本地 NUMA NODE 之后,并将虚拟内存的页表修改为 PROTNONE, 即匿名内存即不可读也不可写。当进程再次访问匿名内存时,MMU 检查到权限异常,那么触发缺页异常。缺页异常发现物理内存发生了 NUMA Balancing,那么将物理页迁移到本地 NUMA NODE,并更新页表,那么进程可以正常对匿名内存进行读写操作.
- 堆场景(Heap): 进程可以从堆上分配内存,堆的特点是紧贴进程的数据段(BSS 段),并向进程地址空间高地址生长,堆内存也是一种匿名内存。当进程从堆上分配的内存并没有与物理内存建立映射,那么进程访问该段堆内存时,MMU 检查到物理内存不存在,那么触发缺页异常,缺页异常处理函数分配新的物理页,并建立页表映射到该物理页上,那么进程可以可以正常访问堆内存.
- 栈场景(Stack): 进程可以使用栈上的内存,该内存是进程创建时分配的,但在特除情况下,堆栈发生了溢出,并且进程访问了溢出的内存,那么 MMU 发现对应的物理内存不存在,并触发缺页异常,异常处理函数检查到栈异常,直接发送 SIG_BUS 导致程序 SegmentFault 退出.
- MCE 场景: 匿名内存已经与物理内存建立页表映射,进程也可以对匿名内存进行读写操作,但由于硬件故障导致物理内存发送了 UE,那么当进程再次访问匿名内存时,硬件检测到 UE 异常直接触发缺页异常,缺页异常处理函数检测到是 UE 故障,那么直接发送 SIG_BUS 导致进程 SegmentFault 退出.
- Protection Key 场景: 匿名内存已经与物理聂村建立页表映射,进程也可以对匿名内存进行读写操作,但进程使用 Protection Key 机制修改匿名内存的权限,使进程没有权限访问匿名内存,那么此时进程访问匿名内存,Protection Key 机制会检查权限异常之后,直接触发缺页异常,缺页异常处理函数检查 Protection Key 权限不足时,直接发送 SIG_BUS 导致进程 SegmentFault 退出.
- THP Migration 场景: 进程分配了大块的匿名内存,进程可以对匿名内存进行访问,此时系统开启了 THP 功能,并且 THP 任务该匿名内存可以合成 THP 大页,那么将匿名内存对应的页表修改,并将对应的零散物理内存迁移到 THP 物理内存中,在迁移过程中,进程再次对匿名内存进行访问,那么系统会终止内存迁移动作,然后做回退动作,重新更新页表,这样进行可以访问匿名内存。
开发者可能在其他地方看到这些术语: 匿名内存、匿名映射、匿名页等,那么它们具体指的是什么。匿名映射: 指的是进程分配一段虚拟内存直接映射到系统物理内存的方式,匿名映射是相对与文件映射而言; 匿名页: 进程通过匿名映射的物理页,每个物理页都用对应的 struct page 数据结构体,那么可以通过 PageAnon(page) 判断物理页是否为匿名页, 另外如果该物理页只被一个 VMA 映射,那么该物理页也称为独占匿名页(Exclusive Anonymous Page), 同理可以使用 PageAnonExclusive(page) 进行区分; 最后一般将进程通过匿名映射的内存称为匿名内存,其既包括虚拟内存部分,也包括物理内存部分, 其对应的 VMA 通过 vma_is_anonymous() 函数进行判断. 匿名内存可以通过 mmap(MAP_PRIVATE | MAP_ANONYMOUS) 方式获得,也可以通过 malloc/brk 从堆上分配,亦或者从堆栈(Stack) 上获得.
当调用 mmap 函数分配匿名内存时,其在 mmap 系统调用处理了逻辑如上图,与文件映射不同的地方是 pgoff 设置为虚拟地址的页号,并且调用 vma_set_anonymous 将 VMA 标记为匿名映射,那么 VMA 对应的 vm_ops 为空,因此可以用该条件判断 VMA 是否采用了匿名映射. 其余操作与文件映射差异不大,经过 mmap 系统调用之后,进程分配到一段虚拟内存,但这段虚拟内存是没有映射物理内存的,那么接下来进程一旦访问这段虚拟内存,那么就会触发缺页异常:
当匿名内存发生缺页时,根据触发场景不同,缺页异常处理函数的流程也会不同,匿名内存能够触发的场景如上图,例如对可读可写匿名读写操作会触发缺页,缺页异常处理函数会走到 ANON-RW-PAGE 分支; 对发生 fork 系统调用的匿名内存进行写操作,会触发缺页,并在缺页流程里完成 COW; 又如对 SWAP OUT 的匿名内存执行读写操作,会触发缺页,缺页流程会走到 SWAP/ZSWAP-PAGE 分支等等. 为了详细分析匿名内存不同场景的缺页,加下来会对每一种场景进行详细分析:
匿名映射可读可写内存缺页场景
可读可写匿名内存 是可以对匿名内存进行读写操作,其可以通过 mmap()/malloc()/brk() 函数进行分配,亦或者从堆栈上分配的匿名内存也是可读可写, 可读可写的匿名内存具有以下特点:
- 当 CPU 首次对可读可写匿名内存写操作: MMU 因发现没有对应的物理内存而触发缺页异常,在缺页异常处理函数里会新分配一个物理页作为匿名页,并更新页表映射到该匿名页上,这样 CPU 可以对匿名内存进行写操作
- 当 CPU 首次对可读可写匿名内存读操作: MMU 同样检查到没有对应的物理内存,那么也会触发缺页异常,在缺页异常处理函数里,如果系统支持 ZERO Page,那么会将页表映射到 Zero Page 上,并将页表修改为写保护,那么 CPU 可以对匿名内存进行读操作,但如果 CPU 接下来对匿名内存发起写操作,MMU 检查权限异常,那么再次触发缺页异常,缺页异常处理函数此时新分配一个物理页作为匿名页,并更新页表映射到该匿名页上,最后将页表修改为可读可写,这样 CPU 就可以对匿名内存进行写操作.
- 当 CPU 首次对可读可写匿名内存读操作: MMU 同样检查到没有对应的物理内存,那么也会触发缺页异常,在缺页异常处理函数里,如果系统不支持 ZERO Page,那么缺页异常处理函数此时新分配一个物理页作为匿名页,并更新页表映射到该匿名页上,这样 CPU 就可以对匿名内存进行读操作,并且后续的写操作不会触发缺页异常.
虽然都是可读可写匿名内存,但访问内存的行为不同,以及 Linux 支持的功能不同,最终导致缺页的行为也是大相径庭。为了更加深刻了解可读可写匿名内存,接下来通过一个实践案例了解这种异常场景,实践案例在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS
make menuconfig
[*] Package --->
[*] Paging Mechanism --->
[*] Page Fault with Anonymous Memory --->
# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
实践案例由一个应用程序构成,进程在 21 行调用 mmap 函数并使用 MAP_PRIVATE 和 MAP_ANONYMOUS 标志分配一段匿名内存,然后在 32 行对匿名内存进行写操作,并在 34 行对匿名内存进行读操作,操作完毕之后就是释放虚拟内存。以上便是一个最基础的实践案例,可以知道 32 行写操作就会触发缺页,为了可以看到内存在缺页异常里的流动,在 32 行前后加上 BS_DEBUG 开关:
接着在匿名内存缺页流程必经之路上任意位置加上 BS_DEBUG 函数,以此观察内存在某个函数里的流动,例如上图在 do_anonymous_page 函数的 4063 行和 4083 行加上 bs_debug 打印,以此确认内存是向哪个函数流动,接下来执行如下命令进行实践:
# 编译应用程序
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-default/
make
make install
make pack
# 编译内核
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-kernel-default/
make build
当 BiscuitOS 启动之后,直接运行 RunBiscuitOS.sh 脚本,脚本里包括实践所需的命令,可以看到进程执行之后对虚拟内存的访问引起了缺页异常,并且该案例的缺页异常处理流程打印了字符串 “Non ZERO Anonymous Memory 0x6000000000”, 那么说明实践案例分配的普通的匿名内存,并非 ZERO Page,同时也可以看到匿名内存按着之前分析的代码路径流动。最后开发者可以在该路径上的任何地方使用 bs_debug 查看匿名内存在缺页异常处理流程里的流动.
对于可读可写的匿名内存,其缺页异常处理流程如上图,如果是首次读操作,那么最终进入映射 “R ZERO Page” 分支; 如果首次是读接下来发生写操作,那么进入 “WP AnonMEM” 分支; 如果首次是写,那么进入 “R/W AnonMem” 分支.
对于首次发起对可读可写匿名内存写操作导致的缺页,do_anonymous_page 函数作为核心处理流程其逻辑如上图,其首先在 4085 行调用 alloc_zeroed_user_highpage_movable 函数从 Buddy 分配器分配一个物理页,然后在 4098 行调用 __SetPageUptodate 函数将物理页标记为物理页的内容是最新的,无需更新。接下来构造 PTE Entry 的内容,首先从 VMA 的 vm_page_prot 成员获得 VMA 设置的页表属性,此时检查到 VMA 可写,那么调用 pte_mkwrite 添加页表的 Write 标志。接下来函数在 4105 行调用 pte_offset_map_lock 获得 PTE Entry,并进行一些检测,函数接着在 4123 行调用 inc_mm_counter_fast 函数增加对 MM_ANONPAGES 的统计,以此表示系统的匿名内存增加的数量,然后调用 page_add_new_anon_rmap 建立物理内存的逆向映射,最后调用 lru_cache_add_inactive_or_unevictable 函数将物理页添加到 LRU 链表,当系统内存紧张的时候会将该物理页交换到 SWAP Space,处理完这些之后,函数在 4127 行调用 set_pte_at 函数更新页表.
对于首次发起对可读可写匿名内存读操作导致的缺页,do_anonymous_page 函数作为核心处理流程其逻辑如上图, 函数首先判断到此时缺页是读操作引起的,并且 mm_forbids_zeropage 返回 0 说明系统支持 ZERO Page,那么缺页异常处理会为读操作映射到 ZERO Page. 所谓 ZERO Page 就是一块特殊的内存,内存的内容全部为 0,并且有 4KiB 粒度和 2MiB 粒度的,只要符合条件的进程都可以将匿名内存映射到 ZERO Page 上,这样可以起到节省内存的作用. 因此函数在 4063 行调用 pte_mkspecial 函数构造了一个指向 ZERO Page 的 PTE Entry 内容,并在 4065 函数调用 pte_offset_map_lock 函数获得 PTE Entry 的地址,如果发现此时 PTE Entry 不为空,那么可能是页表的内容和 TLB 的内容不一致,此时函数进入 4068 行分支调用 update_mmu_tlb 函数更新 TLB,并跳转到 unlock 处; 反之如果此时 PTE Entry 为空,说明是第一次访问匿名内存,符合预期,那么跳转到 setpte 处将准备好的 PTE Entry 内容更新到 PTE Entry 里,待缺页中断返回之后进程可以继续对匿名内存进行读操作而不会引起缺页. 细心的开发者是否发现,整个过程没有设置页表的 _PAGE_RW 标志位,那么说明这段虚拟内存是写保护的,因此接下来只要进程对虚拟内存首次发起写操作,那么还是会触发缺页.
对于首次发起对可读可写匿名内存读操作之后再次发起写操作导致的缺页,do_wp_page 函数作为核心处理流程其逻辑如上图,函数获得匿名内存对于的物理页之后,如果在 3419 行发现匿名页是独占的,也就是此时只有一块虚拟内存映射到该物理页上,那么直接跳转到 reuse 处调用 wp_page_reuse() 函数,该函数的核心作用就是添加 PTE Entry 页表的 _PAGE_RW 标志,以此让匿名内存具有可读可写的权限. 其余逻辑均不再此种场景考虑的范围.
匿名映射只读(Read-Only)内存缺页场景
只读匿名内存是只读对匿名内存进行读操作,其可以通过 mmap() 函数进行分配,其只包含 PROT_READ 权限即可,malloc/brk 函数不可分配只读匿名内存. 只读匿名内存在缺页时存在如下几种场景:
- 当 CPU 首次对只读匿名内存读操作: MMU 因为发现没有对应的物理内存,于是触发缺页异常. 在支持 ZERO Page 的系统,缺页异常处理函数会将页表更新映射到 ZERO Page 上,那么 CPU 可以继续对匿名内存进行读操作,读出来的值全为 0.
- 当 CPU 首次对只读匿名内存读操作: MMU 因为发现没有对应的物理内存,于是触发缺页异常. 在不支持 ZERO Page 的系统,缺页异常处理函数会将页表更新映射到一个新的物理页上,并且页表不包含 _PAGE_RW 标志,那么 CPU 可以继续对匿名内存进行读操作,读出来的值全为 0.
- 当 CPU 对只读匿名内存写操作: MMU 检查到权限异常,于是触发缺页异常,缺页异常处理函数检查到没有写权限,于是发送 SIG_BUS,因此程序会 SegmentFault 异常退出.
虽然都是只读内存,但访问内存的行为不同,以及 Linux 支持的功能不同,最终导致缺页的行为也是大相径庭。为了更加深刻了解只读匿名内存缺页,接下来通过一个实践案例了解该场景,实践案例在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS
make menuconfig
[*] Package --->
[*] Paging Mechanism --->
[*] Page Fault with Anonymous on RO(Read-Only Zero Page) --->
# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-RO-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
实践案例由一个应用程序构成,进程在 21 行调用 mmap 函数并使用 MAP_PRIVATE 和 MAP_ANONYMOUS 标志分配一段匿名内存, 并且只赋予 PROT_READ 属性,然后在 32 行对匿名内存进行读操作,并在 34 行对匿名内存进行写操作,接着为了构造另外一个场景,程序在 38 行对只读匿名内存发起写操作,以此观察程序运行情况,操作完毕之后就是释放虚拟内存。以上便是一个最基础的实践案例,可以知道 32 行读操作和 38 行写操作都会触发缺页,为了可以看到内存在缺页异常里的流动,在 32 行前后加上 BS_DEBUG 开关:
接着在匿名内存缺页流程必经之路上任意位置加上 BS_DEBUG 函数,以此观察内存在某个函数里的流动,例如上图在 do_anonymous_page 函数的 4063 行加上 bs_debug 打印,以此确认内存是向哪个函数流动,接下来执行如下命令进行实践:
# 编译应用程序
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-RO-default/
# 编译内核
make kernel
# 编程程序
make build
当 BiscuitOS 启动之后,直接运行 RunBiscuitOS.sh 脚本,脚本里包括实践所需的命令,可以看到进程执行之后对虚拟内存的访问引起了缺页异常,并且该案例的缺页异常处理流程打印了字符串 “RO-Anonymous Memory do_anonymous_page 0x6000000000”, 那么说明实践案例分配的 ZERO Page 的只读匿名内存,并非普通内存,另外也可以看到进程对只读匿名内存发起写操作,引起了 SegmentFault 导致程序异常退出,通过实践可以看到只读匿名内存按着之前分析的代码路径流动。最后开发者可以在该路径上的任何地方使用 bs_debug 查看匿名内存在缺页异常处理流程里的流动.
对于只读匿名内存,其缺页异常处理流程如上图,如果是读操作,并且支持 ZERO Page,那么最终进入 “R ZERO Page” 分支; 如果是对只读匿名内存进行写操作,那么进入 “SIG_BUS” 分支(上图不包括不支持 ZERO Page 场景).
对于首次发起对只读匿名内存读操作导致的缺页,do_anonymous_page 函数作为核心处理流程其逻辑如上图, 函数首先判断到此时缺页是读操作引起的,并且 mm_forbids_zeropage 返回 0 说明系统支持 ZERO Page,那么缺页异常处理会为读操作映射到 ZERO Page. 所谓 ZERO Page 就是一块特殊的内存,内存的内容全部为 0,并且有 4KiB 粒度和 2MiB 粒度的,只要符合条件的进程都可以将匿名内存映射到 ZERO Page 上,这样可以起到节省内存的作用. 因此函数在 4063 行调用 pte_mkspecial 函数构造了一个指向 ZERO Page 的 PTE Entry 内容,并在 4065 函数调用 pte_offset_map_lock 函数获得 PTE Entry 的地址,如果发现此时 PTE Entry 不为空,那么可能是页表的内容和 TLB 的内容不一致,此时函数进入 4068 行分支调用 update_mmu_tlb 函数更新 TLB,并跳转到 unlock 处; 反之如果此时 PTE Entry 为空,说明是第一次访问匿名内存,符合预期,那么跳转到 setpte 处将准备好的 PTE Entry 内容更新到 PTE Entry 里,待缺页中断返回之后进程可以继续对匿名内存进行读操作而不会引起缺页. 细心的开发者是否发现,整个过程没有设置页表的 _PAGE_RW 标志位,那么说明这段虚拟内存是写保护的,因此接下来只要进程对虚拟内存首次发起写操作,那么还是会触发缺页.
对于只读匿名内存发起写操作,如果是首次对只读匿名内存发起访问,那么此时 MMU 会检查到物理内存不存在,并触发缺页异常,缺页异常处理流程在 do_user_fault() 函数里调用 access_error() 函数检查进程对 VMA 的权限,其中 1110 行可以看到缺页异常的原因包含 PF_WRITE,也就是发生缺页时包括写动作,这里需要说明一下缺页异常不一定是有写操作引起,如果是首次对只读匿名内存发起的访问,物理内存不存在是触发缺页异常的主要原因,另外一种场景是对一块已经映射物理内存的只读匿名内存发起写操作,那么此时写操作是触发缺页异常的主要原因. 继续分析 1110 行检查到有写操作之后,函数继续在 1112 行检查 VMA 是否为可写的,对于只读匿名内存来说,VMA 是不可写的,因此这里条件成立函数返回 1,那么接下来就是发送 SIG_BUS 导致程序 SegmentFault 异常退出.
匿名映射写保护(Write-Protection)内存缺页场景
写保护(Write-Protection)匿名内存是可读可写的虚拟内存,但对应的页表没有 _PAGE_RW 写权限,于是对写保护匿名内存发起写操作会引起缺页. 写保护匿名内存可以通过 mmap 函数进行分配,其可以包含 PROT_READ 和 PROT_WRITE 属性。写保护匿名内存存在以下几种缺页场景:
- 当 CPU 首次对 ZERO Page 写保护匿名内存发起写操作: MMU 因为发现权限异常,于是触发缺页异常,在缺页异常处理函数里分配一个新的物理页,并将页表更新映射新的物理页,并向页表添加 _PAGE_RW 属性,那么 CPU 后续的读写操作不会触发缺页.
- 当 CPU 首次对 Normal Page 写保护匿名内存发起写操作: MMU 因为发现权限异常,于是触发缺页异常,在缺页异常处理函数向页表添加 _PAGE_RW 写属性,那么 CPU 后续的读写操作不会引发缺页.
写保护内存的出现是为了某些特定的场景,比如 COW、或者为了拦截某次页表修改之后的写操作等,但无论如何写保护内存的基本缺页逻辑如上。为了更加深刻了解写保护匿名内存缺页,接下来通过一个实践案例了解该场景,实践案例在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS
make menuconfig
[*] Package --->
[*] Paging Mechanism --->
[*] Page Fault with Anonymous on WP(Write-Protection) --->
# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-WP-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
实践案例由一个应用程序构成,进程在 21 行调用 mmap 函数并使用 MAP_PRIVATE 和 MAP_ANONYMOUS 标志分配一段匿名内存, 并且赋予 PROT_READ 和 PROT_WRITE 属性,然后在 32 行对匿名内存进行读操作,此时匿名内存变成写保护匿名内存,接着为了构造另外一个场景,程序在 34 行对写保护匿名内存发起写操作,并在 36 行再次对匿名内存发起读操作,以此观察程序运行情况,操作完毕之后就是释放虚拟内存。以上便是一个最基础的实践案例,可以知道 32 行读操作和 34 行写操作都会触发缺页,为了可以看到内存在缺页异常里的流动,在 34 行前后加上 BS_DEBUG 开关:
接着在匿名内存缺页流程必经之路上任意位置加上 BS_DEBUG 函数,以此观察内存在某个函数里的流动,例如上图在 do_wp_page 函数的 3367 行加上 bs_debug 打印,以此确认内存是向哪个函数流动,接下来执行如下命令进行实践:
# 编译应用程序
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-WP-default/
# 编译内核
make kernel
# 编程程序
make build
当 BiscuitOS 启动之后,直接运行 RunBiscuitOS.sh 脚本,脚本里包括实践所需的命令,可以看到进程执行之后对虚拟内存的访问引起了缺页异常,并且该案例的缺页异常处理流程打印了字符串 “WP Anonymous Memory do_wp_page 0x6000000000”, 那么说明实践案例分配的 ZERO Page,并构造了写保护匿名内存,另外也可以看到进程对写保护匿名内存发起写操作,缺页异常处理函数正确处理写保护匿名内存,程序最终读到写入的值。通过实践可以看到写保护匿名内存按着之前分析的代码路径流动。最后开发者可以在该路径上的任何地方使用 bs_debug 查看匿名内存在缺页异常处理流程里的流动.
对于写保护匿名内存,其缺页流程如上图,当发起写操作时,匿名内存对应的物理页是 Zero Page,那么进入 “COPY PAGE” 分支进行处理. 当发起写操作是,匿名内存对应的物理页是 Normal Page,那么进入 REUSE PAGE 分支进行处理.
对于当 CPU 首次对 ZERO Page 写保护匿名内存发起写操作, wp_page_copy 函数为核心处理函数,函数首先在 3107 行检查到是写保护匿名内存对应的物理页是 ZERO Page,那么函数进入 3108 行分支,调用 alloc_zeroed_user_highpage_movable 函数分配一个新的物理页,然后 3138 行调用 __SetPageUptodate 函数将新物理页标记为最新的内容,接着在 3148 行获得 PTE Entry,调用多个 PTE Entry 函数构造一个新的页表,页表映射新的物理页,并且包含 _PAGE_RW 标志,接下来的处理就是添加逆向映射和加入 LRU 链表,以及更新统计,待缺页异常处理函数返回,CPU 接下来的读写操作不会再触发缺页异常.
对于当 CPU 首次对 Normal Page 写保护匿名内存发起写操作, wp_page_reuse 函数为核心处理函数,函数的主要目的是为页表添加 _PAGE_RW 标志,使原来的物理页具有写权限. 函数在 3066-3067 行调用 pte_mkyoung 和 maybe_mkwrite 函数向页表增加 _PAGE_RW 和 _PAGE_ACCESS 标志,最后在 3068 行调用 ptep_set_access_flags 函数更新 PTE Entry 页表. 那么 CPU 接下来的读写操作不会再触发缺页异常,为了让开发者感受该场景,提供了对应的实践案例,实践案例在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS
make menuconfig
[*] Package --->
[*] Paging Mechanism --->
[*] Page Fault with Anonymous on WP(REUSE PAGE) --->
# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-WP-REUSE-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
BiscuitOS-PAGING-PF-ANON-WP-REUSE-default Source Code on Gitee
实践案例由一个应用程序构成,进程在 21 行调用 mmap 函数并使用 MAP_PRIVATE 和 MAP_ANONYMOUS 标志分配一段匿名内存, 并且赋予 PROT_READ 和 PROT_WRITE 属性,然后在 32 行对匿名内存进行写操作,此时映射一个物理页,接着为了构造另外一个场景,程序在 36 行调用 fork 函数创建子进程,此时可读可写的内存变成了写保护内存,子进程首先在 38 行对写保护内存发起写操作,随后父进程在 42 行对写保护内存发起写操作,虽然都是对写保护内存进行写操作,但是缺页异常处理逻辑并不相同。这里就不进行实践分析,开发者可以按下面的思路添加 BS_DEBUG 开关:
- REUSE PAGE: 在 42 行代码前后添加 BS_DEBUG 开关,缺页异常处理函数会调用 wp_page_reuse 函数直接将物理页标记为可写后继续使用.
- PAGE COPY: 在 38 行代码前后添加 BS_DEBUG 开关,缺页异常处理函数会调用 wp_page_copy 函数直接拷贝内容到新的物理页之后再使用.
匿名映射 COW(Copy-On-Write) 内存缺页场景
写时拷贝(COW)匿名内存是当进程调用 fork() 函数构造子进程时,子进程继承了父进程的匿名内存,但 fork 系统调用会将父进程和子进程的匿名内存都修改为写保护,那么可以确保父子进程读到同样的内容,而父子进程对匿名内存要写的时候,才会在属于自己的匿名内存上执行写操作。COW 的核心是在写的时候才会分配物理内存承接写操作,其存在两种缺页场景:
- 当 CPU 写入父子进程还在共享的 COW 匿名内存: MMU 因为写保护异常,于是触发缺页异常,在缺页异常处理函数里分配一个新的物理页,并将页表更新映射到新的物理页上,然后添加 _PAGE_RW 属性,最后将原始物理页的内容拷贝到新的物理页上,那么 CPU 后续的读写操作不会触发缺页.
- 当 CPU 写入父进程或者子进程独占的 COW 匿名内存: MMU 因为写保护异常,于是触发缺页异常,在缺页异常处理函数会重新 REUSE 该物理页,并添加页表的 _PAGE_RW 属性,那么 CPU 后续的读写操作不会触发缺页.
在 Linux 里使用 struct anon_vma 和 struct anon_vma_chain 两个数据结构体维护匿名内存的逆向映射,所有逆向映射就是通过匿名页被哪些 VMA 映射。AV 是 struct anon_vma 的简称,AVC 是 struct anon_vma_chain 的简称,每个 VMA 一一对应一个 AV,每个 AV 维护一颗红黑树,红黑树的节点表示映射到 AV 的不同 VMA,每个 VMA 通过 AVC 进行表示,那么只要遍历这颗红黑树就可以知道有多少进程的 VMA 映射到该 AV,那么可以看到一个 AVC 就是一次映射,可以通过 AVC 找到对应的 VMA吗,那么可以将匿名页指向 AV,那么就可以知道有多少进程映射到自己. 每当进程发送一次 fork 动作时,子进程会在父进程 AV 红黑树下新增一个 AVC 节点,AVC 节点指向子进程的 VMA,并且子进程 VMA 对应的 AV 的 Parent 指向父进程 VMA 对应 AV. 以此类推无论进程 fork 多少次,都可以知道一个匿名页被多少进程映射. 为了更加深刻了解 COW 匿名内存缺页,接下来通过一个实践案例了解该场景,实践案例在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS
make menuconfig
[*] Package --->
[*] Paging Mechanism --->
[*] Page Fault with Anonymous on COW(Copy-On-Write) --->
# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-COW-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
实践案例由一个应用程序构成,进程在 21 行调用 mmap 函数并使用 MAP_PRIVATE 和 MAP_ANONYMOUS 标志分配一段匿名内存, 并且赋予 PROT_READ 和 PROT_WRITE 属性,然后在 32 行对匿名内存进行写操作,然后在 34 行再次对匿名内存进行读操作. 程序接着在 37 行调用 fork 函数创建子进程,此时匿名内存变成写保护匿名内存,接着为了构造另外一个场景,39 行为子进程分支,其首先对 COW 匿名内存进行写操作,由于此时父子进程都映射到 COW 匿名内存,那么 39 行的写操作会触发缺页,但缺页异常处理函数会分配一个新匿名页,然后将内容拷贝到新匿名页上,那么接下来子进程在新的匿名页上执行写操作; 同理 41 行是父进程分支,其先 sleep 一段时间之后,COW 匿名内存此时只有父进程映射,因此 43 行的写操作同样会引起缺页,但缺页异常处理函数只是在原匿名页的基础上增加了写权限,那么父进程继续在原来的匿名页上进行写操作. 以上便是一个最基础的实践案例,可以知道 39 行读操作和 43 行写操作都会触发缺页,为了可以看到内存在缺页异常里的流动,本次在 39 行前后加上 BS_DEBUG 开关:
接着在匿名内存缺页流程必经之路上任意位置加上 BS_DEBUG 函数,以此观察内存在某个函数里的流动,例如上图在 wp_page_copy 函数的 3102 行加上 bs_debug 打印,以此确认内存是向哪个函数流动,接下来执行如下命令进行实践:
# 编译应用程序
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-COW-default/
# 编译内核
make kernel
# 编程程序
make build
当 BiscuitOS 启动之后,直接运行 RunBiscuitOS.sh 脚本,脚本里包括实践所需的命令,可以看到进程执行之后对虚拟内存的访问引起了缺页异常,并且该案例的缺页异常处理流程打印了字符串 “COW Anonymous Memory on wp_page_copy 0x6000000000”, 子进程访问 COW 匿名内存时,缺页中断将原始内容拷贝到新匿名页上,子进程接下来都是访问新的匿名页. 通过实践可以看到写保护匿名内存按着之前分析的代码路径流动。最后开发者可以在该路径上的任何地方使用 bs_debug 查看匿名内存在缺页异常处理流程里的流动.
对于 COW 匿名内存,其缺页流程如上图,当子进程/父进程对两者共同映射的 COW 匿名内存发起写操作时,那么进入 “COPY PAGE” 分支进行处理; 当子进程/父进程对独占的 COW 匿名内存发起写操作时,那么进入 “REUSE PAGE” 分支进行处理.
对于 CPU 写入父子进程还在共享的 COW 匿名内存, wp_page_copy 函数为核心处理函数,由于此时匿名内存并不是 ZERO Page,那么函数直接进入 3113 行分支,函数首先调用 alloc_page_vma 函数分配一个新的匿名页,然后在 3118 将调用 __wp_page_copy_user 函数将原始匿名页的内容拷贝到新的物理页上,此时页表已经更新映射到新的匿名页上,并且具有写权限,那么接下来对该匿名内存的读写操作不再会引起缺页异常.
对于 CPU 写入父进程或者子进程独占的 COW 匿名内存, wp_page_reuse 函数为核心处理函数,函数的主要目的将匿名页设置为独占,并为页表添加 _PAGE_RW 标志,使原来的物理页具有写权限. 函数在 3066-3067 行调用 pte_mkyoung 和 maybe_mkwrite 函数向页表增加 _PAGE_RW 和 _PAGE_ACCESS 标志,最后在 3068 行调用 ptep_set_access_flags 函数更新 PTE Entry 页表. 那么 CPU 接下来的读写操作不会再触发缺页异常.
上图是 fork 系统调用处理逻辑,其中核心逻辑是新进程会调用 dup_mmap 函数拷贝父进程的地址空间,其遍历父进程的所有的 VMA,对于匿名映射的 VMA,那么调用 anon_vma_fork 函数建立逆向映射,最后调用 copy_page_range 函数遍历 VMA 对于的页表,将 COW 匿名内存对应页表的 _PAGE_RW 标志去掉,此时可以看到调用 ptep_set_wrprotect 函数进行写保护,最终更新页表. 那么无论是父进程还是子进程的 COW 匿名内存都变成了写保护.
匿名映射内存交换(SWAP) 缺页场景
在 Linux 里,一旦建立了虚拟内存到匿名页的页表映射,那么进程就可以访问这块匿名内存。由于系统长时间的运行,系统可用物理内存在不断变少,为此系统会进行内存回收(Memory Reclaim), 其中对于匿名内存,在开启 SWAP 的前提下,系统会将长时间没有访问的匿名内存的交换到 SWAP Space 上,这里的交换指的是将匿名内存的数据交换到 SWAP Space 上,然后释放匿名页以便缓解系统可用物理内存不足的问题。当进程再次访问匿名内存时,MMU 检查到对应的物理内存不存在,那么触发缺页异常。在缺页异常处理函数里,其检查到虽然匿名内存对应的匿名页不存在,也就是页表的 _PAGE_PRESENT 清零,但页表的值不空,那么缺页异常中断可取确认匿名内存发生了 SWAP OUT,那么接下来缺页异常处理函数从页表中获得 SWAP Entry 的信息,然后 SWAP IN 匿名内存的内容到一个新的物理页上,并更新页表到新的物理页上,那么这个物理页就变成了匿名页,接下来进程可以正常访问匿名内存.
当一个匿名页被交换到 SWAP Space 之后,其对应的页表会记录匿名页在 SWAP Space 信息,缺页异常处理函数将 PTE Entry 非空,且 _PAGE_PRESENT 标志位清零,这类匿名内存归结为发生 SWAP OUT 操作. 此时页表的 [59: 63] 字段记录了 SWAP Type,这里不对 SWAP 机制进行过多介绍,开发者只要了解 SWAP-PTE Entry 的布局即可,缺页异常处理函数可以从 SWAP Type 字段获得 SWAP 机制提供的信息,以便判断匿名页能否正确 SWAP In. 接下来是 [9: 58] 字段记录了匿名页被交换到 SWAP 区域索引的反码, 缺页异常处理函数可以从该字段知道匿名页在 SWAP Space 的具体位置. 其余字段这里不做过多介绍. 接下来通过一个实践案例了解匿名内存 SWAP IN/OUT 缺页过程,实践案例在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS
make menuconfig
[*] Package --->
[*] Paging Mechanism --->
[*] Page Fault with Anonymous on SWAP --->
# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-SWAP-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
实践案例由一个应用程序构成,进程在 21 行调用 mmap 函数并使用 MAP_PRIVATE 和 MAP_ANONYMOUS 标志分配一段匿名内存, 并且赋予 PROT_READ 和 PROT_WRITE 属性,然后在 32 行对匿名内存进行写操作,然后在 34 行再次对匿名内存进行读操作. 程序接下来在 37 行调用 madvise 函数发起 MADV_PAGEOUT 请求,可以将匿名内存精准的交换到 SWAP Space, 然后在 40 行再次对匿名内存发起写请求,此时会触发 SWAP In 操作,最终写入成功。以上便是一个最基础的实践案例,可以知道 32 行读操作和 40 行写操作都会触发缺页,为了可以看到内存在缺页异常里的流动,本次在 40 行前后加上 BS_DEBUG 开关:
接着在匿名内存缺页流程必经之路上任意位置加上 BS_DEBUG 函数,以此观察内存在某个函数里的流动,例如上图在 do_swap_page 函数的 3732 行加上 bs_debug 打印,以此确认内存是向哪个函数流动,接下来执行如下命令进行实践:
# 编译应用程序
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-SWAP-default/
# 编译内核
make kernel
# 编程程序
make build
当 BiscuitOS 启动之后,直接运行 RunBiscuitOS.sh 脚本,脚本里包括实践所需的命令,可以看到进程执行之后对虚拟内存的访问引起了缺页异常,并且该案例的缺页异常处理流程打印了字符串 “SWAP Anonymous Memory on do_swap_page 0x6000000000”, 说明缺页异常处理函数执行过 SWAP IN 操作. 通过实践可以看到实践案例按着之前分析的代码路径流动。最后开发者可以在该路径上的任何地方使用 bs_debug 查看匿名内存在缺页异常处理流程里的流动.
对于被 SWAP OUT 的匿名内存,其缺页流程如上图,当进程访问被 SWAP OUT 的匿名内存,缺页异常处理函数会进入 “SWAP-IN” 分支进行处理.
对于 “SWAP-IN” 分支核心处理函数是 do_swap_page 函数,由于该函数长度太长,这里只做部分解析. 函数首先调用 pte_to_swp_entry() 函数从 PTE Entry 中解析出 SWAP Entry 信息,通过该信息首先判断 SWAP IN 的先决条件,例如是否可以正确的 SWAP In 等.
接着函数从 Entry 里获得 SWAP 后端设备的信息,因为内存压缩和 SWAP 共用了很多代码, 缺页异常处理函数无法区分是 SWAP 还是内存压缩,这里讨论 SWAP 机制和内存压缩机制有点超纲,但开发者只要知道 get_swap_device() 对应的后端设备就是存储匿名页内容的. 函数接下来调用 lookup_swap_cache() 函数在 SWAP CACHE 里首先查看匿名页,如果找到直接从 SWAP CACHE 里提取,如果没有找到,那么需要到 SWAP Space 上提取.
do_swap_page 函数找到对应的匿名页之后,开始重置页表,其中包括给页表添加写权限,以及 SOFT_DIRTY 权限等, 最后就是更新页表. 开发者在这里可以不要过多的陷入 SWAP 机制里,因为对与缺页异常处理函数来说 SWAP 机制就是透明的,其只知道需要从外部存储获得匿名页信息即可.
匿名映射内存压缩(ZSWAP) 缺页场景
在 Linux 里,一旦建立了虚拟内存到匿名页的页表映射,那么进程就可以访问这块匿名内存。由于系统长时间的运行,系统可用物理内存在不断变少,为此系统会进行内存回收(Memory Reclaim), 前面开发者已经了解了 SWAP 方案,那么该方案存在一个缺点就是磁盘 IO 特别多,因为需要把匿名页 SWAP-OUT/SWAP-IN,因此这会增加缺页时的延迟. 为了兼顾方案提出了 ZSWAP 方案,即系统内存压力比较大时,将不常用的匿名页进行压缩,并将压缩后的数据存储物理内存上,以此释放匿名页。待进程再次访问匿名内存时,MMU 发现对应的物理页不存在,那么触发缺页异常,并在缺页异常处理函数中识别发生了内存压缩,因此将对应的内容进行解压到新的物理页上,接着更新页表到该物理页上,该物理页变成匿名页,接下来进程可以对匿名内存正常访问.
当一个匿名页被压缩之后,其对应的页表会记录匿名页在 ZSWAP 信息,缺页异常处理函数将 PTE Entry 非空,且 _PAGE_PRESENT 标志位清零,这类匿名内存归结为发生 SWAP OUT 操作(缺页异常无法识别是发生了 SWAP-OUT 还是内存压缩,统一当做 SWAP OUT 来处理). 此时页表的 [59: 63] 字段记录了 SWAP Type,这里不对 SWAP/ZSWAP 机制进行过多介绍,开发者只要了解 SWAP-PTE Entry 的布局即可,缺页异常处理函数可以从 SWAP Type 字段获得 ZSWAP 机制提供的信息,以便判断匿名页能否正确解压缩. 接下来是 [9: 58] 字段记录了匿名页被交换到 ZSWAP 区域索引的反码, 缺页异常处理函数可以从该字段知道匿名页在 ZSWAP 的具体位置. 其余字段这里不做过多介绍. 接下来通过一个实践案例了解匿名内存压缩和解压缩缺页过程,实践案例在 BiscuitOS 上的部署逻辑如下(实践之前需要打开内核 CONFIG_ZSWAP 宏):
cd BiscuitOS
make menuconfig
[*] DIY BiscuitOS/Broiler Hardware --->
(zswap.enabled=1) CMDLINE on Kernel
[*] Package --->
[*] Paging Mechanism --->
[*] Page Fault with Anonymous on ZSWAP --->
# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-ZSWAP-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
实践案例由一个应用程序构成,进程在 21 行调用 mmap 函数并使用 MAP_PRIVATE 和 MAP_ANONYMOUS 标志分配一段匿名内存, 并且赋予 PROT_READ 和 PROT_WRITE 属性,然后在 34 行对匿名内存进行写操作,然后在 36 行再次对匿名内存进行读操作. 程序接下来在 39 行调用 madvise 函数发起 MADV_PAGEOUT 请求,可以将匿名内存精准压缩, 然后在 42 行再次对匿名内存发起写请求,此时会触发内存解压缩操作,最终写入成功。以上便是一个最基础的实践案例,可以知道 34 行读操作和 42 行写操作都会触发缺页,为了可以看到内存在缺页异常里的流动,本次在 42 行前后加上 BS_DEBUG 开关:
接着在匿名内存缺页流程必经之路上任意位置加上 BS_DEBUG 函数,以此观察内存在某个函数里的流动,例如上图在 do_swap_page 函数的 3732 行加上 bs_debug 打印,以此确认内存是向哪个函数流动,接下来执行如下命令进行实践:
# 编译应用程序
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-ZSWAP-default/
# 编译内核
make kernel
# 编程程序
make build
当 BiscuitOS 启动之后,直接运行 RunBiscuitOS.sh 脚本,脚本里包括实践所需的命令,可以看到进程执行之后对虚拟内存的访问引起了缺页异常,并且该案例的缺页异常处理流程打印了字符串 “ZSWAP Anonymous Memory on do_swap_page 0x6000000000”, 说明缺页异常处理函数执行过内存解压操作. 通过实践可以看到实践案例按着之前分析的代码路径流动。最后开发者可以在该路径上的任何地方使用 bs_debug 查看匿名内存在缺页异常处理流程里的流动. 另外可以通过以下命令查看系统压缩内存的信息:
# Modify SWAP Parameters: Enable ZSWAP
echo Y > /sys/module/zswap/parameters/enabled
# SWAP Information: 查看被压缩内存的数量
cat /sys/kernel/debug/zswap/stored_pages
对于被内存压缩的匿名内存,其缺页流程如上图,当进程访问被压缩的匿名内存,缺页异常处理函数会进入 “DECOMPRESS-PAGE” 分支进行处理.
对于 “DECOMPRESS-PAGE” 分支核心处理函数是 do_swap_page 函数,由于该函数长度太长,这里只做部分解析. 函数首先调用 pte_to_swp_entry() 函数从 PTE Entry 中解析出 SWAP Entry 信息,通过该信息首先判断内存解压的先决条件,例如是否可以正确的内存解压缩等.
接着函数从 Entry 里获得 SWAP 后端设备的信息,因为内存压缩和 SWAP 共用了很多代码, 缺页异常处理函数无法区分是 SWAP 还是内存压缩,这里讨论 SWAP 机制和内存压缩机制有点超纲,但开发者只要知道 get_swap_device() 对应的后端设备就是存储匿名页内容的. 函数接下来调用 lookup_swap_cache() 函数在 SWAP CACHE 里首先查看匿名页,如果找到直接从 SWAP CACHE 里提取,如果没有找到,那么需要到 SWAP Space 上提取.
do_swap_page 函数找到对应的匿名页之后,开始重置页表,其中包括给页表添加写权限,以及 SOFT_DIRTY 权限等, 最后就是更新页表. 开发者在这里可以不要过多的陷入内存压缩机制里,因为对与缺页异常处理函数来说内存压缩机制就是透明的,其只知道需要从外部存储获得匿名页信息即可.
匿名映射内存 KSM 缺页场景
在 Linux 中,KSM(Kernel SamePage Merging),翻译为 内核同页合并,是一个用于优化内存使用的机制。KSM 允许 Linux 内核自动检测并合并相同内容的内存页,以减少虚拟机(VM) 或进程之间的重复内存占用. 对于匿名内存,KSM 机制同样可以将两个相同内容的匿名页合并成一个匿名页,然后将匿名页标记为写保护,那么接下来可以对 KSM 匿名页进行读操作. 一旦某个进程开始对 KSM 匿名页进行写操作,MMU 检查到权限异常并触发缺页异常。在缺页异常处理函数里,存在以下两种缺页场景:
- KSM 匿名页有多个 VMA 映射缺页场景: 当 CPU 访问匿名内存时,MMU 检查到访问权限异常并触发缺页异常,在缺页异常处理函数里,可以识别写保护的匿名页被多个 VMA(或多个进程) 映射,那么将 KSM 匿名页内容拷贝到一个新的物理页上,并将进程对应的页表映射到新的物理页上,且标记页表为可读可写,那么进程后续访问匿名内存不会缺页.
- KSM 匿名页只有一个 VMA 映射缺页场景: 当 CPU 访问匿名内存时,MMU 检查到访问权限异常并触发缺页异常,在缺页异常处理函数里,可以识别写保护的匿名页只有一个 VMA(或者一个进程) 映射,当缺页异常处理函数不会关注这个条件,而是关注其属于 KSM 页,那么将 KSM 页内容拷贝到新的物理页上,并将进程对应的页表映射到新的物理页上,且标记页表为可读可写,那么进程后续访问匿名内存不会缺页.
与 COW 匿名页场景不同,虽然都是写保护匿名页,KSM 都是 COPY 到新页,而 COW 则是 COPY 和 REUSE 共同使用。KSM 最初设计用于虚拟化环境,尤其是在使用基于 KVM(Kernel-based Virtual Machine)的虚拟机管理器时非常有用。虚拟机通常会运行相似的操作系统和应用程序,因此它们可能会有大量相同的内存页,例如共享库、内核代码等。KSM 允许内核检测这些相同的内存页,并将它们合并为一个共享页,从而减少内存消耗。接下来通过一个实践案例了解 KSM 匿名页缺页过程,实践案例在 BiscuitOS 上的部署逻辑如下(实践之前需要打开内核 CONFIG_KSM 宏):
cd BiscuitOS
make menuconfig
[*] Package --->
[*] Paging Mechanism --->
[*] Page Fault with Anonymous on KSM --->
# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-KSM-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
实践案例由一个应用程序构成,进程在 30 行调用 alloc_anonymous_memory() 函数分配一块可读可写的匿名内存,同理在 35 行调用 alloc_anonymous_memory() 函数分配一块可读可写的匿名内存,并在 42-45 行构造两块内存相同的匿名内存,接着在 48-49 行调用 madvise 函数发起 MADV_MERGEABLE 请求,该操作会触发底层 KSM 合并相似页,待 KSM 页合并完成之后,程序在 55 行第一次访问 KSM 匿名页,此时会触发缺页,程序继续在 57 行第二次访问 KSM 匿名页,此时再次触发缺页. 以上便是一个最基础的实践案例,可以知道 55 行读操作和 57 行写操作都会触发缺页,为了可以看到内存在缺页异常里的流动,本次在 55 行前后加上 BS_DEBUG 开关:
接着在匿名内存缺页流程必经之路上任意位置加上 BS_DEBUG 函数,以此观察内存在某个函数里的流动,例如上图在 wp_page_copy 函数的 3102 行加上 bs_debug 打印,以此确认内存是向哪个函数流动,接下来执行如下命令进行实践:
# 编译应用程序
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-KSM-default/
# 编译内核
make kernel
# 编程程序
make build
当 BiscuitOS 启动之后,直接运行 RunBiscuitOS.sh 脚本,脚本里包括实践所需的命令,可以看到进程执行之后对虚拟内存的访问引起了缺页异常,并且该案例的缺页异常处理流程打印了字符串 “KSM Anonymous Memory on wp_page_copy 0x6000000000”, 说明缺页异常处理函数执行过 KSM 写保护缺页. 通过实践可以看到实践案例按着之前分析的代码路径流动。最后开发者可以在该路径上的任何地方使用 bs_debug 查看匿名内存在缺页异常处理流程里的流动.
对于 KSM 匿名内存,其缺页流程如上图,当进程对 KSM 匿名内存写操作时,缺页异常处理函数会进入 “COPY-PAGE” 分支进行处理.
对于 “COPY-PAGE” 分支核心处理函数是 do_copy_page 函数,由于该函数长度太长,这里只做部分解析. 函数首先判断到写保护的匿名内存不是 ZERO Page,于是进入 3113 行分支,此时调用 alloc_page_vma() 函数分配一个新的物理页,然后调用 __wp_page_copy_user 函数将 KSM 匿名页内容拷贝到新的物理页上.
函数接着在 3148 行调用 pte_offset_map_lock 函数获得对应的 PTE Entry,3160 行开始基于 VMA 的 vm_page_prot 构造新的 PTE Entry 内容,由于原先进程的 VMA 就已经包含了 PROT_READ 和 PROT_WRITE,那么新页表的 _PAGE_RW 标志位一定置位。函数接着在 3179 行调用 page_add_new_anon_rmap 函数更新 VMA 的逆向映射,以及调用 lru_cache_add_inactive_or_unevictable 函数将匿名页添加到 LRU 链表上. 最后函数在 3187 行调用 set_pte_at_notify 函数更新页表内容,至此待缺页异常处理函数返回之后,进程可以对 KSM 匿名内存进行读写操作而不会引起缺页异常.
madvise 函数处理 MADV_MERGEABLE 请求的逻辑如上图,其可以强制将某块内存加入到 KSM 内核线程扫描的范围,最后唤醒了 ksm_scan_thread 内核线程,该内核线程属于异步运行,其核心逻辑是在找到两个可以合并的匿名页之后,将其中一个页作为 KSM 匿名页,并将匿名页标记为普通的写保护匿名页.
匿名映射内存 NUMA Balancing 缺页场景
在 Linux 和其他操作系统中,UMA(Uniform Memory Access) 和 NUMA(Non-Uniform Memory Access) 都是与内存访问模型和多处理器系统设计相关的术语。这两个术语描述的是处理器如何访问系统中的内存区域以及相关的性能特性, 上图大致描述了 UMA 和 NUMA 之间的差异,总结如下:
- UMA(Uniform Memory Access): UMA 是一种内存架构,其中所有处理器访问内存所需的时间是均匀的,无论内存是哪一部分. 处理器共享同一内存资源,并且每次访问的延迟都是相同的. 缺点是当多个处理器尝试同时访问内存时,可能会导致内容竞争,从而限制系统的可扩展性.
- NUMA(Non-Uniform Memory Access): NUMA 是一种内存架构,针对多处理器系统设计,其中处理器访问内存的时间取决于内存所在的位置. 在 NUMA 架构中,系统内存被划分为多个节点,每个节点与一个或多个处理器相关联. 当处理器访问其本地节点的内存时,访问速度最快。但是访问其他节点(非本地或远程节点)的内存时,访问速度会变慢. 这种架构的主要优势在于,它能更好地扩展到具有大量处理器的系统,并尽量减少内存访问的竞争状况.
在 NUMA 架构里,正常情况下 CPU 优先分配本地(LOCAL)节点上的内存,但在内存紧缺或者特殊需求场景下,CPU 也可以分配远端(REMOTE)节点的内存,这样做的好处是解决内存压力,但降低内存访问的性能.
在 Linux 中,NUMA Balancing 是一个内核特性,用于自动改善多处理器系统上的应用程序性能,特别是在 NUMA 架构上。这一特性旨在确保内存分配和进程调度与 NUMA 架构保持一致,以便获得最佳的性能. NUMA Balancing 机制一般会做以下几个操作:
- 巡检: 在进程创建的时候,NUMA Balancing 机制会为每个进程启动一个定时任务, 该任务会扫描进程里跨 NUMA 的物理页
- 标记: 巡检过程中,NUMA Balancing 机制会对跨 NUMA 物理页进行标记,将其标记为 PROTNONE,这里不是 PROT_NONE, 被标记的内存没有读写权限.
- 替换: 当进程访问被标记的内存会触发缺页,缺页异常处理函数识别出被标记的页之后,将其迁移(Migrate)到本地(LOCAL)节点上,并恢复正常的页表属性.
NUMA Balancing 机制一个重要任务就是将跨 NUMA Page 对应的页表修改为 PROTNONE,这里的 PROTNONE 是一种特殊的页表,对应的匿名内存即不可读也不可写,与 PROT_NONE 不同的是,PROTNONE 是将原先的 PTE Entry 分作两部分,高于 BIT12 的字段存储物理页帧的反码,低于 BIT12 的字段则将 BIT8 置位,其余全部清零,因此缺页异常处理函数首先判断 _PAGE_PRESENT 清零,然后判断 BIT8(_PAGE_PROTNONE) 置位,那么知道该 Entry 对应着 NUMA Balancing 的页表,因此接下来的逻辑就按 NUMA Balancing Page Fault 进行处理.
上图为 NUMA Balancing 机制的运作逻辑,其为每个进程维护一个 NUMA Balancing 的内核线程,该内核线程通过多个 sysctl 变量进行控制,当 NUMA Balancing 内核线程被唤醒,其将执行 task_numa_work 函数,该函数会检查进程地址空间中那些跨 NUMA 的区域,然后获得对应的 PTE Entry,并调用 pte_modify 函数将页表修改为 PROTNONE. 在不同版本 Linux 里,四个 sysctl 有的导到 sysctl 系统工具里,但有的内核没有导出,开发者可以修改下面几个变量控制任务的执行:
- numa_balancing_scan_period_min_ms: 以毫秒为单位扫描任务虚拟内存的最小时间, 它有效地控制了每个任务的最大扫描速率.
- numa_balancing_scan_delay_ms: 任务初始分叉时使用的起始“扫描延迟
- numa_balancing_scan_period_max_ms: 以毫秒为单位扫描任务虚拟内存的最大时间, 它有效地控制了每个任务的最小扫描速率.
- numa_balancing_scan_size_mb: 为给定的扫描扫描的页面价值多少兆字节.
结合起来,扫描延迟和扫描大小确定了扫描速率。当扫描延迟减小时,扫描速率增加。扫描延迟和因此每个任务的扫描速率都是自适应的,并取决于历史行为。如果页面被正确放置,那么扫描延迟增加,否则扫描延迟减小。 扫描大小不是自适应的,但是扫描大小越大,扫描速率就越高。更高的扫描速率会导致更高的系统开销,因为必须捕获页面错误,并且可能需要迁移数据。但是扫描速率越高,如果工作负载模式发生变化,任务内存就会更快地迁移到本地节点,从而减小由于远程内存访问而引起的性能影响。这些 sysctl 参数控制了扫描延迟和扫描页面数量的阈值。经过对 NUMA Balancing 机制的研究,理论上已经知道其如何工作了,接下来通过一个实践案例进一步了解该场景,实践案例在 BiscuitOS 上的部署逻辑如下(实践之前需要打开内核 CONFIG_NUM-A_BALANCING 宏):
cd BiscuitOS
make menuconfig
[*] DIY BiscuitOS/Broiler Hardware --->
(numa_balancing=disable) CMDLINE on Kernel
[*] Support NUMA Topology
[*] Package --->
[*] Paging Mechanism --->
[*] Page Fault with Anonymous on NUMA --->
# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-NUMA-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
实践案例由一个应用程序构成,其分作三部分,首先是 30 行调用 mmap 函数结合 MAP_PRIVATE 和 MAP_ANONYMOUS 分配一段可读可写的匿名内存,并在 40 行对匿名内存执行写操作. 第二部分是利用 libnuma 提供的库函数,从 43-54 行构造一个跨 NUMA 的匿名页. 最后是第三部分,程序在 57 行再次对匿名内存执行写操作,此时会再次触发缺页并被识别为 NUMA Balancing Page Fault. 以上便是一个最基础的实践案例,可以知道 40 行读操作和 57 行写操作都会触发缺页,为了可以看到内存在缺页异常里的流动,本次在 57 行前后加上 BS_DEBUG 开关:
接着在匿名内存缺页流程必经之路上任意位置加上 BS_DEBUG 函数,以此观察内存在某个函数里的流动,例如上图在 do_numa_page 函数的 4698 行加上 bs_debug 打印,以此确认内存是向哪个函数流动,另外为了加速调试进度,需要修改 “kernel/sched/fair.c” 文件中 sysctl 变量的值,这里全部设置为 0,接下来执行如下命令进行实践:
# 编译应用程序
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-NUMA-default/
# 编译内核
make kernel
# 安装 libnuma
make prepare
# 编程程序
make build
当 BiscuitOS 启动之后,直接运行 RunBiscuitOS.sh 脚本,脚本里包括实践所需的命令,可以看到进程执行之后对虚拟内存的访问引起了缺页异常,并且该案例的缺页异常处理流程打印了字符串 “NUMA Balancing AnonMEM on do_numa_page 0x6000000000”, 说明缺页异常处理函数执行过 NUMA Balancing PROTNONE 缺页. 通过实践可以看到实践案例按着之前分析的代码路径流动。最后开发者可以在该路径上的任何地方使用 bs_debug 查看匿名内存在缺页异常处理流程里的流动.
对于 NUMA Balancing 引起的缺页,缺页异常处理函数根据 PROTNONE 进行区分,一旦确认是 NUMA Balancing Page Fault, 那么进入 “NUMA-PAGE” 分支进行处理.
对于 “NUMA-PAGE” 分支核心处理函数是 do_numa_page 函数,由于该函数长度太长,这里只做部分解析,函数首先获得 PTE Entry 的内容,由于此时还是 PROTNONE 格式,因此函数在 4712 行调用 pte_modify() 函数将 PROTNONE 内容转换成一个正常的 PTE Entry,该 PTE Entry 页表属性字段来自 VMA 提供的 vm_page_prot, 页帧字段则来自 PROTNONE 高 BIT12 字段的反码. 有了 PFN 信息之后,函数在 4714 行调用 vm_normal_page 函数获得对应的匿名页.
do_numa_page 函数在获得匿名页之后,此时在 4741 行调用 page_to_nid 函数获得匿名页所在 NUMA NODE 信息,然后调用 numa_migrate_prep 函数获得进程 LOCAL NUMA NODE 信息,此时 page_nid 和 target_nid 不相等,那么接下来 4751 行调用 migrate_misplaced_page 函数将匿名页迁移到 LOCAL NUMA NODE 上,迁移过程就是在目标 NUMA NODE 上分配一个新的物理页,然后将内容拷贝到新物理页上.
do_numa_page 函数迁移完毕之后,在 4774-4779 行调用相关的函数更新页表,使页表映射到新的物理页上,此时新的物理页变成匿名页. 至此 NUMA Balancing Page Fault 主要处理逻辑已经完成,待缺页异常处理函数返回之后,进程可以继续访问 LOCAL NUMA NODE 的匿名页,且不会再触发缺页.
匿名映射内存 MCE 缺页场景
在 Linux 中,MCE 代表 Machine Check Exception(机器检查异常),它用于处理硬件错误和异常情况。MCE 可以分为 CE(Corrected Error,已纠正的错误) 和 UE(Uncorrected Error, 未纠正的错误) 两种类型。MCE 是 Linux 内核的一部分,用于监视和响应这些硬件错误,以提高系统的可靠性和稳定性。当发生 MCE 时,通常会生成系统日志(例如 /var/log/mcelog),以帮助管理员诊断和解决硬件问题。在服务器和关键应用中,监控和处理 MCE 错误非常重要,以确保系统的可靠性和可用性. CE 和 UE 的区别如下:
- CE(Corrected Error,已纠正的错误): CE 是指硬件错误,但系统可以自动纠正这些错误,而不会导致系统崩溃或严重问题。通常这些错误是由于内存或其他硬件组件的临时问题引起的,例如内存中的单个位翻转。内核会记录这些 CE 错误,但不会采取进一步的行动,因为它们被认为是暂时性的,不需要干预
- UE(Uncorrected Error,未纠正的错误): UE 是指硬件错误,但系统无法自动纠正这些错误,因此可能会导致系统崩溃或数据损坏。UE 错误通常是更严重的硬件问题,例如内存模块中的多个位错误或其他硬件组件的不可恢复故障。内核会尝试采取适当的措施来处理UE错误,例如记录错误信息并尝试停机以防止进一步损坏
在 Linux 里,当硬件检测到硬件错误或者异常,这些硬件错误可能包括内存位翻转、缓存错误、总线错误、内存单元故障等,那么硬件会触发 MCE 异常,MCE 异常处理函数能处理很多硬件错误,其中对于内存 UE 的处理逻辑如上图, 其核心是调用 memory_failure() 函数进行出来,该函数主要完成一下几个任务:
- 标记物理页: 将发生 UE 对于的物理页标记为 PG_hwpoison
- 解除映射: 通过 TTU 机制解除用户进程映射到物理页的映射
- 更新页表: 构造 UE Entry 并更新页表,以防止进程再次访问
MCE 异常处理函数构造的 UE PTE Entry 如上图,可以看到页表 Entry 的 _PAGE_PRESENT 标志位为 0,然后 MSB 5BIT 存储了 SWAP Entry 的类型,UE 场景下该类型是 SWP_HWPOIS-ON,_PAGE_PROTNONE 后面两个 BIT 的字段存储了物理页帧的反码. 因此当进程再次访问 UE 内存时,MMU 检查到对应的物理内存不存在,于是触发缺页异常,缺页异常处理函数再根据 SWP_HWPOISON 可以判断是 UE Page Fault,那么接下来就是将对应的进程杀死. 接下来通过一个实践案例进一步了解该场景,实践案例在 BiscuitOS 上的部署逻辑如下(实践之前需要打开内核 CONFIG_MEMORY_FAILURE 宏):
cd BiscuitOS
make menuconfig
[*] Package --->
[*] Paging Mechanism --->
[*] Page Fault with Anonymous on MCE(UE) --->
# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-MCE-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
实践案例由一个应用程序构成,其分作三部分,首先是 21 行调用 mmap 函数结合 MAP_PRIVATE 和 MAP_ANONYMOUS 分配一段可读可写的匿名内存,并在 32 行对匿名内存执行写操作, 以及在 34 行再次对匿名内存执行读操作. 第二部分是利用 madvise 注入 UE,由于 UE 是硬件行为,所有只能借助软件来模拟 UE 的发生,于是函数在 37 行调用 madvise 函数发送 MADV_HWPOISON 请求. 最后是第三部分,程序在 40 行再次对匿名内存执行写操作,此时会再次触发缺页并被识别为 UE Page Fault. 以上便是一个最基础的实践案例,可以知道 32 行读操作和 40 行写操作都会触发缺页,为了可以看到内存在缺页异常里的流动,本次在 40 行前后加上 BS_DEBUG 开关:
接着在匿名内存缺页流程必经之路上任意位置加上 BS_DEBUG 函数,以此观察内存在某个函数里的流动,例如上图在 do_swap_page 函数的 3747 行加上 bs_debug 打印,以此确认内存是向哪个函数流动,接下来执行如下命令进行实践:
# 编译应用程序
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-MCE-default/
# 编译内核
make kernel
# 编程程序
make build
当 BiscuitOS 启动之后,直接运行 RunBiscuitOS.sh 脚本,脚本里包括实践所需的命令,可以看到进程执行之后对虚拟内存的访问引起了缺页异常,并且该案例的缺页异常处理流程打印了字符串 “UE Anonymous Memory on do_swap_page 0x6000000000”, 说明缺页异常处理函数执行过 UE 缺页. 通过实践可以看到实践案例按着之前分析的代码路径流动。最后开发者可以在该路径上的任何地方使用 bs_debug 查看匿名内存在缺页异常处理流程里的流动.
对于 Memory UE 引起的缺页,缺页异常处理函数根据 UE SWP Entry,一旦确认是 Memory UE Page Fault, 直接退出到 handle_mm_fault 函数,然后执行 force_sig_mceerr 发送信号杀死进程.
对于 Memory UE 缺页核心处理函数是 do_swap_page 函数,由于 MCE 异常将页表修改成了 SWAP Entry,其特点就是 _PAGE_PRESENT 不存在,但在 SWAP TYPE 字段存储着 SWP_HWPOISON 信息,因此 do_swap_page 函数在 3735 行调用 pte_to_swp_entry 函数获得 SWAP Entry 之后,调用 non_swap_entry 函数检查到里面不是正常的 SWAP Entry,接着读取 SWAP TYPE 字段知道符合 is_hwpoison_entry() 函数对应的分支,因此函数在 3747 行将缺页异常返回原因设置为 VM_FAULT_HWPOISON.
在 do_user_addr_fault 函数处理异常处理返回值时,VM_FAULT_HWPOISON 返回值会进入 1454 行分支,并调用 do_sigbus 函数发起信号杀死进程。以上便是 Memory UE Page Fault 缺页处理的流程.
匿名映射之堆(Heap) 缺页场景
用户进程的堆(Heap) 是一个用于动态分配内存的区域,可存储在程序运行时创建和管理的数据。堆内存是一种在程序执行期间分配和释放的内存,与栈(Stack)内存(用于存储函数调用和局部变量)不同。堆内存通常用于存储具有不确定大小的数据结构,如动态数组、链表、对象等。上图是用户进程虚拟地址空间布局,可以看到堆位于数据段与 BSS 段之后,并且堆内存向高地址生长. 堆内存具有如下特点:
- 动态分配和释放: 堆内存的大小可以根据需要动态增长或减小,这使得用户进程能够在运行时分配和释放内存,而不需要在编译时确定内存的大小
- 堆内存分配函数: 在 C/C++ 等编程语言中,可以使用诸如 malloc()、calloc()、realloc() 和 brk() 等函数来分配堆内存。这些函数允许程序员请求特定大小的内存块,并返回一个指向分配内存的指针
- 手动管理: 与栈内存不同,堆内存需要手动管理。这意味着程序员负责在不再需要内存时释放它,以防止内存泄漏(内存未释放导致程序持续占用内存)
- 不连续内存: 堆内存通常是不连续的,因此可以存储分散在不同物理内存位置的数据。这与栈内存不同,栈内存是连续的
- 用途: 堆内存常用于存储动态数据结构,如动态数组、链表、哈希表、树等。它还用于存储程序运行时创建的对象和数据,以及需要在程序的不同部分之间共享的数据
- 释放内存: 一旦不再需要堆内存中的数据,程序员负责使用 free() 等函数将其释放,以便操作系统可以重新分配该内存
需要注意的是,堆内存的手动管理需要小心,因为错误的内存管理可能导致内存泄漏或内存访问错误,从而导致程序崩溃或不稳定。因此在使用堆内存时,程序员需要谨慎地分配和释放内存,以确保程序的稳定性和性能.
Linux 使用 struct task_struct 描述一个用户进程,并使用 struct mm_struct 数据结构描述用户进程的虚拟地址空间,其中 mm->start_brk 记录了堆在进程地址空间起始虚拟地址,mm->brk 指向堆目前可以分配的位置,由于用户进程动态的分配和释放堆内存,那么堆空间形成了零散的虚拟区域,为了保证堆的最大利用,mm->brk 指向上一次堆释放的位置.
当用户进程调用 brk 函数分配堆内存时,系统调用的处理逻辑如上图,首先对请求的地址进行检查,检查通过之后调用 get_unmapped_area 函数从堆内找到一块空闲且能够容乃新分配的虚拟区域,然后检查该区域是否可以和已经存在的 VMA 区域合并,因为堆内存的属性都是相同的,因此方便合并。如果没有可以合并的则调用 vm_area_alloc 函数新分配一个 VMA 管理新的堆内存,并调用 vma_set_anonymous 函数将 VMA 修改为匿名映射的 VMA. 最后就是调用 vm_get_page_prot 获得页表内存,以及 vma_link 函数将 VMA 添加到进程地址空间红黑树上面。通过对源码的分析,堆内存可能会通过两种方式分配物理内存:
- 预分配(PreAlloc): 由于分配的对内存比较少,可以直接与存在的堆内存 VMA 合并,并直接使用已经映射好匿名内存。这种情况下,新分配的堆内存不会发生缺页.
- 惰性分配(LazyAlloc), 当 CPU 首次对堆内存写操作: 新的堆 VMA 没有与 VMA 合并,只有虚拟内存,MMU 因发现没有对应的物理内存而触发缺页异常,在缺页异常处理函数里会新分配一个物理页作为匿名页,并更新页表映射到该匿名页上,这样 CPU 可以对堆内存进行写操作
- 惰性分配(LazyAlloc), 当 CPU 首次对堆内存读操作: 新的堆 VMA 没有与 VMA 合并,只有虚拟内存,MMU 同样检查到没有对应的物理内存,那么也会触发缺页异常,在缺页异常处理函数里,如果系统支持 ZERO Page,那么会将页表映射到 Zero Page 上,并将页表修改为写保护,那么 CPU 可以对堆内存进行读操作,但如果 CPU 接下来对堆内存发起写操作,MMU 检查权限异常,那么再次触发缺页异常,缺页异常处理函数此时新分配一个物理页作为匿名页,并更新页表映射到该匿名页上,最后将页表修改为可读可写,这样 CPU 就可以对堆内存进行写操作.
- 惰性分配(LazyAlloc), 当 CPU 首次对堆内存读操作: 新的堆 VMA 没有与 VMA 合并,只有虚拟内存,MMU 同样检查到没有对应的物理内存,那么也会触发缺页异常,在缺页异常处理函数里,如果系统不支持 ZERO Page,那么缺页异常处理函数此时新分配一个物理页作为匿名页,并更新页表映射到该匿名页上,这样 CPU 就可以对堆内存进行读操作,并且后续的写操作不会触发缺页异常.
堆内存属于可读可写的匿名内存,因此堆的缺页行为和可读可写匿名内存的行为一致. 接下来通过一个实践案例进一步了解该场景,实践案例在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS
make menuconfig
[*] Package --->
[*] Paging Mechanism --->
[*] Page Fault with Anonymous on Heap --->
# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-HEAP-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
实践案例由一个应用程序构成,程序首先在 21 行调用 sbrk(0) 获得当前堆的位置,然后 24 行将堆当前位置加上需要分配的长度,获得堆结束位置,接着将堆结束的位置在 25 行传入 brk() 函数里,这里相对于从堆上分配指定长度的内存。分配成功之后程序在 34 行对堆内存执行写操作,此时堆内存只有虚拟内存,因此会触发缺页异常, 程序接着在 36 行对堆内存进行读操作,最后在 39 行将堆重置到分配起点,这步类似与释放堆内存. 以上便是一个最基础的实践案例,可以知道 34 行读操作会触发缺页,为了可以看到内存在缺页异常里的流动,本次在 34 行前后加上 BS_DEBUG 开关:
接着在匿名内存缺页流程必经之路上任意位置加上 BS_DEBUG 函数,以此观察内存在某个函数里的流动,例如上图在 do_anonymous_page 函数的 4039 行加上 bs_debug 打印,以此确认内存是向哪个函数流动,接下来执行如下命令进行实践:
# 编译应用程序
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-HEAP-default/
# 编译内核
make kernel
# 编程程序
make build
当 BiscuitOS 启动之后,直接运行 RunBiscuitOS.sh 脚本,脚本里包括实践所需的命令,可以看到进程执行之后对虚拟内存的访问引起了缺页异常,并且该案例的缺页异常处理流程打印了字符串 “BRK Anonymous Memory on do_anonymous_page 0x561a1cc8d000”, 说明缺页异常处理函数执行过堆内存缺页. 通过实践可以看到实践案例按着之前分析的代码路径流动。最后开发者可以在该路径上的任何地方使用 bs_debug 查看匿名内存在缺页异常处理流程里的流动.
对于堆内存,其缺页异常处理流程如上图,如果是首次读操作,那么最终进入映射 “HEAP ZERO Page” 分支; 如果首次是读接下来发生写操作,那么进入 “WP HEAP AnonMEM” 分支; 如果首次是写,那么进入 “R/W HEAP AnonMem” 分支.
对于首次发起对堆内存写操作导致的缺页,do_anonymous_page 函数作为核心处理流程其逻辑如上图,其首先在 4085 行调用 alloc_zeroed_user_highpage_movable 函数从 Buddy 分配器分配一个物理页,然后在 4098 行调用 __SetPageUptodate 函数将物理页标记为物理页的内容是最新的,无需更新。接下来构造 PTE Entry 的内容,首先从 VMA 的 vm_page_prot 成员获得 VMA 设置的页表属性,此时检查到 VMA 可写,那么调用 pte_mkwrite 添加页表的 Write 标志。接下来函数在 4105 行调用 pte_offset_map_lock 获得 PTE Entry,并进行一些检测,函数接着在 4123 行调用 inc_mm_counter_fast 函数增加对 MM_ANONPAGES 的统计,以此表示系统的匿名内存增加的数量,然后调用 page_add_new_anon_rmap 建立物理内存的逆向映射,最后调用 lru_cache_add_inactive_or_unevictable 函数将物理页添加到 LRU 链表,当系统内存紧张的时候会将该物理页交换到 SWAP Space,处理完这些之后,函数在 4127 行调用 set_pte_at 函数更新页表.
对于首次发起对堆内存读操作导致的缺页,do_anonymous_page 函数作为核心处理流程其逻辑如上图, 函数首先判断到此时缺页是读操作引起的,并且 mm_forbids_zeropage 返回 0 说明系统支持 ZERO Page,那么缺页异常处理会为读操作映射到 ZERO Page. 所谓 ZERO Page 就是一块特殊的内存,内存的内容全部为 0,并且有 4KiB 粒度和 2MiB 粒度的,只要符合条件的进程都可以将匿名内存映射到 ZERO Page 上,这样可以起到节省内存的作用. 因此函数在 4063 行调用 pte_mkspecial 函数构造了一个指向 ZERO Page 的 PTE Entry 内容,并在 4065 函数调用 pte_offset_map_lock 函数获得 PTE Entry 的地址,如果发现此时 PTE Entry 不为空,那么可能是页表的内容和 TLB 的内容不一致,此时函数进入 4068 行分支调用 update_mmu_tlb 函数更新 TLB,并跳转到 unlock 处; 反之如果此时 PTE Entry 为空,说明是第一次访问匿名内存,符合预期,那么跳转到 setpte 处将准备好的 PTE Entry 内容更新到 PTE Entry 里,待缺页中断返回之后进程可以继续对匿名内存进行读操作而不会引起缺页. 细心的开发者是否发现,整个过程没有设置页表的 _PAGE_RW 标志位,那么说明这段虚拟内存是写保护的,因此接下来只要进程对虚拟内存首次发起写操作,那么还是会触发缺页.
对于首次发起对堆内存读操作之后再次发起写操作导致的缺页,do_wp_page 函数作为核心处理流程其逻辑如上图,函数获得匿名内存对于的物理页之后,如果在 3419 行发现匿名页是独占的,也就是此时只有一块虚拟内存映射到该物理页上,那么直接跳转到 reuse 处调用 wp_page_reuse() 函数,该函数的核心作用就是添加 PTE Entry 页表的 _PAGE_RW 标志,以此让堆内存具有可读可写的权限. 其余逻辑均不再此种场景考虑的范围.
匿名映射之栈(Stack) 缺页场景
用户进程的栈(Stack)是一种用于管理函数调用和局部变量的内存区域。栈是一种特殊的内存结构,用于存储函数的调用信息以及函数内部的局部变量, 每个线程在用户空间都有自己的栈. 上图是用户进程虚拟地址空间布局,可以看到栈位于用户进程地址空间的顶部,并且栈内存低地址生长. 栈内存具有如下特点和用途:
- 函数调用和返回: 栈用于存储函数的调用信息,包括函数的返回地址、参数、局部变量和寄存器的状态。当一个函数被调用时,它的局部变量和其他信息被推入栈中,当函数返回时,这些信息从栈中弹出,恢复调用前的状态
- 后进先出(LIFO): 栈是一种后进先出的数据结构,这意味着最后进栈的数据项会最先出栈。这符合函数调用的自然顺序,因为内部函数通常在外部函数返回之前完成执行
- 栈指针: 每个线程都有一个栈指针(Stack Pointer,SP),它指向当前栈顶的位置。栈操作(如压栈和出栈)通过调整栈指针来执行
- 局部变量: 函数内部的局部变量通常存储在栈上, 这些变量的生命周期与函数的执行周期相对应,当函数返回时它们自动被销毁
- 递归函数: 栈也用于支持递归函数调用, 每个递归函数调用都会在栈上创建一个新的函数调用帧,以保持各个递归调用之间的独立状态
- 栈溢出: 如果栈空间被用尽或者递归层次太深,可能会导致栈溢出错误, 这种错误会导致程序异常终止
总之,用户进程的栈是用于管理函数调用和局部变量的关键内存区域。它在程序执行中动态增长和收缩,以适应不同的函数调用和局部变量的需求。栈的正确使用对于确保程序的正确性和稳定性非常重要。程序员通常不需要直接操作栈,因为编程语言和编译器会自动处理大部分栈操作. 栈内存是在每个线程启动时分配的,并在线程终止时释放。每个线程都有自己的栈内存,用于存储函数调用的局部变量、函数参数以及函数调用的上下文信息, 具体来说,栈内存的分配和释放发生在以下时刻:
- 线程/进程创建时: 当操作系统创建一个新线程/进程时,它会为该线程/进程分配一块栈内存空间。这个空间的大小通常是固定的,是由操作系统或线程/进程创建时的配置参数决定的
- 函数调用时: 每当一个函数被调用时,一个新的栈帧(stack frame) 会被创建并推入栈中。栈帧包含了函数的局部变量、函数参数、返回地址以及其他与函数调用相关的上下文信息, 这个栈帧在函数执行期间一直存在,直到函数返回
- 函数返回时: 当函数执行完成并返回时,它的栈帧会被弹出栈,同时释放掉与之相关的栈内存。这样栈内存中的空间可以被重新用于存储下一个函数调用的栈帧或其他数据
- 线程终止时: 当线程终止时,它的整个栈内存空间会被释放,以便操作系统可以回收这些内存资源
需要注意的是,栈内存的大小通常是有限的,因此在进行递归调用或者分配大量局部变量时,可能会导致栈溢出错误。栈内存的分配和释放是由编程语言和操作系统管理的,程序员通常不需要显式地分配或释放栈内存。编程语言提供的函数调用和返回机制会自动管理栈帧的创建和销毁.
当调用 EXECVE 系统调用创建进程时,其函数调用逻辑如上,可以从中看到在进程创建时会调用 load_elf_binary 函数解析进程对应的 ELF 连接文件,其中包括了解析进程堆栈的信息,并调用 setup_arg_pages 函数划分了堆栈的范围,以及创建对应的 VMA. 从函数调用可以看出堆栈的分配是在进程创建过程中分配好的,而不是进程运行时分配的,并且内核在创建堆栈时使用 GUP 相关的函数,那么创建的时候也为堆栈映射了物理内存,那么堆栈内存不会发生缺页. 虽然堆栈内存不会发生缺页,但是堆栈是可能溢出的,对溢出的内存进行访问,那么会触发缺页! 分析这么多不如通过一个实践案例感受该场景,实践案例在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS
make menuconfig
[*] Package --->
[*] Paging Mechanism --->
[*] Page Fault with Anonymous on Stack --->
# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-STACK-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
实践案例由一个应用程序构成,程序的堆栈中存储字符串数组,其长度为 MAP_SIZE,程序首先在 22 行计算堆栈之外 2 个 MAP_SIZE 的结束地址,然后在 26 行对该地址进行写操作,可以知道该地址是一个堆栈溢出地址,最后在 28 行再对溢出地址进行读. 以上便是一个最基础的实践案例,可以知道 26 行读操作会触发缺页,为了可以看到内存在缺页异常里的流动,本次在 26 行前后加上 BS_DEBUG 开关:
接着在匿名内存缺页流程必经之路上任意位置加上 BS_DEBUG 函数,以此观察内存在某个函数里的流动,例如上图在 do_user_addr_fault 函数的 1367 行加上 bs_debug 打印,以此确认内存是向哪个函数流动,接下来执行如下命令进行实践:
# 编译应用程序
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-STACK-default/
# 编译内核
make kernel
# 编程程序
make build
当 BiscuitOS 启动之后,直接运行 RunBiscuitOS.sh 脚本,脚本里包括实践所需的命令,可以看到进程执行之后对虚拟内存的访问引起了缺页异常,并且该案例的缺页异常处理流程打印了字符串 “STACK OVERFLOW on do_user_addr_fault 0x7fff4167dc4f”, 说明缺页异常处理函数执行过栈溢出处理. 通过实践可以看到实践案例按着之前分析的代码路径流动。最后开发者可以在该路径上的任何地方使用 bs_debug 查看匿名内存在缺页异常处理流程里的流动.
对于栈溢出,其缺页异常处理流程如上图,缺页异常处理函数找到堆栈对应的 VMA,发现地址不属于堆栈而是属于紧邻的 Hole,那么进入 “STACK OVERFLOW” 分支进行处理.
在 “STACK OVERFLOW” 分支里,缺页异常处理函数处理栈溢出,其核心逻辑位于 do_user_addr_fault 函数,如上图逻辑: 函数首先在 1359 行调用 find_vma 函数获得堆栈对应的 VMA,并在 1364 行检查堆栈溢出地址与堆栈 VMA 的关系,此时可以看到堆栈溢出地址小于堆栈的起始地址,因此这段属于 Hole 区域,函数接着在 1366 行检查确认 VMA 区域是堆栈,那么可以石锤缺页地址是栈溢出地址,那么进入 1367 行调用 bad_area 函数发送 SIG_BUS 信息终止进程. 以上便是堆栈溢出缺页场景, 开发者可以在整个流程添加 BS_DEBUG 查看内存的流动.
匿名映射内存 Protection Key 缺页场景
在基于页表的权限管理里,如果用户进程需要修改页表的权限,那么首先需要通过 mprotect 系统调用修改对应的页表权限,然后进行 TLB 刷新,这是因为 TLB 机制会缓存虚拟地址到最后一级页表的内容,CPU 访问虚拟内存时,硬件首先在 TLB 查找映射关系,如果映射关系存在,那么硬件直接使用映射关系实现内存访问; 反之 TLB 映射关系不存在,那么 MMU 才会去页表里查找映射关系。如果只修改虚拟区域对应页表的权限,那么 TLB 存在映射关系且 MMU 硬件使用旧的权限进行访问,因此修改完页表权限之后需要更新 TLB。上图是 TLB Entry 存储的信息,可以看到 TLB 缓存了最后一级页表的多个权限信息.
Memory Protection Keys for Userspace(PKU) 是 Linux 中的一种内存保护机制,旨在改善用户空间进程的内存访问权限控制和安全性。PKU 引入了一种新的硬件特性,允许用户空间进程定义和管理内存保护密钥,从而实现细粒度的内存保护。PKU 的主要目标是提供以下功能:
- 细粒度内存访问权限控制: PKU 允许用户空间进程定义多个内存保护密钥,每个密钥可以与特定的内存区域相关联。这意味着进程可以为不同的内存区域设置不同的保护级别,从而提供更细粒度的内存访问权限控制
- 减少内核介入: 传统的内存权限更改通常需要涉及内核,并且可能涉及昂贵的系统调用。PKU 允许用户进程在不涉及内核的情况下更改内存保护密钥,从而减少了内核介入的次数,提高了性能
- 提高安全性: 通过允许用户空间进程定义和管理内存保护密钥,PKU 可以帮助防止内存访问错误和缓冲区溢出攻击。只有具有正确密钥的进程才能访问受保护的内存区域
PKU 的实现依赖于特定的硬件支持,因此需要支持 PKU 的处理器。此外操作系统内核也需要对 PKU 进行支持,以便用户空间进程可以利用这一特性. 在匿名内存场景下,如果使用 PKU 对用户空间匿名内存设置权限,如果进程对匿名内存没有 PKU 权限,那么会导致权限异常而触发缺页. PKU 缺页的场景可以通过一个实践案例进行了解,其在 BiscuitOS 上的部署逻辑如下:
cd BiscuitOS
make menuconfig
[*] DIY BiscuitOS/Broiler Hardware --->
[*] Support Host CPU Feature Passthrough
[*] Package --->
[*] Paging Mechanism --->
[*] Page Fault with Anonymous on Protection Key --->
# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-PK-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
实践案例由一个应用程序构成,应用程序有点长被分作三部分,第一部分是 45-60 行调用 mmap 分配一段可读可写匿名内存,并对匿名内存进行写操作,然后是第二部分 63-80 行,其主要目的是使用 PKU 机制将匿名内存设置为不可读不可写。第三部分是 89 行对 PKU 修改过后的区域进行读操作,此时由于 PKU 权限不够引起缺页异常. 最后 92-98 行就是回收测试资源. 以上便是一个最基础的实践案例,可以知道 89 行读操作会触发缺页,为了可以看到内存在缺页异常里的流动,本次在 89 行前后加上 BS_DEBUG 开关:
接着在匿名内存缺页流程必经之路上任意位置加上 BS_DEBUG 函数,以此观察内存在某个函数里的流动,例如上图在 do_user_addr_fault 函数的 1381 行加上 bs_debug 打印,以此确认内存是向哪个函数流动,接下来执行如下命令进行实践:
# 编译应用程序
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-PK-default/
# 编译内核
make kernel
# 编程程序
make build
当 BiscuitOS 启动之后,直接运行 RunBiscuitOS.sh 脚本,脚本里包括实践所需的命令,可以看到进程执行之后对虚拟内存的访问引起了缺页异常,并且该案例的缺页异常处理流程打印了字符串 “PKU Anonymous Memory on do_user_addr_fault 0x6000000000”, 说明缺页异常处理函数执行过 PKU 缺页. 通过实践可以看到实践案例按着之前分析的代码路径流动。最后开发者可以在该路径上的任何地方使用 bs_debug 查看匿名内存在缺页异常处理流程里的流动.
对于 PKU 缺页场景,其异常处理流程如上图,缺页异常处理找到匿名内存对应的 VMA 之后,检查地址合法之后跳转到 good_area 出,此时调用 access_error 函数进行权限检查,对于 PKU 引起的缺页直接返回 1,因此调用 bad_area_access_error 函数发送 SIG_BUS 终结程序.
对于 PKU 权限引起的缺页,缺页异常原因中会包含 PF_PK, 因此在 access_error 函数里检查 PF_PK 之后直接返回 true. 以上便是 Protection Key 缺页场景.