process credentials(二)

1、前言linux

为何要写一个关于进程如何建立的文档?其实用do_fork做为关键字进行索引,你会发现网上的相关文档数以万计。做为一个内核工程师,对进程以及进程相关的内容固然是很是感兴趣,可是网上的资料并不能令我很是满意(也许是我没有检索到好的文章),一个简单的例子以下:shell

static void copy_flags(unsigned long clone_flags, struct task_struct *p)
{
    unsigned long new_flags = p->flags; 安全

    new_flags &= ~(PF_SUPERPRIV | PF_WQ_WORKER);
    new_flags |= PF_FORKNOEXEC;
    p->flags = new_flags;
} 网络

上面的代码是进程建立过程的一个片断,网上的解释通常都是对代码逻辑的描述:清除PF_SUPERPRIV 和PF_WQ_WORKER这两个flag的标记,设定PF_FORKNOEXEC标记。坦率的讲,这样的代码解析没有任何意义,其实c代码都已是很是清楚了。固然,也有的文章进行了进一步的分析,例如对PF_SUPERPRIV 被清除进行了这样的解释:代表进程是否拥有超级用户权限的PF_SUPERPRIV标志被清0。很遗憾,这样的解释不能使人信服,由于若是父进程是超级用户权限,其建立的子进程是要继承超级用户权限的。数据结构

正由于如此,我想对linux kernel中进程建立涉及的方方面面的系统知识进行梳理,在个人能力范围内对进程建立的source code进行逐行解析。一言以蔽之,do_fork的source code只是索引,重要的是与其相关的各个知识点。多线程

因为进程建立是一个大工程,所以分红若干的部分。本文是第一部分,主要内容包括:app

一、从用户空间看进程建立框架

二、系统调用层面看进程建立函数

三、trace的处理性能

四、参数检查

五、复制thread_info和task_struct

注:本文引用的内核代码来自3.14版本的linux kernel。

 

2、用户空间如何建立进程

应用程序在用户空间建立进程有两种场景:

一、建立的子进程和父进程共用一个elf文件。这种状况,elf文件中的正文段中的部分代码是父进程和子进程共享,部分代码是属于父进程,部分代码属于子进程。这种状况适合于大多数的网络服务程序。

二、建立的子进程须要加载本身的elf文件。例如shell。

为了应对这些需求,linux采用了fork then exec两段式的方式来建立进程。对于场景1,程序直接fork便可,对于场景2,使用fork then exec来应对。本文主要focus在fork操做上,对于exec的操做,在进程加载文档中描述。

应用程序能够经过fork系统调用建立进程,该新建立的进程是调用fork进程的子进程。fork以后,一个进程会象细胞分裂那样变成两个进程。子进程复制了父进程(也就是调用fork的那个进程)的绝大部分的资源(文件描述符、信号处理、当前工做目录等),更细节的信息能够参考后面具体的内核代码分析。

彻底复制父进程的资源的开销很是大,特别是对于场景2,全部的开销都是彻底的没有任何意义,由于系统load新的elf文件后,会重建text、data等segment。不过,在引入COW(copy-on-write)技术后,fork的开销其实也不算特别大,大部分的copy都是经过share完成的,主要的开销集中在复制父进程的页表上。在某些特定的场合下,若是程序想把复制父进程页表这一点开销也节省掉,那么linux还提供了vfork函数。Vfork和fork是相似的,除了下面两点:
一、阻塞父进程
二、不复制父进程的页表
之因此vfork要阻塞父进程是由于vfork后父子进程使用的是彻底相同的memory descriptor, 也就是说使用的是彻底相同的虚拟内存空间, 包括栈也相同。因此两个进程不能同时运行, 不然栈就乱掉了。因此vfork后, 父进程是阻塞的,直到调用了exec系列函数或者exit函数后。这时候,子进程的mm(old_mm)须要释放掉,再也不与父进程共用了,这时候就能够解 除父进程的阻塞状态。
除了fork和vfork,Linux内核还提供的clone的系统调用接口主要用于线程的建立,这个接口提供了更多的灵活性,可让用户指定父进程和子进程(也就是建立的进程)共享的内容。其实经过传递不一样的参数,clone接口能够实现fork和vfork的功能。更多细节能够参考后面具体的内核代码分析

 

3、系统调用相关代码分析

fork对应的系统调用代码以下:

