linux 新进程的创建


一、背景知识:

1、进程与程序的关系:

进程是动态的,而程序是静态的;从结构上看,每个进程的实体都是由代码断和相应的数据段两部分组成的,这与程序的含义很相近;一个进程可以涉及多个程序的执行,一个程序也可以对应多个进程,即一个程序段可在不同数据集合上运行,构成不同的进程;并发性;进程具有创建其他进程的功能;操作系统中的每一个进程都是在一个进程现场中运行的。

linux中用户进程是由fork系统调用创建的。计算机的内存、CPU 等资源都是由操作系统来分配的,而操作系统在分配资源时,大多数情况下是以进程为个体的。

每一个进程只有一个父进程,但是一个父进程却可以有多个子进程,当进程创建时,操作系统会给子进程创建新的地址空间,并把父进程的地址空间的映射复制到子进程的地址空间去;父进程和子进程共享只读数据和代码段,但是堆栈和堆是分离的。

2、进程的组成:

进程控制块代码数据

进程的代码和数据由程序提供,而进程控制块则是由操作系统提供。

3、进程控制块的组成:

进程标识符进程上下文环境进程调度信息进程控制信息

进程标识符:

进程ID进程名进程家族关系拥有该进程的用户标识

进程的上下文环境:(主要指进程运行时CPU的各寄存器的内容)

通用寄存器程序状态在寄存器堆栈指针寄存器指令指针寄存器标志寄存器等

进程调度信息:

进程的状态进程的调度策略进程的优先级进程的运行睡眠时间进程的阻塞原因进程的队列指针等

当进程处于不同的状态时,会被放到不同的队列中。

进程控制信息:

进程的代码、数据、堆栈的起始地址进程的资源控制(进程的内存描述符、文件描述符、信号描述符、IPC描述符等)

进程使用的所有资源都会在PCB中描述。

进程创建时,内核为其分配PCB块,当进程请求资源时内核会将相应的资源描述信息加入到进程的PCB中,进程退出时内核会释放PCB块。通常来说进程退出时应该释放它申请的资源,如文件描述符等。为了防止进程遗忘某些资源(或是某些恶意进程)从而导致资源泄漏,内核通常会根据PCB中的信息回收进程使用过的资源。

4、task_struct 在内存中的存储:

在linux中进程控制块定义为task_struct, 下图为task_struct的主要成员:

\

在2.6以前的内核中,各个进程的task_struct存放在他们内核栈的尾端。这样做是为了让那些像X86那样寄存器较少的硬件体系结构只要通过栈指针就能计算出它的位置,而避免使用额外的寄存器来专门记录。由于现在使用slab分配器动态生成task_struct,所以只需在栈底或栈顶创建一个新的结果struct thread_info(在文件 asm/thread_info.h中定义)
struct thread_info{
struct task_struct *task;
struct exec_domain *exec_domain;
__u32 flags;
__u32 status;
__u32 cpu;
int preempt_count;
mm_segment addr_limit;
struct restart_block restart_block;
void *sysenter_return;
int uaccess_err;
};

\

5、fork()、vfork()的联系:

Fork() 在2.6版本的内核中Linux通过clone()系统调用实现fork()。这个系统调用通过一系列的参数标志来指明父、子进程需要共享的资源。Fork()、vfork()和库函数都根据各自需要的参数标志去调用clone(),然后由clone()去调用do_fork().
do_fork()完成了创建中的大部分工作,它的定义在kernel/fork.c文件中。该函数调用copy_process()函数,然后进程开始运行。Copy_process()函数完成的工作很有意思:
1)、调用dup_task_struct()为新进程创建一个内核堆栈、thread_info结构和task_struct结构,这些值与当前进程的值完全相同。此时子进程和父进程的描述符是完全相同的。
2)、检查并确保新创建这个子进程后,当前用户所拥有的进程数目没有超出给他分配的资源的限制。
3)、子进程着手是自己与父进程区别开来。进程描述符内的许多成员变量都要被清零或设为初始值。那些不是继承而来的进程描述符成员,主要是统计信息。Task_struc中的大多数据都依然未被修改。
4)、子进程的状态被设置为TASK_UNINTRRUPTIBLE,以保证它不会被投入运行。
5)、copy_process()调用copy_flags()以更新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()函数,这样可以避免写时拷贝(copy-on-write)的额外开销,如果父进程首先执行的话,有可能会开始向地址空间写入。
Vfork() 除了不拷贝父进程的页表项外vfork()和fork()的功能相同。子进程作为父进程的一个单独的线程在它的地址空间里运行,父进程被阻塞,直到子进程退出或执行exec()。子进程不能向地址空间写入(在没有实现写时拷贝的linux版本中,这一优化是很有用的)。

