图片无法显示,请右键点击新窗口打开图片

BiscuitOS 内存管理之分页大专题订阅入口

Centralized Mapping Project 项目的目的是将用户空间超级大块连续内存映射到同一个 ZERO PAGE 上,并且都使用同一 P4D、PUD、PMD、PTE 页表。可能各位开发者不太理解这句话是什么意思,以及它要解决什么问题,那么接下来我从事情起因一一道来,通过事情的发展了解整个项目从提出到实现的整个过程. 项目起因是在 BiscuitOS SIG 社群里有个同学提出了一个需求,我简化解释一下需求。这位同学需要将 512G 的虚拟内存都映射到一个零页上,因为都映射到同一零页上,并且用户进程只对 512G 虚拟内存进行读操作。那么这位同学就想反正这 512G 虚拟内存都映射到同一个物理页上,能不能将页表的数量减少到 2-3 个。我当时听了理解决绝,直接给出不可能,当时想着这会违反 MMU 查页表的规则。可是后来我越想越觉得这个事情没有我想的那么简单,于是试了试看能不能行,那么接下来我就直接在 BiscuitOS 上开干.

首先捋一下传统分页的做法,当用户进程分配了 512G 虚拟内存之后,系统会按 PAGE_SIZE(4KiB) 粒度为虚拟内存其构建页表,例如一个 4KiB 虚拟内存,系统需要根据虚拟地址的不同字段找到每一级页表的页表项,然后填充页表项的内容,使其指向下一级页表,依次循环找到最后一级页表项时,将 4KiB 物理页的页帧填充到最后一级页表项里。填充完这些页表项之后,进程才能访问这 4KiB 的虚拟内存. 同理 512G 的虚拟内存按 PAGE_SIZE 粒度划分之后就依次建立页表,无论采用 PREALLOC 还是 LAZYALLOC 分配方式,这将是消耗很多时间,并且页表页本省也要占用内存的,可以来计算一下 512G 需要消耗的页表页数量.

以上是 512Gig 虚拟内存需要消耗的页表页计数公式,通过公式可以知道 512G 虚拟内存光页表页就需要 1G 物理内存,因此 512G 虚拟内存实际消耗 513G 物理内存,多出 0.2% 的物理内存支出. 这么再回想这个同学提的需求还是挺有道理的,反正这 512G 虚拟内存最终到映射到同一个零页上,那么可以将 512G 虚拟内存都只用一套页表就可以节省很多内存,具体思路如下:

做法很简单,由于 512G 虚拟内存的 PGD Entry 和 P4D Entry 都相同,因此不用修改这两个项. 那么接下来向分配一个 2MiB 的大页作为 ZERO HUGE PAGE 使用,然后分配一个 4KiB 物理页作为 PMD 页表页,并构造 PMD Entry,使其指向 2MiB 的 ZERO PAGE,最后再将 PMD 页表页的全部 Entry 填充成新构造的 PMD Entry. 同理在分配一个 4KiB 物理页作为 PUD 页表页,并构造 PUD Entry,使其指向新建的 PMD 页表页,最后将 PUD 页表页的全部 Entry 填充成新构造的 PUD Entry. 设置完毕再将 P4D Entry 指向新建的 PUD 页表页即可.

通过这样设计之后,整个 512G 虚拟内存只使用了 2 个页表页,比原先减少了 262142 个页表页,确实有把这件事做下去的动力了。分析完毕之后接下来就是直接撸起袖子开整. 由于通常的内存申请路径是不支持这么完的,因此需要设计一个模块为用户空间提供专业的内存分配,于是可以从 MISC 驱动框架作为基础开始设计,这次可以在 BiscuitOS 上边设计边实践,先部署一个 MISC 驱动,如下:

cd BiscuitOS
make menuconfig

# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-MISC-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build

部署完毕之后,开发者可以先在 BiscuitOS 上实践该案例,直接使用使用 ‘make build’ 命令即可,该实践案例是一个 MISC 驱动框架使用案例,可以直接在上面进行魔改,首先是修改其 app.c 程序,修改如下:

