Unix的进程创建很特别。许多其他的操作系统都提供了产生进程的机制,首先在新的地址空间创建进程,读入可执行的文件,最后开始执行。Unix采用了与众不同的实现方式,它把上述步骤分解到两个单独的函数中去执行:fork()和exec()。(这里的exec是指exec一族的函数,内核实现了execve函数,在此基础上还实现了execlp、execle、execv和execvp等)。首先fork通过拷贝当前进程创建一个子进程。子进程与父进程的区别仅仅在于PID(每个进程是唯一的)、PPID(父进程的进程号,子进程将其设置为被拷贝进程的PID)和某些资源和统计量(例如,挂起的信号,它没有必要被继承)。exec函数负责读取可执行文件并将其载入地址空间开始运行。
上面我们先简单的述说了linux内核产生进程的机制,在述说写时拷贝技术之前,下面我们先详细说明下fork和exec都做了哪些事情:
或许许多朋友可能对fork和exec的调用比较模糊的,学过c语言的都知道,Linux下某个进程的地址空间分为:代码段、数据段、堆空间和栈空间。代码段是用来存放运行的代码的,数据段是用来存放全局变量的和static变量,堆空间是进程调用malloc分配地址空间的,栈是用来存放程序的临时变量的。
fork()函数:
linux通过clone系统调用实现fork。这个调用通过一系列的参数标志来指明父、子进程需要共享的资源。fork和vfork和__clone库函数都是根据各自需要的参数标志去调用clone,然后clone去调用do_fork:
do_fork完成了创建中的大部分工作,它定义在kernel/fork.c文件中。该函数调用copy_process函数,然后让进程开始运行。copy_process函数完成的工作如下:
(1)调用dup_task_struct为新进程创建一个内核栈、thread_info结构和task_struct,这些值与当前进程的值相同。此时,子进程和父进程的描述符完全相同。
(2)检查并确保新创建这个子进程后,当前用户所拥有的进程数目没有超出给它分配的资源的限制
(3)子进程着手使自己与父进程区别开来。进程描述符内的许多成员都要被清0或设为初始值。那些不是继承而来的进程描述符成员,主要是统计信息。task_struct中的大多数数据都依然未被修改
(4)子进程的状态设置为TASK_UNINTERRUPTIBLE,以保证它不会投入运行
(5)copy_process调用copy_flag以更新task_struct的flags成员。表明进程是否拥有超级用户权限的PF_SUPERPRIV标志被清0.表用进程还没有调用exec函数PF_FORKNOEXEC标志被设置
(6)调用alloc_pid为新进程分配一个有效的PID
(7)根据传递给clone的参数标志,copy_process拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间等。在一般情况下,这些资源会被给定进程的所有进程共享;否则,这些资源对每个进程是不同的,因此被拷贝到这里。
(8)最后,copy_process做扫尾工作并返回一个指向子进程的指针。
再回到do_fork函数,如果copy_process函数成功返回,新创建的子进程被唤醒并让其投入运行。内核有意选择子进程首先执行。因为一般子进程都会马上调用exec函数,这样可以避免写时拷贝的额外开销,如果父进程首先执行的话,有可能会开始向地址空间写入(这个相关内容后续会进一步说明)
exec( )函数:
exec函数在当前进程的上下文中加载并运行一个新的程序,只有在出错的情况下exec 函数才会返回到调用程序中,所以与fork函数调用一次返回两次不同,exec调用一次并从不返回。
exec( )执行过程:
1)删除当前进程虚拟地址空间的用户部门已经存在的区域结构。
2)加载可执行文件,用可执行文件中的内容覆盖当前进程地址空间相应区域
3)设置程序计数器即eip中的值,使它指向新的代码区的入口点,调用启动代码,启动代码设置栈,控制传给新程序的主函数
其中加载可执行文件的执行过程如下:
1)启动加载器,加载器删除子进程现有的虚拟地址段
2)加载器根据可执行目标文件中的段头部表信息,创建一组新的代码段、数据段、堆段和栈段。新的堆、栈段初始化为零。代码段和数据段映射为可执行文件的代码段和数据段。
3)根据可执行文件 ELF 中的.interp段查找动态链接器ld.so的路径名,动态链接器实际上也是一个共享对象,加载器同样通过映射的方式将它加载到进程的地址空间。然后把控制权交给动态链接器的入口地址(与可执行文件一样,共享对象也有入口地址),当动态链接器得到控制权后,进行一系列初始化操作,然后根据可执行文件ELF中.dynamic段,这个段里保存了动态链接器所需要的相关信息,比如依赖于哪些共享对象(例如libc.so)、动态链接符号表位置、动态链接重定位表的位置、共享对象初始化代码的地址等信息,根据它们查找和加载可执行文件所依赖的共享对象,并映射到进程地址空间的共享区域中。
4)当所有动态链接工作完成以后,动态连接器会将控制权交给可执行文件的入口地址,即跳转到可执行文件的_start 启动代码并调用新程序中的main函数开始执行。
task_struct进程控制块,ELF文件格式与进程地址空间的联系:
task_struct进程控制块中的mm字段所指向的mm_struct结构描述了进程地址空间的信息,包括代码段、数据段、堆段、栈段所在地址空间里的起始和结束地址等信息。
ELF文件格式中的 ELF头部、段头部表、.init、.text、.rodata段对应进程地址空间中的代码段,在加载可执行文件时,会把它们映射到进程地址空间中的代码段区域。
ELF文件格式中的 .data、.bss段 对应 进程地址空间中的 数据段,在加载可执行文件时,会把它们映射到进程地址空间的数据段区域。
讲完exec的执行过程大家可能就更容易理解了,一个进程一旦调用exec类函数,它本身就“死亡”了,系统把代码段替换成新的程序的代码,废弃原有的数据段和堆栈段,并为新程序分配新的数据段和堆栈段,唯一留下的,就是进程号,也就是说,对系统而言,还是同一个进程,不过已经是另外一个程序了
COW技术:
在Linux程序中,fork()会产生一个和父进程完全相同的子进程,但子进程在此后多会exec系统调用,出于效率考虑,linux中引入了“写时复制“技术,也就是只有进程空间的各段的内容要发生变化时,才会将父进程的内容复制一份给子进程。
那么子进程的物理空间没有代码,怎么去取指令执行exec系统调用呢?
在fork之后exec之前两个进程用的是相同的物理空间(内存区),子进程的代码段、数据段、堆栈都是指向父进程的物理空间,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。当父子进程中有更改相应段的行为发生时,再为子进程相应的段分配物理空间,如果不是因为exec,内核会给子进程的数据段、堆栈段分配相应的物理空间(至此两者有各自的进程空间,互不影响),而代码段继续共享父进程的物理空间(两者的代码完全相同)。而如果是因为exec,由于两者执行的代码不同,子进程的代码段也会分配单独的物理空间(exec相关内容请参考上面)。
还有个细节问题就是,fork之后内核会通过将子进程放在队列的前面,以让子进程先执行,以免父进程执行导致写时复制,而后子进程执行exec系统调用,因无意义的复制而造成效率的下降。(因为如果让父进程先执行的话,那么会进行写时拷贝,也就是为子进程分配了相应的数据段、堆栈段的物理空间,如果再执行exec的话,又会为新的程序分配新的数据段、堆栈段等,这样fork函数的执行效率就会降低)
为了节约物理内存,在调用fork生成新进程时,新进程与原进程会共享同一内存区。只有当其中一进程进行写操作时,系统才会为其另外分配内存页面。这就是写时拷贝(copy on write)的概念的引出。
当进程A使用系统调用fork创建一个子进程B时,由于子进程B实际上是父进程A的一个拷贝,因此会拥有与父进程相同的物理页面。也即为了达到节约内存和加快创建速度的目标,fork函数会让子进程B以只读的方式共享父进程A的物理页面。同时将父进程A对这些物理页面的访问权限也设置成只读。这样一来当父进程A或者子进程B任何一方对这些以共享的物理页面执行写操作时,都会产生页面出错异常中断,此时cpu会执行系统提供的异常处理函数do_wp_page来试图解决这个异常。
do_wp_page会对这块导致写入异常中断的物理页面进行取消共享操作(使用un_up_page),为写进程复制一新的物理页面,使父进程A和子进程B各自拥有一块内容相同的物理页面。这时才真正地执行了复制操作(只复制这一块物理页面)。并且将要执行写入操作的这块物理页面标记成可以写访问的。最后从异常处理函数中返回,cpu就会重新执行刚才导致异常的写入操作指令,使进程能够继续执行下去