代码改变世界

内核是如何管理内存的(翻译)

2014-09-01 22:24  付哲  阅读(1268)  评论(0编辑  收藏  举报

How the Kernel Manages Your Memory

在研究完进程的虚拟地址布局后,我们来看看内核是怎么管理用户内存的。还是以gonzo为例:

Linux进程在内核中被实现为task_struct(即进程描述符)的实例。task_struct的mm域指向mm_struct,即内存描述符,负责管理整个程序的内存。它保存着上图中各内存段的开始和结束地址,进程使用的物理内存页数(rss意为Resident Set Size,即“驻留集大小”),使用的虚拟地址空间总数,等等。在mm_struct中我们还能找到两个内存管理结构:虚拟内存区集合,以及页表。gonzo的内存区见下图:

每个虚拟内存区(VMA)是一个连续虚拟地址空间,这些区域之间没有重叠。每个内存区可由vm_area_struct表示,包括它的开始和结束地址、访问标志、可能还有代表映射自哪个文件的vm_file域。没有映射自文件的VMA是匿名的。上面说的每个内存段(如堆、栈)都对应一个单独的VMA,但mmap段例外。虽然标准没有这样规定(每个内存段单独一个VMA),但x86机器上通常都是这样的。VMA并不关心它处于哪个内存段。

在进程的mm_struct中,VMA同时保存在mmap域的链表(按起始虚拟地址排序)和mm_rb域的红黑树中。红黑树允许内核快速查找到覆盖了指定虚拟地址的内存区。当你读/proc/pid/maps时,内核就是简单的沿链表将每个VMA打印出来

Windows中的EPROCESS块差不多是task_struct加mm_struct的。Windows中与VMA相对的是VAD(虚拟地址描述符),VAD保存在一个AVL树中。你知道与Windows和Linux有关的最有趣的事是什么吗?就是它们之间的差别居然这么小。

4GB的虚拟地址空间被分成了若干个页(page)。32位的X86处理器支持4KB、2MB和4MB的页大小。Linux和Windows在用户空间都使用4KB的页。0-4095B处于第0页,4096-8191是第1页,依次类推。VMA大小必须是页大小的整数倍。3GB的用户空间分成页就是这样:

处理器用页表(page table)将虚拟地址转换为物理地址。每个处理器都有自己的页表集合。每当发生进程切换时,对应的用户空间的页表也会切换。Linux将进程页表的指针保存在mm_struct的pgd成员里。对应每个虚拟页,页表中都有一个页表项(PTE),在常见的X86机器上每个PTE一般是4Byte:

Linux有函数可以PTE中每个标志位。P位代表虚页是否已处在物理内存中。如果P位为0,对它的访问会导致一次缺页中断。如果P位为0,内核可以任意处置其它位。R/W位表示read/write,为0表示只读页。U/S位代表用户/内核,为0表示只能被内核访问。这些标志位是用来实现只读内存,以及保护我们上面看到的内核空间。

D和A位代表脏位和访问位。被写过的页就是脏页,而被读写过的页则是访问过的页。这两个位都很诡异:处理器只负责置其为1,只有内核才能置0。最后,PTE保存了指向这个页的起始物理地址,按4KB对齐。这个字段的设计很轻率,是某些问题的根源:它将可访问的物理内存限制为4GB。其它PTE
字段都是为未来的扩展准备的,又称为PAE(物理地址扩展)。

虚拟页是内存保护的单位,因为每个页的所有字节共享一个U/S和R/W标志。但相同的物理内存可以映射为不同的页,且可能有着不同的保护标志。注意PTE中没有关于执行权限的字段。这就是为什么经典的X86分页机制允许执行栈上的代码,从而更容易发现栈溢出的漏洞(在不可执行的栈上,通过return-to-libc等手段仍然可能发起攻击)。PTE缺乏可执行标志这件事表明了一个更广泛意义的真相:VMA里的权限标志可能会,也可能不会完全转换成硬件的保护。内核做了它能做的,但最终硬件架构限制了它能做到什么程度。

虚拟内存不保存什么,它只是把进程的地址空间简单的映射为底层的物理内存,后者是由处理器访问的一大块空间,称为物理地址空间内存操作总会涉及总线,在这里我们可以忽略总线,假设物理地址的范围是从0开始直到最大可能的地址,单位是1Byte。物理地址被内核分成若干个页帧(page frame)。处理器不用关心页帧是什么,但内核很需要,因为页帧是物理内存管理的基本单元。Linux和Windows在32位下的页帧大小都为4KB,下图是一个2GB内存的机器:

Linux中每个页帧都由一个描述符和多个标志追踪。这些描述符加起来就可以追踪整个机器的物理内存。我们总能知道每个页帧的准确状态。物理内存使用了伙伴内存分配技术进行管理。如果一个页帧在伙伴系统中可分配,它就是空闲的。已分配的页帧可能是匿名的,保存程序的数据,或是页缓存,保存文件或块设备中的数据。页帧还有一些其它用途,这里先不提。Windows有一个差不多的数据库PFN(页帧数)来追踪物理内存。

咱们把虚拟内存区、页表项和页帧放到一起,看一下它们是怎么工作的,见下图:

蓝色矩形代表VMA中的页,箭头则代表负责将页映射为页帧的页表项。有些虚拟页没有箭头,这表明对应的PTE的P位是0。可能是因为这些页从没有被接触过,或它们的内容已经被换出了。两种情况下访问这些页都会产生页错误,即使这些页在VMA中也一样。VMA和页表不一致这件事看起来可能很奇怪,但确实经常发生。

VMA像是程序与内核间的一个合同。你要求完成某事(例如分配内存、映射文件等),内核说“好”,之后它创建或更新了相应的VMA。但内核没有立刻处理请求,它会等到页错误再真正处理请求。内核是一个懒惰的,有欺骗性的容器,这是虚拟内存的基本原则。这项原则在大多数情况下都适用,有些大家很熟悉,有些则会让人惊讶。一个规则就是VMA记录什么已经被批准了,而PTE则反映了懒惰的内核实际上做了什么。两者共同管理程序的内存,共同负责解决页错误、释放内存、换出内存等。下面我们看一个简单的内存分配的例子:

当程序通过brk()系统调用申请内存时,内核只是更新了堆的VMA后就返回OK。此时实际上没有分配任何页帧,新申请的页也没有与物理内存有联系。当程序访问这些页时,处理器产生页错误,并调用do_page_fault()。do_page_fault会调用find_vma()查找覆盖了目标地址的VMA。如果找到了,就会进一步检查VMA的读写权限。如果没找到,则进程会收到段错误。

找到VMA时,内核需要通过查看PTE的内容和VMA的类型来处理页错误。在我们的例子中,PTE显示该页未表达。事实上,我们的PTE是全空的(全是0),在Linux中意思是这个页还没有被映射过。既然这是一个匿名VMA(不是映射自文件或设备),我们就有了一个纯内存事件,需要调用do_anonymous_page()处理。它会分配页帧,并让PTE将产生页错误的虚拟页与刚分出来的物理页映射在一起。

有时会有其它情况发生。例如,如果PTE对应的页被换出了,它的P位为0,但并不是空的。相反,它会将保存了页内容的换出位置记下来,之后通过do_swap_page()将其从磁盘中读出来再载入到某个页帧中,这称为一次major fault