5.4.3 按需载入指令和数据

在建立进程的地址空间时,我们看到,内核仅仅是将地址映射进来,没有加载任何实际指令和数据到内存中。这主要还是出于效率的考虑,一个进程的所有指令和数据并不一定全部要用到,比如某些处理错误的代码。某些错误可能根本不会发生,如果也将这些错误代码加载进内存,就是白白占据内存资源。而且特别对于某些大型程序,如果启动时全部加载进内存,也会使启动时间延长,让用户难以忍受。所以,在实际需要这些指令和数据时,内核才会通过缺页中断处理函数将指令和数据从文件按需加载进内存。这一节,我们就来具体讨论这一过程。

1.获取引起缺页异常的地址

IA32架构的缺页中断的处理函数do_page_fault调用函数__do_page_fault处理缺页中断,相关代码如下:

5.4.3 按需载入指令和数据 - 图1

5.4.3 按需载入指令和数据 - 图2

在发生缺页中断时,寄存器CR2中保存的是引起缺页中断的线性地址。因此,第4行代码首先到寄存器CR2中读取线性地址。然后,函数__do_page_fault检查这个地址的合法性,判断条件就是这个地址是否在某个vma的范围内。注意这里的函数find_vma,它从映射进程地址空间底部的vm_area_struct对象开始遍历,当找到第一个结束地址恰好要大于这个异常地址、但是却是最接近这个异常地址的vm_area_struct对象时,就将这个对象返回。如果找不到这样一个vm_area_struct对象,那么就说明这个地址是非法的,内核将向进程发送SIGSEGV信号,这是程序运行时出现segment fault的原因之一,见代码第6~10行。

如果找到了一个vm_area_struct对象,并且引起异常的这个地址也大于这个vm_area_struct对象的起始地址,那么就说明这个地址恰好在其涵盖的范围内,直接跳转到标号good_area处,见代码第11~12行。

但是,假如引起异常的这个地址小于vm_area_struct对象的起始地址,那也不能一棍子打死,我们还要给它一次机会。在前面讨论栈段的创建时,我们已经看到,内核初始只为进程分配了一个页面大小的空间,因此,其完全有可能是一次压栈操作,但是栈空间尚未映射具体的物理地址,如图5-29所示。

5.4.3 按需载入指令和数据 - 图3

图 5-29 栈异常

那么如何判断这个vm_area_struct对象是否是代码的栈段呢?那就要看其成员vm_flags中是否设置了VM_GROWSDOWN这个标志。如果这个vm_area_struct对象是栈段,则首先对栈进行扩展。代码第13~21行就是处理这种特殊情况的。

2.更新页表

在复制子进程时,子进程也需要复制或者共享父进程的页表。如果没有页表,子进程寸步难行,指令或者数据的地址根本没有办法映射到物理地址,更不用提从物理内存读取指令了。当子进程替换(exec)为一个新的程序时,无论子进程是共享或者复制了父进程的页表,子进程都需要创建新的页表。创建页表的函数如下:

5.4.3 按需载入指令和数据 - 图4

函数pgd_alloc申请了一个物理内存页面,然后调用函数pgd_ctor将存储在swapper_pg_dir处的页目录中的映射内核空间的页目录项复制过来。我们看到,这里没有复制映射用户空间的页目录项,而且也不需要复制,因为用户空间需要映射到一个新的程序。于是,页目录中映射用户空间的这些页目录项的自然为空,更不用提那些还没有影儿的页表了。当访问地址落在这些空的页目录项映射范围内时,自然就引发了缺页异常。那么在缺页异常处理函数中,自然就需要分配页面、分配页表、更新页目录、更新页表项等。

为了可以映射更大的地址空间,Linux中使用多个级别的页面映射。因此,我们先来理解一下内核中页表的管理。比如缺页异常处理函数调用的函数handle_mm_fault,代码如下:

5.4.3 按需载入指令和数据 - 图5

从上述代码中我们看到了pgd、pud、pmd和pte。读者应该可以猜出来,内核使用了4级页表机制。但是这4级页表是如何与物理上的页表结合的呢?我们以没有开启PAE的IA32架构为例,来探讨这一过程。