#ifdef __ARCH_WANT_SYS_FORK
SYSCALL_DEFINE0(fork)
{
#ifdef CONFIG_MMU
    return do_fork(SIGCHLD, 0, 0, NULL, NULL);
#else
    /* can not support in nommu mode */
    return(-EINVAL);
#endif
}
#endif

对于fork的实现,在kernel中会使用COW技术,若是没有MMU的话,也就没有虚拟地址、页表这些概念,也就没法实现COW版本的fork。在这样的条件下,若是强行实现fork,那么也只能是:

一、彻底复制。也就是说,内核为子进程选择适合的地址空间,而且copy完整的父进程的地址空间到子进程。

二、禁止fork,用vfork+exec来实现fork

上面的代码已经很清楚了,内核采用了方法2。

vfork对应的系统调用代码以下:

#ifdef __ARCH_WANT_SYS_VFORK
SYSCALL_DEFINE0(vfork)
{
    return do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, 0,
            0, NULL, NULL);
}
#endif

fork和vfork的实现是和CPU architecture相关的(参见source code中的__ARCH_WANT_SYS_FORK和__ARCH_WANT_SYS_VFORK)。在POSIX标准中对vfork描述以下:Applications are recommended to use the fork( ) function instead of this function。也就是说,标准不建议实现vfork,可是linux kernel仍是保留了该系统调用,一方面是有些应用对performance特别敏感,vfork能够得到一些的性能优点。此外,在没有MMU支持的CPU上,vfork+exec来能够用来实现fork。

clone对应的系统调用代码以下:

#ifdef __ARCH_WANT_SYS_CLONE
#ifdef CONFIG_CLONE_BACKWARDS
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
         int __user *, parent_tidptr,
         int, tls_val,
         int __user *, child_tidptr)
#elif defined(CONFIG_CLONE_BACKWARDS2)
SYSCALL_DEFINE5(clone, unsigned long, newsp, unsigned long, clone_flags,
         int __user *, parent_tidptr,
         int __user *, child_tidptr,
         int, tls_val)
#elif defined(CONFIG_CLONE_BACKWARDS3)
SYSCALL_DEFINE6(clone, unsigned long, clone_flags, unsigned long, newsp,
        int, stack_size,
        int __user *, parent_tidptr,
        int __user *, child_tidptr,
        int, tls_val)
#else
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
         int __user *, parent_tidptr,
         int __user *, child_tidptr,
         int, tls_val)
#endif
{
    return do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr);
}
#endif

在我熟悉的平台上(ARM和X86),clone的实现是上面粗体的定义。不一样的CPU architecture会有一些区别(例如,参数顺序不同,stack的增加方向等),这不是本文的主题,所以暂且略过。

从上面的代码片断能够看出,不管哪个系统调用,最终都是使用了do_fork这个内核函数,后续咱们的分析主要几种在对这个函数逐行解读。

4、trace相关的处理

if (!(clone_flags & CLONE_UNTRACED)) {
        if (clone_flags & CLONE_VFORK)
            trace = PTRACE_EVENT_VFORK;
        else if ((clone_flags & CSIGNAL) != SIGCHLD)
            trace = PTRACE_EVENT_CLONE;
        else
            trace = PTRACE_EVENT_FORK;

        if (likely(!ptrace_event_enabled(current, trace)))
            trace = 0;
    }

Linux的内核提供了ptrace这样的系统调用,经过它,一个进程(咱们称之 tracer,例如strace、gdb)能够观测和控制另一个进程(被trace的进程,咱们称之tracee)的执行。一旦Tracer和 tracee创建了跟踪关系,那么全部发送给tracee的信号(除SIGKILL)都会汇报给Tracer,以便Tracer能够控制或者观测 tracee的执行。例如断点的操做。Tracer程序通常会提供界面,以便用户能够设定一个断点(当tracee运行到断点时,会停下来)。当用户设定 了断点后,tracer就会保存该位置的指令,而后向该位置写入SWI __ARM_NR_breakpoint(这种断点是soft break point,能够设定无限多个,对于hard break point是和CPU体系结构相关,通常支持2个)。当执行到断点位置的时候,发生软中断,内核会给tracee进程发出SIGTRAP信号,固然这个信号也会被tracer捕获。对于tracee,当收到信号的时候,不管是什么信号,甚至是ignor的信号,tracee进程都会中止运行。Tracer进程能够对tracee进行各类操做,例如观察tracer的寄存器,观察变量等等。

