1.开场白

本文主要从内存管理和进程管理两个维度来窥探一下fork背后隐藏的技术细节,希望能够通过本文让大家站在一个高度去看进程创建。

全文分为两部分讲解:fork的内存管理部分和进程管理部分,内存管理主要讲解子进程如何构建自己的内存管理相关基础设施,父子进程如何共享地址空间的,写时复制如何发生,页表层面为我们做了哪些事情等等。而进程管理主要讲解,子进程如何构建自己的进程管理相关基础设施,如何加入到cpu的运行队列,第一次运行时如何执行等等。

实际上,除了0号进程,其他的所有进程无论是内核线程还是普通的用户进程和线程都是fork出来的,而创建进程是内核所做的事情,要么在内核空间直接创建出所谓的内核线程,要么是通过fork,clone这样的系统调用陷入内核空间来创建。对于内核线程没有异常级别的切换,构建好调度相关基础数据结构时就可以在第一次参与调度的时候执行他的执行函数,任务切换的时候也不需要进行地址空间切换。而对于用户任务来说,需要异常级别的切换(也是一种上下文切换),任务切换的时候甚至还需要切换地址空间。

说明:我们将参与调度的实体称为任务,包括用户进程,用户线程,内核线程。

2.fork的内存管理

2.1 内存相关基础设施构建

我们移步到如下调用路径(当前处于copy_mm函数中):

kernel_clone   //kernel/fork.c
->copy_process->copy_mm

首先,任务在创建的时候根据传递的fork的参数clone_flags来决定是否需要创建一个mm_struct结构来管理任务的地址空间,如果传递过来的clone_flags带有CLONE_VM标志,则不需要创建,直接和父进程共享地址空间即可,如内核线程和用户线程

  if (clone_flags & CLONE_VM) {     mmget(oldmm);             mm = oldmm;               goto good_mm;             }    mm = dup_mm(tsk, current->mm);...good_mm:                     tsk->mm = mm;        tsk->active_mm = mm; return 0;            

而当传递过来的clone_flags不带CLONE_VM标志,则需要为子进程创建新的地址空间,如创建子进程,就调用到dup_mm中。为了看的清晰贴出了如下代码:

static struct mm_struct *dup_mm(struct task_struct *tsk,                 struct mm_struct *oldmm)
{                                                                        struct mm_struct *mm;                                            int err;                                                         mm = allocate_mm();                                              if (!mm)                                                         goto fail_nomem;                                         memcpy(mm, oldmm, sizeof(*mm));                                  if (!mm_init(mm, tsk, mm->user_ns))                              goto fail_nomem;                                         err = dup_mmap(mm, oldmm);                                       if (err)                                                         goto free_pt;                                            ....
}                                                                        

这里需要注意的地方有三个地方:1.通过allocate_mm分配属于进程自己的mm_struct结构来管理自己的地址空间;2.通过mm_init来初始化mm_struct中相关成员;3.通过dup_mmap来复制父进程的地址空间(实际上后面我们会看到是复制父进程的vma以及页表)。

分配mm_struct结构就不需要赘述,我们先看下mm_init,调用链如下:

mm_init
->mm->mmap = NULL;                mm->mm_rb = RB_ROOT;            mm->vmacache_seqnum = 0; ...if (mm_alloc_pgd(mm))         goto fail_nopgd;      if (init_new_context(p, mm))  goto fail_nocontext;  

这里有两个地方暗藏玄机,首先是mm_alloc_pgd,对于像amr64这种处理器架构来说,只不过是分配一个进程私有pge页而已,当va->pa转换的时候,查找属于当前进程的pgd表项。

mm_alloc_pgd   //arch/arm64/mm/pgd.c->mm->pgd = pgd_alloc(mm);->__get_free_page

但是,当我们看其他处理器架构mm_alloc_pgd的实现时会发现,除了会分配pge页还会做主内核页表的内核空间的pge表项的同步工作,如riscv,x86。下面是riscv的实现:

static inline pgd_t *pgd_alloc(struct mm_struct *mm)  //arch/riscv/include/asm/pgalloc.h
{pgd_t *pgd;pgd = (pgd_t *)__get_free_page(GFP_KERNEL);  //分配进程私有的pge页if (likely(pgd != NULL)) {memset(pgd, 0, USER_PTRS_PER_PGD * sizeof(pgd_t));/* Copy kernel mappings */memcpy(pgd + USER_PTRS_PER_PGD,init_mm.pgd + USER_PTRS_PER_PGD,(PTRS_PER_PGD - USER_PTRS_PER_PGD) * sizeof(pgd_t));   //同步主内核页表的内核空间的pge表项到进程的pgd页中}return pgd;
}

这些处理器为什么要这样多此一举呢?原因是这样的:当内核初始化完成转换,进程切换的时候都是使用tsk->mm->pgd指向的页表作为base来进程页表遍历(walk),对于arm64架构来说,他有两个页表基址寄存器ttbr0_el1和ttbr1_el1(只考虑阶段1的el0和el1的地址转换),内核在初始化的时候会将主内核页表swapper_pg_dir的地址存放在ttbr1_el1,进程切换的时候将进程tsk->mm->pgd存放在ttbr0_el1,当进行va->pa的转换的时候,mmu会判断地址是属于用户空间地址还是内核空间,如果是用户空间就使用ttbr0_el1作为base来进行页表walk,当地址属于内核空间地址就使用ttbr1_el1作为base来进行页表walk。所有不需要同步内核空间的pgd表项,在访问内核地址空间的内容的时候没有任何问题。

但是,像x86这样的处理器架构就不一样了,只有一个页表基址寄存器如cr3,所有fork子进程的时候就需要同步主内核页表的内核相关部分的pgd表项,这样通过一个页表基址寄存器就可以找到内核空间的各级表项。

接下来我们看一下,mm_init中的另一个比较重要的初始化:

mm_init
->init_new_context->atomic64_set(&mm->context.id, 0)

可以看的最后设置了mm->context.id为0,这点很重要,当进程调度的时候进行地址空间切换,如果mm->context.id为0就为进程分配新的ASID(ASID技术为了在进程地址空间切换的时候防止频繁刷tlb的一种优化)。

好了,讲完了mm_init相关的一些隐藏的技术细节,我们在返回dup_mm中来看看dup_mmap的实现:

dup_mm
->dup_mmap-> for (mpnt = oldmm->mmap; mpnt; mpnt = mpnt->vm_next) {...tmp = vm_area_dup(mpnt);    //分配拷贝父进程的vmacopy_page_range  //进程页表复制

这里注意看两个地方,分别是:vm_area_dup和copy_page_range(这是fork的主要内存开销)。

vm_area_dup主要是拷贝父进程的vma,代码实现很简单,我们重点来看copy_page_range。

对于每一个vma都调用copy_page_range,此函数会遍历vma中每一个虚拟页,然后拷贝父进程的页表到子进程(虚拟页对应的页表存在的话),这里主要是页表遍历的代码,从pgd->pud->pmd->pte。我们不关注页表拷贝过程,我们把目光聚集到对私有页面的处理上来:

copy_page_range->is_cow = is_cow_mapping(src_vma->vm_flags)   //判断当前vma是否为私有可写的属性->copy_p4d_range->copy_pud_range->copy_pmd_range->copy_pte_range->copy_present_pte-> /*                                                    ¦* If it's a COW mapping, write protect it both       ¦* in the parent and the child                        ¦*/                                                   if (is_cow_mapping(vm_flags) && pte_write(pte)) {     //写保护处理ptep_set_wrprotect(src_mm, addr, src_pte);    pte = pte_wrprotect(pte);                     }                                                     

最终,我们看的在copy_present_pte函数中,对父子进程的写保护处理,也就是当发现父进程的vma的属性为私有可写的时候,就设置父进程和子进程的相关的页表项为只读。这点很重要,因为这样既保证了父子进程的地址空间的共享(读的时候),又保证了他们有独立的地址空间(写的时候)。

总结来说:fork中构建了内存管理相关的基础设施如mm_struct ,vma,pgd页等,以及拷贝父进程的vma和拷贝父进程的页表来达到和父进程共享地址空间的目的,可以看的处理这种共享并不是像共享内存那种纯粹意义上的共享,而是让子进程能够使用父进程的内存资源,而且在写的时候能够让父子进程开来创造了条件(写保护)。当然这种方式并没有拷贝父进程的任何物理页,只是通过页表来共享而已,当然这种内存开销也是很大的,如果子进程fork之后立马进程exec加载自己的程序,这这种写时复制意义并不大,但是试想,如果不通过页表共享,则子进程寸步难行,甚至连exec都无法调用。

2.2 内存基础设施的使用之--写实复制的发生

fork创建完子进程后,通过复制父进程的页表来共享父进程的地址空间,我们知道对于私有的可写的页,设置了父子进程的相应页表为为只读,这样就为写实复制创造了页表层面上的条件。当父进程或者子进程,写写保护的页时触发访问权限异常:

处理器架构捕获异常后,进入通用的缺页异常处理路径:

 ...   //处理器架构处理do_page_fault                       // arch/arm64/mm/fault.c
->  __do_page_fault->  handle_mm_fault -> handle_pte_fault      //mm/memory.c->  if (vmf->flags & FAULT_FLAG_WRITE) {          if (!pte_write(entry))                return do_wp_page(vmf);       entry = pte_mkdirty(entry);           }                                             

在匿名页缺页异常处理路径中,判断这个页错误是写保护错误(也就是判断虚拟页可写可是对应的页表为只读)时,就会调用do_wp_page做写实复制处理:

do_wp_page
->wp_page_copy->new_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, vmf->address); //分配新的页面              ->cow_user_page(new_page, old_page, vmf) //拷贝原理共享的页面到新页面->entry = mk_pte(new_page, vma->vm_page_prot);     entry = pte_sw_mkyoung(entry);                  entry = maybe_mkwrite(pte_mkdirty(entry), vma);  //设置为可写->set_pte_at_notify(mm, vmf->address, vmf->pte, entry)  //页表属性设置到进程对应的页表项中

可以看的出来,fork时对私有可写的页面做写保护的准备,在父子进程有一方发生写操作时触发了处理器的访问权限缺页异常,异常处理程序重新分配了新的页面给了发起写操作的进程,父子进程对应这个页面的引用就此分道扬镳。

2.3 内存基础设施的使用之--各级页表创建

我们知道,对于用户进程来说,内核并不是马上满足进程对于物理页的请求,而仅仅是为他分配虚拟页,内核采用一种惰性的内存分配的方式,知道访问的最后一刻才为进程分配物理页,这既是所谓的内核的按需分配/掉页机制。进程fork的时候,仅仅分配了一级页表页也就是私有的pgd页,其他的各级页表并没有分配,当进程第一次访问虚拟页时,发生缺页异常来分配:缺页异常中分配各级页表路径如下:

handle_mm_fault
->__handle_mm_fault->pgd = pgd_offset(mm, address)    //根据发生缺页的地址和mm->pgd计算出pgd表项->p4d = p4d_alloc(mm, pgd, address)   //获得p4d表项  arm64没有使用p4d    直接(p4d_t *)pgd-> vmf.pud = pud_alloc(mm, p4d, address)  //获得pud项  没有pud页则创建-> vmf.pmd = pmd_alloc(mm, vmf.pud, address)  //获得pm项  没有pm页则创建->handle_pte_fault->do_anonymous_page   //匿名映射缺页异常为例->pte_alloc(vma->vm_mm, vmf->pmd)   //获得pte  没有pte页则创建

可以看的缺页异常处理中按需创建了所需要的各级页表。

2.4 内存基础设施的使用之--进程调度地址空间的切换