构造应用程序

应用程序修改为上图,首先在 16 行定义 MAP_SIZE 宏,其长度为 512G,这里不要漏了 ULL 字符串,不然 MAP_SIZE 会被截断成 0. 然后 MISC 驱动模块会向用户空间提供 “/dev/BiscuitOS-CETMAP” 文件,分配虚拟内存需要通过该文件与 MISC 驱动模块进行交互. 程序接着在 25 行调用 open 函数打开 “/dev/BiscuitOS-CETMAP” 文件,接着调用 mmap 函数,此时分配虚拟内存的长度为 MAP_SIZE, 并且 mmap 使用 fd 标志进行映射,那么此时 MISC 驱动会为应用程序分配虚拟内存. 分配成功之后,程序在 44 行对 512G 访问内的虚拟内存随机访问,此时由于页表已经建立,访问并不会触发缺页异常,接着在 45 行将读到的数据进行打印,看是否为 0。最后程序在 47 行添加了 sleep 无限睡眠,这么做的目的是先把分配功能调通,然后再调试回收功能.

分配对齐的虚拟地址

接下来在 main.c 中修改 MISC 驱动模块,函数首先要解决的问题是要使用独立的 PUD 页表页,那么虚拟地址必须按 512G 对齐,不让就会出现 512G 虚拟内存会和其他区域共用 PUD 页表页的情况,这样会将问题复杂化,那么这里先提供 BiscuitOS_get_unmapped_area 函数,其作为 BiscuitOS_fops 的 get_unmapped_area 回调函数,那么在应用程序调用 mmap 系统调用分配虚拟内存的时候会调用到 BiscuitOS_get_unmapped_area 函数. 该函数使用 12-15 行逻辑可以在用户进程地址空间找到一块空闲且区域起始地址按 512G 对齐的虚拟内存. 在 main.c 函数的头部定义 ALIGN_512G 宏,其值为 512G. 最后将 DEV_NAME 宏修改为 “BiscuitOS-CETMAP”.

页表映射

