第九章--进程地址空间

        内核分配内存空间是基于以下两个原因:
        * 内核是操作系统中优先级最高的成分。如果某个内核函数请求动态内存,那么,必定有正当的理由发出那个请求,因此,没有道理试图推迟这个请求。
        * 内核信任自己。所有的内核函数都被假定是没有错误的。因此内核函数不必插入针对编程错误的任何保护措施。
        当给用户态进程分配内存时,情况完全不同:
        * 进程对动态内存的请求被认为是不紧迫的。例如,当进程的可执行文件被装入时,进程并不一定立即对所有的代码页进行访问。类似地,当进程调用malloc以获得请求的动态内存时,也并不意味着进程很快就会访问所有所获得的内存,因此,一般来说,内核总是尽量推迟给用户态进程分配动态内存。
        * 由于用户进程是不可信任的,因此,内核必须能随时准备捕获用户态进程引起的所有寻址错误。
        当用户态进程请求动态内存时,并没有获得请求的页框,而仅仅获得对一个新的线性地址区间的使用权,而这一线性地址区间就成为进程地址空间的一部分。这一区间叫做“线性区(memory region)”
一、进程的地址空间
        进程的地址空间(address space)由允许进程使用的全部线性地址组成。每个进程所看到的线性地址集合是不同的,一个进程所使用的地址与另外一个进程所使用的地址之间没有什么关系。后面我们会看到,内核可以通过增加或删除某些线性地址空间来动态修改进程的地址空间。
        内核通过所谓线性区的资源来表示线性地址区间,线性区是由起始线性地址、长度和一些访问权限来描述的。为了效率起见,起始地址和线性区的长度都必须是4096的倍数,以便每个线性区所识别的数据完全填满分配给它的页框。下面是进程获得新线性区的一些典型情况:
        * 当用户在控制台输入一条命令时,shell进程创建一个新的进程去执行这个命令。结果是,一个全新的地址空间(也就是一组线性区)分配给了新进程。
        * 正在运行的进程有可能决定装入一个完全不同的程序。在这种情况下,进程描述符仍然保持不变,可以在装入这个程序以前所使用的线性区却被释放,并有一组新的线性区被分配给这个进程。
        * 正在有女性的进程可能对一个文件(或它的一部分)执行“内存映射”。在这种情况下,内核给这个进程分配一个新的线性区来映射这个文件。
        * 进程可能持续向它的用户态堆栈增加数据,直到映射这个堆栈的线性区用完为止。在这种情况下,内核也许会决定扩展这个线性区的大小。
        * 进程可能创建一个IPC共享线性区来与其他合作进程共享数据。在这种情况下,内核给这个进程分配一个新的线性区以实现这个方案。
        * 进程可能通过调用类似malloc()这样的函数扩展自己的动态区(堆)。结果是,内核可能决定扩展给这个堆所分配的线性区。
          确定一个进程当前所拥有的线性区(即进程的地址空间)是内核的基本任务,因为这可以让缺页异常处理程序有效地区分引发这个异常处理程序的两种不同类型的无效线性地址:
        * 由编程错误引发的无效线性地址。
        * 由缺页引发的无效线性地址;即使这个线性地址属于进程的地址空间,但是对应于这个地址的页框仍然有待分配。
        从进程的观点来看,后一种地址不是无效的,内核要利用这种缺页以实现请求调页:内核通过提供页框来处理这种缺页,并让进程继续执行。
