现在的位置: 首页 > 综合 > 正文

<深入浅出>进程地址空间与缺页

2013年06月03日 ⁄ 综合 ⁄ 共 4355字 ⁄ 字号 评论关闭

 在学习一个新的东西时,最好能摸清来龙去脉,以达到格物致知的境界。

然而大家推崇的学习方法是一叶知秋,而非一叶障目。以前有一本很经典的情景分析,大家
夸赞它入木三分的同时,也惋惜只见树木,不见森林。

具体来讲,就是先理顺核心流程,去掉一些容错分支,再在此基础上逐步完善。

pthread库,即是对用户态线程的管理。
下面以pthread_create为例,说明线程创建和执行的流程。

先来看这么一段结论:
pthread库里的线程,实际上都是各自独立的微型进程。在同一个进程下的所有pthread线程,
有不同的进程描述符,栈空间,但是他们有相同的进程地址空间。

上面这段话怎么理解?
其实就是说,通过pthread_create创建的进程,都是通过do_fork创建的(不同的进程描述符),
每个进程的栈是独立分配的,并且fork时的参数为CLONE_VM(共享地址空间)

在深入这个问题之前,我们先看看,进程如何执行的。
举例来说,在shell提示符下,执行一个程序test时,首先
shell通过fork系统调用,生成一个新进程a, 在新进程里调用
exec加载test的二进制镜像到内存后,新进程切换成a',
在exec系统调用返回用户态时切换到新进程a'的入口函数

在上面的流程里,几个地方需要专门提出来:
1)fork出的新进程a的用户态栈,内核态栈,是如何指定的?
2)exec加载新进程a'后,第一次访问a'的代码段等区间时,系统会发生什么?

先来看第一个问题,当fork时,内核进入sys_fork

int sys_fork(struct pt_regs *regs)
{
 return do_fork(SIGCHLD, regs->sp, regs, 0, NULL, NULL);
}

可以看出,此时新进程的栈,用的是父进程发生fork系统调用时的栈指针。注意,这里的栈指的是用户态栈。

(一)下面来看用户态栈地址,是如何赋值给新进程的:

do_fork --> copy_process -->copy_thread

1) 子进程栈顶,准备存放上下文
x86

childregs = ((struct pt_regs *)
   (THREAD_SIZE + task_stack_page(p))) - 1;
mips64
childksp = (unsigned long)task_stack_page(p) + THREAD_SIZE - 32;
childregs = (struct pt_regs *) childksp - 1;
childksp = (unsigned long) childregs;

ps:  为什么x86的childregs不需要减去一个pt_regs长度? 因为x86是小端,而mips是大端。

2)子进程上下文

*childregs = *regs;

3)子进程返回用户态的栈地址,修改为指定值(fork的话父子进程一样)

childregs->regs[29] = usp;

4)子进程被调度到的时候,需要恢复的栈指针,以及PC指针

p->thread.reg29 = (unsigned long) childregs;      sp
p->thread.reg31 = (unsigned long) ret_from_fork;  ra

现在来看看,当新进程a被调度到时,栈是如何恢复的。

resume函数里,cpu_restore_nonscratch a1负责加载新进程的上下文。其中,有

LONG_L sp, THREAD_REG29(\thread)
OFFSET(THREAD_REG29, task_struct, thread.reg29);

操作之后,新进程的栈sp指向一个 pt_regs结构,而ra则为ret_from_fork
当进程切换执行
jr ra后
跳转至ret_from_fork
最后执行
RESTORE_SP_AND_RET的时候,根据sp上的值恢复用户态栈,也就是3步的sp
接着跳转至用户态入口

LONG_L k0, PT_EPC(sp) //这里的epc实际上是父进程执行fork时的pc值
LONG_L sp, PT_R29(sp)
jr k0
rfe

(二)进程的执行以及缺页

在加载进程elf镜像时,实际上最主要的两个步骤,分别是进程空间的建立,和文件操作集合与进程空间对应

关系的建立,后者通俗的讲,就是
缺页回调函数的设置。

当第一次访问代码段时,发生tlb miss,引发tlb refill,由于进程用户态页表里,代码段虚拟地址对应的

pte都是invalid pte,重填
tlb后,由于V=0, 再次访存引发tlb_load异常,进入page fault。

handle_mm_fault-->handle_pte_fault

再继续之前,反正我们已经走到这里,就顺便把handle_mm_fault里的几个重要函数分析一下。

pte = pte_alloc_map(mm, pmd, address);