同样在 main.c 中修改 MISC 驱动模块,此时函数要解决的问题是为 512G 虚拟内存建立页表. 此时在 MISC 驱动模块里提供 BiscuitOS_mmap 函数,其作为 BiscuitOS_fops 的 mmap 回调函数,那么应用程序调用 mmap 系统调用最终会调用到 BiscuitOS_mmap 函数,函数的处理逻辑如下:

  • 分配 PUD 页表页: 函数在 16 行调用 get_zeroed_page 函数带上 GFP_PGTABLE_USER 标志分配了一个全零的物理页,物理页大小为 PAGE_SIZE. 该物理页会作为 PUD 页表页使用, 函数返回的是物理页的虚拟地址,将其存储在 pud_pg 变量里.
  • 分配 PMD 页表页: 函数在 17 行调用 get_zeroed_page 函数带上 GFP_PGTABLE_USER 标志分配了一个全零的物理页,物理页大小为 PAGE_SIZE. 该物理页会作为 PMD 页表页使用,函数返回的是物理页的虚拟地址,将其存储在 pmd_pg 变量里.
  • 构造 P4D Entry: 函数在 20 行调用 __p4d 函数构造一个 P4D Entry,在设计方案里 P4D Entry 指向预设好的 PUD 页表页,因此这里使用 __pa(pud_pg) 函数获得 PUD 页表页的物理地址,然后由于 P4D Entry 指向的是一个页表页,而不是最后一级页表,因此页表的属性使用 _PAGE_TABLE.
  • 构造 PUD Entry: 函数在 22 行调用 __pud 函数构造一个 PUD Entry,在设计方案里 PUD Entry 指向预设好的 PMD 页表页,因此这里使用 __pa(pmd_pg) 函数获得 PMD 页表页的物理地址,然后由于 PUD Entry 指向的是一个页表页,而不是最后一级页表,因此页表的属性使用 _PAGE_TABLE.
  • 构造 ZERO HUGE PAGE: 为了最大程度上节省页表的开支,那么将 PMD 页表页作为最后一级页表,那么 PMD 页表页的所有 PMD Entry 都会映射到同一个 2MiB 的物理页上,于是函数在 25 行调用 alloc_pages 函数分配一个 2MiB 的物理页,并且使用 __GFP_ZERO 标志,那么 2MiB 大页里全是 0.
  • 构造 PMD Entry: 由于 PMD 页表页是最后一级页表,因此函数在 27 行调用 mk_pmd 函数构造 PMD Entry 的内容,此时将 2MiB 大页传入进去,由于只对 512G 的虚拟内存发起读操作,因此页表使用 PAGE_READONLY 即可. 另外由于 PMD 页表页是最后一级页表,那么需要调用 pmd_mkhuge 函数将 PAGE_PSE 标志置位.
  • 填充 PUD Entry: 在方案设计里,将 PUD 页表页的所有 PUD Entry 都指向同一个 PMD 页表页,那么只需将 PUD 页表页的所有 Entry 都设置为同一个值即可,该值就是刚刚构造好的 PUD Entry. 函数在 30 行使用 FOR 循环遍历 PTRS_PER_PTE 次循环,也就是遍历页表页里所有 Entry. 每当遍历一次,函数首先在 31 行获得一个新的 PUD Entry,然后在 35 行将构建好的 PUD Entry 内容填充到该 PUD Entry 里. 循环结束之后,PUD 页表页的所有 PUD Entry 都指向了同一个 PMD 页表页.
  • 填充 PMD Entry: 在方案设计里,将 PMD 页表页的所有 PMD Entry 都指向同一个 2MiB 大页,那么只需将 PMD 页表页的所有 Entry 都设置为同一个值即可,该值就是刚刚构造好的 PMD Entry. 函数在 30 行使用 FOR 循环遍历 PTRS_PER_PTE 次循环,也就是遍历页表页里所有 Entry. 每当遍历一次,函数首先在 32 行获得一个新的 PMD Entry,然后在 37 行将构建好的 PMD Entry 内容填充到该 PMD Entry 里. 循环结束之后,PMD 页表页的所有 PMD Entry 都指向了同一个 2MiB 大页.
  • 填充 P4D Entry: 对于 512G 的虚拟内存来说,其 PGD Entry 和 P4D Entry 都是相同的,因此函数在 41 行调用 pgd_offset 函数获得 PGD Entry, 接着在 45 行调用 p4d_offset 函数获得 P4D Entry,按设计的方案,此时只需将 P4D Entry 指向准备好的 PUD 页表页即可,于是在 50 行调用 WRITE_ONCE 函数将准备好的 P4D Entry 内容填充到 P4D Entry 里.
缺页验证

通过上面的操作之后,512G 的虚拟内存的页表建立完毕,那么接下来 BiscuitOS_mmap 函数返回之后,用户进程分配虚拟内存成功,接下来就是访问虚拟内存和检查是否缺页. 为了验证虚拟内存是否缺页,在应用程序 44 行处添加 BS_DEBUG 开关进行检查.

图片无法显示,请右键点击新窗口打开图片 图片无法显示,请右键点击新窗口打开图片

接着在内核内存缺页流程必经之路上任意位置加上 BS_DEBUG 函数,以此观察内存在某个函数里的流动,例如上图在 do_anonymous_page 函数的 4065 行加上 bs_debug 打印,以此确认内存是向哪个函数流动,接下来执行如下命令进行实践:

# 编译应用程序
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-MISC-default/
# 编译内核
make kernel
# 编程程序
make build

图片无法显示,请右键点击新窗口打开图片

BiscuitOS 启动之后,直接运行 RunBiscuitOS.sh 脚本,脚本里包含了实践案例运行所需的所有命令。通过实践可以看到并没有发生缺页异常,并且从 512G 虚拟内存的第 1G 位置读到了 0. 可以看到实践结果符合预期的.

