Linux用户进程创建过程浅析

前言

网上分析内核进程创建初始化的文章很多,例如:https://www.cnblogs.com/LittleHann/p/3853854.html,这位仁兄分析的非常详细和深入,非常值得初次看这段代码的人参考。
但是本文主要想解释一些常见但不太有文章详细解释的概念。比如一般都说进程有自己独立的地址空间,线程则共享;线程有自己的独立内核栈等等。像这些概念在代码上是怎么体现的呢?这就是本文的重点。

fork & vfork & clone在内核的实现

fork/vfork/clone是user创建进程/线程时候主要用到的系统调用。在内核中,这些接口最终都会调用到_do_fork来干活,只不过传入的参数/flags有所区别。这里先列出这些参数,后续分析中重点观察这些输入参数/flags是如何控制内核,让内核按照我们的意愿去创建相关数据结构的。

kernel/fork.c

1. fork:

_do_fork(SIGCHLD, 0, 0, NULL, NULL, 0);

2. vfork:

_do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, 0, 0, NULL, NULL, 0);

3. clone:

_do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr, tls);

这里先放一些结论。
1.根据各方面资料可知vfork创建的进程和父进程是共享地址空间的(CLONE_VM的作用)。并且子进程会先于父进程执行(CLONE_VFORK的作用)。
2.用户通过调用clone()接口时设置不同flags来指定子进程/线程需要共享的资源。这一方法定制化程度比较高,但需要深刻理解各个flags的作用以及需求。
3.clone()的另一大用处就是用户空间通过pthread线程库的接口来调用。跟踪某个版本的glibc源码中的线程创建接口pthread_create()->create_thread()可知,创建thread时设置的clone_flags如下: (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SYSVSEM | CLONE_SIGHAND | CLONE_THREAD | CLONE_SETTLS | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID | 0).这里的CLONE_VM | CLONE_FS | CLONE_FILES就是线程和父进程共享的资源。

clone flags如何影响内核

从前面的分析已经知道,无论创建线程还是进程,内核最终都是通过同一个函数接口_do_fork()来完成的。关键是不同的需求,传入的clone flags有差异。最终通过这个flags来影响内核的行为,从而创建出进程or线程。下面结合具体代码分析,重点关注CLONE_VM / CLONE_FILES等flags的影响。先说明,由于内核不区分进程or线程,均采用task_struct管理,所以下文中统一用new process来指代内核新创建task_struct的管理对象。它有可能表示的是进程也可能表示的是线程,需根据实际情况判断。

task_struct创建

_do_fork()->copy_process()->dup_task_struct()

 341     tsk = alloc_task_struct_node(node);
 342     if (!tsk)        
 343         return NULL; 
 344                      
 345     ti = alloc_thread_info_node(tsk, node);
 346     if (!ti)         
 347         goto free_tsk;
 348                      
 349     err = arch_dup_task_struct(tsk, orig);
 350     if (err)         
 351         goto free_ti;
 352                      
 353     tsk->stack = ti; 

dup_task_struct函数从名字就可以看出只要是dup一个task_struct。可以清楚看见,新创建的new process内核中都会分配一个task_struct以及一个thread_info。然后将父process的task_struct完整的复制给子process,接着再将新task_struct中的stack修改为自己的stack。
所以:无论创建的是进程还是线程,内核都会创建一个新的task_struct来描述它,并且它们之间的内核栈是独立的。
dup_task_struct后续代码就是做一些状态重置,需要把从父进程继承过来的比如调度状态等清除,具体不分析了。

CLONE_FILES

_do_fork()->copy_process()->copy_files()

1050     if (clone_flags & CLONE_FILES) {
1051         atomic_inc(&oldf->count);
1052         goto out;
1053     }         
1054               
1055     newf = dup_fd(oldf, &error);

如1050~1052所示,如果系统调用传入的参数中指定了CLONE_FILES时,内核会增加文件引用计数就结束了,内核并没有分配新的内存空间去管理这个打开的文件。也就是说线程或进程(设置了CLONE_FLAG的进程)会和父进程共享打开的文件,也就是说各自对文件的修改对方都可见。
如果未设置CLONE_FLAGS,则内核跳到1055行执行。内核通过dup_fd()会首先从kmem_cache中拿到一个新的files_struct结构体,然后将其初始化。这就是说进程会继承父进程打开的文件,且拥有的是一份拷贝,也就是说各自对文件的修改对方都不可见。