进程fork之后最终会参与系统调度,系统为其分配一定的cpu时间,在进程切换的时候,对于用户进程来说,处理要切换处理器状态(如pc,sp等),最重要的就是切换地址空间,这样进程运行的时候访问的才是自己地址空间的东西,也达到了虚拟地址空间隔离的效果。

现在我们移步到进程调度相关代码,主要来看下进程地址空间切换部分:

... //主动调度或者抢占式调度
__schedule
->next = pick_next_task(rq, prev, &rf)  //选择合适的进程调度
->context_switch    //上下文切换if (!next->mm) {  //对于内核线程next->active_mm = prev->active_mm;  //引用前一个进程的active_mm} else {  //对于用户任务switch_mm_irqs_off   //切换地址空间}

可以看的对于内核线程,它不需要切换地址空间,其实这里的地址空间指得是用户虚拟地址空间,因为它只使用内核空间(所有进程共享),但是他做了一步比较重要的操作,即是next->active_mm = prev->active_mm,这样做的目的是:内核线程运行过程中也会不断的发生va->pa的转换,而转化需要页表,就借用上一个用户进程的页表作为base(页表walk的时候从prev->active_mm->pgd开始)。

说完了内核线程我们来看看用户任务是如何切换地址空间的。

switch_mm_irqs_off
->switch_mm    //  arch/arm64/include/asm/mmu_context.h-> if (prev != next)   __switch_mm(next)

这里依然有我们需要注意的地方,那就是当发现prev != next,即是前一个任务和即将要切换的任务的地址空间不相等的时候才会执行__switch_mm做地址空间切换,如果相等就不需要切换,大家可能已经知道了,如果是两个属于同一进程的不同线程之间(也有可能是同一进程)不需要切换地址空间(他们共享地址空间,但是调度是独立调度)。

接下来,当发现是两个不同的进程直接切换,那么需要切换地址空间了。

switch_mm
->__switch_mm->check_and_switch_context(next)    //next为下一个进程的进程描述符  arch/arm64/mm/context.c-> ...    //ASID分配相关若干代码->cpu_switch_mm(mm->pgd, mm)->cpu_do_switch_mm(virt_to_phys(pgd),mm)   //virt_to_phys(pgd)将进程的mm->pgd转化为了物理地址->  unsigned long ttbr1 = read_sysreg(ttbr1_el1);        //读取 ttbr1_el1寄存器                unsigned long asid = ASID(mm);             //获得进程的ASID                            unsigned long ttbr0 = phys_to_ttbr(pgd_phys);     //取pgd地址                    /* Skip CNP for the reserved ASID */                                  if (system_supports_cnp() && asid)                                    ttbr0 |= TTBR_CNP_BIT;                                        /* SW PAN needs a copy of the ASID in TTBR0 for entry */              if (IS_ENABLED(CONFIG_ARM64_SW_TTBR0_PAN))                            ttbr0 |= FIELD_PREP(TTBR_ASID_MASK, asid);                    /* Set ASID in TTBR1 since TCR.A1 is set */                           ttbr1 &= ~TTBR_ASID_MASK;                                             ttbr1 |= FIELD_PREP(TTBR_ASID_MASK, asid);     //ASID设置到   ttbr1                    write_sysreg(ttbr1, ttbr1_el1); //设置ttbr1_el1                           isb();                                                                write_sysreg(ttbr0, ttbr0_el1);//ttbr0_el1                                       isb();                                                                post_ttbr_update_workaround();                                        

实际上,完成了上面的操作也就完成了进程地址空间的切换。这里需要设置两个页表基址寄存器:ttbr0_el1 和 ttbr1_el1 。内核将mm->pgd的虚拟地址转化为物理地址之后设置到了ttbr0_el1,将为进程分配的ASID设置到了ttbr1_el1(其实ttbr0_el1和ttbr1_el1都有ASID域,究竟设置到那个寄存器由TCR_EL1的A1, bit [22]来决定,为1设置到ttbr1_el1,内核初始化的时候就是设置为1)。