内存回收

图片无法显示,请右键点击新窗口打开图片

那么接下来处理内存回收,因为进程有分配内存的动作,那么就要有回收的动作,之前应用程序就已经使用 munmap 函数进行回收,只是在应用程序的 47 行使用了 sleep 函数,让虚拟内存没有回收,那么接下来看看去掉 sleep 之后直接调用 munmap 函数回收内存会发生什么事情,在 BiscuitOS 实践去掉 sleep 之后的实践案例:

图片无法显示,请右键点击新窗口打开图片

可以看到调用 munmap 回收虚拟内存的时候,umap_page_range 出错了,回想一下之前的涉及是 512G 共用一个页表,那么页表会被释放很多次,但方案里页表只有使用一次,这样引起异常也不奇怪,既然分析出原因,那么可以指定修复方案。如果针对问题去解决问题的话,那么 umap_page_range 函数一直在不断遍历 PUD 页表页和 PMD 页表页,这里面的内容都是相同的,没必要浪费时间让其这么做。换个思路,当 munmap 释放 512G 内存的页表时,直接将 P4D Entry 对应的 PUD 页表、PMD 页表页和 PMD 大页直接释放,那么 munmap 系统调用就只需一次就可以把 512G 的虚拟内存页表释放完。那么接下来按这个思路去涉及代码:

图片无法显示,请右键点击新窗口打开图片

回收第一件事就是要知道何时应用程序调用 munmap 系统调用回收内存,这时可以通过 MMU Notifier 机制来获得,因此驱动模块为 BiscuitOS_fops 添加了 open 和 release 回调函数,当应用程序打开 “/dev/BiscuitOS-CETMAP” 文件时,BiscuitOS_open 函数就会被调用,其在 11-12 行注册 MMU Notifier 监听钩子 BiscuitOS_mn_ops,BiscuitOS_mn_ops 只对 invalidate_range_start 进行监听,也就是 munmap 函数开始释放页表的时候会调用该回调函数,那么最终调用 BiscuitOS_invalidate_range_start 函数. 同理在关闭文件的时候,release 的回调函数 BiscuitOS_release 也会被调用,该函数是注销 MMU notifier.

图片无法显示,请右键点击新窗口打开图片