do_fork() --> clone() --> fork() 、vfork() 、__clone() ----->exec()

clone()函数的参数及其意思如下:

CLONE_FILES 父子进程共享打开的文件
CLONE_FS 父子进程共享文件系统信息
CLONE_IDLETASK 将PID设置为0(只供idle进程使用)
CLONE_NEWNS 为子进程创建新的命名空间
CLONE_PARENT 指定子进程与父进程拥有同一个父进程
CLONE_PTRACE 继续调试子进程
CLONE_SETTID 将TID写回到用户空间
CLONE_SETTLS 为子进程创建新的TLS
CLONE_SIGHAND 父子进程共享信号处理函数以及被阻断的信号
CLONE_SYSVSEM 父子进程共享System V SEM_UNDO语义
CLONE_THREAD 父子进程放进相同的进程组
CLONE_VFORK 调用Vfork(),所以父进程准备睡眠等待子进程将其唤醒
CLONE_UNTRACED 防止跟踪进程在子进程上强制执行CLONE_PTRACE
CLONE_STOP 以TASK_SROPPED状态开始执行
CLONE_SETTLS 为子进程创建新的TLS(thread-local storage)
CLONE_CHILD_CLEARTID 清除子进程的TID
CLONE_CHILD_SETTID 设置子进程的TID
CLONE_PARENT_SETTID 设置父进程的TID

CLONE_VM 父子进程共享地址空间

二、GDB追踪fork()系统调用。

GDB 调试的相关内容可以参考:GDB追踪内核启动 篇 这里不再占用过多篇幅赘述。下面先直接上图,在详细分析代码的运行过程。

\

启动GDB后分别在sys_clone、do_fork、copy_process、copy_thread、ret_from_fork、syscall_exit等位置设置好断点,见证fork()函数的执行过程(运行环境与GDB追踪内核启动 篇完全一致)

\

可以看到,当我们在menuos中运行fork 命令的时候,内核会先调用clone,在sys_clone 断点处停下来了。

\

在调用sys_clone() 后,内核根据不同的参数去调用do_fork()系统调用。进入do_fork()后就去又运行了copy_process().

\

\

在copy_process() 中又运行了copy_thread(),然后跳转到了ret_from_fork 处运行一段汇编代码,再然后就跳到了syscall_exit(这是在arch/x86/kernel/entry_32.S中的一个标号,是执行系统调用后用于退出内核空间的汇编程序。),

\

可以看到,GDB追踪到syscall_exit 后就无法继续追踪了.................

三、代码分析(3.18.6版本的内核)

在3.18.6版本的内核 kernel/fork.c文件中:

#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
#ifdef __ARCH_WANT_SYS_VFORK
SYSCALL_DEFINE0(vfork)
{
return do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, 0, 0, NULL, NULL);
}
#endif
#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

从以上fork()、vfork()、clone() 的定义可以看出,三者都是根据不同的情况传递不同的参数直接调用了do_fork()函数,去掉了中间环节clone()。

进入do_fork 后:

\

在do_fork中首先是对参数做了大量的参数检查,然后就执行就执行 copy_process将父进程的PCB复制一份到子进程,作为子进程的PCB,再然后根据copy_process的返回值P判断进程PCB复制是否成功,如果成功就先唤醒子进程,让子进程就绪准备运行。

所以在do_fork中最重要的也就是copy_process()了,它完成了子进程PCB的复制与初始化操作。下面就进入copy_process中看看内核是如何实现的:

\