在了解完上述的背景以后,再来看代码就比较简单了。这个代码块控制建立进程是否向tracer上报信号,若是须要上报,那么要上报哪些信号。若是用户进程 在建立的时候有携带CLONE_UNTRACED的flag,那么该进程则不能被trace。对于内核线程,在建立的时候都会携带该flag,这也就意味着,内核线程是没法被traced,也就不须要上报event给tracer。

5、参数检查和安全检查

if ((clone_flags & (CLONE_NEWNS|CLONE_FS)) == (CLONE_NEWNS|CLONE_FS))
        return ERR_PTR(-EINVAL);

在 2.4.19版本以前,系统中的全部进程都是共享一个mount namespace,在这种状况下,任何进程经过mount或者umount来改变mount namespace都会反应到其余的进程中。从2.4.19版本开始,linux提供了per-process的mount namespace机制,也就是说每一个进程都是拥有本身私有的mount namespace(呵呵~~~是否是有点怀念过去简单而美好的日子了)。

CLONE_NEWNS这个flag就是用来控制在clone的时候,父子进程是否要共享mount namespace的。经过fork建立的进程老是和父进程共享mount namespace的(固然子进程也能够调用unshare来解除共享)。当调用clone建立进程的时候,能够有更多的灵活性,能够经过 CLONE_NEWNS这个flag能够不和父进程共享mount namespace(注意:子进程的这个private mount namespace仍然用父进程的mount namespace来初始化,只是以后,子进程和父进程的mount namespace就分道扬镳了,这时候,子进程的mount或者umount的动做将不会影响到父进程)。

CLONE_FS flag是用来控制父子进程是否共享文件系统信息(例如文件系统的root、当前工做目录等),若是设定了该flag,那么父子进程共享文件系统信息,如 果不设定该flag,那么子进程则copy父进程的文件系统信息,以后,子进程调用chroot,chdir,umask来改变文件系统信息将不会影响到 父进程。

在内核中,CLONE_NEWNS和CLONE_FS是排他的。一个进程的文件系统信息在内核中是用struct fs_struct来抽象,这个结构中就有mount namespace的信息,所以若是想共享文件系统信息,其前提条件就是要处于同一个mount namespace中。

if ((clone_flags & (CLONE_NEWUSER|CLONE_FS)) == (CLONE_NEWUSER|CLONE_FS))
        return ERR_PTR(-EINVAL);

CLONE_NEWUSER这个flag是和user namespace相关的标识,在经过clone函数fork进程的时候,咱们能够选择clone以前的user namespace,固然也能够经过传递该标识来建立新的user namespace。user namespace是linux kernel支持虚拟化以后引入的一个机制,能够容许系统建立不一样的user namespace(以前系统只有一个user namespace)。user namespace用来管理user ID和group ID的映射。一个user namespace造成一个container,该user namespace的user ID和group ID的权限被限定在container内部。也就是说,某一个user namespace中的root(UID等于0)并不是具有任意的权限,他仅仅是在该user namespace中是privileges的,在该user namespace以外,该user并不是是特权用户。

CLONE_NEWUSER|CLONE_FS的组合会致使一个系统漏洞,可让一个普通用户窃取到root的权限,具体能够参考下面的链接:
http://www.openwall.com/lists/oss-security/2013/03/13/10

if ((clone_flags & CLONE_THREAD) && !(clone_flags & CLONE_SIGHAND))
        return ERR_PTR(-EINVAL);

POSIX规定一个进程内部的多个thread要共享一个PID,可是,在linux kernel中,不管是进程仍是线程,都是会分配一个task struct而且分配一个惟一的PID(这时候,PID其实就是thread ID)。这样,为了知足POSIX的线程规定,linux引入了线程组的概念,一个进程中的全部线程所共享的那个PID被称为线程组ID,也就是task struct中的tgid成员。所以,在linux kernel中,线程组ID(tgid,thread group id)就是传统意义的进程ID。对于sys_getpid系统调用,linux内核返回了tgid。对于sys_gettid系统调用,本意是要求返回线 程ID,在linux内核中,返回了task struct的pid成员。一言以蔽之,POSIX的进程ID就是linux中的线程组ID。POSIX的线程ID也就是linux中的pid。

在了解了线程组ID和线程ID以后,咱们来看一看CLONE_THREAD这个flag。这个flag被设定的话,则表示被建立的子进程与父进程在一个线程组中。不然会建立一个新的线程组。

若是设定CLONE_SIGHAND这个flag,则表示建立的子进程与父进程共享相同的信号处理(signal handler)表。线程组应该共享signal handler(POSIX规定),所以,当设定了CLONE_THREAD后必须同时设定CLONE_SIGHAND