当应用程序调用 munmap 函数释放页表时,BiscuitOS_invalidate_range_start 函数就会被调用,函数首先在 12 行检查是否发生了页表释放,如果不是直接返回,在这里释放页表的事件就是 MMU_NOTIFY_UNMAP. 接下来就是遍历页表找到所有的页表,其逻辑如下:

  • 查找 PGD Entry: 函数在 16 行调用 pgd_offset 函数,结合 mni->mm 和 range->start 找到 512G 虚拟内存对应的 PGD Entry. 此时 mni->mm 对应着进程的地址空间,range 的 start 和 end 成员包含了需要释放页表的范围,此处可以知道 range 的范围即是 512G 虚拟内存. 函数在 17 行调用 pgd_none 函数对 PGD Entry 进行检查,此时 PGD Entry 不能为空,否则直接返回错误.
  • 查找 P4D Entry: 函数在 20 行调用 p4d_offset 函数,结合 pgd 和 range->start 找到 512G 虚拟内存对应的 P4D Entry. 512G 虚拟内存区域共用一个 pgd,range 的 start 和 end 成员包含了需要释放页表的范围,此处可以知道 range 的范围即是 512G 虚拟内存. 函数在 21 行调用 p4d_none 函数对 P4D Entry 进行检查,此时 P4D Entry 不能为空,否则直接返回错误.
  • 查找 PUD Entry: 函数在 24 行调用 pud_offset 函数,结合 p4d 和 range->start 找到 512G 虚拟内存对应的 PUD Entry. 512G 虚拟内存区域使用的 PUD Entry 内容都一样,range 的 start 和 end 成员包含了需要释放页表的范围,此处可以知道 range 的范围即是 512G 虚拟内存. 函数在 24 行调用 pud_none 函数对 PUD Entry 进行检查,此时 PUD Entry 不能为空,否则直接返回错误.
  • 查找 PMD Entry: 函数在 28 行调用 pmd_offset 函数,结合 pud 和 range->start 找到 512G 虚拟内存对应的 PMD Entry. 512G 虚拟内存区域使用的 PMD Entry 内容都一样,range 的 start 和 end 成员包含了需要释放页表的范围,此处可以知道 range 的范围即是 512G 虚拟内存. 函数在 29 行调用 pmd_none 函数对 PMD Entry 进行检查,此时 PMD Entry 不能为空,否则直接返回错误.
  • 释放 PMD 大页: PMD 大页作为 ZERO HUGE,因此直接释放就可以,于是函数在 33 行调用 pmd_page 函数从 PMD Entry 里获得 PMD 大页对应的 STRUCT page, 然后调用 __free_pages 函数将大页释放回 Buddy 分配器.
  • 释放 PMD 页表页: PMD 页表作为最后一级页表,并且 PMD 页表里的所有项内容都相同,没必要去清零页表项,直接释放即可。函数在 35 行调用 pud_page 函数从 PUD Entry 里获得 PMD 页表页对应的 STRUCT buddy,然后直接调用 __free_page 函数将 PMD 页表页直接释放.
  • 释放 PUD 页表页: PUD 页表里的所有项内容都相同,没必要去清零页表项,直接释放即可。函数在 37 行调用 p4d_page 函数从 P4D Entry 里获得 PUD 页表页对应的 STRUCT buddy,然后直接调用 __free_page 函数将 PUD 页表页直接释放.
  • 清零 P4D Entry: 将 PUD 页表页释放完毕之后,仅需将 P4D Entry 清零即可,那么接下来 munmap 系统调用遍历到 P4D Entry 为空就不会再对这 512G 虚拟内存进行页表释放了,那么函数在 42 行调用 WRITE_ONCE 函数将 __p4d(0) 内容写入到 P4D Entry 里.

释放页表的动作比普通的页表释放要快上很多,该方案只用释放三个页,而原始的 512G 内存需要释放 262144 个页,时间上不是一个数量级的,那么接下来在 BiscuitOS 上实践该案例:

图片无法显示,请右键点击新窗口打开图片

BiscuitOS 启动之后,实践案例再次运行,此时应用程序可以分配 512G 虚拟内存,并从 512G 虚拟内存里读到 0,最后还可以正常释放内存,因此可以验证方案已经实现群里同学提出的设想了.

思考

回过头来看看整个事件,从一个开始的全盘否定别人的设想,到接受并实现别人的设想,一致都是很迷幻的,但该总结思考的还是要的,有以下几点思考总结:

  • 不要被固有思维拘束: 之前的群里大佬就说过,内核做的不一定是最好的,你要随时去思考内核现有的做法是最优的吗? 这次的经历确实给了我很大的冲击,我一直坚信内核的做法是最好最正确的解,但其实实现方案和具体的需求场景下,内核的做法可能不是最好的,因此要结合实际场景去思考问题
  • 先思考之后再回答问题: 这次刚听到同学的需求之后,就利用脑子里的刻板映象去回答问题,这是容易产生错误的结论和错误的引导,因此当别人提出合理的需求时,要先思考一下如果是我来做我可以怎么做,是不是真的不可以做,这样至少不会让自己错的太离谱.
  • 强执行力: 在思考同学的需求可行之后,立马在 BiscuitOS 上进行验证,然后不同实践不断发现问题并解决问题,最终将方案落地。整个过程就是要强执行和强实践,才能看到问题并解决问题

图片无法显示,请右键点击新窗口打开图片

完整项目

上面是整个项目的创建过程,BiscuitOS 上也收集了项目的完整代码,开发者可以参考下面命令进行部署:

cd BiscuitOS
make menuconfig

# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-EA-CENTRALIZED-MAPPING-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build

图片无法显示,请右键点击新窗口打开图片