当前位置:操作系统 > Unix/Linux >>

利用异常表处理Linux内核态缺页异常

前言

  在程序的执行过程中,因为遇到某种障碍而使 CPU 无法最终访问到相应的物理内存单元,即无法完成从虚拟地址到物理地址映射的时候,CPU 会产生一次缺页异常,从而进行相应的缺页异常处理。基于 CPU 的这一特性,Linux 采用了请求调页(Demand Paging)和写时复制(Copy On Write)的技术

  1. 请求调页是一种动态内存分配技术,它把页框的分配推迟到不能再推迟为止。这种技术的动机是:进程开始运行的时候并不访问地址空间中的全部内容。事实上,有一部分地址也许永远也不会被进程所使用。程序的局部性原理也保证了在程序执行的每个阶段,真正使用的进程页只有一小部分,对于临时用不到的页,其所在的页框可以由其它进程使用。因此,请求分页技术增加了系统中的空闲页框的平均数,使内存得到了很好的利用。从另外一个角度来看,在不改变内存大小的情况下,请求分页能够提高系统的吞吐量。当进程要访问的页不在内存中的时候,就通过缺页异常处理将所需页调入内存中。

  2. 写时复制主要应用于系统调用fork,父子进程以只读方式共享页框,当其中之一要修改页框时,内核才通过缺页异常处理程序分配一个新的页框,并将页框标记为可写。这种处理方式能够较大的提高系统的性能,这和Linux创建进程的操作过程有一定的关系。在一般情况下,子进程被创建以后会马上通过系统调用execve将一个可执行程序的映象装载进内存中,此时会重新分配子进程的页框。那么,如果fork的时候就对页框进行复制的话,显然是很不合适的。

  在上述的两种情况下出现缺页异常,进程运行于用户态,异常处理程序可以让进程从出现异常的指令处恢复执行,使用户感觉不到异常的发生。当然,也会有异常无法正常恢复的情况,这时,异常处理程序会进行一些善后的工作,并结束该进程。也就是说,运行在用户态的进程如果出现缺页异常,不会对操作系统核心的稳定性造成影响。那么对于运行在核心态的进程如果发生了无法正常恢复的缺页异常,应该如何处理呢?是否会导致系统的崩溃呢?是否能够解决好内核态缺页异常对于操作系统核心的稳定性来说会产生很大的影响,如果一个误操作就会造成系统的Oops,这对于用户来说显然是不能容忍的。本文正是针对这个问题,介绍了一种Linux内核中所采取的解决方法。

  在读者继续往下阅读之前,有一点需要先说明一下,本文示例中所选的代码取自于Linux-2.4.0,编译环境是gcc-2.96,objdump的版本是2.11.93.0.2,具体的版本信息可以通过以下的命令进行查询:

  $ gcc -v

  Reading specs from /usr/lib/gcc-lib/i386-redhat-linux/2.96/specs

  gcc version 2.96 20000731 (Red Hat Linux 7.3 2.96-110)

  $ objdump -v

  GNU objdump 2.11.93.0.2 20020207

  Copyright 2002 Free Software Foundation, Inc.

  GCC的扩展功能

  由于本文中会用到GCC的扩展功能,即汇编器as中提供的.section伪操作,在文章开始之前我再作一个简要的介绍。此伪操作对于不同的可执行文件格式有不同的解释,我也不一一列举,仅对我们所感兴趣的Linux中常用的ELF格式的用法加以描述,其指令格式如下:

  .section NAME[, "FLAGS"]

  大家所熟知的C程序一般由以下的几个部分组成:代码段(text section)、初始化数据段(data section)、非初始化数据段(bss section)、栈(heap)以及堆(stack),具体的地址空间布局可以参考《UNIX环境高级编程》一书。

  在Linux内核中,通过使用.section的伪操作,可以把随后的代码汇编到一个由NAME指定的段中。而FLAGS字段则说明了该段的属性,它可以用下面介绍的单个字符来表示,也可以是多个字符的组合。

  'a' 可重定位的段

  'w' 可写段

  'x' 可执行段

  'W' 可合并的段

  's' 共享段

  举个例子来说明,读者在后面会看到的:.section .fixup, "ax"

  这样的一条指令定义了一个名为.fixup的段,随后的指令会被加入到这个段中,该段的属性是可重定位并可执行。

  内核缺页异常处理

  运行在核心态的进程经常需要访问用户地址空间的内容,但是谁都无法保证内核所得到的这些从用户空间传入的地址信息是"合法"的。为了保护内核不受错误信息的攻击,需要验证这些从用户空间传入的地址信息的正确性。

  在老版本的Linux中,这个工作是通过函数verify_area来完成的:

  extern inline int verify_area(int type, const void * addr, unsigned long size)

  该函数验证了是否可以以type中说明的访问类型(read or write)访问从地址addr开始、大小为size的一块虚拟存储区域。为了做到这一点,verify_read首先需要找到包含地址addr的虚拟存储区域(vma)。一般的情况下(正确运行的程序)这个测试都会成功返回,在少数情况下才会出现失败的情况。也就是说,大部分的情况下内核在一些无用的验证操作上花费了不算短的时间,这从操作系统运行效率的角度来说是不可接受的。

  为了解决这个问题,现在的Linux设计中将验证的工作交给虚存中的硬件设备来完成。当系统启动分页机制以后,如果一条指令的虚拟地址所对应的页框(page frame)不在内存中或者访问的类型有错误,就会发生缺页异常。处理器把引起缺页异常的虚拟地址装到寄存器CR2中,并提供一个出错码,指示引起缺页异常的存储器访问的类型,随后调用Linux的缺页异常处理函数进行处理。

  Linux中进行缺页异常处理的函数如下:

  asmlinkage void do_page_fault(struct pt_regs *regs, unsigned long error_code)

  {

  ……………………

  __asm__("movl %%cr2,%0":"=r" (address));

  ……………………

  vma = find_vma(mm, 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;

  ……………………

  bad_area:

  ……………………

  no_context:

  /* Are we prepared to handle this kernel fault?

  */

  if ((fixup = search_exception_table(regs->eip)) != 0) {

  regs->eip = fixup;

  return;

  }

  ………………………

  }

  首先让我们来看看传给这个函数调用的两个参数:它们都是通过entry.S在堆栈中建立的(arch/i386/kernel/entry.S),参数regs指向保存在堆栈中的寄存器,error_code中存放着异常的出错码,具体的堆栈布局参见图一(堆栈的生成过程请参考《Linux内核源代码情景分析》一书)

  


  该函数首先从CPU的控制寄存器CR2中获取出现缺页异常的虚拟地址。由于缺页异常处理程序需要处理的缺页异常类型很多,分支也很复杂。基于本文的主旨,我们只关心以下的几种内核缺页异常处理的情况:

  1. 程序要访问的内核地址空间的内容不在内存中,先跳转到标号vmalloc_fault,如果当前访问的内容所对应的页目录项不在内存中,再跳转到标号no_context;

  2. 缺页异常发生在中断或者内核线程中,跳转到标号no_context;

  3. 程序在核心态运行时访问用户空间的数据,被访问的数据不在内存中

  a) 出现异常的虚拟地址在进程的某个vma中,但是系统内存无法分配空闲页框(page frame),则先跳转到标号out_of_memory,再跳转到标号no_context;

  b) 出现异常的虚拟地址不属于进程任一个vma,而且不属于堆栈扩展的范畴,则先跳转到标号bad_area,最终也是到达标号no_context。

  从上面的这几种情况来看,我们关注的焦点最后集中到标号no_context处,即对函数search_exception_table的调用。这个函数的作用就是通过发生缺页异常的指令(regs->eip)在异常表(exception table)中寻找下一条可以继续运行的指令(fixup)。这里提到的异常表包含一些地址对,地址对中的前一个地址表示出现异常的指令的地址,后一个表示当前一个指令出现错误时,程序可以继续得以执行的修复地址。

  如果这个查找操作成功的话,缺页异常处理程序将堆栈中的返回地址(regs->eip)修改成修复地址并返回,随后,发生异常的进程将按照fixup中安排好的指令继续执行下去。当然,如果无法找到与之匹配的修复地址,系统只有打印出出错信息并停止运作。

  那么,这个所谓的修复地址又是如何生成的呢?是系统自动生成的吗?答案当然是否定的,这些修复指令都是编程人员通过as提供的扩展功能写进内核源码中的。下面我们就来分析一下其实现机制。

  异常表的实现机制

  笔者取include/asm-i386/uaccess.h中的宏定义__copy_user编写了一段程序作为例子加以讲解。

  /* hello.c */

  #include

  #include

  #define __copy_user(to,from,size)do {int __d0, __d1;__asm__ __volatile__("0:rep; movsl\n""movl %3,%0\n""1:rep; movsb\n""2:\n"".
CopyRight © 2012 站长网 编程知识问答 www.zzzyk.com All Rights Reserved
部份技术文章来自网络,