那么问题来了,为什么说我设置了ttbr0_el1 和 ttbr1_el1就完成了进程地址空间切换呢?待我一一到来(假如地址合法)。1)访问用户空间虚拟地址 当第一次访问一个虚拟地址的时候,则mmu会在tlb中查找对应的表项,显然查找不到,则这个时候就需要遍历多级页表,那么这个时候就需要有一个base地址开始遍历,判断地址属于用户空间地址,那么就从ttbr0_el1中获取这个地址,然后就会根据ttbr0_el1找到属于当前进程在fork时创建的pgd页,然后结合虚拟地址就可以遍历各级页表表项(当然会由缺页异常来分配各级页表并填充相应表项),最终将叶子表项(即是最后一级页表表项)填充到tlb中,并返回物理地址。第二次再访问的时候,就直接可以在tlb中找到,不需要遍历多级页表。2)访问内核空间虚拟地址 访问内核空间虚拟地址,也会首先从tlb中查找对应的表项,找不到就会从ttbr1_el1开始遍历各级页表,然后最终将叶子表项(即是最后一级页表表项)填充到tlb中,并返回物理地址。

可以看到每一次做va->pa的地址翻译的时候首先在tlb中查找,上面忘记说了一点,那就是对于用户空间虚拟地址tlb的查找需要根据va和ASID共同查找(内核空间虚拟地址所有进程共享不需要ASID), tlb没有找到就要接受系统惩罚,需要遍历多级页表项然后获得所需要的表项从表项中获得物理地址,这个过程呢需要根据是用户空间虚拟地址还是内核空间虚拟地址,从ttbr0_el1或 ttbr1_el1开始遍历多级页表,然后将表项填入到tlb。这里就使用了fork时创建的基础设施,mm->pgd已经相应的ASID结构,在缺页异常时填充各级表项,进程切换时来使用他们。

下面给出了一个用户进程的内存组织图(有fork时创建以及缺页异常时创建和填充)


讲到这里,我们的fork时的第一个维度内存管理部分讲解完了,下面给出大致总结:fork的时候会创建内核管理的一些基础设施:如mm_struct, vma等用于描述进程自己的地址空间,然后会创建出进程私有的pgd页,用于页表遍历时填充页表,然后还会拷贝父进程所有的vma,然后就是对于每个vma做页表的拷贝和写保护操作。后面的pud pmd的其他各级页表的创建和填充工作由缺页异常处理来完成,可以看的fork的主要开销为vma和页表的拷贝,而这种拷贝看似多余但又不可或缺。

接下来介绍fork的第二个维度-进程管理相关的隐藏技术细节,由于本文章篇幅较长,将放在下一篇文章中,敬请期待,感谢阅读!

5T技术资源大放送!包括但不限于:C/C++,Arm, Linux,Android,人工智能,单片机,树莓派,等等。在公众号内回复「peter」,即可免费获取!!

 记得点击分享在看,给我充点儿电吧