二、内存描述符
        与进程地址空间有关的全部信息都包含在一个叫做内存描述符(memory descriptor)的数据结构中,这个结构的类型为mm_struct,进程描述符的mm字段就指向这个结构。
        mm_users字段存放共享mm_struct数据结构的轻量级进程的个数。mm_count字段是内存描述符的主使用计数器,在mm_users次使用计数器中的所有用户在mm_count中只作为一个单位。每当mm_count递减时,内核都要检查它是否变为0,如果是,就要解除这个内存描述符,因为不再有用户使用它。
        考虑一个内存描述符由两个轻量级进程共享。它的mm_users字段通常存放的值为2,而mm_count字段存放的值为1(两个所有者进程算作一个)。
        mm_alloc()函数用来获得一个新的内存描述符。由于这些描述符被保存在slab分配器高速缓存中,因此,mm_alloc()调用kmem_cache_alloc()来初始化新的内存描述符,并把mm_count和mm_users字段都置为1。
        相反,mmput()函数递减内存描述符的mm_users字段。如果该字段变为0,这个函数就释放局部描述符表、线性区描述符及由内存描述符所引用的页表,并调用mmdrop()。后一个函数把mm_count字段减1,如果该字段变为0,就释放mm_struct数据结构。
2.1、内核线程的内存描述符
        内核线程仅运行在内核态,因此,它们永远不会访问低于TASK_SIZE(等于PAGE_OFFSET,通常为0xc0000000)的地址。与普通进程相反,内核线程不用线性区,因此,内存描述符的很多字段对内核线程是没有意义的。

三、线性区
        Linux通过类型为vm_area_struct的对象实现线性区。
        每个线性区描述符表示一个线性地址区间。vm_start字段包含区间的第一个线性地址,而vm_end字段包含区间之外的第一个线性地址。vm_end - vm_start表示线性区的长度。vm_mm字段指向拥有这个区间的进程的mm_struct内存描述符。
        进程所拥有的线性区从来不重叠,并且内核尽力把新分配的线性区与紧邻的现有线性区进行合并。如果两个相邻区的访问权限相匹配,就能把它们合并在一起。
3.1、线性区数据结构
        进程所拥有的所有线性区是通过一个简单的链表链接在一起的。出现在链表中的线性区是按内存地址的升序排列的;不过,每两个线性区可以由未用的内存地址区隔开。每个vm_area_struct元素的vm_next字段指向链表的下一个元素。内核通过进程的内存描述符的mmap字段来查找线性区,其中mmap字段指向链表的第一个线性区描述符。
        Linux2.6把内存描述符存放在叫做红-黑树(red-black tree)的数据结构中,在红-黑树中,每个元素(或节点)通常有两个孩子:左孩子和右孩子。树中的元素被排序。对每个节点N,N的左子树上的所有元素都排在N之前,相反,N的右子树上的所有元素都排在N之后;节点的关键字被写入节点内部。此外,红-黑树必须满足下列4条规则
        1、每个节点必须或为黑或为红。
        2、树的根必须为黑。
        3、红节点的孩子必须为黑。
        4、从一个节点到后代叶子节点的每个路径都包含相同数量的黑节点。当统计黑节点个数时,空指针也算作黑节点
        这4条规则确保具有n个内部节点的任何红-黑树其高度最多为2 * log(n + 1)。
        为了存放进程的线性区,Linux即使用了链表,也使用了红-黑树。这两种数据结构包含指向同一线性区描述符的指针,当插入或删除一个线性区描述符时,内核通过红-黑树搜索前后元素,并用搜索结果快速更新链表而不用扫描链表。
        一般来说,红-黑树用来确定含有指定地址的线性区,而链表通常在扫描整个线性区集合来使用。
