linux 内核源代码情景分析——越界访问
页式存储管理机制通过页面目录和页面表将每个线性地址转换成物理地址,当遇到下面几种情况就会使CPU产生一次缺页中断,从而执行预定的页面异常处理程序:
① 相应的页面目录或页表项为空,也就是该线性地址与物理地址的关系还没建立或者已经撤销
② 相应的物理页面不在内存中
③ 指令规定的访问方式与页面的权限不符
我们假设一个情景,当我们的用户程序将一个打开的文件通过mmap()映射到内存,然后又通过munmap()撤销映射。在撤销一个映射区间时,常常会在虚存地址空间中留下一个空洞,而相应的地址则不应继续使用了,但是,程序中可能会有错误继续访问这个已经撤销的区域,这时候,一次因越界访问一个无效地址而引起映射失败,从而产生了一次页面出错异常。 我们假设CPU已经运行到页面异常服务程序的主体函数do_page_fault()的入口处
1 asmlinkage void do_page_fault(struct pt_regs *regs, unsigned long error_code) 2 { 3 struct task_struct *tsk; 4 struct mm_struct *mm; 5 struct vm_area_struct * vma; 6 unsigned long address; 7 unsigned long page; 8 unsigned long fixup; 9 int write; 10 siginfo_t info; 11 12 /* get the address */ 13 __asm__("movl %%cr2,%0":"=r" (address)); 14 15 tsk = current; 16 17 /* 18 * We fault-in kernel-space virtual memory on-demand. The 19 * 'reference' page table is init_mm.pgd. 20 * 21 * NOTE! We MUST NOT take any locks for this case. We may 22 * be in an interrupt or a critical region, and should 23 * only copy the information from the master page table, 24 * nothing more. 25 */ 26 if (address >= TASK_SIZE) 27 goto vmalloc_fault; 28 29 mm = tsk->mm; 30 info.si_code = SEGV_MAPERR; 31 32 /* 33 * If we're in an interrupt or have no user 34 * context, we must not take the fault.. 35 */ 36 if (in_interrupt() || !mm) 37 goto no_context; 38 39 down(&mm->mmap_sem); 40 41 vma = find_vma(mm, address); 42 if (!vma) 43 goto bad_area; 44 if (vma->vm_start <= address) 45 goto good_area; 46 if (!(vma->vm_flags & VM_GROWSDOWN)) 47 goto bad_area;
此函数参数,一个是pt_regs 结构指针regs,它指向异常发生前夕 CPU 中各寄存器内容的一份副本,这是由内核的中断响应机制保存下来的“现场”,另一个参数error_code则指明映射失败的具体原因。
首先是一行汇编代码,引文当i386 CPU 产生“页面错”异常时,CPU 将导致映射失败的线性地址放在控制寄存器CR2中,这行代码的作用就是读取CR2的内容到变量address。
然后是通过宏操作current来取得当前进程的task_struct结构的地址。
接下来,需要检测两个特殊情况,一个特殊情况是in_interrupt()返回非0,说明映射的失败发生在某个中断服务程序中,因而与当前进程毫无关系,另一个特殊情况是当前进程的mm指针为空,也就是该进程尚未建立映射,也就不可能与当前进程有关。若mm指针为空,且in_interrupt返回非0,,那这次异常发生在什么地方呢?其实还是在某个中断/异常的服务程序中,只不过不在in_interrupt()能检测到的范围而已。
以下有互斥的要求了,从down()返回后就不会有别的进程来打扰了。
在知道了发生映射失败的地址以及所属的进程以后,接下来应该要搞清楚的是这个地址是否落在某个已建立起映射的区间,或者进一步具体指出在哪个区间。事实正是这样,这就是find_vma()所做的事。以前讲过,find_vma()试图在一个虚存空间中找出结束地址大于给定地址的第一个区间,如果找不到的话,那么本次页面异常就必定是因越界访问而引起,那么,在什么情况下会找不到呢?回忆一下内核对用户虚存空间的使用,堆栈在用户区的顶部,从上而下扩展,而进程的代码和数据都是自底向上分配空间。如果没有一个区间的结束地址高于给定的地址,那就说明这个地址是在堆栈之上,也就是3G字节以上了,要从用户空间访问内核空间,当然是越界了,然后就转向bad_area,发生映射失败的地址对应下图的①
如果找到了这么一个区间,而且其起始地址又不高于给定的地址,那就说明给定的地址恰好落在这个区间,这样,映射肯定已经建立,所以就转向good_area去进一步检查失败的原因,发生映射失败的地址对应下图的②或⑤
剩下的情况就是给定地址正好落在两个区间当中的空洞里,也就是该地址所在页面的映射尚未建立或已经撤销,在用户虚存空间中,可能有两种不同的空洞,第一种空洞只能有一个,那就是堆栈区以下的那个大空洞,它代表着工动态分配而仍未分配出去的空间。但是怎样知道这个地址是落在这个空洞里呢?我们知道,堆栈是向下扩展的,如果find_vma()找到的区间是堆栈区间,那么它的vm_flags中应该有标志位VM_GROWSDOWN。要是该标志位为0的话,那就说明空洞上方的区间并非堆栈区,说明这个空洞是因为一个映射区间被撤销而留下的,或者是在建立映射时跳过了一块地址,发生映射失败的地址对应下图的④
下面,我们就随着goto语句转向bad_area,
【do_page_fault】
1 bad_area: 2 up(&mm->mmap_sem); 3 4 bad_area_nosemaphore: 5 /* User mode accesses just cause a SIGSEGV */ 6 if (error_code & 4) { 7 tsk->thread.cr2 = address; 8 tsk->thread.error_code = error_code; 9 tsk->thread.trap_no = 14; 10 info.si_signo = SIGSEGV; 11 info.si_errno = 0; 12 /* info.si_code has been set above */ 13 info.si_addr = (void *)address; 14 force_sig_info(SIGSEGV, &info, tsk); 15 return; 16 }
当error_code的bit2为1时,表示失败是当CPU处于用户模式时发生的,这与我们的情景相符,所以控制将进入第6行,在那里,对当前进程的task_struct结构内的一些成员进行一些设置以后,就向该进程发出一个强制的“信号”SIGSEGV,至此,本次例外服务程序就结束了。
每次中断/异常返回之前,都要检查当前进程是否有悬而未决的信号需要处理,然后,内核根据这些待处理的信号的性质以及进程本身的选择决定怎么办,而对于SIGSEGV的处理结果是在该进程的显示屏上显示“Segment Fault”提示,然后结束进程