天天看点

Linux内核剖析 之 进程地址空间(三)缺页异常处理程序创建和删除进程的地址空间堆的管理

本节主要讲述缺页异常处理程序和堆的管理等内容。

触发缺页异常程序的两种情况:

1. 由编程错误引起的异常(如访问越界,地址不属于进程地址空间)。

2. 地址属于线性地址空间,但内核还未分配相应的物理页,导致缺页异常。

缺页异常处理程序总体方案:

Linux内核剖析 之 进程地址空间(三)缺页异常处理程序创建和删除进程的地址空间堆的管理

线性区描述符可以让缺页异常处理程序非常有效的完成它的工作。

do_page_fault()函数是80x86上的缺页中断服务程序,它把引起缺页的线性地址和当前进程的线性区相比较,从而根据具体方案选择适当的方法处理此异常。

标识符vmalloc_fault、good_area、do_sigbus、bad_area、no_context、survive、out_of_memory和bad_area_nosemaphore对应的代码段对不同的缺页异常进行不同的处理操作。

接收参数:

pt_regs结构的地址regs,该结构包含异常发生时的微处理器寄存器的值。

3位的error_code:

===>>error_code:

*第0位被清零,访问一个不存在的页——异常;第0位被置位,无效的访问权限——异常。

*第1位被清零,读访问或者执行访问——异常;第1位被置位,写访问——异常。

*第2位被清零,处理器内核态——异常;第2位被置位,处理器用户态——异常。

执行步骤:

*读取引起缺页的线性地址。当异常发生时,cpu控制单元将此线性地址存放在cr2控制寄存器中。

pt_regs结构指针regs指向异常发生前cpu中各寄存器内容的一份副本,这是由内核的中断响应机制保存下来的“现场”,而error_code则进一步指明映射失败的具体原因。如果缺页发生之前或cpu运行在80x86模式时就打开了本地中断,则使能local_irq_enable(),并将指向current进程描述符的指针保存在tsk局部变量中。

*接下来:

Linux内核剖析 之 进程地址空间(三)缺页异常处理程序创建和删除进程的地址空间堆的管理

对此图进行说明:

do_page_fault()首先检查引起缺页的线性地址是否在内核地址空间:

如果是,则当内核试图访问不存在的页,跳转执行非连续内存区地址访问代码,即vmalloc_fault标记处后的代码,否则,执行bad_area_ nosemaphore标记处后的代码。

如果不是,则引起缺页的线性地址在用户地址空间。此时,判断缺页是否发生中断处理程序、可延迟函数、临界区或内核线程中:

如果是,由于中断处理程序等不使用小于task_size的地址,故转而执行bad_area_nosemaphore标识处代码。

如果没有,即却也没有发生在中断处理程序、可延迟函数、临界区或者内核线程中,则函数检查进程所拥有的线性区以决定引起缺页的线性地址是否包含在进程的地址空间中,为此必须获得进程的mmap_sem读写信号量。

当函数获取了mmap_sem信号量,do_page_fault()开始搜索错误线性地址所在的线性区,并根据vma的值,跳转到相应标志代码段。

如果address(引起缺页的线性地址)不属于进程的地址空间,则do_page_fault()函数执行bad_area标记处的语句。

如果异常发生在用户态,则发送一个sigsegv信号给current进程并结束函数;

其中,force_sig_info()确信进程不忽略或阻塞sigsegv信号,并通过info局部变量传递附加信息的同时把该信号发送给用户态进程。

如果异常发生在内核态(error_code的第二位被清零),有两种可选的情况(在no_context代码段实现):

*异常的引起是由于把某个线性地址作为系统调用的参数传给内核;

*异常是因一个真正的内核缺陷引起的。

对于第一种情况,代码跳转到一段“修正代码”处,这段代码的典型操作就是向当前进程发送sigsegv信号,或用一个适当的出错码终止系统调用处理程序。

对于第二种情况,函数把cpu寄存器和内核态堆栈的全部转储打印到控制台,并输出到系统消息缓冲区,然后调用do_exit()杀死当前进程。——内核漏洞“kernel oops”。

如果address地址属于进程的地址空间,则do_page_fault()转到good_area标记处执行程序:

对error_code&3===>>>

case 2:如果异常由写访问引起,函数检查这个线性区是否可写。如果不可写(!(vma->vm_flags & vm_write)),跳到bad_area代码处;如果可写,把write局部变量置为1.

case 1 and case 0:如果异常是由读或执行访问引起,函数检查这一页是否已经存在于ram中。在存在的情况下,异常发生是由于进程试图访问用户态下的一个有特权的页框,因此函数跳转到bad_area代码处。在不存在的情况下,函数还将检查这个线性区是否可读或可执行。

