接下来的几篇文章将会分析fork系统调用在内核中的实现,以作为本学期linux操作系统课程总的分析报告。本文所采用的源码均为v2.6.11。和最新内核相比,do_fork函数的变化较大,而核心函数copy_process则几乎保持不变。
操作系统需要一种机制用于创建新进程,fork()就是Linux或Unix提供给程序员用于创建进程的方法。fork函数的相关信息如下:
通常,我们程序中直接使用的fork函数是将fork系统调用封装之后而产生的。通过上面的表格,我们知道fork函数用于创建新的进程,所创建的进程称为当前进程的子进程,而当前进程也随之称为子进程的父进程。通常可以通过父子进程不同的返回值来区分父子进程,并使其执行不同功能的代码。
那么,用户态下的fork函数是如何调用fork系统调用的?内核中是如何创建子进程的?父子进程的返回值是如何产生的?这是本文所重点讨论的。
进程描述符
进程是操作系统中一个重要的基本概念。通常我们认为进程是程序的一次执行过程。为了描述和控制进程的运行,操作系统为每个进程定义了一个数据结构,即进程控制块(Process Control Block,PCB)。我们通常所说的进程实体包含程序段,数据段和PCB三部分,PCB在进程实体中占据重要的地位。所谓的创建进程,实质上就是创建PCB的过程;而撤销进程,实质上也就是对PCB的撤销。
上述内容是我们在操作系统原理课上所学习到的。在Linux内核中,PCB对应着一个具体的结构体——task_struct,也就是所谓的进程描述符(process descriptor)。该数据结构中包含了与一个进程相关的所有信息,比如包含众多描述进程属性的字段,以及指向其他与进程相关的结构体的指针。因此,进程描述符内部是比较复杂的。我们可以通过图1大致的了解进程描述符的结构。
图1(图片来自ULK)
可以看到,进程描述符中有指向mm_struct结构体的指针mm,这个结构体是对该进程用户空间的描述;也有指向fs_struct结构体的指针fs,这个结构体是对进程当前所在目录的描述;也有指向files_struct结构体的指针files,这个结构体是对该进程已打开的所有文件进行描述;另外还有一个小型的进程描述符(low-level information)——thread_info。在这个结构体中,也有指向该进程描述符的指针task。因此,这两个结构体是相互关联的。关于thread_info结构的详细说明可以参考本系列文章的后续分析。
与存储在磁盘上的程序相比,我们认为是进程是动态的。这是因为一个进程在其“一生”中可能处于不同的状态。因此,在进程描述符中,使用state字段对该进程当前所处的状态进行描述。在内核中,使用一组宏来描述进程可能所处的状态。这些宏之间是互斥的,也就是说进程一次最多只能使用一个宏。因为,进程在某一刻只可能处于一种状态。下面对本文中所涉及到的进程状态进行简单的描述:
可运行状态(TASK_RUNNING)
如果进程正在CPU上执行或者正在等待被调度程序所调度,那么它的状态就处于可运行状态。
暂停状态(TASK_STOPPED)
进程的执行被暂定,也就是我们常说的阻塞状态和等待状态。当进程接收到SIGSTOP、SIGSTP等信号时,就进入该状态。
跟踪状态(TASK_TRACED)
当一个进程被另一个进程跟踪监控时,这个进程就处于该状态。最常见的场景就是我们调试一个程序,被调试的程序就处于此状态。
fork系统调用
在用户态下,使用fork()创建一个进程对我们来说已经不再陌生。除了这个函数,一个新进程的诞生还可以分别通过vfork()和clone()。fork、vfork和clone三个API函数均由C库提供,它们分别在C库中封装了与其同名的系统调用fork(),vfork()和clone()。API所封装的系统调用对编程者是隐藏的,编程者只需知道如何使用这些API即可。
上述三个系统调用所对应的系统调用号在linux/include/asm-i386/unistd.h中定义如下:
#define __NR_restart_syscall 0 #define __NR_exit 1 #define __NR_fork 2 …… …… #define __NR_clone 120 …… …… #define __NR_vfork 190 …… ……
传统的创建一个新进程的方式是子进程拷贝父进程所有资源,这无疑使得进程的创建效率低,因为子进程需要拷贝父进程的整个地址空间。更糟糕的是,如果子进程创建后又立马去执行exec族函数,那么刚刚才从父进程那里拷贝的地址空间又要被清除以便装入新的进程映像。
为了解决这个问题,内核中提供了上述三种不同的系统调用。
1. 内核采用写时复制技术对传统的fork函数进行了下面的优化。即子进程创建后,父子以只读的方式共享父进程的资源(并不包括父进程的页表项)。当子进程需要修改进程地址空间的某一页时,才为子进程复制该页。采用这样的技术可以避免对父进程中某些数据不必要的复制。
2. 使用vfork函数创建的子进程会完全共享父进程的地址空间,甚至是父进程的页表项。父子进程任意一方对任何数据的修改使得另一方都可以感知到。为了使得双方不受这种影响,vfork函数创建了子进程后,父进程便被阻塞直至子进程调用了exec()或exit()。由于现在fork函数引入了写时复制技术,在不考虑复制父进程页表项的情况下,vfork函数几乎不会被使用。
3. clone函数创建子进程时灵活度比较大,因为它可以通过传递不同的参数来选择性的复制父进程的资源。具体参数可参见表1
就像一开始所分析的那样,用户程序并不直接使用系统调用,而是通过C库中的API。而系统调用在内核中也并不是直接实现的,而是通过调用各自对应的服务例程。系统调用fork、vfork和clone在内核中对应的服务例程分别为sys_fork(),sys_vfork()和sys_clone()。因此,想要了解fork等系统调用的详细执行过程,就必须查看它们所对应的内核函数(也就是服务例程)是如何实现的。上述三个系统调用对应的服务例程分别定义在linux/arch/i386/kernel/process.c 中,具体如下:
asmlinkage int sys_fork(struct pt_regs regs) { return do_fork(SIGCHLD, regs.esp, ®s, 0, NULL, NULL); } asmlinkage int sys_clone(struct pt_regs regs) { unsigned long clone_flags; unsigned long newsp; int __user *parent_tidptr, *child_tidptr; clone_flags = regs.ebx; newsp = regs.ecx; parent_tidptr = (int __user *)regs.edx; child_tidptr = (int __user *)regs.edi; if (!newsp) newsp = regs.esp; return do_fork(clone_flags, newsp, ®s, 0, parent_tidptr, child_tidptr); } asmlinkage int sys_vfork(struct pt_regs regs) { return do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, regs.esp, ®s, 0, NULL, NULL); }
可以看到do_fork()均被上述三个服务例程调用。而在do_fork()内部又调用了copy_process(),因此我们可以通过图2来理解上述的调用关系。
图2
从上面的分析中,我们已经明确了do_fork()和copy_process()是本文的主要分析对象。但是在这之前,我们有必要先分析一下用户进程进入系统调用的过程。详见下文。