虚拟地址空间的创建:
1、当程序运行的时候,操作系统就要给每一个进程分配一个task_struct,这个task_struct 就相当于每个进程的身份证,唯一地标识每一个进程,是内核和CPU调度的对象;
2、当我们访问每一条指令或者数据的地址的时候,操作系统就要在我们的task_struct里面的一个成员变量struct mm_struct *mm,里面查找地址,那么mm其实就是进程整个虚拟地址空间的入口指针;
3、接下来我们就看一下struct mm_struct 结构体的成员变量表示的都是虚拟地址空间的布局,像代码段的起始、结尾,bss的下页,brk堆顶的位置,栈的开始等等,其中比较重要的两个成员变量pgd_t * pgd,struct vm_area_struct * mmap;
4、当进程进行切换的时候,我们会调用Mmu_context.h/switch_mm()函数,进行虚拟地址空间的切换,我们的pgd中保存的就是PDE(页目录)和PTE(页表)的入口地址,这一条路走的就是我们的虚拟地址映射一系列步骤;
static inline void switch_mm(struct mm_struct *prev,
struct mm_struct *next,
struct task_struct *tsk)
{
int cpu = smp_processor_id();
if (likely(prev != next)) {
/* stop flush ipis for the previous mm */
cpu_clear(cpu, prev->cpu_vm_mask);
#ifdef CONFIG_SMP
per_cpu(cpu_tlbstate, cpu).state = TLBSTATE_OK;
per_cpu(cpu_tlbstate, cpu).active_mm = next;
#endif
cpu_set(cpu, next->cpu_vm_mask);
/* Re-load page tables */
load_cr3(next->pgd);
/*
* load the LDT, if the LDT is different:
*/
if (unlikely(prev->context.ldt != next->context.ldt))
load_LDT_nolock(&next->context, cpu);
}
#ifdef CONFIG_SMP
else {
per_cpu(cpu_tlbstate, cpu).state = TLBSTATE_OK;
BUG_ON(per_cpu(cpu_tlbstate, cpu).active_mm != next);
if (!cpu_test_and_set(cpu, next->cpu_vm_mask)) {
/* We were in lazy tlb mode and leave_mm disabled
* tlb flush IPI delivery. We must reload %cr3.
*/
load_cr3(next->pgd);
load_LDT_nolock(&next->context, cpu);
}
}
#endif
}
5、那么我们的PDE中保存的就是页目录的起始地址,PTE中的元素的高20位保存的是我们的物理页面的框号,低12位保存的是我们的一些权限相关的内容,根据PTE中存的框号找到保存物理页面的数组中对应的物理页面的地址,然后加上后12位,就能够在找到的物理页面中找到对应的准确位置;
PS:在我们的操作系统中每个物理页面对应一个结构体struct page(在mm.h中),所有的物理页面都是被一个数组管理着,每个数组的元素为struct page,代表一个物理页面,那么我们访问数组的时候缺什么呢?缺一个下标,这个对应的下标就保存在PTE的元素的高20位中,低12位都为0,是因为x86都是以4k大小为边界对齐的,一个页表项可以指向一个4k大小的物理页面;
6、接下来我们再讨论一下struct mm_struct中的另外一个成员变量:struct vm_area_struct * mmap;
struct mm_struct {
/**
* 指向线性区对象的链表头。
*/
struct vm_area_struct * mmap; /* list of VMAs */
/**
* 指向线性区对象的红-黑树的根
*/
struct rb_root mm_rb;
/**
* 指向最后一个引用的线性区对象。
*/
struct vm_area_struct * mmap_cache; /* last find_vma result */
...
}
7、那么在mmp中保存的就是一个个的struct vm_area_struct节点的头节点的地址,每个节点的内容就是我们虚拟地址空间上的对应的各种段,如.text,.data,.bss等段的起始地址、结尾地址,还有我们每个段对应的访问权限等信息,像我们之前画的虚拟地址空间其实就是靠着这里的每个节点拼接起来的;
ps:进程运行的第一步就是建立地址空间:地址空间是假的,所谓的地址空间就是在内核建立了地址映射所需要的一系列数据结构,说的就是我们链表vm_area_struct;
8、6中的代码片段,我们可以看到,struct vm_area_struct里面还有一个成员变量是mm_rb,红黑树的根,我们的红黑树建立了和线性链表一样的数据结构,那为什么这里需要红黑树呢????
ps:当我们判断一个地址是否有效的时候,我们需要查找对应段中是否存在这个地址,那么对于链表的查找效率,红黑树的查找效率更高,性能更强,我们以空间换时间,所以就建立了红黑树;
如果访问的地址无效,就抛出一个segment fault段错误;
那为什么要用红黑树,不用AVL树呢???
红黑树相对于AVL树提出了为节点增色,红黑树使用非严格的平衡来换取增删节点的时候,旋转的次数降低,任何不平衡在在3此之内都会旋转平衡,而AVL树是严格的平衡树,每次插入删除的时候,根据情况的不同,每次旋转的次数都比红黑树多,所以,红黑树的插入效率更高。在大量的数据进行插入或者删除的时候,AVL树的不平衡的频率要比红黑树高很多。So,红黑树的性能更加稳定!!!
写时拷贝开始了:
此图来自:http://blog.youkuaiyun.com/wendy_keeping
根据上图我们回顾一下,fork的实现,从上节中的fork底层实现可以看出,虚拟地址空间的拷贝是在函数copy_mm中完成的,那么接下来让我们瞧瞧copy_mm的实现过程吧!
1、参数检查;
2、判断是否指定了标志位CLONE_VM,和clone_flags与操作后的结果为1说明设置了此标志,只是创建一个线程,并将m_users加一,让子进程的mm_struct指向父进程的mm_struct ;如果与后的结果为0,那么说明创建的是进程,为新进程分配一个新的内存描述符(代码一);
//代码一
mm = allocate_mm();
3、将父进程中的mm内容复制给子进程;
memcpy(mm, oldmm, sizeof(*mm));
4、调用dup_mmap,不仅复制了线性区和页表,同时也设置了mm的属性,改变父进程的私有,可写的也为只读的;
retval = dup_mmap(mm, oldmm);
5、如果以上步骤执行完毕,就返回retval,调用处判断是否成功执行;
接下来呢?我们再进入到dup_mmap的底层实现,看看它是怎么把线性区和页表复制给子进程的。
1、copy_mm中dup_mmap的调用;
//copy_mm中dup_mmap的调用
retval = dup_mmap(mm, oldmm);
2、dup_mmap的实现;
static inline int dup_mmap(struct mm_struct * mm, struct mm_struct * oldmm)
3、定义vm_area_struct、rb_node指针;
struct vm_area_struct * mpnt, *tmp, **pprev;
struct rb_node **rb_link, *rb_parent;
4、循环遍历并复制父进程的每一个vm_area_struct线性区描述符,并把复制品插入到子进程的线性区链表和红黑树中,循环中还有另外一个函数copy_page_range,创建必要的页表来映射线性区所包含的一组页,并且初始化新页表的表项,对私有、可写的页(无VM_SHARED标志,有VM_MAYWRITE标志),对父子进程都标记为只读的,为写时复制进行处理。
for (mpnt = current->mm->mmap ; mpnt ; mpnt = mpnt->vm_next) {
struct file *file;
if (mpnt->vm_flags & VM_DONTCOPY) {
__vm_stat_account(mm, mpnt->vm_flags, mpnt->vm_file,
-vma_pages(mpnt));
continue;
}
charge = 0;
if (mpnt->vm_flags & VM_ACCOUNT) {
unsigned int len = (mpnt->vm_end - mpnt->vm_start) >> PAGE_SHIFT;
if (security_vm_enough_memory(len))
goto fail_nomem;
charge = len;
}
tmp = kmem_cache_alloc(vm_area_cachep, SLAB_KERNEL);
if (!tmp)
goto fail_nomem;
*tmp = *mpnt;
pol = mpol_copy(vma_policy(mpnt));
retval = PTR_ERR(pol);
if (IS_ERR(pol))
goto fail_nomem_policy;
vma_set_policy(tmp, pol);
tmp->vm_flags &= ~VM_LOCKED;
tmp->vm_mm = mm;
tmp->vm_next = NULL;
anon_vma_link(tmp);
file = tmp->vm_file;
if (file) {
struct inode *inode = file->f_dentry->d_inode;
get_file(file);
if (tmp->vm_flags & VM_DENYWRITE)
atomic_dec(&inode->i_writecount);
/* insert tmp into the share list, just after mpnt */
spin_lock(&file->f_mapping->i_mmap_lock);
tmp->vm_truncate_count = mpnt->vm_truncate_count;
flush_dcache_mmap_lock(file->f_mapping);
vma_prio_tree_add(tmp, mpnt);
flush_dcache_mmap_unlock(file->f_mapping);
spin_unlock(&file->f_mapping->i_mmap_lock);
}
/*
* Link in the new vma and copy the page table entries:
* link in first so that swapoff can see swap entries,
* and try_to_unmap_one's find_vma find the new vma.
*/
spin_lock(&mm->page_table_lock);
*pprev = tmp;
pprev = &tmp->vm_next;
__vma_link_rb(mm, tmp, rb_link, rb_parent);
rb_link = &tmp->vm_rb.rb_right;
rb_parent = &tmp->vm_rb;
mm->map_count++;
/**
* copy_page_range创建必要的页表来映射线性区所包含的一组页。并且初始化新页表的表项。
* 对私有、可写的页(无VM_SHARED标志,有VM_MAYWRITE标志),对父子进程都标记为只读的。
* 为写时复制进行处理。
*/
retval = copy_page_range(mm, current->mm, tmp);
spin_unlock(&mm->page_table_lock);
if (tmp->vm_ops && tmp->vm_ops->open)
tmp->vm_ops->open(tmp);
if (retval)
goto out;
}
5、由于页表项都是一模一样的,再怎么映射它都会映射到同一个物理页面上,所以父子进程目前是共享的同一个物理页面,当发生写时拷贝,触发写保护的权限,父子进程谁触发,谁就会发生缺页异常,重新分配物理页面,并把之前的物理页面上的内容都拷贝到新的物理页面上,顺着进程树去掉写保护权限,从此父子进程就分道扬镳了;
重点:为什么需要写时拷贝技术???
首先写时拷贝是我们程序的一个拖延战术,如果没有execv一系列函数,那估计是不会有写时拷贝的出现,execv是将本程序完全替换成另外一个程序了,它是将vm_area_struct中的线性链表,rb_node红黑树,物理页面,页表、页目录都进行删除,并分配新的链表,红黑树,物理页面,页表,页目录;如果我fork的时候是完完全全将父进程的东西都拷贝过来,当我刚刚产生的子进程,好不容易才拷贝完的,现在就因为execv我的将我拷贝的全部删除,并申请新的结构,那我子进程是不是很亏。所以,我们的写时拷贝就解决了这个问题,只有发生写的动作的时候,我们才进行物理页面的重新映射。一般刚创建的进程,都会被安排到父进程的运行队列,先于父进程运行,因为一般情况下,子进程产生了以后,都会被execv,替换为其它的进程,这样子的话,写时拷贝就避免了,多余空间的申请并释放;So,写时拷贝的技术是很实用的;