default and case2(write==1):如果这个线性区的访问权限于引起异常的访问类型相匹配,调用handle_mm_fault()函数分配一个新的页框(survive代码段):

关键:handle_mm_fault()函数

参数:异常发生时正在cpu上运行的进程的内存描述符指针mm,引起异常的线性地址所在线性区的描述符指针vma,引起异常的线性地址address,write_access(如果tsk试图向address写则置位,如果tsk试图向address读或执行则清零)。

步骤:

*函数首先检查发生异常的原因,然后检查用来映射address的页目录和页表是否存在,再执行分配页目录和页表的任务;

*handle_pte_fault()函数检查address地址对应的页表项,并决定如何为进程分配一个新页框:

===>>>

#如果访问的页在内存中不存在,也就是说,这个页还没有被存放在任何一个页框中,则内核分配一个新的页框并适当初始化。这种技术称为请求调页(demand paging);

#如果被访问的页存在但是标记为只读,也就是说,它已经被存放在一个页框中,则内核分配一个新的页框,并把旧的页框的数据拷贝到新页框来初始化它的内容。这种技术称为写时复制(copy on write,cow)。

*如果线性区的访问权限与引起异常的访问类型相匹配,handle_mm_fault()函数分配一个新的页框:

当handle_mm_fault()成功地给进程分配一个页框,则返回vm_fault_minor或vm_fault_major。值vm_fault_minor表示在没有阻塞当前进程的情况下处理了缺页,这种缺页叫做次缺页(minor fault)。值vm_fault_major表示缺页迫使当前进程睡眠,阻塞当前进程的缺页叫做主缺页(major fault)。

当没有足够内存时,函数返回vm_fault_oom,此时函数不分配新的页框,内核通常杀死当前进程。不过,如果当前进程是init进程,则只是把它放在运行队列的末尾并调用调度程序,一旦init恢复执行,则handle_mm_fault又执行:

out_of_memory标记处代码(过程如上所述):

请求调页指的是一种动态内存分配技术,它把页框的分配推迟到不能再推迟为止,也就是说,一直推迟到进程要访问的页不再内存中时为止,由此引起缺页异常。

请求调页技术的动机是:请求调页能增加系统中的空闲页框的平均数,从而更好地利用空闲内存,从总体上能使系统有更大的吞吐量。

付出的代价是系统额外的开销:由请求调页所引发的每个“缺页”异常必须由内核处理。

有关请求调页的代码:

pte_present()宏指明entry页是否在主存中。如果entry页不在主存中,其原因或是进程从未访问过该页,或是内核已经回收了相应的页框。

在这两种情况下,缺页处理程序必须为进程分配新的页框。不过,如何初始化这个页框有三种特殊情况:

*entry页从未被进程访问到且没有映射到磁盘文件:

pte_none()宏==>do_no_page()函数;

*entry页属于非线性磁盘文件的映射:

pte_file()宏==>do_file_page()函数;

*entry页已经被进程访问过,但是其内容被临时保存在磁盘中(present=dirty=0):

==> do_swap_page()函数。

handle_pte_fault()函数通过检查address对应页表项的标志能够区分这三种情况,并根据不同标志来进行不同的函数处理。

===>>>匿名页和映射页:

在linux虚拟内存中,如果页对应的vma映射的是文件,则称为映射页;如果不是映射的文件,则称为匿名页。两者最大的区别体现在页和vma的组织上,因为在页框回收处理时要通过页来逆向搜索映射了该页的vma。对于匿名页的逆映射,vma都是通过vma结构体中的vma_anon_node(链表节点)和anon_vma(链表头)组织起来,再把该链表头的信息保存在页描述符中;而映射页和 vma的组织是通过vma中的优先树节点和页描述符中的mapping->i_mmap优先树树根进行组织的。

原始的进程创建:

当发出fork()系统调用时,内核原样将父进程的整个地址空间复制一份给子进程。这种方式非常耗时:

1. 为子进程的地址空间分配页框

2. 为子进程的页表分配页框

3. 初始化子进程的页表

4. 将父进程的页复制到子进程相应的页

缺点:耗费cpu周期。

现在linux系统采用一种写时复制的技术;

原理:父子进程共享页框而不是复制;共享意味着不能被修改,父子进程无论何时试图写页框,就会产生异常;这时内核将这个物理页复制到一个新的页框,标记为可写。

页描述符的_count字段用于跟踪共享相应页框的进程数目。只要进程释放一个页框或者在它上面执行写时复制,它的_count字段就减小;只有当_count变为-1时,此页框才被释放。

写时复制相关代码:

核心函数:do_wp_page()函数

