linux内存管理-用户堆栈的扩展

在上一情景中,我们游览参观了一次因越界访问而造成映射失败从而引起进程流产的过程。但是,我们也许会感到惊奇,越界访问有时是正常的。不过,这只是发生在一种情况下,现在我们就来看看当用户堆栈过小,但是因越界访问而因祸得福得以伸展的情景。在阅读本情景之前,我们应该温习一下前一个情景。

假设在进程运行的过程中,已经用尽了为本进程分配的堆栈区间,也就是从堆栈的顶部开始(记住,堆栈是从上向下伸展的),已经到达了已经映射的堆栈区间的下沿。或者说,CPU中的堆栈指针%esp已经指向堆栈区间的起始地址。见下图:

假定现在需要调用某个子程序,因此CPU需将返回地址压入堆栈,也就是要将返回地址写入虚存空间中地址为(%esp-4)的地方。可是,在我们这个情景中地址(%esp-4)落入了空洞中,这是尚未映射的地址,因此必然要引起一次页面异常。让我们顺着上一个情景中已经走过的路线到达第151行:

	if (!(vma->vm_flags & VM_GROWSDOWN))
		goto bad_area;
	if (error_code & 4) {
		/*
		 * accessing the stack below %esp is always a bug.
		 * The "+ 32" is there due to some instructions (like
		 * pusha) doing post-decrement on the stack and that
		 * doesn't show up until later..
		 */
		if (address + 32 < regs->esp)
			goto bad_area;
	}
	if (expand_stack(vma, address))
		goto bad_area;

这一次,空洞上方的区间是堆栈区间,其VM_GROWSDOWN标志位为1,所以CPU就继续往前执行。当映射失败发生在用户空间(bit2为1)时,因堆栈操作而引起的越界是作为特殊情况对待的,所以还需要检查发生异常时的地址是否紧挨着堆栈指针所指的地方。在我们这个情景中,那是%esp-4,当然是紧挨着的。但是如果%esp-40呢?那就不会是因为正常的堆栈操作而引起,而是货真价实的非法越界访问了。可是,怎么来判定正常或不正常呢?通常,一次压入堆栈的是4字节,所以改地址应该是%esp-4。但是i386 CPU有一条pusha指令,可以一次将32个字节(8个32位寄存器的内容)压入堆栈。所以,检查的准则是%esp-32。超出这个范围就一定是错的了,所以跟在前一个情景中一样,转向bad_area。而在我们现在这个情景中,这个测试应该是顺利通过了。

既然是属于正常的堆栈扩展要求,那就应该从空洞的顶部开始分配若干页面建立映射,并将之并入堆栈区间,使其得以扩展。所以就要调用expand_stack,这是一个inline函数:

do_page_fault=>expand_stack


/* vma is the first one with  address < vma->vm_end,
 * and even  address < vma->vm_start. Have to extend vma. */
static inline int expand_stack(struct vm_area_struct * vma, unsigned long address)
{
	unsigned long grow;

	address &= PAGE_MASK;
	grow = (vma->vm_start - address) >> PAGE_SHIFT;
	if (vma->vm_end - address > current->rlim[RLIMIT_STACK].rlim_cur ||
	    ((vma->vm_mm->total_vm + grow) << PAGE_SHIFT) > current->rlim[RLIMIT_AS].rlim_cur)
		return -ENOMEM;
	vma->vm_start = address;
	vma->vm_pgoff -= grow;
	vma->vm_mm->total_vm += grow;
	if (vma->vm_flags & VM_LOCKED)
		vma->vm_mm->locked_vm += grow;
	return 0;
}