先从整体上看一下,发现,copy_process中开头一部分的代码同样是参数的检查和根据不同的参数执行一些相关的操作,然后创建了一个任务,接着dup_task_struct(current)将当前进程的task_struct 复制了一份,并将新的task_struct地址作为指针返回!

\


在dup_task_struct中为子进程创建了一个task_struct结构体、一个thread_info 结构体,并进行了简单的初始化,但是这是子进程的task_struct还是空的所以接下来的中间一部显然是要将父子进程task_struct中相同的部分从父进程拷贝到子进程,然后不同的部分再在子进程中进行初始化。

最后面的一部分则是,出现各种错误后的退出口。

下面来看一下中间那部分:如何将父子进程相同的、不同的部分区别开来。

\

可以看到,内核先是将父进程的stask_struct中的内容不管三七二十一全都拷贝到子进程的stask_struct中了(这里面大部分的内容都是和父进程一样,只有少部分根据参数的不同稍作修改),每一个模块拷贝结束后都进行了相应的检查,看是否拷贝成功,如果失败就跳到相应的出口处执行恢复操作。最后又执行了一个copy_thread(),

\

在copy_thread这个函数中做了两件非常重要的事情:1、就是把子进程的 eax 赋值为 0,childregs->ax = 0,使得 fork 在子进程中返回 0;2、将子进程唤醒后执行的第一条指令定向到 ret_from_fork。所以这里可以看到子进程的执行从ret_from_fork开始。

借来继续看copy_process中的代码。拷贝完父进程中的内容后,就要对子进程进行“个性化”,

\

从代码也可以看出,这里是对子进程中的其他成员进程初始化操作。然后就退出了copy_process,回到了do_fork()中。

再接着看一下do_fork()中“扫尾“工作是怎么做的:

\

前面植根据参数做一些变量的修改,后面两个操作比较重要,如果是通过fork() 创建子进程,那么最后就直接将子进程唤醒,但是如果是通过vfork()来创建子进程,那么就要通知父进程必须等子进程运行结束才能开始运行。

总结:

综上所述:内核在创建一个新进程的时候,主要执行了一下任务:

1、父进程执行一个系统调用fork()或vfork();但最后都是通过调用do_fork()函数来操作,只不过fork(),vfork()传递给do_fork()的参数不同。

2、在do_fork()函数中,前面做参数检查,后面负责唤醒子进程(如果是vfork则让父进程等待),中间部分负责创建子进程和子进程的PCB的初始化,这些工作都在copy_process()中完成。

3、在copy_process()中先是例行的参数检查和根据参数进行配置;然后是调用大量的copy_***** 函数将父进程task_struct中的内容拷贝到子进程的task_struct中,然后对于子进程与父进程之间不同的地方,在子进程中初始化或是清零。

4、完成子进程的创建和初始化后,将子进程唤醒,优先让子进程先运行,因为如果让父进程先运行的话,由于linux的写时拷贝机制,父进程很可能会对数据进行写操作,这时就需要拷贝数据段和代码断的内容了,但如果先执行子进程的话,子进程通常都会通过exec()转去执行其他的任务,直接将新任务的数据和代码拷过来就行了,而不需要像前面那样先把父进程的数据代码拷过来,然后拷新任务的代码的时候又将其覆盖掉。

5、执行完copy_process()后就回到了do_fork()中,接着父进程回到system_call中执行syscall_exit: 后面的代码,而子进程则先从ret_from_fork: 处开始执行,然后在回到system_call 中去执行syscall_exit:.

ENTRY(ret_from_fork)
CFI_STARTPROC
pushl_cfi %eax
call schedule_tail
GET_THREAD_INFO(%ebp)
popl_cfi %eax
pushl_cfi $0x0202 # Reset kernel eflags
popfl_cfi
jmp syscall_exit
CFI_ENDPROC
END(ret_from_fork)

6、父进程和子进程最后都是通过system_call 的出口从内核空间回到用户空间,回到用户空间后,由于fork()函数对父子进程的返回值不同,所以根据返回值判断出回来的是父进程还是子进程,然后分别执行不同的操作。

\

相关内容