本文翻译自 How The Kernel Manages Your Memory

在介绍完进程中虚拟地址空间的布局后,我们来看一看内核是如何管理内存的:

内核中使用结构体 task_struct 来描述进程,其中含有一个 mm_struct 类型的成员 mm,该类型是内存管理的主要数据结构,如上图所示,它存储着以下内容:

  • 每一个虚拟地址段的起始地址
  • 进程使用的物理页面 (Physical Page) 的数量
  • 进程使用的虚拟地址空间(Virtual Address Space)的数量
  • 其他额外的信息

还有两个和内存管理相关的重要概念:Virtual Memory Area 和 Page Table

 

每一个 Virtual Memory Area (以下简称 VMA)是一段连续且不重叠的虚拟地址,内核用 vm_area_struct 来描述 VMA,它记录着如下信息:

  • VMA 起始地址
  • 访问权限的标记
  • 映射文件(如果有的话。没有映射文件的 VMA 是匿名映射)

在上图中,每一个 Memory Segment (如 heap,stack 等) 都对应着一个 VMA。

一个进程的所有 VMA 都被保存在它的内存描述符中的一个已排序(根据虚拟地址的起始地址)的链表(在mmap字段中)和红黑树(mm_rb)中。使用红黑树可以使内核快速查找一给定地址所在的 VMA 中。通过查看 /proc/pid_of_process/maps 文件,内核会遍历该进程的 VMA 链表并打印出来。

在 Windows 中,EPROCESS block 大致是 task_struct 和 mm_struct 的混合体。存储 VMA 的结构在 Windows 中叫 VAD(Virtual Address Descriptor),VAD 存放在一棵 AVL 树中。

4GB 的虚拟地址空间被分割成 页(Page),x86 的32位处理器支持 4KB、2MB 和 4MB 大小的页,Linux 和 Windows 都默认使用 4KB 大小的页。一个 VMA 的大小必须是页大小的整数倍。下图是 3GB 大小用户空间的 Page 示例图:

处理器使用页表(Page Table)来将虚拟地址转换为物理地址,每一个进程都维护着它自己的页表,当进程切换时,对应的页表(用户空间的)也会发生切换。Linux 在内存描述符使用 pgd 字段指向该进程的页表,每 一个虚拟页面都对应着一个页表项(Page Table Entry,PTE),在x86机器上如下所示:

PTE 中有很多 flag,Linux 使用专门的函数来设置和读取这些 flag。

  • P 标志位表示该页面是否已经映射到物理内存,如果该位为0,访问该页将触发缺页中断,Keep in mind that when this bit is zero, the kernel can do whatever it pleases with the remaining fields.
  • R/W 标志位表示该页的读写属性,0为只读;
  • U/S 表示该页的访问权限,0表示只能被内核访问。

以上这些标志位实现了内存的写保护和内核态内存空间。

  • D - Dirty,表示脏页面,如果一个页面被写过,它的 D 位被置为 1;
  • A - Access,如果该页面被访问过(读或写),它的 A 位被置为 1。

这些标志位可以被 CPU 设置,但是只能由内核去 clear 。

最后,PTE 存储着该页面对应的 4K 对齐的起始物理地址。通常页面的最大映射范围是 4GB 的物理内存,但是可以通过 PTE 的其他位进行拓展。

一个虚拟页面是内存保护的基本单位,因为如果从物理页的角度看,同一个物理页可以对应多个虚拟页,从而有不同的保护标志位。注意 PTE 没有关于执行权限的标记,所以在经典 x86 体系机中允许栈上的代码被执行,这容易引致缓冲区溢出攻击。This lack of a PTE no-execute flag illustrates a broader fact: permission flags in a VMA may or may not translate cleanly into hardware protection. The kernel does what it can, but ultimately the architecture limits what is possible.

虚拟页面不实际存储数据,它只是将进程的地址空间映射到底层的物理内存。

在总线上的内存操作比较复杂, 我们可以假设物理地址区间是从0到可用内存按照字节自增的。物理地址空间被内核以页大小为单位划分页帧(Frame)。CPU不知道页帧的存在, 但是页帧是对内核却很重要, 也是内核内存管理最基本的单元。 Linux和Windows都使用4KB大小的页帧(32位模式);下图是一个拥有2G内存的例子:

在 Linux 中使用一个描述符和几个标记位来表示页帧,这些描述符整体描述了整个的物理内存,一个页帧的状态总是确定且已知的。物理内存通过伙伴算法进行管理和分配,如果一个页桢当前可通过伙伴算法被分配,则称它是 free 的。

一个已分配的页桢有两种状态:一种是“匿名”的,存放着程序的数据;另一种情况在 page cache 中,存放着 文件 块设备 中的数据(暂不考虑其他奇怪的用法)。Windows 使用 PFN (Page Frame Number) 数据库来管理物理内存。

下面这个图描述了 虚拟内存空间(VMA),页表项和页桢的关系:

中间蓝色的方块表示 VMA 中的 page,它的箭头表示通过页表项指向了物理页桢。其中一部分 page 是没有箭头的,这意味着其页表项中的P(resent)标记位被清零,这可能是由于该页不再被使用或其内容已经被 swaped out,这两种情况下访问该页都会导致缺页中断。

VMA 像是内核和上层应用的桥梁,上层应用向内核请求做一件事(内存分配、文件映射等),内核会直接同意,然后修改 VMA,但是内核不会立即去执行上层应用请求的事,真正的执行请求需要一个缺页中断来做。从这个角度看,内核懒惰且不诚实,但这也是 virtual memory 的基准准则。VMA 记录着内核已经“同意”的事,而页表项记录着内核已经“做成”的事。这两个结构是内存管理的主要成员。下面是一个内存分配的流程示例:

内存分配流程
内存分配流程

应用程序通过 brk() 系统调用来申请更多内存时后,内核扩展 heap VMA,但新扩展的部分还没有对应上物理内存;当应用程序访问新申请的内存后,系统调用 do_page_fault() 会被调用,它会通过 find_vma() 在 VMA 中查找没有对应上物理内存的 virtual address,如果找到的话,会检查该 VMA 的相关权限并进行下一步操作*,否则会造成 Segmentation Fault.

上面说的“下一步操作”即查找页表项,在上图的情况下,其页表项的 P 标记位会显示其 Not Pressent,(实际上整个页表项是 blank 的,即全为0)。由于当前 VMA 是匿名的,后面会通过 do_anonymous_page() 来进行页桢的分配和更新页表项。

这一步操作也会有另外一种情况,即页表项的 P 标记位为0,但整个页表项不是 blank 的,这意味着它之前被 swaped out,这种情况可以在页表项中找到其 swap 地址,之后由 do_swap_pages() 来处理。