对于没有启用PAE的IA32,其映射的地址空间为4GB,所以理论上内核使用与IA32物理上相同的2级页表就足够了。但是为了代码的一致性,内核依然保留了pud和pmd等概念。只不过通过一些巧妙的定义,绕过了中间的pud和pmd。当配置内核时,如果未开启支持IA32的PAE特性,内核实质上将使用2级页表。内核使用了文件pgtable-nopud.h和pgtable-nopmd.h中有关pud和pmd的一些定义:

5.4.3 按需载入指令和数据 - 图6

从文件名字我们就已经看出了内核的意图,就是要屏蔽pud和pmd层。下面,我们就结合这两个文件中的定义,来看看内核是如何绕过pud和pmd的。以函数handle_mm_fault中使用的函数pud_alloc为例:

5.4.3 按需载入指令和数据 - 图7

在文件pgtable-nopud.h中函数pgd_none的定义为:

5.4.3 按需载入指令和数据 - 图8

也就是说,函数pgd_none永远返回0,那么pud_alloc中的函数__pud_alloc就不需要执行了,而且pud_alloc返回的值就是函数pud_offset的返回值,这个函数当然也对应的是文件pgtable-nopud.h中的实现:

5.4.3 按需载入指令和数据 - 图9

5.4.3 按需载入指令和数据 - 图10

看到函数pud_offset的实现,我想读者一定会恍然大悟。显然,pud就是pgd。pmd_alloc亦是如此处理的,读者可以仿照上面的方法自行查看。也就是说在使用了2级页表的情况下,pud和pmd就像透明的空气,根本不存在,内核绕了一个圈后又回到原点。虽然代码中还有所谓的pud、pmd等,但是所谓的pud和pmd都是pgd。

读者亦不要被诸如pgd_t、pud_t、pmd_t等这些封装的数据类型所迷惑,说白了,它们就是一些表项中的值。再直白一点,就是一个整数,或者至多是个整数的数组而已。这里之所以使用了一个结构体将这些整数封装起来,就是为了屏蔽这些表项的细节,避免日后的改动影响更多的代码。

了解了内核的页表管理机制后,下面我们就来具体看一下函数handle_mm_fault。

5.4.3 按需载入指令和数据 - 图11

函数handle_mm_fault首先确定引起异常的地址是由哪个页目录项映射的,见第9行代码。

在确定了页目录项pgd后,如前面讨论,我们可以无视第10~13行关于pud和pmd的部分。后面的pud和pmd都是pgd。

确定了页目录项后,第15行代码就要判断页目录项是否为空。如果页目录项为空,显然还要分配页表。前面我们已经看到,对于一个新加载的程序,页目录表中映射用户空间的页目录项是空的。所以pmd_none的值一定是True。进而继续执行函数__pte_alloc分配页表,代码如下:

5.4.3 按需载入指令和数据 - 图12

5.4.3 按需载入指令和数据 - 图13

函数__pte_alloc首先调用pte_alloc_one分配了一个物理页面承载页表,然后调用函数pmd_populate,将这个页表的地址填充进页目录表中对应的表项。这里,对于使用2级页表的情况,pmd表就是页目录表,所以函数pmd_populate也不是在填充什么pmd表,而是填充页目录表。

3.从文件载入指令和数据

页表准备就绪后,handle_mm_fault最后准备载入指令和数据了:

5.4.3 按需载入指令和数据 - 图14

handle_mm_fault首先调用函数pte_offset_map取得引起异常的地址在页表中的页表项。毫无疑问,这个页表项pte也是空的。然后handle_mm_fault调用函数handle_pte_fault从文件载入指令和数据:

5.4.3 按需载入指令和数据 - 图15

5.4.3 按需载入指令和数据 - 图16

handle_pte_fault首先调用pte_present检查这个pte是否存在,见第9行代码。

如果不存在,那么可能有两种情况:第一种情况是页面是首次访问,也就是这个页表项是彻底的空的,而不仅仅是没有置位present,在这种情况下,需要建立页面映射,见代码第10~17行;第二种情况是页面映射已经建立了,只不过是目前交换出内存了,即代码第18~20行处理的情况。我们只讨论第一种情况。