CLONE_VM

_do_fork()->copy_process()->copy_mm()

 998     if (clone_flags & CLONE_VM) {
 999         atomic_inc(&oldmm->mm_users);
1000         mm = oldmm;
1001         goto good_mm;
1002     }
1003  
1004     retval = -ENOMEM;
1005     mm = dup_mm(tsk);

同CLONE_FILES一样,如果设置了CLONE_VM,表明我们需要共享mm_struct。此时内核仅增加父进程mm_struct引用的引用计数便结束了。如果没有设置这个flag,则内核需要将父进程的mm_struct复制一份。 由此可知,线程由于没有独立的mm_struct,所以说它没有独立的地址空间。

进程地址空间初始化

进程间共享内核空间的实现

大家都知道内核启动时会为自己初始化一个名为swapper_pg_dir的内核页表。这里面保存着低端物理内存的线性映射关系。进程拥有0~4GB的地址空间,而内核位于高地址,且内核是所有进程共享的。怎么理解这个共享?它是如何实现的呢?
根据前面的介绍,如果是线程或设置了CLONE_VM的进程,因为mm_struct是和parent共享的,所以毫无疑问内核空间也是共享。但如果是进程,那它会有自己独立的0~4GB空间,且进程之间相互隔离,如何做到内核共享呢?
我们从进程中管理地址空间的数据结构mm_struct的初始化说起,分析进程在创建过程中如何初始化内核空间部分。没错,依然是前面分析过的copy_mm()函数。
_do_fork()->copy_process()->copy_mm()->dup_mmap()->mm_init()

 948     if (!mm_init(mm, tsk, mm->user_ns))                                                                                                                                    
 949         goto fail_nomem;

mm_init()在对一些成员做了初始化之后便调用了 mm_alloc_pgd()函数来初始化进程自己的一级页表了。

 623     if (mm_alloc_pgd(mm))                                                                                                                                                  
 624         goto fail_nopgd;
 625        

mm_alloc_pgd只是封装函数,真正干活的是pgd_alloc(), pgd_alloc()中和主题相关的代码如下
_do_fork()->copy_process()->copy_mm()->mm_init()->mm_alloc_pgd()->pgd_alloc()

 40     new_pgd = __pgd_alloc();
 41     if (!new_pgd)        
 42         goto no_pgd;     
 43                          
 44     memset(new_pgd, 0, USER_PTRS_PER_PGD * sizeof(pgd_t));
 45                          
 46     /*                   
 47      * Copy over the kernel and IO PGD entries
 48      */                  
 49     init_pgd = pgd_offset_k(0);
 50     memcpy(new_pgd + USER_PTRS_PER_PGD, init_pgd + USER_PTRS_PER_PGD,                                                                                                       
 51                (PTRS_PER_PGD - USER_PTRS_PER_PGD) * sizeof(pgd_t));

最最关键的就是49和50两行代码。49行通过pgd_offset_k(0)能直接找到内核页表存放的物理起始地址(PAGE_OFFSET + TEXT_OFFSET - PG_DIR_SIZE)。50行则根据前面得到的内核页表起始地址信息,将内核页表完整的复制到进程自己的一级页表中,根据第三个参数也能知道,拷贝的就是内核页表的尺寸。所以每个拥有独立地址空间的进程,在初始化时都会创建独立的mm_struct。在mm_struct初始化时,会为进程单独分配一级页表,且分配完一级页表后马上会将内核页表拷贝到这个一级页表中。这就是每个进程最终都能共享内核的奥秘,因为每个进程自己的页表中都有一份一模一样的内核页表。

进程初始化用户空间的实现