3.2、线性区访问权限
        我们使用“页”这个术语既表示一组线性地址又表示这组地址中所存放的数据。
        并不能把线性区的访问权限直接转换成页保护位,这是因为:
        * 在某些情况下,即使由相应线性区描述符的vm_flags字段所指定的某个页的访问权限允许对该页进行访问,但是,对该页的访问还是应当产生一个缺页异常。例如,我们在本章后面的“写时复制”一节会看到,内核可能决定把属于两个不同进程的两个完全一样的可写私有页(它的VM_SHARE标志被清0)存入同一个页框中;在这种情况下,无论哪一个进程试图改动这个页都应当产生一个异常。
        * 正如第二章中提到的,80x86处理器的页表仅有两个保护位,即Read/Write和User/Supervisor标志。此外,一个线性区所包含的任何一个页的User/Supervisor标志必须置1,因为用户态进程必须总能够访问其中的页。
        * 启用PAE的新近Intel Pentium 4微处理器,在所有64位页表项中支持NC(No eXecute)标志。
        如果内核没有被编译成支持PAE,那么Linux采取以下规则克服80x86微处理器的硬件限制:
        * 读访问权限总是隐含着执行访问权限,反之亦然。
        * 写访问权限总是隐含着读访问权限。
        反之,如果内核被编译成支持PAE,而且CPU有NX标志,Linux就采取不同的规则:
        * 执行访问权限总是隐含着读访问权限。
        * 写访问权限总是隐含着读访问权限。
        此外,为了做到在“写时复制”技术中适当地推迟页框的分配,只要相应的页不是由多个进程所共享,那么,这种页框都是写保护的。
        因此,要根据以下规则精简由读、写、执行和共享访问权限的16中可能组合:
        * 如果页具有写和共享两种访问权限,那么,Read/Write位被设置为1。
        * 如果页具有读或执行访问权限,但是既没有写也没有共享访问权限,那么,Read/Write位被清0。
        * 如果支持NX位,而且页没有执行访问权限,那么,把NX位设置为1。
        * 如果页没有任何访问权限,那么,Present位被清0,以便每次访问都产生一个缺页异常。然而,为了把这种情况与真正的页框不存在的情况相区分,Linux还把Page size位置为1。
        访问权限的每种组合所对应的精简后的保护位存放在protection_map数组的16个元素中。
3.3、线性区的处理

3.3.1、查找给定地址的最临近区:find_vma()

3.3.2、查找一个与给定的地址区间相重叠的线性区:find_vma_intersection()

3.3.3、查找一个空闲的地址空间:get_unmapped_area()
        函数get_unmapped_area()搜查进程的地址空间以找到一个可以使用的线性地址区间。len参数制定区间的长度,而非空的addr参数指定必须从哪个地址开始进行查找。如果查找成功,函数返回这个新区间的起始地址;否则返回错误码-ENOMEM。

3.3.4、向内存描述符链表中插入一个线性区:insert_vm_struct()

3.4、分配线性地址空间

3.5、释放线性地址空间
        内核使用do_munmap()函数从当前进程的地址空间中删除一个线性地址空间。参数为:进程内存描述符的地址mm,地址区间的起始地址start和它的长度len。要删除的区间并不总是对应一个线性区,它或许是一个线性区的一部分,或许跨越两个或多个线性区。
3.5.1、do_munmap()函数

3.5.2、split_vma()函数
        split_vma()函数的功能是把与线性地址区间交叉的线性区划分成两个较小的区,一个在线性地址区间外部,另一个在区间的内部。

3.5.3、unmap_region()函数
        unmap_region()函数遍历线性区链表并释放它们的页框。该函数作用于5个参数:内存描述符指针mm,指向第一个被删除线性区描述符的指针vma,指向进程链表中vma前面线性区的指针prev,以及两个地址start和end。

四、缺页异常处理程序
        Linux的缺页(Page Fault)异常处理程序必须区分以下两种情况:由编程错误所引起的异常,及由引用属于进程地址空间但还尚未分配物理页框的页所引起的异常。
        如果缺页发生在下面任何一种情况下,则in_atomic()宏产生等于1的值:
        * 内核正在执行中断处理程序或可延迟函数。
        * 内核正在禁用内核抢占的情况下执行临界区代码。
        我们稍微离题一点,解释一下栈是如何映射到线性区上的。每个向低地址扩展的栈所在的区,它的VM_GROWSDOWN标志被设置,这样,当vm_start字段的值可能被减小的时候,而vm_end字段的值保持不变。这种线性区的边界包括、但不严格限定用户态堆栈当前的大小。这种细微的差别主要基于以下原因:
        * 线性区的大小是4KB的倍数(必须包含完整的页),而栈的大小却是任意的。
        * 分配给一个线性区的页框在这个线性区被删除前永远不被释放。尤其是,一个栈所在线性区的vm_start字段的值只能减少,永远也不能增加。甚至进程执行一系列的pop指令时,这个线性区的大小仍然保持不变。
        现在这一点就很清楚了,当进程填满分配给它的堆栈的最后一个页框后,进程如何引起一个“缺页”异常----push引用了这个线性区以外的一个地址(即引用一个不存在的页框)。注意,这种异常不是由程序错误引起的,因此它必须由缺页处理程序单独处理。