参数vma指向一个vm_area_struct数据结构,代表着一个区间,在这里就是代表着用户空间堆栈所在的区间。首先,将地址按页面边界对齐,并计算需要增长几个页面才能把给定的地址包括进来(通常是一个)。这里还有个问题,堆栈的这种扩展是否不受限制,直到把空间中的整个空间用完为止呢?不是的。每个进程的task_struct结构中都有个rlim结构数组,规定了对每种资源分配使用的限制,而RLIMIT_STACK就是对用户空间堆栈大小的限制。所以,这里就进行这样的检查。如果扩展以后的区间大小超过了可用于堆栈的资源,或者使动态分配的页面总量超过了可用于该进程的资源限制,那就不能扩展了,就会返回一个负的出错代码-ENOMEM,表示没有存储空间可以分配了;否则就应该返回0。当expand_stack返回的值为非0,也即-ENOMEM时,在do_page_fault中也会转向bad_area,其结果就与前一个情景一样了。不过一般情况下都不至于用尽资源,所以expand_stack一般都是正常返回的。但是,我们已经看到,expand_stack只是改变了堆栈区间的vm_area_struct结构,而并不建立起新扩展的页面对物理内存的映射。这个任务由接下去的good_area完成:

/*
 * Ok, we have a good vm_area for this memory access, so
 * we can handle it..
 */
good_area:
	info.si_code = SEGV_ACCERR;
	write = 0;
	switch (error_code & 3) {
		default:	/* 3: write, present */
#ifdef TEST_VERIFY_AREA
			if (regs->cs == KERNEL_CS)
				printk("WP fault at %08lx\n", regs->eip);
#endif
			/* fall through */
		case 2:		/* write, not present */
			if (!(vma->vm_flags & VM_WRITE))
				goto bad_area;
			write++;
			break;
		case 1:		/* read, present */
			goto bad_area;
		case 0:		/* read, not present */
			if (!(vma->vm_flags & (VM_READ | VM_EXEC)))
				goto bad_area;
	}

	/*
	 * If for any reason at all we couldn't handle the fault,
	 * make sure we exit gracefully rather than endlessly redo
	 * the fault.
	 */
	switch (handle_mm_fault(mm, vma, address, write)) {
	case 1:
		tsk->min_flt++;
		break;
	case 2:
		tsk->maj_flt++;
		break;
	case 0:
		goto do_sigbus;
	default:
		goto out_of_memory;
	}

在这里的switch语句中,内核根据由中断响应机制传过来的error_code来进一步确定映射失败的原因并采取相应的对策(error_code最低3位的定义已经在前面中列出)。就现在这个情景而言,bit0位0,表示没有物理页面,而bit1为1表示写操作。所以最低两位的值为2。既然是写操作,当然要检查相应的区间是否允许写入,而堆栈段式允许写入的。于是,就到达了196行,调用虚存管理handle_mm_fault了。该函数定义如下:

do_page_fault=>handle_mm_fault


/*
 * By the time we get here, we already hold the mm semaphore
 */
int handle_mm_fault(struct mm_struct *mm, struct vm_area_struct * vma,
	unsigned long address, int write_access)
{
	int ret = -1;
	pgd_t *pgd;
	pmd_t *pmd;

	pgd = pgd_offset(mm, address);
	pmd = pmd_alloc(pgd, address);

	if (pmd) {
		pte_t * pte = pte_alloc(pmd, address);
		if (pte)
			ret = handle_pte_fault(mm, vma, address, write_access, pte);
	}
	return ret;
}

根据给定的地址和代表着具体虚存空间的mm_struct数据结构,由宏操作pgd_offset计算出指向该地址所属页面目录的指针,定义如下:


/* to find an entry in a page-table-directory. */
#define pgd_index(address) ((address >> PGDIR_SHIFT) & (PTRS_PER_PGD-1))


#define pgd_offset(mm, address) ((mm)->pgd+pgd_index(address))

至于下面的pmd_alloc,本来是应该分配(或者找到)一个中间目录项的。由于i386只使用两层映射,所以实现如下:return (pmd_t *) pgd;。也就是说,在i386 CPU中,把具体的目录项当成一个只含有一个表项(表的大小为1)的中间目录。所以,对于i386 CPU而言,pmd_alloc是决不会失败的,所以这里的pmd不能为0。我们不妨顺着线性地址的映射过程想想,接下来需要做些什么?页面目录总是在的,相应的目录项也许已经指向一个页面表,此时需要根据给定的地址在表中找到相应的页面表项。或者,目录项也可能还是空的,那样的话就需要先分配一个页面表,再在页面中找到相应的表项。这样,才可以为下面分配物理内存页面并建立映射做好准备。这是通过pte_alloc完成的,其代码如下:

do_page_fault=>handle_mm_fault=>pte_alloc


extern inline pte_t * pte_alloc(pmd_t * pmd, unsigned long address)
{
	address = (address >> PAGE_SHIFT) & (PTRS_PER_PTE - 1);

	if (pmd_none(*pmd))
		goto getnew;
	if (pmd_bad(*pmd))
		goto fix;
	return (pte_t *)pmd_page(*pmd) + address;
getnew:
{
	unsigned long page = (unsigned long) get_pte_fast();
	
	if (!page)
		return get_pte_slow(pmd, address);
	set_pmd(pmd, __pmd(_PAGE_TABLE + __pa(page)));
	return (pte_t *)page + address;
}
fix:
	__handle_bad_pmd(pmd);
	return NULL;
}

先将给定的地址转换成其所属页面表中的下标。在我们这个情景中,假定指针pmd所指向的目录项为空,所以需要转到标号getnew处分配一个页面表。一个页面表所占用的空间恰好是一个物理页面。内核中对页面表的分配作了些优化。当释放一个页面表时,内核将释放的页面表先保存在一个缓冲池中,而先不将其物理内存页面释放。只有在缓冲池已满的情况下才真的将页面表所占的物理内存页面释放。这样,在要分配一个页面表时,就可以先看一下缓冲池,这就是get_pte_fast。要是缓冲池已经空了,那就只好通过get_pte_slow来分配了。我们也许会想,分配一个物理内存用作页面表就这么麻烦吗,为什么是slow?回答是有时候可能会很慢。只要想一下物理内存页面由可能已经用完,需要把内存中已经占用的页面交换到磁盘上去,就可以明白了,分配到一个页面表以后,就通过set_pmd中将其起始地址连同一些属性标志位一起写入中间目录项pmd中,而对i386 却实际上写入到了页面目录项pgd中。这样,映射所需的基础设施都已经齐全了,但页面表项pte还是空的。剩下的就是物理内存页面本身了,那是由handle_pte_fault完成的。代码如下:

do_page_fault=>handle_mm_fault=>handle_pte_fault


/*
 * These routines also need to handle stuff like marking pages dirty
 * and/or accessed for architectures that don't do it in hardware (most
 * RISC architectures).  The early dirtying is also good on the i386.
 *
 * There is also a hook called "update_mmu_cache()" that architectures
 * with external mmu caches can use to update those (ie the Sparc or
 * PowerPC hashed page tables that act as extended TLBs).
 *
 * Note the "page_table_lock". It is to protect against kswapd removing
 * pages from under us. Note that kswapd only ever _removes_ pages, never
 * adds them. As such, once we have noticed that the page is not present,
 * we can drop the lock early.
 *
 * The adding of pages is protected by the MM semaphore (which we hold),
 * so we don't need to worry about a page being suddenly been added into
 * our VM.
 */
static inline int handle_pte_fault(struct mm_struct *mm,
	struct vm_area_struct * vma, unsigned long address,
	int write_access, pte_t * pte)
{
	pte_t entry;

	/*
	 * We need the page table lock to synchronize with kswapd
	 * and the SMP-safe atomic PTE updates.
	 */
	spin_lock(&mm->page_table_lock);
	entry = *pte;
	if (!pte_present(entry)) {
		/*
		 * If it truly wasn't present, we know that kswapd
		 * and the PTE updates will not touch it later. So
		 * drop the lock.
		 */
		spin_unlock(&mm->page_table_lock);
		if (pte_none(entry))
			return do_no_page(mm, vma, address, write_access, pte);
		return do_swap_page(mm, vma, address, pte, pte_to_swp_entry(entry), write_access);
	}

	if (write_access) {
		if (!pte_write(entry))
			return do_wp_page(mm, vma, address, pte, entry);

		entry = pte_mkdirty(entry);
	}
	entry = pte_mkyoung(entry);
	establish_pte(vma, address, pte, entry);
	spin_unlock(&mm->page_table_lock);
	return 1;
}