这个pte_alloc_map的意思是,如果pte表不存在,则分配一个,并返回该pte页表里,对应此地址具体的某个

pte项指针

#define pte_alloc_map(mm, pmd, address)   \
 ((unlikely(!pmd_present(*(pmd))) && __pte_alloc(mm, pmd, address))? \
  NULL: pte_offset_map(pmd, address))

当pmd里的pte指针不存在时,就调用pte_alloc分配一个pte页表,并将该pte页表的地址填入pmd相应位置,最

后返回pte页表地址。
(pte页表是pte entry的集合,每个pte entry存放的是页框物理地址)

这里有个小的trick,__pte_alloc的实现,
mips里面,pte页表,乃至pmd,pud,pgd都是在低端内存分配的,而x86的话,32位和64位有不同的实现。
对于32位来说,如果配置了高端PTE,则pte是放到高端内存的,那么访问的话需要kmap

#if defined(CONFIG_HIGHPTE)
#define pte_offset_map(dir, address)     \
 ((pte_t *)kmap_atomic(pmd_page(*(dir))) +  \
  pte_index((address)))

为什么要把pte表放到高端内存呢,我们来看内核里蛋疼的解释:

The VM uses one page table entry for each page of physical memory. For systems with a lot of
RAM, this can be wasteful of precious low memory. Setting this option will put user-space page
table entries in high memory

言归正传,继续回到>handle_pte_fault

handle_pte_fault是缺页的核心所在,大体框架如下:

 entry = *pte;
 if(!pte_present(entry)){  //检查页是否存在于内存之中,就是检查pte entry是否有_PAGE_PRESENT标志
     if(pte_none(entry){   //pte entry根本不存在,也就是没有加载页框进来,就是检查pte_val是否为0
          if(vma->file->fault) //文件缺页,例如第一次访问代码段
          {
             return vma->vm_ops->fault(vma,addr);
          }else{
            return do_anonymous_fault(vma,addr); //匿名页,例如第一次访问malloc区域
          }
     }else{
          return do_swap_fault();  //pte entry存在,但该页代表的页框内存已经被交换到磁盘
     }
 }
 if(!pte_write(entry))
   return do_wp_fault(vma,addr); //该页属性不对,对写保护的页进行了写操作

 

pte_present是判断请求调页(demond paging)还是写时复制(COW)的关键,只要不在内存中,就需要请求调页

,否则是写时复制。

如果是第一次访问代码段,由于代码段目前预读到了缓存中,并没有建立对应的页表,因此发生文件缺页,

vma->vm_ops->fault的
回调函数是filemap_fault 。

如果是匿名请求调页,走的是do_anonymous_page,省略一些步骤,伪码如下:

static int do_anonymous_page(struct mm_struct *mm, struct vm_area_struct *vma,
		unsigned long address, pte_t *page_table, pmd_t *pmd,
		int write_access)
{
	if (write_access) {
		/* Allocate our own private page. */
		
		anon_vma_prepare(vma);	
		page = alloc_page();
		entry = mk_pte(page);

		page->mapping = (struct address_space *) anon_vma;
	} else {
		/* Map the ZERO_PAGE - vm_page_prot is readonly */
		page = ZERO_PAGE();
		entry = mk_pte(page, vma->vm_page_prot);

	}

	set_pte_at(mm, address, page_table, entry);
}

匿名映射的请求调页流程,为什么会出现区分读写操作?因为对于一个malloc出来的空间,第一次读一定是返回0值,

既然这样如果是第一次读引起的匿名缺页我们就直接给他返回一个0,即将pte对应的页表项置为一个静态分配好的零页(页的内容为0),

这样免去了alloc_page可能造成的延时,并且将pte的属性置只读,这样在下次对这个malloc出来的区间进行写时,就走写流程。

在第一次写引起的缺页时,除了为其分配页框,还由于匿名映射是可能被回收交换的,所以会加入到系统的匿名映射链,这通过anon_vma_prepare

函数来完成的。一个page对应着一个anon_vma链表,这个链表里存放的是所有映射到此页面的vma。当请求调页写流程走到anon_vma_prepare

后,

int anon_vma_prepare(struct vm_area_struct *vma)
{
	struct anon_vma *anon_vma = vma->anon_vma;
	if (!anon_vma) {
		vma->anon_vma = anon_vma_alloc();
		list_add_tail(&vma->anon_vma_node, &anon_vma->head);	
	}
	return 0;
}

抱歉!评论已关闭.