4.1、处理地址空间以外的错误地址
        如果异常发生在内核态(error_code的第2位被清0),仍然有两种可选的情况:
        * 异常的引起是由于把某个线性地址作为系统调用的参数传递给内核。
        * 异常是因一个真正的内核缺陷所引起的。

4.2、处理地址空间内的错误地址
        pmd_alloc()函数分别分配一个新的页上级目录和页中间目录。然后,如果需要的话,调用pte_alloc_map()函数分配一个新的页表。如果这两步都成功,pte局部变量所指向的页表项就是引用address的表项。然后调用handle_pte_fault()函数检查address地址所对应的页表项,并决定如何为进程分配一个新页框。
        * 如果被访问的页不存在,也就是说,这个页还没有被存放在任何一个页框中,那么内核分配一个新的页框并适当地初始化。这种技术称为请求调页(demand paging)。
        * 如果被访问的页存在但是标记为只读,也就是说,它已经被存放在一个页框中,那么,内核分配一个新的页框,并把旧页框的数据拷贝到新页框来初始化它的内容。这种技术称为写时复制(Copy On Write, COW)。
4.3、请求调页
        术语“请求调页”指的是一种动态内存分配技术,它把页框的分配推迟到不能再推迟为止,也就是说,一直推迟到进程要访问的页不在RAM中时为止,由此引起一个缺页异常。
        请求调页技术背后的动机是:进程开始运行的时候并不访问其地址空间中的全部地址,事实上,有一部分地址也许永远不被进程使用。
        被访问的页可能不在主存中,其原因或者是进程从没访问过该页,或者是内核已经回收了相应的页框。
        在这两种情况下,缺页处理程序必须为进程分配新的页框。不过,如何初始化这个页框取决于是哪一种页以及页以前是否被进程访问过。特殊情况下:
        1、或者这个页从未被进程访问到且没有映射磁盘文件,或者页映射了磁盘文件。内核能够识别这些情况,这是因为页表相应的表项被填充为0,也就是说,pte_none宏返回1。
        2、页属于非线性磁盘文件的映射。内核能够识别这种情况,因为Present标志被清0而且Dirty标志被置1,也就是说,pte_file宏返回1。
        3、进程已经访问过这个页,但是其内容被临时保存在磁盘上。内核能够识别这种情况,这是因为相应表项没有被填充为0,但是Present和Dirty标志被清0。

4.4、写时复制
        写时复制思想:父进程和子进程共享页框而不是复制页框。然后,只要页框被共享,它们就不能被修改。无论父进程还是子进程何时试图写一个共享的页框,就产生一个异常,这是内核就把这个页复制到一个新的页框中并标记为可写。原来的页框仍然是写保护的:当其他进程试图写入时,内核检查写进程是否是这个页框的唯一属主,如果是,就把这个页框标记为对这个进程是可写的。

4.4.1、处理非连续内存区访问

五、创建和删除进程的地址空间

5.1、创建进程的地址空间
        当创建一个新的进程时内核调用copy_mm()函数。这个函数通过建立新进程的所有页表和内存描述符来创建进程的地址空间。
        通常,每个进程都有自己的地址空间,但是轻量级进程可以通过调用clone()函数(设置了CLINE_VM标志)来创建。这些轻量级进程共享同一地址空间,也就说,允许它们对同一组页进行寻址。

5.2、删除进程的地址空间
        当进程结束时,内核调用exit_mm()函数释放进程的地址空间:

六、堆的管理
        每个Unix进程都拥有一个特殊的线性区,这个线性区就是所谓的堆(heap),堆用于满足进程的动态内存请求。内存描述符的start_brk与brk字段分别限定了这个区的开始地址和结束地址。


你可能感兴趣的:(linux内核)