系统允许一个进程创建新进程,新进程即为子进程,子进程还可以创建新的子进程,形成进程树结构模型。整个linux系统的所有进程也是一个树形结构。树根是系统自动构造的,即在内核态下执行的0号进程,它是所有进程的祖先。由0号进程创建1号进程(内核态),1号负责执行内核的部分初始化工作及进行系统配置,并创建若干个用于高速缓存和虚拟主存管理的内核线程。随后,1号进程调用execve()运行可执行程序init,并演变成用户态1号进程,即init进程。它按照配置文件/etc/initab的要求,完成系统启动工作,创建编号为1号、2号...的若干终端注册进程getty。每个getty进程设置其进程组标识号,并监视配置到系统终端的接口线路。当检测到来自终端的连接信号时,getty进程将通过函数execve()执行注册程序login,此时用户就可输入注册名和密码进入登录过程,如果成功,由login程序再通过函数execv()执行shell,该shell进程接收getty进程的pid,取代原来的getty进程。再由shell直接或间接地产生其他进程。
  上述过程可描述为:0号进程->1号内核进程->1号内核线程->1号用户进程(init进程)->getty进程->shell进程

   注意,上述过程描述中提到:1号内核进程调用执行init并演变成1号用户态进程(init进程),这里前者是init是函数,后者是进程。

两者容易混淆,区别如下:

   1.init()函数在内核态运行,是内核代码
   2.init进程是内核启动并运行的第一个用户进程,运行在用户态下。
   3.init()函数调用execve()从文件/etc/inittab中加载可执行程序init并执行,这个过程并没有使用调用do_fork(),因此两个进程都是1号进程。

fork,vfork,clone都是linux的系统调用,用来创建子进程的,但是大家在使用时经常混淆,这里给出具体例子讲解三者的联系与区别。
  我们知道,进程由4个要素组成:

  1.进程控制块:进程标志   

  2.进程程序块:可与其他进程共享  

  3.进程数据块:进程专属空间,用于存放各种私有数据以及堆栈空间。

  4.独立的空间(如果没有4则认为是线程)

   一、fork
    fork 创造的子进程复制了父亲进程的资源,包括内存的内容task_struct内容,新旧进程使用同一代码段,复制数据段和堆栈段,这里的复制采用了注明的copy_on_write技术,即一旦子进程开始运行,则新旧进程的地址空间已经分开,两者运行独立。如:
    int main() {
        int num = 1;
        int child;
        if(!(child = fork())) { 
                printf("This is son, his num is: %d. and his pid is: %d\n", ++num, getpid());
        } else {
                printf("This is father, his num is: %d, his pid is: %d\n", num, getpid());
        }
   }

执行结果为:This is son, his num is: 2. and his pid is: 2139
This is father, his num is: 1, his pid is: 2138

  从代码里面可以看出2者的pid不同,子进程改变了num的值,而父进程中的num没有改变。

 总结:优点是子进程的执行独立于父进程,具有良好的并发性。缺点是两者的通信需要专门的通信机制,如pipe、fifo和system V等。有人认为这 样大批量的复制会导致执行效率过低。其实在复制过程中,子进程复制了父进程的task_struct,系统堆栈空间和页面表,在子进程运行前,两者指向同一页面。而当子进程改变了父进程的变量时候,会通过copy_on_write的手 段为所涉及的页面建立一个新的副本。因此fork效率并不低。


  二、vfork
  vfork函数创建的子进程完全运行在父进程的地址空间上,子进程对虚拟地址空间任何数据的修改都为父进程所见。这与fork是完全不同的,fork进程是独立的空间。另外一点不同的是vfork创建的子进程后,父进程会被阻塞,直到子进程执行exec()和exit()。如:
int main() {
        int num = 1;
        int child;
        if(!(child = fork())) { 
                printf("This is son, his num is: %d. and his pid is: %d\n", ++num, getpid());
        } else {
                printf("This is father, his num is: %d, his pid is: %d\n", num, getpid());
        }
   }

运行结果为:This is son, his num is: 2. and his pid is:4139
This is father, his num is: 2, his pid is: 4138