前面介绍了进程内核空间的初始化。如果是32位的CPU,进程除了2G的内核空间,还有2G的用户空间需要初始化。
进程的用户空是直接从父进程复制过来的。Linux通过COW技术令进程只在写内存这个动作发生时才真正去分配物理内存。也就是说fork之后如果父子进程都只read而不write,它俩访问的其实都是同一片地址空间(包括虚拟地址和物理地址)。只有其中一个进程往里写之后,写的进程通过缺页异常才会重新分配物理地址给自己,进而把受影响的几项pte修改成新分配的物理地址。到时,父子进程的这部分地址空间才开始分道扬镳,不论read还是write都是不同的物理地址(虚拟地址倒是还有可能相同)互不影响。剩下没有发生写动作的地址,父子进程依然共享。
内核是如何做到这一点的呢?看dup_mm()吧。
_do_fork()->copy_process()->copy_mm()->dup_mm()->dup_mmap()
我们主要关注dup_mmap()中复制父进程各个vm的操作。

432     for (mpnt = oldmm->mmap; mpnt; mpnt = mpnt->vm_next) {
 433         struct file *file;
 434                       
 435         if (mpnt->vm_flags & VM_DONTCOPY) {
 436             vm_stat_account(mm, mpnt->vm_flags, mpnt->vm_file,
 437                             -vma_pages(mpnt));
 438             continue; 
 439         }     
            ... ...
503         retval = copy_page_range(mm, oldmm, mpnt);
            ... ...
510     }

dup_mmap()函数很长,直接看核心的for循环。这个for循环基本就是复制old进程的vm结构给new进程。先看第一个if判断,如果flags是VM_DONTCOPY,则子进程不会复制这部分空间。相应的,内核对相关vm数据管理结构在这里也要调整一下,然后开始下一次循环。 中间还有很多其它对VM flags的判断,这里现省略。直接看最重要的复制物理内存的copy_page_range()函数。
copy_page_range()->copy_pud_range()->copy_pmd_range()->copy_pte_range()
我们知道内核本身支持好几级的页表管理,有pgd/pud/pmd/pte,这里只分析两级页表的管理结构。这里虽然函数调用栈很深,但其实只需要关心最后一级pte的管理就可以了。所以直接看copy_pte_range()的实现。
copy_pte_range也有一个核心的循环函数。

 952     do {
 953         /*
 954          * We are holding two locks at this point - either of them
 955          * could generate latencies in another task on another CPU.
 956          */
 957         if (progress >= 32) {
 958             progress = 0;
 959             if (need_resched() ||
 960                 spin_needbreak(src_ptl) || spin_needbreak(dst_ptl))
 961                 break;
 962         }
 963         if (pte_none(*src_pte)) {
 964             progress++;
 965             continue;
 966         }
 967         entry.val = copy_one_pte(dst_mm, src_mm, dst_pte, src_pte,
 968                             vma, addr, rss);
 969         if (entry.val)
 970             break;
 971         progress += 8;
 972     } while (dst_pte++, src_pte++, addr += PAGE_SIZE, addr != end);
 973     

内核通过copy_onte_pte()函数将父进程的pte一项一项拷贝到子进程。
846 static inline unsigned long
847 copy_one_pte(struct mm_struct *dst_mm, struct mm_struct *src_mm,
848 pte_t *dst_pte, pte_t *src_pte, struct vm_area_struct *vma,
849 unsigned long addr, int *rss)
850 {
… …
900 if (is_cow_mapping(vm_flags)) {
901 ptep_set_wrprotect(src_mm, addr, src_pte);
902 pte = pte_wrprotect(pte);
903 }
904 … …
这个函数还挺长的,其中第一个if函数是现判断当前这条pte是否在sawp中。如果是的话需要现做一些额外工作。现不分析这些复杂的情况,直接看第900行。可以看到,如果VM flags支持cow。在拷贝的过程中会首先将父进程的这条pte设为写保护模式,然后再将这条写保护的pte写到子进程的页表中!
到这里就发现,
1. Linux通过fork等创建的子进程,在继承父进程用户空间地址的过程中会同时在父子进程的页表中将这部分地址空间设置为写保护模式!所以不论父子进程,只要有一方向这部分空间执行写操作就会发生缺页异常。
2. 大家都知道子进程不会完全继承父进程的资源,比如阻塞信号,定时器等(通过man 2 fork 可以查看资源继承情况)。但事实上vm结构也并不会完全继承,比如说父进程对某些设了VM_DONTCOPY flags的VM结构等。

接下去分析子进程如果写了write protect部分的vm,内核如果走到page fault,然后内核如何处理的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值