6.5.1 页故障的产生
页故障的产生有三种原因:
(1) 一是程序出现错误,例如向随机物理内存中写入数据,或页错误发生在TASK_SIZE(3G)的范围外,这些情况下,虚拟地址无效, Linux 将向进程发送 SIGSEGV 信号并终止进程的运行;
(2) 另一种情况是,虚拟地址有效,但其所对应的页当前不在物理内存中,即缺页错误,这时,操作系统必须从磁盘映象或交换文件(此页被换出)中将其装入物理内存。这是本节要讨论的主要内容。
(3) 最后一种情况是,要访问的虚地址被写保护,即保护错误,这时,操作系统必须判断:如是用户进程正在写当前进程的地址空间,则发 SIGSEGV 信号并终止进程的运行。但是,如果错误发生在一旧的共享页上时,则处理方法有所不同,也就是要对这一共享页进行复制,这就是我们后面要讲的写时复制(Copy On Write 简称COW)技术
有关页错误的发生次数的信息可在目录proc/stat下找到。
6.5.2页错误的定位
页错误的定位既包含虚拟地址的定位,也包含被调入页在交换文件(swapfile)或在可执行映象中的定位。
具体地说,在一个进程访问一个无效页表项时,处理器产生一个陷入并报告一个页错误,它描述了页错误发生的虚地址和访问类型,这些类型通过页的错误码error_code中的前三位来判别,具体如下:
* bit 0 == 0 means no page found, 1 means protection fault
* bit 1 == 0 means read, 1 means write
* bit 2 == 0 means kernel, 1 means user-mode。
也就是说,如果第0位为0,则错误是由访问一个不存在的页引起的(页表的表项中present标志为0);否则,如果第0位为1,则错误是由无效的访问权所引起的。如果第1位为0,则错误是由读访问或执行访问所引起;如果为1,则错误是由写访问所引起的。如果第2位为0,则错误发生在处理器处于内核态时,否则,错误发生在处理器处于用户态时。
页错误的线性地址被存于CR2 寄存器,操作系统必须在vm_area_struct中找到页错误发生时页的虚拟地址(通过红黑树或旧版本中的AVL树),下面通过do_page_fault()中的一部分源代码来说明这个问题:
/* CR2中包含有最新的页错误发生时的虚拟地址*/
__asm__("movl %%cr2,%0":"=r" (address));
vma = find_vma(current, address);
如果没找到,则说明访问了非法虚地址,Linux会发信号终止进程(如果必要)。否则,检查页错误类型,如果是非法类型(越界错误,段权限错误等)同样会发信号终止进程,部分源代码如下:
vma = find_vma(current, address);
if (!vma)
goto bad_area;
if (vma->vm_start <= address)
goto good_area;
if (!(vma->vm_flags & VM_GROWSDOWN))
goto bad_area;
if (error_code & 4) { /*如是用户态进程*/
/* 不可访问堆栈空间*/
if (address + 32 < regs->esp)
goto bad_area;
}
if (expand_stack(vma, address))
goto bad_area;
bad_area: /* 用户态的访问*/
{
if (error_code & 4){
current->tss.cr2 = address;
current->tss.error_code = error_code;
current->tss.trap_no = 14;
fore_sig(SIGSEGV, current); /* 给当前进程发杀死信号*/
return;
……
die_if_kernel("Oops", regs, error_code); /*报告内核 */
do_exit(SIGKILL); /*强行杀死进程*/
6.5.3 进程地址空间中的缺页异常处理
对有效的虚拟地址,如果是缺页错误的话,Linux 必须区分页所在的位置,即判断页是在交换文件中,还是在可执行映象中。为此,Linux 通过页表项中的信息区分页所在的位置。如果该页的页表项是无效的,但非空,则说明该页处于交换文件中,操作系统要从交换文件装入页。对于有效的虚拟地址address,do_page_fault( )转到good_area标号处的语句执行:
good_area:
write = 0;
if (error_code & 2) { /* 写访问 */
if (!(vma->vm_flags & VM_WRITE))
goto bad_area;
write++;
} else /* 读访问 */
if (error_code & 1 ||
!(vma->vm_flags & (VM_READ | VM_EXEC)))
goto bad_area;
如果错误由写访问引起,函数检查这个虚拟区是否可写。如果不可写,跳到bad_area代码处;如果可写,把write局部变量置为1。
如果错误由读或执行访问引起,函数检查这一页是否已经存在于物理内存中。如果在,错误的发生就是由于进程试图访问用户态下的一个有特权的页面(页面的User/Supervisor标志被清除),因此函数跳到bad_area代码处(实际上这种情况从不发生,因为内核根本不会给用户进程分配有特权的页面)。如果不存在物理内存,函数还将检查这个虚拟区是否可读或可执行。
如果这个虚拟区的访问权限与引起错误的访问类型相匹配,则调用handle_mm_fault( )函数:
if (!handle_mm_fault(tsk, vma, address, write)) {
tsk->tss.cr2 = address;
tsk->tss.error_code = error_code;
tsk->tss.trap_no = 14;
force_sig(SIGBUS, tsk);
if (!(error_code & 4)) /* 内核态 */
goto no_context;
}
如果handle_mm_fault( )函数成功地给进程分配一个页面,则返回1;否则返回一个适当的错误码,以便do_page_fault( )函数可以给进程发送SIGBUS信号。handle_mm_fault( )函数有4个参数:tsk指向错误发生时正在CPU上运行的进程;vma 指向引起错误的虚拟地址所在虚拟区;address 为引起错误的虚拟地址;write_access:如果tsk试图向address写,则置为1,如果tsk试图读或执行address,则置为0
handle_mm_fault()函数首先检查用来映射address的页中间目录和页表是否存在。即使address属于进程的地址空间,但相应的页表可能还没有分配,因此,在做别的事情之前首先执行分配页目录和页表的任务:
pgd = pgd_offset(vma->vm_mm, address);
pmd = pmd_alloc(pgd, address);
if (!pmd)
return -1;
pte = pte_alloc(pmd, address);
if (!pte)
return -1;
pgd_offset()宏计算出address所在页在页目录中的目录项指针;如果有中间目录(i386不起作用),调用pmd_alloc( )函数分配一个新的中间目录。然后,如果需要的话,调用pte_alloc( )函数分配一个新的页表。如果这两步都成功,pte局部变量所指向的页表表项就是引用address的表项。然后调用handle_pte_fault( )函数检查address地址所对应的页表表项:
return handle_pte_fault(tsk, vma, address, write_access, pte);
handle_pte_fault( )函数决定怎样给进程分配一个新的页面:
如果被访问的页不存在,也就是说,这个页还没有被存放在任何一个页面中,那么,内核分配一个新的页面并适当地初始化;这种技术称为请求调页。
如果被访问的页存在但是被标为只读,也就是说,它已经被存放在一个页面中,那么,内核分配一个新的页面,并把旧页面的数据拷贝到新页面来初始化它的内容;这种技术称为写时复制。
6.5.4 请求调页
术语请求调页指的是一种动态内存分配技术,它把页面的分配推迟到不能再推迟为止,也就是说,一直推迟到进程要访问的页不在物理内存时为止,由此引起一个缺页错误。
请求调页技术的引入主要是因为进程开始运行的时候并不访问其地址空间中的全部地址;事实上,有一部分地址也许进程永远不使用。此外,程序的局部性原理保证了在程序执行的每个阶段,真正使用的进程页只有一小部分,因此临时用不着的页所在的物理页面可以由其它进程来使用。因此,对于全局分配(一开始就给进程分配所需要的全部页面,直到程序结束才释放这些页面)来说,请求调页是首选的,因为它增加了系统中的空闲页面的平均数,从而更好地利用空闲内存。从另一个观点来看,在内存总数保持不变的情况下,请求调页从总体上能使系统有更大的吞吐量。
为这一切优点付出的代价是系统额外的开销:由请求调页所引发的每个“缺页”错误必须由内核处理,这将浪费CPU的周期。幸运的是,局部性原理保证了一旦进程开始在一组页上运行,在接下来相当长的一段时间内它会一直停留在这些页上而不去访问其它的页:这样我们就可以认为“缺页”错误是一种稀有事件。
基于以下原因,被寻址的页可以不在主存中:
· 进程永远也没有访问到这个页。内核能够识别这种情况,这是因为页表相应的表项被填充为0,也就是说,pte_none宏返回1。
· 进程已经访问过这个页,但是这个页的内容被临时保存在磁盘上。内核能够识别这种情况,这是因为页表相应表项没被填充为0(然而,由于页面不存在物理内存中,Present为0)。
handle_pte_fault( )函数通过检查与address相关的页表表项来区分这两种情况:
entry = *pte;
if (!pte_present(entry)) {
if (pte_none(entry))
return do_no_page(tsk, vma, address, write_access,
pte);
return do_swap_page(tsk, vma, address, pte, entry,
write_access);
}
我们将在交换机制一节检查页被保存到磁盘上的这种情况(do_swap_page( ) 函数)。
在其它情况下,当页从未被访问时则调用do_no_page( )函数。有两种方法装入所缺的页,这取决于这个页是否被映射到磁盘文件。该函数通过检查vma虚拟区描述符的nopage域来确定这一点,如果页与文件建立起了映射关系,则nopage域就指向一个把所缺的页从磁盘装入到RAM的函数。因此,可能的情况是:
· vma->vm_ops->nopage域不为NULL。在这种情况下,某个虚拟区映射一个磁盘文件,nopage域指向从磁盘读入的函数。这种情况涉及到磁盘文件的低层操作。
· 或者vm_ops域为NULL,或者vma->vm_ops->nopage域为NULL。在这种情况下,虚拟区没有映射磁盘文件,也就是说,它是一个匿名映射。因此,do_no_page( )调用do_anonymous_page( )函数获得一个新的页面:
if (!vma->vm_ops || !vma->vm_ops->nopage)
return do_anonymous_page(tsk, vma, page_table,
write_access);
do_anonymous_page( )函数分别处理写请求和读请求:
if (write_access) {
page = __get_free_page(GFP_USER);
memset((void *)(page), 0, PAGE_SIZE)
entry = pte_mkwrite(pte_mkdirty(mk_pte(page,
vma->vm_page_prot)));
vma->vm_mm->rss++;
tsk->min_flt++;
set_pte(pte, entry);
return 1;
}
当处理写访问时,该函数调用__get_free_page( )分配一个新的页面,并利用memset宏把新页面填为0。然后该函数增加tsk的min_flt域以跟踪由进程引起的次级缺页(这些缺页只需要一个新页面)的数目,再增加进程的内存区结构vma->vm_mm的rss域以跟踪分配给进程的页面数目。然后页表相应的表项被设为页面的物理地址,并把这个页面标记为可写和脏两个标志。
相反,当处理读访问时,页的内容是无关紧要的,因为进程正在对它进行第一次寻址。给进程一个填充为0的页要比给它一个由其它进程填充了信息的旧页更为安全。Linux在请求调页方面做得更深入一些。没有必要立即给进程分配一个填充为零的新页面,由于我们也可以给它一个现有的称为零页的页,这样可以进一步推迟页面的分配。零页在内核初始化期间被静态分配,并存放在empty_zero_page变量中(一个有1024个长整数的数组,并用0填充);它存放在第六个页面中(从物理地址0x00005000开始),并且可以通过ZERO_PAGE宏来引用。
因此页表表项被设为零页的物理地址:
entry = pte_wrprotect(mk_pte(ZERO_PAGE, vma->vm_page_prot));
set_pte(pte, entry);
return 1;
由于这个页被标记为不可写,如果进程试图写这个页,则写时复制机制被激活。当且仅当在这个时候,进程才获得一个属于自己的页并对它进行写。这种机制在下一部分进行描述。
6.5.5写时复制
写时复制技术最初产生于Unix系统,用于实现一种傻瓜式的进程创建:当发出fork( )系统调用时,内核原样复制父进程的整个地址空间并把复制的那一份分配给子进程。这种行为是非常耗时的,因为它需要:
· 为子进程的页表分配页面
· 为子进程的页分配页面
· 初始化子进程的页表
· 把父进程的页复制到子进程相应的页中
创建一个地址空间的这种方法涉及许多内存访问,消耗许多CPU周期,并且完全破坏了高速缓存中的内容。在大多数情况下,这样做常常是毫无意义的,因为许多子进程通过装入一个新的程序开始它们的执行,这样就完全丢弃了所继承的地址空间。
现在的Unix内核(包括Linux),采用一种更为有效的方法称之为写时复制(或COW)。这种思想相当简单:父进程和子进程共享页面而不是复制页面。然而,只要页面被共享,它们就不能被修改。无论父进程和子进程何时试图写一个共享的页面,就产生一个错误,这时内核就把这个页复制到一个新的页面中并标记为可写。原来的页面仍然是写保护的:当其它进程试图写入时,内核检查写进程是否是这个页面的唯一属主;如果是,它把这个页面标记为对这个进程是可写的。
Page结构的count域用于跟踪共享相应页面的进程数目。只要进程释放一个页面或者在它上面执行写时复制,它的count域就递减;只有当count变为NULL时,这个页面才被释放。
现在我们讲述Linux怎样实现写时复制(COW)。当handle_pte_fault( )确定“缺页”错误是由请求写一个页面所引起的时(这个页面存在于内存中且是写保护的),它执行以下语句:
if (pte_present(pte)) {
entry = pte_mkyoung(entry);
set_pte(pte, entry);
flush_tlb_page(vma, address);
if (write_access) {
if (!pte_write(entry))
return do_wp_page(tsk, vma, address, pte);
entry = pte_mkdirty(entry);
set_pte(pte, entry);
flush_tlb_page(vma, address);
}
return 1;
}
首先,调用pte_mkyoung( ) 和 set_pte( )函数来设置引起错误的页所对应页表项的访问位。这个设置使页“年轻”并减少它被交换到磁盘上的机会。如果错误由违背写保护而引起的,handle_pte_fault( ) 返回由do_wp_page( )函数产生的值;否则,则已检测到某一错误情况(例如,用户态地址空间中的页,其User/Supervisor标志为0),且函数返回1。
do_wp_page( )函数首先把page_table参数所引用的页表表项装入局部变量pte,然后再获得一个新页面:
pte = *page_table;
new_page = __get_free_page(GFP_USER);
由于页面的分配可能阻塞进程,因此,一旦获得页面,这个函数就在页表表项上执行下面的一致性检查:
· 当进程等待一个空闲的页面时,这个页是否已经被交换出去(pte 和 *page_table的值不相同)
· 这个页是否已不在物理内存中(页表表项中页的Present标志为0)
· 页现在是否可写(页项中页的Read/Write标志为1)
如果这些情况中的任意一个发生,do_wp_page( )释放以前所获得的页面,并返回1。
现在,函数更新次级缺页的数目,并把引起错误的页的页描述符指针保存到page_map局部变量中。
tsk->min_flt++;
page_map = mem_map + MAP_NR(old_page);
接下来,函数必须确定是否必须真的把这个页复制一份。如果仅有一个进程使用这个页,就无须应用写时复制技术,而且进程应该能够自由地写这个页。因此,这个页面被标记为可写,这样当试图写入的时候就不会再次引起“缺页”错误,以前分配的新的页面也被释放,函数结束并返回1。这种检查是通过读取page结构的count域而进行的:
if (page_map->count == 1) {
set_pte(page_table, pte_mkdirty(pte_mkwrite(pte)));
flush_tlb_page(vma, address);
if (new_page)
free_page(new_page);
return 1;
}
相反,如果这个页面由两个或多个进程所共享,函数把旧页面(old_page)的内容复制到新分配的页面(new_page)中:
if (old_page == ZERO_PAGE)
memset((void *) new_page, 0, PAGE_SIZE);
else
memcpy((void *) new_page, (void *) old_page, PAGE_SIZE);
set_pte(page_table, pte_mkwrite(pte_mkdirty(
mk_pte(new_page, vma->vm_page_prot))));
flush_tlb_page(vma, address);
__free_page(page_map);
return 1;
如果旧页面是零页面,就使用memset宏把新的页面填充为0。否则,使用memcpy宏复制页面的内容。不要求一定要对零页作特殊的处理,但是特殊处理确实能够提高系统的性能,因为它使用很少的地址而保护了微处理器的硬件高速缓存。
然后,用新页面的物理地址更新页表的表项,并把新页面标记为可写和脏。最后,函数调用__free_pages( )减小对旧页面的引用计数。
6.5.6 对本节的几点说明
1. 通过fork()建立进程,开始时只有一个页目录和一页左右的可执行页 ,于是缺页异常会频繁发生。
2. 虚拟地址映射到物理地址,只有在请页时才完成,这时要建立页表和更新页表(页表是动态建立的)。页表不可被换出,不记年龄,它们被内核中保留,只有在exit时清除。
页故障的产生有三种原因:
(1) 一是程序出现错误,例如向随机物理内存中写入数据,或页错误发生在TASK_SIZE(3G)的范围外,这些情况下,虚拟地址无效, Linux 将向进程发送 SIGSEGV 信号并终止进程的运行;
(2) 另一种情况是,虚拟地址有效,但其所对应的页当前不在物理内存中,即缺页错误,这时,操作系统必须从磁盘映象或交换文件(此页被换出)中将其装入物理内存。这是本节要讨论的主要内容。
(3) 最后一种情况是,要访问的虚地址被写保护,即保护错误,这时,操作系统必须判断:如是用户进程正在写当前进程的地址空间,则发 SIGSEGV 信号并终止进程的运行。但是,如果错误发生在一旧的共享页上时,则处理方法有所不同,也就是要对这一共享页进行复制,这就是我们后面要讲的写时复制(Copy On Write 简称COW)技术
有关页错误的发生次数的信息可在目录proc/stat下找到。
6.5.2页错误的定位
页错误的定位既包含虚拟地址的定位,也包含被调入页在交换文件(swapfile)或在可执行映象中的定位。
具体地说,在一个进程访问一个无效页表项时,处理器产生一个陷入并报告一个页错误,它描述了页错误发生的虚地址和访问类型,这些类型通过页的错误码error_code中的前三位来判别,具体如下:
* bit 0 == 0 means no page found, 1 means protection fault
* bit 1 == 0 means read, 1 means write
* bit 2 == 0 means kernel, 1 means user-mode。
也就是说,如果第0位为0,则错误是由访问一个不存在的页引起的(页表的表项中present标志为0);否则,如果第0位为1,则错误是由无效的访问权所引起的。如果第1位为0,则错误是由读访问或执行访问所引起;如果为1,则错误是由写访问所引起的。如果第2位为0,则错误发生在处理器处于内核态时,否则,错误发生在处理器处于用户态时。
页错误的线性地址被存于CR2 寄存器,操作系统必须在vm_area_struct中找到页错误发生时页的虚拟地址(通过红黑树或旧版本中的AVL树),下面通过do_page_fault()中的一部分源代码来说明这个问题:
/* CR2中包含有最新的页错误发生时的虚拟地址*/
__asm__("movl %%cr2,%0":"=r" (address));
vma = find_vma(current, address);
如果没找到,则说明访问了非法虚地址,Linux会发信号终止进程(如果必要)。否则,检查页错误类型,如果是非法类型(越界错误,段权限错误等)同样会发信号终止进程,部分源代码如下:
vma = find_vma(current, address);
if (!vma)
goto bad_area;
if (vma->vm_start <= address)
goto good_area;
if (!(vma->vm_flags & VM_GROWSDOWN))
goto bad_area;
if (error_code & 4) { /*如是用户态进程*/
/* 不可访问堆栈空间*/
if (address + 32 < regs->esp)
goto bad_area;
}
if (expand_stack(vma, address))
goto bad_area;
bad_area: /* 用户态的访问*/
{
if (error_code & 4){
current->tss.cr2 = address;
current->tss.error_code = error_code;
current->tss.trap_no = 14;
fore_sig(SIGSEGV, current); /* 给当前进程发杀死信号*/
return;
……
die_if_kernel("Oops", regs, error_code); /*报告内核 */
do_exit(SIGKILL); /*强行杀死进程*/
6.5.3 进程地址空间中的缺页异常处理
对有效的虚拟地址,如果是缺页错误的话,Linux 必须区分页所在的位置,即判断页是在交换文件中,还是在可执行映象中。为此,Linux 通过页表项中的信息区分页所在的位置。如果该页的页表项是无效的,但非空,则说明该页处于交换文件中,操作系统要从交换文件装入页。对于有效的虚拟地址address,do_page_fault( )转到good_area标号处的语句执行:
good_area:
write = 0;
if (error_code & 2) { /* 写访问 */
if (!(vma->vm_flags & VM_WRITE))
goto bad_area;
write++;
} else /* 读访问 */
if (error_code & 1 ||
!(vma->vm_flags & (VM_READ | VM_EXEC)))
goto bad_area;
如果错误由写访问引起,函数检查这个虚拟区是否可写。如果不可写,跳到bad_area代码处;如果可写,把write局部变量置为1。
如果错误由读或执行访问引起,函数检查这一页是否已经存在于物理内存中。如果在,错误的发生就是由于进程试图访问用户态下的一个有特权的页面(页面的User/Supervisor标志被清除),因此函数跳到bad_area代码处(实际上这种情况从不发生,因为内核根本不会给用户进程分配有特权的页面)。如果不存在物理内存,函数还将检查这个虚拟区是否可读或可执行。
如果这个虚拟区的访问权限与引起错误的访问类型相匹配,则调用handle_mm_fault( )函数:
if (!handle_mm_fault(tsk, vma, address, write)) {
tsk->tss.cr2 = address;
tsk->tss.error_code = error_code;
tsk->tss.trap_no = 14;
force_sig(SIGBUS, tsk);
if (!(error_code & 4)) /* 内核态 */
goto no_context;
}
如果handle_mm_fault( )函数成功地给进程分配一个页面,则返回1;否则返回一个适当的错误码,以便do_page_fault( )函数可以给进程发送SIGBUS信号。handle_mm_fault( )函数有4个参数:tsk指向错误发生时正在CPU上运行的进程;vma 指向引起错误的虚拟地址所在虚拟区;address 为引起错误的虚拟地址;write_access:如果tsk试图向address写,则置为1,如果tsk试图读或执行address,则置为0
handle_mm_fault()函数首先检查用来映射address的页中间目录和页表是否存在。即使address属于进程的地址空间,但相应的页表可能还没有分配,因此,在做别的事情之前首先执行分配页目录和页表的任务:
pgd = pgd_offset(vma->vm_mm, address);
pmd = pmd_alloc(pgd, address);
if (!pmd)
return -1;
pte = pte_alloc(pmd, address);
if (!pte)
return -1;
pgd_offset()宏计算出address所在页在页目录中的目录项指针;如果有中间目录(i386不起作用),调用pmd_alloc( )函数分配一个新的中间目录。然后,如果需要的话,调用pte_alloc( )函数分配一个新的页表。如果这两步都成功,pte局部变量所指向的页表表项就是引用address的表项。然后调用handle_pte_fault( )函数检查address地址所对应的页表表项:
return handle_pte_fault(tsk, vma, address, write_access, pte);
handle_pte_fault( )函数决定怎样给进程分配一个新的页面:
如果被访问的页不存在,也就是说,这个页还没有被存放在任何一个页面中,那么,内核分配一个新的页面并适当地初始化;这种技术称为请求调页。
如果被访问的页存在但是被标为只读,也就是说,它已经被存放在一个页面中,那么,内核分配一个新的页面,并把旧页面的数据拷贝到新页面来初始化它的内容;这种技术称为写时复制。
6.5.4 请求调页
术语请求调页指的是一种动态内存分配技术,它把页面的分配推迟到不能再推迟为止,也就是说,一直推迟到进程要访问的页不在物理内存时为止,由此引起一个缺页错误。
请求调页技术的引入主要是因为进程开始运行的时候并不访问其地址空间中的全部地址;事实上,有一部分地址也许进程永远不使用。此外,程序的局部性原理保证了在程序执行的每个阶段,真正使用的进程页只有一小部分,因此临时用不着的页所在的物理页面可以由其它进程来使用。因此,对于全局分配(一开始就给进程分配所需要的全部页面,直到程序结束才释放这些页面)来说,请求调页是首选的,因为它增加了系统中的空闲页面的平均数,从而更好地利用空闲内存。从另一个观点来看,在内存总数保持不变的情况下,请求调页从总体上能使系统有更大的吞吐量。
为这一切优点付出的代价是系统额外的开销:由请求调页所引发的每个“缺页”错误必须由内核处理,这将浪费CPU的周期。幸运的是,局部性原理保证了一旦进程开始在一组页上运行,在接下来相当长的一段时间内它会一直停留在这些页上而不去访问其它的页:这样我们就可以认为“缺页”错误是一种稀有事件。
基于以下原因,被寻址的页可以不在主存中:
· 进程永远也没有访问到这个页。内核能够识别这种情况,这是因为页表相应的表项被填充为0,也就是说,pte_none宏返回1。
· 进程已经访问过这个页,但是这个页的内容被临时保存在磁盘上。内核能够识别这种情况,这是因为页表相应表项没被填充为0(然而,由于页面不存在物理内存中,Present为0)。
handle_pte_fault( )函数通过检查与address相关的页表表项来区分这两种情况:
entry = *pte;
if (!pte_present(entry)) {
if (pte_none(entry))
return do_no_page(tsk, vma, address, write_access,
pte);
return do_swap_page(tsk, vma, address, pte, entry,
write_access);
}
我们将在交换机制一节检查页被保存到磁盘上的这种情况(do_swap_page( ) 函数)。
在其它情况下,当页从未被访问时则调用do_no_page( )函数。有两种方法装入所缺的页,这取决于这个页是否被映射到磁盘文件。该函数通过检查vma虚拟区描述符的nopage域来确定这一点,如果页与文件建立起了映射关系,则nopage域就指向一个把所缺的页从磁盘装入到RAM的函数。因此,可能的情况是:
· vma->vm_ops->nopage域不为NULL。在这种情况下,某个虚拟区映射一个磁盘文件,nopage域指向从磁盘读入的函数。这种情况涉及到磁盘文件的低层操作。
· 或者vm_ops域为NULL,或者vma->vm_ops->nopage域为NULL。在这种情况下,虚拟区没有映射磁盘文件,也就是说,它是一个匿名映射。因此,do_no_page( )调用do_anonymous_page( )函数获得一个新的页面:
if (!vma->vm_ops || !vma->vm_ops->nopage)
return do_anonymous_page(tsk, vma, page_table,
write_access);
do_anonymous_page( )函数分别处理写请求和读请求:
if (write_access) {
page = __get_free_page(GFP_USER);
memset((void *)(page), 0, PAGE_SIZE)
entry = pte_mkwrite(pte_mkdirty(mk_pte(page,
vma->vm_page_prot)));
vma->vm_mm->rss++;
tsk->min_flt++;
set_pte(pte, entry);
return 1;
}
当处理写访问时,该函数调用__get_free_page( )分配一个新的页面,并利用memset宏把新页面填为0。然后该函数增加tsk的min_flt域以跟踪由进程引起的次级缺页(这些缺页只需要一个新页面)的数目,再增加进程的内存区结构vma->vm_mm的rss域以跟踪分配给进程的页面数目。然后页表相应的表项被设为页面的物理地址,并把这个页面标记为可写和脏两个标志。
相反,当处理读访问时,页的内容是无关紧要的,因为进程正在对它进行第一次寻址。给进程一个填充为0的页要比给它一个由其它进程填充了信息的旧页更为安全。Linux在请求调页方面做得更深入一些。没有必要立即给进程分配一个填充为零的新页面,由于我们也可以给它一个现有的称为零页的页,这样可以进一步推迟页面的分配。零页在内核初始化期间被静态分配,并存放在empty_zero_page变量中(一个有1024个长整数的数组,并用0填充);它存放在第六个页面中(从物理地址0x00005000开始),并且可以通过ZERO_PAGE宏来引用。
因此页表表项被设为零页的物理地址:
entry = pte_wrprotect(mk_pte(ZERO_PAGE, vma->vm_page_prot));
set_pte(pte, entry);
return 1;
由于这个页被标记为不可写,如果进程试图写这个页,则写时复制机制被激活。当且仅当在这个时候,进程才获得一个属于自己的页并对它进行写。这种机制在下一部分进行描述。
6.5.5写时复制
写时复制技术最初产生于Unix系统,用于实现一种傻瓜式的进程创建:当发出fork( )系统调用时,内核原样复制父进程的整个地址空间并把复制的那一份分配给子进程。这种行为是非常耗时的,因为它需要:
· 为子进程的页表分配页面
· 为子进程的页分配页面
· 初始化子进程的页表
· 把父进程的页复制到子进程相应的页中
创建一个地址空间的这种方法涉及许多内存访问,消耗许多CPU周期,并且完全破坏了高速缓存中的内容。在大多数情况下,这样做常常是毫无意义的,因为许多子进程通过装入一个新的程序开始它们的执行,这样就完全丢弃了所继承的地址空间。
现在的Unix内核(包括Linux),采用一种更为有效的方法称之为写时复制(或COW)。这种思想相当简单:父进程和子进程共享页面而不是复制页面。然而,只要页面被共享,它们就不能被修改。无论父进程和子进程何时试图写一个共享的页面,就产生一个错误,这时内核就把这个页复制到一个新的页面中并标记为可写。原来的页面仍然是写保护的:当其它进程试图写入时,内核检查写进程是否是这个页面的唯一属主;如果是,它把这个页面标记为对这个进程是可写的。
Page结构的count域用于跟踪共享相应页面的进程数目。只要进程释放一个页面或者在它上面执行写时复制,它的count域就递减;只有当count变为NULL时,这个页面才被释放。
现在我们讲述Linux怎样实现写时复制(COW)。当handle_pte_fault( )确定“缺页”错误是由请求写一个页面所引起的时(这个页面存在于内存中且是写保护的),它执行以下语句:
if (pte_present(pte)) {
entry = pte_mkyoung(entry);
set_pte(pte, entry);
flush_tlb_page(vma, address);
if (write_access) {
if (!pte_write(entry))
return do_wp_page(tsk, vma, address, pte);
entry = pte_mkdirty(entry);
set_pte(pte, entry);
flush_tlb_page(vma, address);
}
return 1;
}
首先,调用pte_mkyoung( ) 和 set_pte( )函数来设置引起错误的页所对应页表项的访问位。这个设置使页“年轻”并减少它被交换到磁盘上的机会。如果错误由违背写保护而引起的,handle_pte_fault( ) 返回由do_wp_page( )函数产生的值;否则,则已检测到某一错误情况(例如,用户态地址空间中的页,其User/Supervisor标志为0),且函数返回1。
do_wp_page( )函数首先把page_table参数所引用的页表表项装入局部变量pte,然后再获得一个新页面:
pte = *page_table;
new_page = __get_free_page(GFP_USER);
由于页面的分配可能阻塞进程,因此,一旦获得页面,这个函数就在页表表项上执行下面的一致性检查:
· 当进程等待一个空闲的页面时,这个页是否已经被交换出去(pte 和 *page_table的值不相同)
· 这个页是否已不在物理内存中(页表表项中页的Present标志为0)
· 页现在是否可写(页项中页的Read/Write标志为1)
如果这些情况中的任意一个发生,do_wp_page( )释放以前所获得的页面,并返回1。
现在,函数更新次级缺页的数目,并把引起错误的页的页描述符指针保存到page_map局部变量中。
tsk->min_flt++;
page_map = mem_map + MAP_NR(old_page);
接下来,函数必须确定是否必须真的把这个页复制一份。如果仅有一个进程使用这个页,就无须应用写时复制技术,而且进程应该能够自由地写这个页。因此,这个页面被标记为可写,这样当试图写入的时候就不会再次引起“缺页”错误,以前分配的新的页面也被释放,函数结束并返回1。这种检查是通过读取page结构的count域而进行的:
if (page_map->count == 1) {
set_pte(page_table, pte_mkdirty(pte_mkwrite(pte)));
flush_tlb_page(vma, address);
if (new_page)
free_page(new_page);
return 1;
}
相反,如果这个页面由两个或多个进程所共享,函数把旧页面(old_page)的内容复制到新分配的页面(new_page)中:
if (old_page == ZERO_PAGE)
memset((void *) new_page, 0, PAGE_SIZE);
else
memcpy((void *) new_page, (void *) old_page, PAGE_SIZE);
set_pte(page_table, pte_mkwrite(pte_mkdirty(
mk_pte(new_page, vma->vm_page_prot))));
flush_tlb_page(vma, address);
__free_page(page_map);
return 1;
如果旧页面是零页面,就使用memset宏把新的页面填充为0。否则,使用memcpy宏复制页面的内容。不要求一定要对零页作特殊的处理,但是特殊处理确实能够提高系统的性能,因为它使用很少的地址而保护了微处理器的硬件高速缓存。
然后,用新页面的物理地址更新页表的表项,并把新页面标记为可写和脏。最后,函数调用__free_pages( )减小对旧页面的引用计数。
6.5.6 对本节的几点说明
1. 通过fork()建立进程,开始时只有一个页目录和一页左右的可执行页 ,于是缺页异常会频繁发生。
2. 虚拟地址映射到物理地址,只有在请页时才完成,这时要建立页表和更新页表(页表是动态建立的)。页表不可被换出,不记年龄,它们被内核中保留,只有在exit时清除。
3. 在处理页故障的过程中,因为要涉及到磁盘访问等耗时操作,因此操作系统会选择另外一个进程进入执行状态,即进行新一轮调度。
源地址:http://www.eefocus.com/article/09-06/75169s.html