在学习一个新的东西时,最好能摸清来龙去脉,以达到格物致知的境界。
然而大家推崇的学习方法是一叶知秋,而非一叶障目。以前有一本很经典的情景分析,大家
夸赞它入木三分的同时,也惋惜只见树木,不见森林。
具体来讲,就是先理顺核心流程,去掉一些容错分支,再在此基础上逐步完善。
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; }