从运行结果可以看到vfork创建出的子进程(线程)共享了父进程的num变量,这一次是指针复制,2者的指针指向了同一个内存
  总结:当创建子进程的目的仅仅是为了调用exec()执行另一个程序时,子进程不会对父进程的地址空间又任何引用。因此,此时对地址空间的复制是多余的,通过vfork可以减少不必要的开销。

  三、clone
  函数功能强大,带了众多参数,因此由他创建的进程要比前面2种方法要复杂。clone可以让你有选择性的继承父进程的资源,你可以选择想vfork一样和 父进程共享一个虚存空间,从而使创造的是线程,你也可以不和父进程共享,你甚至可以选择创造出来的进程和父进程不再是父子关系,而是兄弟关系int clone(int (*fn)(void *), void *child_stack, int flags, void *arg);这里fn是函数指针,我们知道进程的4要素,这个就是指向程序的指针,就是所谓的“剧本", child_stack明显是为子进程分配系统堆栈空 间(在linux下系统堆栈空间是2页面,就是8K的内存,其中在这块内存中,低地址上放入了值,这个值就是进程控制块task_struct的 值),flags就是标志用来描述你需要从父进程继承那些资源, arg就是传给子进程的参数)。
总结:

clone, fork, vfork实现方式 大致相同:系统调用服务例程sys_clone, sys_fork, sys_vfork三者最终都是调用do_fork函数完成.do_fork的参数与clone系统调用的参数类似, 不过多了一个regs(内核栈保存的用户模式寄存器). 实际上其他的参数也都是用regs取的。区别在于:

clone:
    clone的API外衣, 把fn, arg压入用户栈中, 然后引发系统调用. 返回用户模式后下一条指令就是fn.
    sysclone: parent_tidptr, child_tidptr都传到了 do_fork的参数中

    sysclone: 检查是否有新的栈, 如果没有就用父进程的栈 (开始地址就是regs.esp)

fork, vfork:

    服务例程就是直接调用do_fork, 不过参数稍加修改clone_flags:
   sys_fork: SIGCHLD|0;
   sys_vfork: SIGCHLD| (clone_vfork | clone_vm)
    用户栈: 都是父进程的栈.

    parent_tidptr, child_ctidptr都是NULL.

进程的创建过程

------基于Linux0.11源码分析

1. 背景

进程的创建过程无疑是最重要的操作系统处理过程之一,很多书和教材上说的最多的还是一些原理的部分,忽略了很多细节。比如,子进程复制父进程所拥有的资源,或者子进程和父进程共享相同的物理页面,拥有自己的地址空间,子进程创建后接受统一调度执行等等。

原理性的书籍更多地关注了进程创建过程中各个关键部分的功能,但由于过于抽象,很难理解,因此如果自己能够实际操作,实践这个过程就很重要,可以让那些看起来抽象的概念变的现实而容易理解,比如所谓的父进程的资源,父进程所拥有的物理页面,甚至父进程的地址空间等等,这些抽象的概念其实只要实际操作一次就更能有感性的认识。本人参考Linux0.11源代码实践了创建进程和调度,这个过程获益匪浅,这里把主要的学习成果结合实践总结一下。

 

2.  0号进程

子进程的创建是基于父进程的,因此一直追溯上去,总有一个进程是原始的,即没有父进程的。这个进程在Linux中的进程号是0,也就是传说中的0号进程(可惜很多理论书上对这个重要的进程只字不提)。

如果说子进程可以通过规范的创建进程的函数(如:fork())基于父进程复制创建,那么0号进程并没有可以复制和参考的对象,也就是说0号进程拥有的所有信息和资源都是强制设置的,不是复制的,这个过程我称为手工设置,也就是说0号进程是“纯手工打造”,这是操作系统中“最原始”的一个进程,它是一个模子,后面的任何进程都是基于0号进程生成的。

手工打造0号进程最主要包括两个部分:创建进程0运行时所需的所有信息,即填充0号进程,让它充满“血肉”;二是调度0号进程的执行,即让它“动”起来,只有动起来,才是真正意义上的进程,因为进程本身实际上是个动态的概念。

    不同的操作系统或者同一个操作系统的不同版本进程信息的内涵可能会有些细微的差距,但大体上关键的部分和逻辑是没有什么不同的,我这里只是基于Linux0.11的实现来描述进程创建的关键步骤和关键细节。

 

1)填充0号进程信息

       进程包括的内容非常复杂,但总的来说进程的信息都是由进程的描述符引导标识的,因此填充0号进程的过程逻辑上是以填充其描述符为牵引完成的(也有书将进程描述符称为进程控制块)。下面是Linux0.11版进程的描述符信息结构体:

struct task_struct {

       long state,counter,priority, signal;

       struct sigaction sigaction[32];

       long blocked; 

       int exit_code;

       unsigned long start_code,end_code,end_data,brk,start_stack;

       long pid,father,pgrp,session,leader;

       unsigned short uid,euid,suid,gid,egid,sgid;

       long alarm;

       long utime,stime,cutime,cstime,start_time;

       unsigned short used_math;

       int tty;    

       unsigned short umask;

       struct m_inode * pwd;

       struct m_inode * root;

       struct m_inode * executable;

       unsigned long close_on_exec;

       struct file * filp[NR_OPEN];

       struct desc_struct ldt[3];

       struct tss_struct tss;

};

可以看到进程描述符里的信息很多,大体上有几部分:

a. 进程的运行信息,如进程的当前状态(state),进程的各种时间片消耗记录(utime、stime等),进程的信号(signal)和优先级(priority)等。

b. 进程的基本创建信息,如进程号(pid),进程的创建用户(uid)等。

c. 进程的资源类信息,如使用的tty自设备号(tty),文件根目录i节点结构(root)等。

d. 进程执行和切换CPU需要使用的关键信息:局部描述符表(LDT)、任务状态段(TSS)信息。

 

这些信息并不是在进程创建的时候就全部确定的,大部分只是暂时赋一个初值,在运行的时候会动态更改,也有一些是要在进程运行前设置好的,才能保证进程被正确地执行起来。实际上,我们最需要填充的信息是那些使得操作系统可以顺利切换到0号进程的信息,最重要的显然是进程的LDT和TSS信息。TSS是CPU在切换任务时需要使用的信息,而LDT是局部描述符表,0号进程是第一个运行在用户态的进程,需要使用自己的LDT。TSS和LDT是保证不同进程之间相互隔离的重要机制。

实际上还有一个重要的信息不是放在进程本身的描述符里的,而是放在全局描述符表GDT中,因为所有的进程是由操作系统统一管理的,因此操作系统至少要保持对它们的索引,这种索引性质的信息放在操作系统内核的GDT中。对于Linux0.11来说,每个进程都有一个LDT和一个TSS描述符,而Linux2.4之后是每个CPU一个TSS描述符并存储在GDT中,而不是每个进程一个。当然这种区别会造成进程创建和切换过程中一些细节上的差异,但本质的部分和任务的切换过程并没有任何不同。

下面是Linux0.11手动填充进程0的进程描述符信息的宏:

#define INIT_TASK \

  { 0,15,15, \

    0,{{},},0, \

0,0,0,0,0,0, \

  0,-1,0,0,0, \

    0,0,0,0,0,0, \

      0,0,0,0,0,0, \

      0, \

    -1,0022,NULL,NULL,NULL,0, \

  {NULL,}, \

{ \

             {0,0}, \

         {0x9f,0xc0fa00}, \

                {0x9f,0xc0f200}, \

          }, \

  0,PAGE_SIZE+(long)&init_task,0x10,0,0,0,0,(long)&pg_dir,\

             0,0,0,0,0,0,0,0, \

             0,0,0x17,0x17,0x17,0x17,0x17,0x17, \

             _LDT(0),0x80000000, {} \

          }, \

}

除了填充进程描述符的信息外,还需要在GDT中设置相关的项,即进程0的LDT和TSS选择符,这个工作是在sched_init()里完成的:

void sched_init(void){

...

set_tss_desc(gdt+FIRST_TSS_ENTRY,&(init_task.task.tss));

       set_ldt_desc(gdt+FIRST_LDT_ENTRY,&(init_task.task.ldt));

...

ltr(0);

       lldt(0);

}

可以看到,在进程0的TSS和LDT描述符信息设置到GDT中后,立刻设置了TR寄存器和LDTR寄存器,为即将运行0号进程作准备。

 

2)运行0号进程

   进程0是运行在用户态下的进程,因此就意味着进程0的运行过程实际上是一个从0级特权级到3级特权级切换的过程,使用的是CPU指令iret,模拟了中断调用的返回过程,具体执行过程由move_to_user_mode完成:

#define move_to_user_mode() \