对于第一种情况,也存在两种情况:一种是如果vm_area_struct对象中的成员vm_ops存在,并且vm_ops中提供了函数fault,那么说明段是映射到文件的,见代码第11~15行;否则是匿名映射。这里我们只讨论典型的从文件加载的情况,代码如下:

5.4.3 按需载入指令和数据 - 图17

do_linear_fault这个函数非看似简单,仅一条计算指令,计算了变量pgoff的值,然后就将后续处理丢给了函数__do_fault。但是小计算大智慧,不要小看这条计算指令,它计算出的pgoff是从文件载入指令和数据的关键。

我们知道,每次从文件加载指令或者数据时,都是以页面为单位的,所以我们可以将文件想象为多个连续的页面。那么如何确定引起异常的这个地址对应于文件中的哪个页面呢?

事实上,当从文件中将段映射到进程地址空间时,创建的段的vm_area_struct对象中的成员vm_pgoff已经记录了段在文件中的偏移,而且是以页为单位的。一个段可以占据一个或者多个页面。

当发生缺页异常时,虽然不能确定引起异常的地址是在文件中的哪一个页面,但是可以计算出这个地址相对于段的起始地址的差值。将这个差值转换为以页为单位,再加上段在文件中的偏移,即可确定这个地址在文件中的哪个页面上。

我们用图5-30来更直观地表示一下这个过程。图5-30表示数据段及其相应的vm_area_struct对象,其中使用虚线框起来的页是数据段所映射的范围。

5.4.3 按需载入指令和数据 - 图18

图 5-30 异常地址到页面的计算

我们以下面程序中变量g_a为例,具体体验一下这个偏移的计算过程。

5.4.3 按需载入指令和数据 - 图19

为了更具有代表性,我们使用静态链接,这样编译出的可执行文件尺寸大一点,页面偏移可以多一点。

5.4.3 按需载入指令和数据 - 图20

(1)段在文件的偏移(vm_pgoff)

因为变量g_a在数据段,所以我们看看数据段在可执行文件中的偏移:

5.4.3 按需载入指令和数据 - 图21

我们看到数据段在文件中的偏移是0x0a5fa4,按照页面对齐后,数据段应该从文件中偏移0x0a5000处开始映射。而0x0a5000/0x1000=165,也就是说,数据段映射的文件的起始位置是第165个页。

(2)引起异常的地址在段内的偏移

一个段可能会映射到文件中的多个页,所以我们还要计算具体的地址在段内的偏移(以页为单位)。

5.4.3 按需载入指令和数据 - 图22

变量g_a的地址为0x080ef068。因为映射是以页为单位的,所以这个地址应该包含在从0x080ef000到0x080f0000一个页面中。因此,使用地址0x080ef000与段的起始地址0x080ee000做差,从而得出这个地址所在页在段内的偏移:

5.4.3 按需载入指令和数据 - 图23

即偏移一个页。也就是说,在段在文件中的偏移的基础上,再偏移一个页就可以了,即载入文件第166(165+1)页的数据到内存。

根据上面的讨论可见,do_linear_fault这个函数的主要目的正如同其名字一样,是处理这个线性的缺页异常地址,将其从线性地址转换为相应的页单元。偏移这个参数准备好了,我们继续往下看__do_fault。

5.4.3 按需载入指令和数据 - 图24

函数__do_fault调用具体文件系统中的fault函数,将所需的文件数据读入到内存。至于读入哪个页面,当然要使用刚刚计算的pgoff了。以ext4文件系统为例,其为vma提供的vm_operations_struct如下:

5.4.3 按需载入指令和数据 - 图25

ext4文件系统中的filemap_fault将指定偏移处的页面读入内存,其中参数vmf中的page就是指向从文件载入的页面。

载入页面后,还有最后一步要做:更新页表项。函数__do_fault锁定页表中映射这个页面的页表项,然后调用函数mk_pte创建页表项的值,最后调用set_pte_at将页表项的值填充到页表中对应的页表项。