通过fork来剖析Linux内核的内存管理和进程管理(上)相关推荐

  1. 通过fork来剖析Linux内核的内存管理和进程管理(下)

    上一篇文章我们讲到fork的时候内存管理相关的内容,时间大概隔了快一周了,发布下篇文章,写文章确实费时费力,需要仔细推敲,原创不易,希望大家多多支持吧.本文讲解fork的时候进程管理相关的内容,主要讲 ...

  2. Linux内核源码分析《进程管理》

    Linux内核源码分析<进程管理> 前言 1. Linux 内核源码分析架构 2. 进程原理分析 2.1 进程基础知识 2.2 Linux进程四要素 2.3 进程描述符 task_stru ...

  3. Linux内核设计与实现:进程管理

    1. 关于进程 在linux操作系统中,进程通过fork()函数调用,通过复制一个现有的进程来创建一个新的进程: 调用fork()的叫做父进程,而创建的进程叫子进程,fork()调用从内核返回两次,一 ...

  4. fork的黑科技,它到底做了个啥,源码级分析linux内核的内存管理

    最近一直在学习linux内核源码,总结一下 https://github.com/xiaozhang8tuo/linux-kernel-0.11 一份带注释的源码,学习用. fork的黑科技,它到底做 ...

  5. linux 内核 课程,Linux内核分析课程-全面剖析Linux内核技术 揭开Linux内核的面纱 Linux内核学习视频教 ......

    课程名称 Linux内核分析课程-全面剖析Linux内核技术 揭开Linux内核的面纱 Linux内核学习视频 课程目录 (1)\1, 计算机是如何工作的?:目录中文件数:0个 (2)\2, 操作系统 ...

  6. 从底层原理出发,了解Linux内核之内存管理

    本文讲解更加底层,基本都是从Linux内核出发,会更深入.所以当你都读完,然后再次审视这些功能的实现和设计时,我相信你会有种豁然开朗的感觉. 1.页 内核把物理页作为内存管理的基本单元. 尽管处理器的 ...

  7. Linux内核学习--内存管理模块

    Linux内核学习--内存管理模块 首先,Linux内核主要由五个部分组成,他们分别是:进程调度模块.内存管理模块.文件系统模块.进程间通信模块和网络接口模块. 本部分所讲的内存是内存管理模块,其主要 ...

  8. 【Linux内核】内存映射原理

    [Linux内核]内存映射原理 物理地址空间 物理地址是处理器在总线上能看到的地址,使用RISC(Reduced Instruction Set Computing精简指令集)的处理器通常只实现一个物 ...

  9. 深度剖析Linux内核地址映射机制

    深度剖析Linux内核地址映射机制 1.虚拟空间数据结构   2.进程虚拟空间  3.内存映射 视频讲解如下,点击观看: Linux内核开发系列第7讲--深度剖析Linux内核地址映射机制 C/C++ ...

最新文章

  1. AI模型走下高科技神坛、走进大规模量产、深入渗透产业界丨百度研究院2020十大预测...
  2. .gitignore文件将已经纳入版本管理的文件删除
  3. 【机器学习】 - keras学习 - 图片生成器ImageDataGenerator
  4. 【2017年第4期】流式大数据实时处理技术、平台及应用
  5. 当微信遇上 10 万战绩的「跳一跳」外挂,程序员还能“逍遥”多久?
  6. wordpress让百度分享支持https
  7. 手把手教你彻底卸载MySQL
  8. 软考中级网络工程师知识点笔记
  9. 中标麒麟桌面版7.0(NeoKylin linux desktop release 7.0)配置pyqt5运行环境说明
  10. wow大芒果mysql,大芒果魔兽世界3.3.5GM命令范例.doc
  11. 【studio】整理了下studio中make Project、clean Project、Rebuild Project的区别
  12. esp32 采集某个路由器信号强度
  13. mysql b树子节点个数_MySQL 和 B 树的那些事-爱可生
  14. Windows10只关闭显卡驱动更新
  15. Elasticsearch 使用初级入门 【入门篇】
  16. 计算机考研854题型,2017年中央民族大学854计算机基础综合考研大纲
  17. 【物体检测快速入门系列 | 01 】基于Tensorflow2.x Object Detection API构建自定义物体检测器
  18. 万家乐“天工系列”预热AWE2019 厨卫电器呈现新形态
  19. C++ pair的比较大小
  20. 折腾日记:树莓派如何硬盘、u盘启动 (usb boot)

热门文章

  1. uni-app 拍照或从相册上传图片
  2. C++ 小白 学习记录15
  3. 白大脑比超级计算机还,巨金怪 - 神奇宝贝百科,关于宝可梦的百科全书
  4. 使用html语言实现学生成绩表展示
  5. 营销新套路:冒充腾讯云 阿里云客服打电话
  6. Excel使用技巧总结二
  7. 乐视TV呼叫中心总监李霜:生态时代的服务运营之道
  8. 计算机主板会自动切断电源是怎么回事,主机启动一秒又自动断电一直循环 就只擦内存条...
  9. 如何取消程序的默认打开方式 windows亲测简单有效!
  10. ubuntu忘记密码?怎么办