if ((clone_flags & CLONE_SIGHAND) && !(clone_flags & CLONE_VM))
        return ERR_PTR(-EINVAL);

设定了CLONE_SIGHAND表示共享signal handler,前提条件就是要共享地址空间(也就是说必须设定CLONE_VM),不然,没法共享signal handler。由于若是不共享地址空间,即使是一样地址的handler,其物理地址都是不同的。

if ((clone_flags & CLONE_PARENT) &&
                current->signal->flags & SIGNAL_UNKILLABLE)
        return ERR_PTR(-EINVAL);

CLONE_PARENT这个flag表示新fork的进程想要和建立该进程的cloner拥有一样的父进程。

SIGNAL_UNKILLABLE这个flag是for init进程的,其余进程不会设定这个flag。

Linux kernel会静态定义一个init task,该task的pid是0,被称做swapper(其实就是idle进程,在系统没有任何进程可调度的时候会执行该进程)。系统中的全部进程(包括内核线程)由此开始。对于用户空间进程,内核会首先建立init进程,全部其余用户空间的进程都是由init进程派生出来的。所以init进程要负责为全部用户空间的进程处理后事(不然会变成僵 尸进程)。可是若是init进程想要建立兄弟进程(其父亲是swapper),那么该进程没法由init进程回收,其父亲swapper进程也不会收养用户空间建立的init的兄弟进程,这种状况下,这类进程退出都会变成zombie,所以要杜绝。

if (clone_flags & CLONE_SIGHAND) {
        if ((clone_flags & (CLONE_NEWUSER | CLONE_NEWPID)) ||
            (task_active_pid_ns(current) !=
                current->nsproxy->pid_ns_for_children))
            return ERR_PTR(-EINVAL);
    }

当CLONE_SIGHAND被设定的时候,父子进程应该共享signal disposition table。也就是说,一个进程修改了某一个signal的handler,另一个进程也能够感知的到。

CLONE_NEWPID这个flag是和PID namespace相关的标识。思路同CLONE_NEWUSER。 这两个flag是和虚拟化技术相关的。虚拟化技术就须要资源隔离,也就是说,不一样的虚拟主机(实际上在一台物理主机上)资源是互相不可见的。所以,linux kernel增长了若干个name space,例如user name space、PID namespace、IPC namespace、uts namespace、network namespace等。以PID namespace为例,原来的linux kernel中,PID惟一的标识了一个process,在引入PID namespace以后,不一样的namespace能够拥有一样的ID,也就是说,标识一个进程的是PID namespace + PID。

CLONE_NEWUSER设定的时候,就会为fork的进程建立一个新的user namespace,以便隔离USER ID。linux 系统内的一个进程和某个user namespace内的uid和gid相关。user namespace被实现成树状结构,新的user namespace中第一个进程的uid就是0,也就是root用户。这个进程在这个新的user namespace中有超级权限,可是,在其父user namespace中只是一个普通用户。

更详细的解释TODO。

retval = security_task_create(clone_flags);
    if (retval)
        goto fork_out;

这一段代码是和LinuxSecurity Modules相关的。LinuxSecurity Modules是一个安全框架,容许各类安全模型插入到内核。你们熟知的一个计算机安全模型就是selinux。具体这里就再也不描述。若是本次操做经过了 安全校验,那么后续的操做能够顺利进行

6、复制内核栈、thread_info和task_struct

retval = -ENOMEM;
    p = dup_task_struct(current);
    if (!p)
        goto fork_out;

每个用户空间进程都有一个内核栈和一个用户空间的栈(对于多线程的进程,应该有多个用户空间栈和内核栈)。内核栈和thread_info数据结构共同占用了THREAD_SIZE(通常是2个page)的memory。thread_info数据结构和CPU architecture相关,thread_info数据结构的task 成员指向进程描述符(也就是task struct数据结构)。进程描述符的stack成员指向对应的thread_info数据结构。

dup_task_struct这段代码主要动做序列包括:

一、分配内核栈和thread_info数据结构所须要的memory(统一分配),分配task sturct须要的memory。

二、设定内核栈和thread_info以及task sturct之间的联系

三、将父进程的thread_info和task_struct数据结构的内容彻底copy到子进程的thread_info和task_struct数据结构

四、将task_struct数据结构的usage成员设定为2。usage成员其实就是一个reference count。之因此被设定为2,由于fork以后已经存在两个reference了,一个是本身,另一个是其父进程。

相关文章
相关标签/搜索