在我们这个情景里,不管页面表是新分配的还是原来就有的,相应的页面表项却一定是空的。这样,程序一开头的if语句的条件一定能满足,因为pte_present调试一个表项所映射的页面是否在内存中,而我们的物理内存页面还没有分配。进一步,pte_none所测试的条件也一定满足,因为它测试一个表项是否为空。所以,就必定会进入do_no_page(否则就是do_swap_page)。顺便讲一下,如果pte_present的测试结果是该表项所映射的页面确实在内存中,那么问题一定出在访问权限,或者根本就没有问题了。

函数do_no_page也在同一个文件中定义。这里先简要地介绍一下,然后再来看代码。

以前我们曾经提起过,在虚存区间结构vm_area_struct中有个指针vm_ops,指向一个vm_operations_struct数据结构。这个数据结构实际上是一个函数跳转表,结构中通常是一些与文件操作有关的函数指针。其中有一个函数指针就是用于物理内存页面的分配。物理内存页面的分配为什么与文件操作有关呢?因为这对于可能的文件共享是很有意义的。当多个进程将同一个文件映射到各自的虚存空间中时,内存中通常只要保存一份物理页面就可以了。只有当一个进程需要写入该文件时才有必要另外复制一份独立的副本,称为“copy on write” 或者COW。关于COW我们在进程的博客中讲到fork时还要作较为详细的介绍。这样,当通过mmap将一块虚存区间跟一个已打开文件(包括设备)建立映射后,就可以通过对这些函数的调用将对内存的操作转换成对文件的操作,或者进行一些必要的对文件的附加操作。另一方面,物理页面的盘区交换显然也是跟文件操作有关的。所以,为特定的虚存空间预先指定一些特定的操作常常是很有必要的。于是,如果已经预先为一个虚存区间vma指定了分配物理内存页面的操作的话,那就是vma->vm_ops->nopage。但是,vma->vm_ops->nopage和vma->vm_ops都有可能为空,那就表示没有为之指定具体的nopage操作,或者根本就没有配备一个vm_operations_struct结构。当没有指定的nopage操作时,内核就调用一个函数do_anonymous_page来分配物理内存页面。

现在来看看do_no_page的开头几行代码:

do_page_fault=>handle_mm_fault=>handle_pte_fault=>do_no_page

/*
 * do_no_page() tries to create a new page mapping. It aggressively
 * tries to share with existing pages, but makes a separate copy if
 * the "write_access" parameter is true in order to avoid the next
 * page fault.
 *
 * As this is called only for pages that do not currently exist, we
 * do not need to flush old virtual caches or the TLB.
 *
 * This is called with the MM semaphore held.
 */
static int do_no_page(struct mm_struct * mm, struct vm_area_struct * vma,
	unsigned long address, int write_access, pte_t *page_table)
{
	struct page * new_page;
	pte_t entry;

	if (!vma->vm_ops || !vma->vm_ops->nopage)
		return do_anonymous_page(mm, vma, page_table, write_access, address);
......
}

对于我们这个情景来说,所涉及的虚存区间是供堆栈用的,跟文件系统或页面共享没有什么关系,不会有指定的nopage操作,所以进入do_anonymous_page。

do_page_fault=>handle_mm_fault=>handle_pte_fault=>do_no_page=>do_anonymous_page


/*
 * This only needs the MM semaphore
 */
static int do_anonymous_page(struct mm_struct * mm, struct vm_area_struct * vma, pte_t *page_table, int write_access, unsigned long addr)
{
	struct page *page = NULL;
	pte_t entry = pte_wrprotect(mk_pte(ZERO_PAGE(addr), vma->vm_page_prot));
	if (write_access) {
		page = alloc_page(GFP_HIGHUSER);
		if (!page)
			return -1;
		clear_user_highpage(page, addr);
		entry = pte_mkwrite(pte_mkdirty(mk_pte(page, vma->vm_page_prot)));
		mm->rss++;
		flush_page_to_ram(page);
	}
	set_pte(page_table, entry);
	/* No need to invalidate - it was non-present before */
	update_mmu_cache(vma, addr, entry);
	return 1;	/* Minor fault */
}

