linux 内核源代码情景分析——用户堆栈的扩展
上一节中,我们浏览了一次因越界访问而造成映射失败从而引起进程流产的过程,不过有时候,越界访问时正常的。现在我们就来看看当用户堆栈过小,但是因越界访问而“因祸得福”得以伸展的情景.
假设在进程运行的过程中,已经用尽了为本进程分配的堆栈空间,此时CPU 中的堆栈指针%esp已经指向堆栈区间的起始地址,如下图
假设现在需要调用某个子程序,因此CPU 需将返回地址压入堆栈,也就是要将返回地址写入虚存空间中地址为(%esp-4)的地方,可以在我们这个情景中地址(%esp-4)落入了空洞中,这是尚未映射的地址,必然会引起一次页面异常,让我们顺着上一节情景中已经走过的路线到达文件arch/i386/mm/fault.c中
【do_page_fault】
1 if (!(vma->vm_flags & VM_GROWSDOWN)) 2 goto bad_area; 3 if (error_code & 4) { 4 /* 5 * accessing the stack below %esp is always a bug. 6 * The "+ 32" is there due to some instructions (like 7 * pusha) doing post-decrement on the stack and that 8 * doesn't show up until later.. 9 */ 10 if (address + 32 < regs->esp) 11 goto bad_area; 12 } 13 if (expand_stack(vma, address)) 14 goto bad_area;
这一次,空洞上方的区间是堆栈区间,其VM_GROWSDOWN标志位为1,所以CPU 就继续向下执行。
当映射失败发生在用户空间(bit2为1)时,因堆栈操作而引起的越界是作为特殊情况对待的,所以还要检查发生异常时的地址是否紧挨着堆栈指针所指的地方,在我们这个情景中,那是%esp-4,当然是紧挨着的。但是如果是%esp-40呢?那就不是因为正常的堆栈操作而引起的,而是货真价实的非法越界访问了,所以,怎样来判定是正常还是不正常呢?
通常,一次压入堆栈的是4个字节,所以该地址应该是%esp-4,但是i386有条pusha指令,可以一次将32个字节压入堆栈,所以,检查的准则是%esp-32,超出这个范围肯定是错的了(为什么这么判断?)。
既然是属于正常的堆栈扩展要求,那就应该从空洞的顶部开始分配若干页面建立映射,并将之并入堆栈区间,使其得以扩展,所以就要调用expand_stack(),这是在文件include/linux/mm.h中定义的一个inline函数:
【do_page_fault > expand_stack()】
1 static inline int expand_stack(struct vm_area_struct * vma, unsigned long address) 2 { 3 unsigned long grow; 4 5 address &= PAGE_MASK; 6 grow = (vma->vm_start - address) >> PAGE_SHIFT; 7 if (vma->vm_end - address > current->rlim[RLIMIT_STACK].rlim_cur || 8 ((vma->vm_mm->total_vm + grow) << PAGE_SHIFT) > current->rlim[RLIMIT_AS].rlim_cur) 9 return -ENOMEM; 10 vma->vm_start = address; 11 vma->vm_pgoff -= grow; 12 vma->vm_mm->total_vm += grow; 13 if (vma->vm_flags & VM_LOCKED) 14 vma->vm_mm->locked_vm += grow; 15 return 0; 16 }
参数vma指向一个vm_area_struct数据结构,代表着一个空间,在这里是代表着用户空间堆栈所在的区间,参数address是引起缺页异常的地址
首先,将地址按页面边界对齐,
并计算需要增长几个页面才能把给定的地址包括进去,通常是一个
这里还有一个问题,堆栈的这种扩展是否不收限制,知道把空间中的空洞用完为止,答案是否定的,每个进程的task_struct结构中都有个rlim结构数组,规定了对每种资源分配使用的限制,而RLIMIT_STACK就是对用户空间堆栈大小的限制,所以,这里就进行这样的检查,如果扩展以后的区间大小超过了可用于堆栈的资源,或者使用动态分配的页面总量超过了可用于该进程的资源限制,就不能扩展了,就要返回错误代码-ENOMEM,表示没有储存空间可以分配了。当expand_stack() 返回的值非0,在do_page_fault()也会转到bad_area执行,其结果就和前面的情景一样了,不过一般情况下都不至于用尽资源,所以expand_stack()ibanez都是正常返回的,但是,我们看到,expand_stack()只是改变了堆栈区的vm_area_struct结构,而没有建立起新扩展的页面对物理内存的映射,这个任务由接下去的good_area完成:
1 good_area: 2 info.si_code = SEGV_ACCERR; 3 write = 0; 4 switch (error_code & 3) { 5 default: /* 3: write, present */ 6 #ifdef TEST_VERIFY_AREA 7 if (regs->cs == KERNEL_CS) 8 printk("WP fault at %08lx\n", regs->eip); 9 #endif 10 /* fall through */ 11 case 2: /* write, not present */ 12 if (!(vma->vm_flags & VM_WRITE)) 13 goto bad_area; 14 write++; 15 break; 16 case 1: /* read, present */ 17 goto bad_area; 18 case 0: /* read, not present */ 19 if (!(vma->vm_flags & (VM_READ | VM_EXEC))) 20 goto bad_area; 21 } 22 23 /* 24 * If for any reason at all we couldn't handle the fault, 25 * make sure we exit gracefully rather than endlessly redo 26 * the fault. 27 */ 28 switch (handle_mm_fault(mm, vma, address, write)) { 29 case 1: 30 tsk->min_flt++; 31 break; 32 case 2: 33 tsk->maj_flt++; 34 break; 35 case 0: 36 goto do_sigbus; 37 default: 38 goto out_of_memory; 39 }
在这里的switch语句中,内核根据由中断响应机制传过来的error_code来进一步确定映射失败的原因,并采取响应的对策,现在这个情景bit0为0,表示没有物理页面,bit1为1表示可写,所以,最低两位的值为2,于是到达了28行,调用虚存管理handle_mm_fault()了
【do_page_fault() > handle_mm_fault()】
1 int handle_mm_fault(struct mm_struct *mm, struct vm_area_struct * vma, 2 unsigned long address, int write_access) 3 { 4 int ret = -1; 5 pgd_t *pgd; 6 pmd_t *pmd; 7 8 pgd = pgd_offset(mm, address); 9 pmd = pmd_alloc(pgd, address); 10 11 if (pmd) { 12 pte_t * pte = pte_alloc(pmd, address); 13 if (pte) 14 ret = handle_pte_fault(mm, vma, address, write_access, pte); 15 } 16 return ret; 17 }
根据给定的地址和代表着具体虚存空间的mm_struct数据结构,由宏pgd_offset()计算出指向该地址所属页面目录项的指针
#define pgd_offset(mm, address) ((mm)->pgd+pgd_index(address))/*pgd的基地址+address的高10位*/
下面的pmd_alloc()是用来分配一个中间目录的,但是i386只使用了两层映射,
1 extern inline pmd_t * pmd_alloc(pgd_t *pgd, unsigned long address) 2 { 3 if (!pgd) 4 BUG(); 5 return (pmd_t *) pgd; 6 }
所以,我们看到pmd = pgd,现在有两种可能,一种是由pmd即pgd所指向的页面表为空,此时,要先分配一个页面表,然后再根据address的中间10位来找到相应的页面表项,另一种情况是它所指的页面表不为空,这样就能直接来找到相应的页面表项。这里我们假设所指的页面表为空
【do_page_fault() > handle_mm_fault() >pte_alloc()】
1 extern inline pte_t * pte_alloc(pmd_t * pmd, unsigned long address) 2 { 3 address = (address >> PAGE_SHIFT) & (PTRS_PER_PTE - 1);/*取中间10位为页表下标*/ 4 5 if (pmd_none(*pmd))/*若pmd为空,表示还没有对应的页表,所以应该去建立一个页表*/ 6 goto getnew; 7 if (pmd_bad(*pmd)) 8 goto fix; 9 return (pte_t *)pmd_page(*pmd) + address;/*若存在页表,直接返回页表项(即指向物理页面的基地址,因为下一步要靠它来获取物理地址了,所以这里页表项的内容为物理地址)*/ 10 getnew: 11 { 12 unsigned long page = (unsigned long) get_pte_fast(); 13 14 if (!page) 15 return get_pte_slow(pmd, address); 16 set_pmd(pmd, __pmd(_PAGE_TABLE + __pa(page))); 17 return (pte_t *)page + address; 18 } 19 fix: 20 __handle_bad_pmd(pmd); 21 return NULL; 22 }
第3行将给定的地址转换成页面表的下标,我们假设pmd所指向的页表为空,所以跳到getnew:处去分配一个页表。一个页表所占用的空间恰好是一个物理页面。内核中队页表的分配做了优化:当释放一个页表时,内核将要释放的页表先保存在一个缓冲池中,并且先不将其对应的物理内存页面释放,只有在缓冲池已满的情况下才真的将页表所对应的物理内存页面释放。这样,当要分配一个页表时,就可以先看一下缓冲池,这就是get_pte_fast(),要是缓冲池已经空了,就只好通过get_pte_slow()来分配了。分配到一个页表之后,就通过set_pmd()将其起始地址连同一些属性标志位一起写入中间目录项pmd中,i386最终是写到pgd中,此时,页表项pte还是空的,下面就该分配物理内存页面来填充pte了
【do_page_fault() > handle_mm_fault() >handle_pte_fault()】
1 static inline int handle_pte_fault(struct mm_struct *mm, 2 struct vm_area_struct * vma, unsigned long address, 3 int write_access, pte_t * pte) 4 { 5 pte_t entry; 6 7 /* 8 * We need the page table lock to synchronize with kswapd 9 * and the SMP-safe atomic PTE updates. 10 */ 11 spin_lock(&mm->page_table_lock); 12 entry = *pte; 13 if (!pte_present(entry)) { 14 /* 15 * If it truly wasn't present, we know that kswapd 16 * and the PTE updates will not touch it later. So 17 * drop the lock. 18 */ 19 spin_unlock(&mm->page_table_lock); 20 if (pte_none(entry)) 21 return do_no_page(mm, vma, address, write_access, pte); 22 return do_swap_page(mm, vma, address, pte, pte_to_swp_entry(entry), write_access); 23 } 24 25 if (write_access) { 26 if (!pte_write(entry)) 27 return do_wp_page(mm, vma, address, pte, entry); 28 29 entry = pte_mkdirty(entry); 30 } 31 entry = pte_mkyoung(entry); 32 establish_pte(vma, address, pte, entry); 33 spin_unlock(&mm->page_table_lock); 34 return 1; 35 }
我们这里假设不管页表是新分配的还是原来就有的,相应的页表项一定是空的。
#define pte_present(x) ((x).pte_low & (_PAGE_PRESENT | _PAGE_PROTNONE))
所以第13行可以直接进去了。
#define pte_none(x) (!(x).pte_low)
所以第20行为真,接着就要执行do_no_page()了。
1 static int do_no_page(struct mm_struct * mm, struct vm_area_struct * vma, 2 unsigned long address, int write_access, pte_t *page_table) 3 { 4 struct page * new_page; 5 pte_t entry; 6 7 if (!vma->vm_ops || !vma->vm_ops->nopage) 8 return do_anonymous_page(mm, vma, page_table, write_access, address);
在虚存区间结构vm_area_struct 结构中有个指针vm_ops,指向一个vm_operations_struct,这个数据结构实际上是一个函数跳转表,结构中是一些与文件操作相关的函数指针,其中nopage指针就是用于物理内存页面的分配。
如果已经预先为一个虚存区间vma制定了分配物理内存页面的操作的话,那就是vma->vm_ops->vm_ops_nopage(),反之vma->vm_ops->vm_ops_nopage()就为空,这时,就要调用内核一个函数do_anonymous_page()来分配物理内存页面了。
1 static int do_anonymous_page(struct mm_struct * mm, struct vm_area_struct * vma, pte_t *page_table, int write_access, unsigned long addr) 2 { 3 struct page *page = NULL; 4 pte_t entry = pte_wrprotect(mk_pte(ZERO_PAGE(addr), vma->vm_page_prot)); 5 if (write_access) { 6 page = alloc_page(GFP_HIGHUSER); 7 if (!page) 8 return -1; 9 clear_user_highpage(page, addr); 10 entry = pte_mkwrite(pte_mkdirty(mk_pte(page, vma->vm_page_prot))); 11 mm->rss++; 12 flush_page_to_ram(page); 13 } 14 set_pte(page_table, entry); 15 /* No need to invalidate - it was non-present before */ 16 update_mmu_cache(vma, addr, entry); 17 return 1; /* Minor fault */ 18 }
如果引起页面异常的是一次读操作,那么由mk_pte()构筑的映射表项要通过pte_wrprotect()加以修正,即把_PAGE_RW标志位变为0,表示这个物理页面只允许读;对于读操作,页面表项所映射的物理页面总是ZERO_PAGE,就是说,只要是只读的页面,开始时都一律映射到同一个物理内存页面empty_zero_page,而不管其虚拟地址是什么
我们这里所需要的页面是在堆栈区,并且是由写引起的,所以进入第5行,然后通过alloc_page()为其分配一个物理内存页面,并将分配到的物理页面连同所有的状态及标志位,一起通过set_pte()设置进指针page_table()所指的页面表项,至此,从虚存页面到物理内存页面的映射终于建立了