__asm__ ("movl %%esp,%%eax\n\t" \

              "pushl $0x17\n\t" \

              "pushl %%eax\n\t" \

              "pushfl\n\t" \

              "pushl $0x0f\n\t" \

              "pushl $1f\n\t" \

              "iret\n" \

              "1:\tmovl $0x17,%%eax\n\t" \

...)

这个宏将进程0执行时的ss,esp,eflags.cs,eip信息全部压栈,待到执行iret指令时,CPU将这几项信息从栈中弹出加载到相应的寄存器中,这样就实现了进程0的启动执行。从这里也可以看出,进程0刚开始执行时几个关键寄存器的信息也是在其运行前事先设定好的,从进程描述符信息到执行信息均是人为设置,因此我称之为“纯手工打造的进程”。

      

3. 子进程的创建

       有了0号进程这个原始的进程,再来看子进程的创建就比较容易理解一些。除了0号进程外,其余的进程均使用系统调用fork()完成,其具体工作由内核态的_sys_fork实现:

_sys_fork:

       call _find_empty_process

       testl %eax,%eax

       js 1f

       push %gs

       pushl %esi

       pushl %edi

       pushl %ebp

       pushl %eax

       call _copy_process

       addl $20,%esp

1:     ret

       可以看到,一个进程的创建主要有两个步骤:一是找到一个空闲进程资源(find_empty_process),Linux0.11来说可以同时运行的进程数目是64个,是有限的,因此需要先得到一个空闲的进程表中的一项用来索引即将创建的进程信息;第二个主要步骤就是复制(copy_process),这个函数具体来实现子进程基于父进程的复制创建。

主要包括的步骤和内容是:

1)  为新进程在内存中分配一个物理页,将新进程的描述符信息填充在该页的开头,并设置新进程的描述符里各项信息;

2)  拷贝父进程的页表,使得它们共同指向相同的物理页,同时将父进程的各个页表属性改为只读,这样将来可以使用写时复制机制。

3)  在GDT中设置该进程项的TSS和LDT选择符。

       Linux0.11版本子进程内容的设置主要内容就是这些,当然不同版本会有不同,在改进执行性能上也会有改进,但这个版本体现出来的最基本创建过程基本上反映了操作系统创建进程的主要过程。

 

4. 子进程的运行

       子进程在创建好后并不能立即执行,至少需要一次调度,而这个调度到子进程的运行过程就完全不需要像进程0那样人为在栈上设置信息然后用iret方式,而是执行的任务的切换过程。不考虑进程调度的各个算法和选择细节,最终负责完成切换操作的函数如下:

#define switch_to(n) {\

struct {long a,b;} __tmp; \

__asm__("cmpl %%ecx,_current\n\t" \

       "je 1f\n\t" \

       "movw %%dx,%1\n\t" \

       "xchgl %%ecx,_current\n\t" \

       "ljmp %0\n\t" \

       "cmpl %%ecx,_last_task_used_math\n\t" \

       "jne 1f\n\t" \

       "clts\n" \

       "1:" \

       ::"m" (*&__tmp.a),"m" (*&__tmp.b), \

       "d" (_TSS(n)),"c" ((long) task[n])); \

}

       最终的切换执行了一个ljmp操作,它的操作数是一个任务描述符,这会导致CPU执行一次任务切换,根据新进程的TSS信息将相关信息加载进cs,eip,eflags,ss,esp寄存器开始执行新的代码。当然由于先前拷贝的父进程的相关页面被设置为只读,子进程第一次执行到该页面时会触发页保护的异常,这时会触发写时复制操作,为子进程分配自己的相应页面。

      

符:任务(task)和进程(process)的区别

    任务和进程很容易被人混淆,甚至在Linux中进程描述符结构体也是用task_struct表示,而不是process,这更让人有的时候搞不清楚。我个人认为,其实任务的概念更底层,可以认为是基于CPU的角度来考虑的,进程所处的层次更高一些,应当可以认为是操作系统一级的概念。

    任务关注点是一组程序操作,这组操作实现了某个功能,它最终会涉及到指令级别,我们说任务的切换最终需要关注的还是CPU的相关指令。

    进程的概念通常是指程序的执行,是动态的过程。进程除了包含其要运行的程序之外,还包括运行时的诸多信息,如运行时间,信号等等。

http://blog.chinaunix.net/uid-25436678-id-3076217.html
Logo

更多推荐