该函数首先获取与缺页异常相关的页框描述符。接下来,函数确定页的复制是否真正必要。具体说来,函数读取页描述符的_count字段,如果它等于0(只有一个所有者),写时复制就不必进行。如果多个进程通过写时复制共享页框,那么函数就把旧页框的内容复制到新分配的页框中(copy_page()宏)。然后,新页框的物理地址最终被写进页表项,且使对应的tlb寄存器无效。同时,lru_cache_add_active()函数把新页框插入到与交换相关的数据结构中。最后,do_wp_page()把old_page的使用计数器减少两次(pte_unmap()函数),第一次减少是取消复制页框内容之前进行的安全性增加;第二次减少是反映当前进程不再拥有该页框的事实。

异常发生在内核态且产生缺页的线性地址大于task_size。此时,do_page_fault()检查相应的主内核页全表项:

do_page_fault()把存放在cr3寄存器中的当前进程页全局目录的物理地址赋给局部变量pgd_paddr,把与pgd_paddr对应的线性地址赋给局部变量pgd,并且把主内核页全局目录的线性地址赋给pgd_k局部变量。

如果产生缺页的线性地址所对应的主内核页全局目录项为空,则函数跳到标号为no_context的代码处。否则,函数检查与错误线性地址相对应的主内核页上级目录项和主内核页中间目录项。如果它们中间有一个为空,就再次跳转到no_context处。否则,就把主目录项复制到进程页中间目录的相应项中。随后对主页表项重复上述操作。

进程获得一个新线性区的六种典型情况:

    程序执行

    exec()函数

    缺页异常处理程序

    内存映射

    ipc共享内存

    malloc()函数

fork()系统调用要求为子进程创建一个完整的新地址空间。相反,当进程结束是,内核撤销它的地址空间。

vfork()/fork()/clone()系统调用=====>>>:

copy_mm()函数:

如果flag参数的clone_vm标志被置位,copy_mm()函数把父进程(current)地址空间给子进程。

如果没有设置clone_vm标志,copy_mm()函数创建新的地址空间,分配一个新的内存描述符,复制父进程的mm内容到新的进程描述符中。

然后调用函数init_new_context()和init_mm()函数进行初始化工作;

最后调用dup_mmap()函数复制父进程的线性区和页表。

当进程结束时,内核调用exit_mm()释放进程的地址空间。

mm_release()函数唤醒tsk->vfork_done补充原语上睡眠的任一进程。

如果正在被终止的进程不是内核线程,exit_mm()函数释放内存描述符和所有相关的数据结构。首先,它检查mm->core_waiters标志是否置位:如果是,进程就把内存的所有内容卸载到一个转储文件中。为了避免转储文件的混乱,函数利用mm->core_done和mm->core_startup_done补充原语使共享同一内存描述符mm的轻量级进程的执行串行化。

接下来,函数递增内存描述符的主使用计数器,重新设置进程描述符的mm字段,并使处理器处于懒惰tlb模式。

最后,调用mmput()函数释放局部描述符表、线性区描述符和页表。由于exit_mm()函数已经递增了主使用计数器,所以并不释放内存描述符本身。当要把正在被终止的进程从本地cpu撤销时,将由finish_task_switch()函数释放内存描述符。

每个进程都有一个特殊的线性区,即堆(heap)。用于满足进程的动态内存请求。内存描述符中的start_brk与brk字段分别表示堆的起始地址和结束地址。

操作函数

说明

malloc(size)

请求size个字节的动态内存,如果成功,则返回第一个字节的线性地址

calloc(n, size)

请求n个大小为size的元素的内存,如果成功,返回第一个元素的线性地址

realloc(ptr,size)

改变由前面malloc\calloc分配的内存大小

free(addr)

释放由malloc、calloc分配的起始地址为addr的线性区

brk(addr)

直接修改堆的大小。addr参数指定current->mm->brk的新值,返回值是线性区新的结束地址。

sbrk(incr)

类似brk(),不过其中的incr参数指定是增加还是减少以字节为单位的堆大小

至此,进程地址空间一章节结束。

遗留问题:

1、在写时复制时,对于父进程和子进程,其分配页框的具体机制如何?

2、每个线性区是否都会划分代码段、数据段、堆栈等线性地址空间?

3、线性区的分配是动态分配还是静态分配,即线性区的分配会不会随着使用内存的增加而动态添加分配?还是说程序执行的时候,系统会提前分配好线性区?

4、前面提到过一句在写时复制时,do_wp_page()把old_page的使用计数器减少两次(pte_unmap()函数),第一次减少是取消复制页框内容之前进行的安全性增加;第二次减少是反映当前进程不再拥有该页框的事实。这里,为什么需要进行安全性增加?

继续阅读