首先我们注意到,如果引起页面异常的是一次读操作,那么由mk_pte构筑的映射表项要通过pte_wrprotect加以修正;而如果是写操作(参数write_access为非0),则通过pte_mkwrite加以修正。这二者有什么不同呢?

static inline pte_t pte_wrprotect(pte_t pte)	{ (pte).pte_low &= ~_PAGE_RW; return pte; }

static inline pte_t pte_mkwrite(pte_t pte)	{ (pte).pte_low |= _PAGE_RW; return pte; }

对比一下,就可看出,在pte_wrprotect中,把_PAGE_RW标志位设成0,表示这个物理页面之允许读;而在pte_mkwrite却把这个标志位设成1。同时,对于读操作,所映射的物理页面总是ZERO_PAGE,这个页面的定义如下:

/*
 * ZERO_PAGE is a global shared page that is always zero: used
 * for zero-mapped memory areas etc..
 */
extern unsigned long empty_zero_page[1024];
#define ZERO_PAGE(vaddr) (virt_to_page(empty_zero_page))

就是说,只要是只读(也就是写保护)的页面,开始时都一律映射到同一个物理内存页面empty_zero_page,而不管其虚拟地址是什么。实际上,这个页面的内容全为0,所以映射之初若从该页面读出,则全是0。只有可写的页面,才通过alloc_page为其分配独立的物理内存。在我们这个情景中,所需要的页面时在堆栈区,并且是由于写操作才引起异常的,所以要通过alloc_page为其分配一个物理内存页面,并将分配到的物理页面连同所有的状态及标志位(见程序1129行),一起通过set_pte设置进指针page_table所指的页面表项。至此,从虚存页面到物理内存页面的映射终于建立了。这里的update_mmu_cache对i386 CPU是个空函数,因为i386的MMU(内存管理单元)是实现在CPU内部,而并没有独立的MMU。

映射既然已经建立,下面就是逐层返回了。由于映射成功,各个层次中的返回值都是1,直至do_page_fault。在函数do_page_fault中,还要处理一个与VM86模式以及VGA的图像存储区有关的特殊情况,但是那与我们这个情景已经没有关系了:

do_page_fault

	/*
	 * Did it hit the DOS screen memory VA from vm86 mode?
	 */
	if (regs->eflags & VM_MASK) {
		unsigned long bit = (address - 0xA0000) >> PAGE_SHIFT;
		if (bit < 32)
			tsk->thread.screen_bitmap |= 1 << bit;
	}
	up(&mm->mmap_sem);
	return;

最后,特别要指出,当CPU从一次页面错异常处理返回到用户空间时,将会先重新执行因映射失败而中途夭折的那条指令,然后才继续往下执行,这是异常处理的特殊性。学过有关课程的读者都知道,中断以及自陷(trap指令)发生时,CPU都会将下一条指令,也就是接下去本来应该执行的指令的地址压入堆栈作为中断服务的返回地址。但是异常却不同。当异常发生时,CPU将因无法完成,(例如除以0,映射失败等)而夭折的指令本身的地址(而不是下一条指令的地址)压入堆栈。这样,就可以在从异常处理返回时完成未竟的事业。这个特殊性是在CPU的内部电路实现的,而不需要由软件干预。从这个意义上讲,所谓缺页中断是不对的,应该叫缺页异常才对。在我们这个情景中,当初是因为在一条指令中要压栈,但是越出了已经为堆栈区分配的空间而引起的。那条指令在当时已经中途夭折了,并没有产生什么效果(例如堆栈指针%esp还是只想原来的位置)。现在,从异常处理返回以后,堆栈区已经扩展了,再重新执行一遍以前夭折的那条压栈指令,然后就可以继续往下执行了。对于用户程序来说,这整个过程都是透明的,就像什么事情都没发生过,而堆栈区间就仿佛从一开始就已经分配好了足够大的空间一样。

Guess you like

Origin blog.csdn.net/guoguangwu/article/details/120638682