Linux内核do_fork()分析

  • Post author:
  • Post category:linux




进程的创建:系统调用fork(),vfork()和clone()

任何进程都是由其他进程创建的。


操作系统通过系统调用fork(),vfork()和clone()函数来完成进程的创建。



  • 这三个系统调用最终都调用了内核函数do_fork()



  • 这三个函数的唯一区别在于随后调用do_fork(0时设置的标志不同。


    在这里插入图片描述



fork函数的执行过程

fork函数的执行过程:普通程序调用fork()–>库函数fork()–>系统调用(fork功能号)–>由功能号在 sys_call_table[]中寻到sys_fork()函数地址–>调用sys_fork(),这就完成了从用户态到内核态的变化过程。所以,实际上,fork函数对应的实现就是sys_fork。

下面我们具体来看一下内核中sys_fork的实现

asmlinkage int sys_fork(struct pt_regs regs)
{
	return do_fork(SIGCHLD, regs.esp, &regs, 0, NULL, NULL);
}

可以看到sys_fork()实际是调用了do_fork()这个函数。真正的fork实现就在do_fork()这个函数中。



do_fork()实现过程




  • 首先为子进程申请一个pid

通过查找pidmap_array位图来获取pid

long pid = alloc_pidmap();



  • 检查父进程是否允许被跟踪

if (unlikely(current->ptrace)) {
		trace = fork_traceflag (clone_flags);
		if (trace)
			clone_flags |= CLONE_PTRACE;
	}

追踪功能在处理进程的函数中普遍使用。在这里我们介绍

ptrace功能



为了确定子进程是否被跟踪,fork_traceflag()必须检查clone_flags的值。



子进程会被跟踪的条件




  • 如果在clone_flags中设置了CLONE_VFORK标志,并且SIGCHLD信号没有被父进程捕获



  • 如果当前进程也设置了PT_TRACE_FORK标志,那么子进程就会被跟踪。




  • 创建新进程并复制寄存器的值

p = copy_process(clone_flags, stack_start, regs, stack_size, parent_tidptr, child_tidptr, pid);

这是创建进程的关键步骤,稍后会详细讨论相关细节




  • 检查do_fork()是否被vfork()所调用

if (!IS_ERR(p)) {
		struct completion vfork;

		if (clone_flags & CLONE_VFORK) {
			p->vfork_done = &vfork;
			init_completion(&vfork);
		}

		/*
		 * 如果父进程被跟踪或克隆操作被设置成CLONE_STOPPED标志
		 * 那么子进程在启动时将会收到一个SIGSTOP信号
		 * 这样子子进程就是以暂停状态启动的
		 */
		if ((p->ptrace & PT_PTRACED) || (clone_flags & CLONE_STOPPED)) {
			/*
			 * We'll start up with an immediate SIGSTOP.
			 */
			sigaddset(&p->pending.signal, SIGSTOP);
			set_tsk_thread_flag(p, TIF_SIGPENDING);
		}



  • 如果没有设置CLONE_STOPPED,就调用wake_up_new_task,否则进行相关处理

    ,
if (!(clone_flags & CLONE_STOPPED))
          //
			wake_up_new_task(p, clone_flags);
			
		else
		/*
		  * 如果CLONE_STOPPED标志被设置,就把子进程设置为TASK_STOPPED状态,
		  * 让它等待一个唤醒信号
		*/
	     p->state = TASK_STOPPED;

		
       /*
       如果在父进程上激活跟踪功能,则发送一个通知
       */
		if (unlikely (trace)) {
			current->ptrace_message = pid;
			ptrace_notify ((trace << 8) | SIGTRAP);
		}

		/*
		 * 如果设置了CLONE_VFORK,也就是说这是对vfork()的调用
		 * 那么就将父进ERRUO程设置成阻塞状态
		 * 并发送通知给一个跟踪者(如果父进程激活了跟踪功能的话)。
		 * 这是通过把父进程放在等待队列中,并让它保持TASK_UNINTERRUPTIBLEZ状态。
		 * 直到子进程调用exit()或者execv()来实现的
		 */
		if (clone_flags & CLONE_VFORK) {
			wait_for_completion(&vfork);
			if (unlikely (current->ptrace & PT_TRACE_VFORK_DONE))
				ptrace_notify ((PTRACE_EVENT_VFORK_DONE << 8) | SIGTRAP);
		}
	} else {
		free_pidmap(pid);
		pid = PTR_ERR(p);
	}
	return pid;
}



copy_process()



  • 调用

    dup_task_struct()

    为子进程创建一个内核栈,thread_info结构和task_struct,这些值与当前进程的值相同。此时,子进程和父进程的描述符是完全相同的



p = dup_task_struct(current);


  • 检查新创建这个子进程以后,当前用户所拥有的进程数目有没有超过给他分配的资源的限制

if (atomic_read(&p->user->processes) >=
			p->signal->rlim[RLIMIT_NPROC].rlim_cur) {
		/**
		 * 当然,用户有root权限就另当别论了
		 */
		if (!capable(CAP_SYS_ADMIN) && !capable(CAP_SYS_RESOURCE) &&
				p->user != &root_user)
			goto bad_fork_free;
	}

	/**
	 * 递增user结构的使用计数器
	 */
	atomic_inc(&p->user->__count);
	/**
	 * 增加用户拥有的进程计数。
	 */
	atomic_inc(&p->user->processes);
	get_group_info(p->group_info);

	/*
	 * 除了检查每个用户有有的进程数量外,接着检查系统中的任务总数(所有用户的进程数加系统的内核线程数)是否好过了最大值max_threads,如果是,也不允许再创建子进程。
	 * max_threads的缺省值是由系统内存容量决定的。总的原则是:所有的thread_info描述符和内核栈所占用的空间
	 * 不能超过物理内存的1/8。不过,系统管理可以通过写/proc/sys/kernel/thread-max文件来改变这个值。
	 */
	if (nr_threads >= max_threads)
		goto bad_fork_cleanup_count;

    if (!try_module_get(p->thread_info->exec_domain->module))
		goto bad_fork_cleanup_count;

	if (p->binfmt && !try_module_get(p->binfmt->module))
		goto bad_fork_cleanup_put_domain;

在task_struct结构中有个指针

user

,用来指向一个user_struct结构。

一个用户常常有多个进程,所以有关用户的信息并不专属于某一个进程,这样,属于同一个用户的进程就可以通过指针user共享这些信息。

显然,

每个用户有且只有一个user_struct结构

。该结构中有一个引用计数器**count,**对属于该用户的进程数量进行统计。

可想而知,

内核线程并不属于某个用户,所以其task_struct中的user指针为0


每个进程task_struct结构中有个数组rlim,对该进程占用的各种资源的数量做出限制

,而rlim[RLIMIT_NPROC]就规定了该进程所属用户可以拥有的进程数量。

所以,如果当前进程时一个用户进程,并且该用户进程拥有的进程数量已经达到了规定的界限值,就不允许它fork()了。



  • 现在,子进程着手使自己与父进程区别开来

进程描述符中的许多成员都要被清0或设置为初始值。进程描述符中的成员值并不是继承而来的,而主要是统计信息。进程描述符中的大多数数据都是共享的。



  • 接下来设置子进程的状态为TASK_UNINTERRUPTINLE以保证它不会投入运行



  • 调用

    copy_flags()

    以更新task_struct的flags成员。



copy_flags(clone_flags, p);

copy_flags()函数将clone_flags参数中的标志位略加补充和变换,然后写入p->flags。

表明进程是否拥有超级用户权限的PF_SUPERPIV标志被清0,表明进程还没有调用exec()函数的PF_FORKNOEXEC标志被设置



  • 初始化子进程描述符中的list_head数据结构和自旋锁,并为挂起信号,定时器及时间统计表相关的几个字段



  • 根据clone_flags中的标志,进行一系列的拷贝或共享工作

if ((retval = copy_semundo(clone_flags, p)))
		goto bad_fork_cleanup_audit;
	if ((retval = copy_files(clone_flags, p)))
		goto bad_fork_cleanup_semundo;
	if ((retval = copy_fs(clone_flags, p)))
		goto bad_fork_cleanup_files;
	if ((retval = copy_sighand(clone_flags, p)))
		goto bad_fork_cleanup_fs;
	if ((retval = copy_signal(clone_flags, p)))
		goto bad_fork_cleanup_sighand;
	if ((retval = copy_mm(clone_flags, p)))
		goto bad_fork_cleanup_signal;
	if ((retval = copy_keys(clone_flags, p)))
		goto bad_fork_cleanup_mm;
	if ((retval = copy_namespace(clone_flags, p)))
		goto bad_fork_cleanup_keys;

//用父进程内核栈中的内容来初始化子进程的内核栈
retval = copy_thread(0, clone_flags, stack_start, stack_size, p, regs);
	if (retval)
		goto bad_fork_cleanup_namespace;



copy_process()拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间等


。在一般情况下,这些资源会被给定进程的所有线程共享;否则,这些资源对每个进程都是不同的,因此被拷贝到这里。

前面在赋值父进程的task_struct结构时把父进程的所有域都照抄过来,但实际上很多域的值必须重新赋初值,因此,后面的赋值语句就是对子进程task_struct结构的初始化。


copy_files()有条件地复制以打开文件的控制结构

,也就是说,这种复制只有在clone_flags中的CLONE_FILE标志为0时才真正进行,否则只是共享父进程已打开的文件。

当一个进程中有已打开文件时,task_struct结构中的指针files指向一个file_struct结构,否则为0。所有与终端设备tty向关联的用户进程的3个标准头文件stdin,stdout和stderr都是预先打开的,所以指针一般不为空。


copy_fs()也是只有在clone_flags中的CLONE_FS中的标志为0时才加以复制



在task_struct中有一个指向fs_struct结构中的指针,fs_struct结构中存放的时进程的根目录root、当前工作目录pwd、一个用于文件操作权限的umask,还有一个计数器**。类似地,


copy_sighand()也是只有在CLONE_SIGHAND为0时才真正复制父进程的信号结构

,否则就共享。

信号是进程间通信的一种手段,信号随时都可以发向一个进程,就像中断随时和都可以发向一个处理器一样。进程可以为各种信号设置相应的信号处理程序,一旦进程设置了信号处理程序,其task_struct结构中的指针sig就指向signal_struct结构。



  • 调用sched_fork完成对新进程调度程序数据结构的初始化

sched_fork(p);

该函数把新进程的状态设置为TASK_RUNNING,并把thread_info 结构中的preempt_count字段设置为1(表示该进程可抢占)



  • 建立进程的家族关系



  • 通过

    SET_LINKS()宏

    将子进程的task_struct及结构插入到内核中其他进程组成的双向链表中



SET_LINKS(p);


  • 根据新进程的pid将对应的进程描述符插入

    pidhash散列表


attach_pid(p, PIDTYPE_PID, p->pid);
attach_pid(p, PIDTYPE_TGID, p->tgid);


  • 对该进程属于的

    用户的进程数量加1,

    对索引数组的元素个数加1



	total_forks++;
    nr_threads++;


  • 最后,返回一个指向子进程的指针



dup_task_struct()



dup_task_struct()函数用来获取一个进程描述符

执行步骤:


  • 先将父进程的寄存器信息保存到父进程的thread_info中
prepare_to_copy(orig);

  • alloc_task_struct宏为新进程获取进程描述符
tsk = alloc_task_struct();

  • 为子进程申请一个thread_info结构体
ti = alloc_thread_info(tsk);

  • 用结构赋值语句把当前进程的task_struct结构中的所有内容都拷贝到新进程中。稍后,子进程不该继承的域会被设置成为正确的值。
    *ti = *orig->thread_info;
	*tsk = *orig;
	tsk->thread_info = ti;
	ti->task = tsk;

在这里插入图片描述


  • 把新进程描述符的使用计数器usage设置为2,用来表示描述符正在被使用而且其相应的进程处于活动状态。进程状态既不是EXIT_ZOMBIE,也不是EXIT_DEAD

  • 返回指向进程描述符的指针


alloc_thread_info()宏



alloc_thread_info()为子进程分配两个连续的物理页面,低端用来存放子进程的task_struct结构,高端用作其内核空间的堆栈。


内核栈的分布如图所示


在Tntel中,栈起始于末端,并朝这个内存区开始的方向增长。从用户态刚切换到内核态以后,进程的内核栈时空的,因此,esp寄存器指向这个内存区的顶端。



在include/linux/sched.h中定义了如下一个联合结构

union task_union
{
	struct task_struct  task;
	unsigned long  stack[2408];
}

从这个结构与可以看出,内核栈占8KB的内存区。实际上进程的task_struct结构所占的内存是由内核动态分配的,更确切地说,内核根本不给task_struct单独分配内存,而仅仅给内核栈分配8KB的内存,并把其中的一部分给task_struct使用。

task_struct结构大约占1K字节左右,其具体数字于内核版本有关,因为不同的内核版本其域稍有不同。因此,内核栈的大小不能超过7KB,否则,内核栈会覆盖task_struct结构,从而导致内核崩溃。



task_struct结构和内核栈放在一起的好处:



  • 内核可以方便而快速地找出这个结构

用伪代码描述如下:

task_struct=(struct task_struct *) STACK_POINTER &0xffffe000


  • 避免在创建进程时动态分配额外的内存



  • task_struct结构的起始地址总是开始于页大小(PAGE_SIZE)的边界



copy_mm()



用户空间的继承是通过copy_mm()函数完成的

进程的task_struct结构中有一个指针


mm


,就代表着进程空间的


mm_struct


,结构。对mm_struct结构的复制也是在clone_flags中的CLONE_VM标志为0时才真正进行,否则,就只是通过已经复制的指针共享父进程的用户空间。对mm_struct的复制并不只是复制这个数据结构本身,还包括对更深层次数据结构的复制,其中最主要的是对


vm_area_struct


,结构和页表的复制,这是由dum_mmap()函数完成的。







mm_struct和vm_area_struct结构如下图所示


在这里插入图片描述



如果CLONE_VM标志没有被设置,copy_mm()函数必须创建一个新的地址空间



  • 这个函数

    分配一个新的内存描述符

    并把它的地址存放在新进程描述符的mm域中。
  • 然后把新进程描述符的许多域初始化为零。并且

    调用copy_segment()函数建立LDT描述符
  • 下一步copy_mm()

    调用new_page_tables()分配全局目录



    这个表最后的一些表项(对应于高于PAGE_OFFSET的线性地址)是从swapper进程的页全局目录中复制来的,而其余部分则设置为0,new_page_table()把页全局目录的地址存放在新内存描述符的mm->pgd域中,
  • 然后

    调用dup_mmap()函数既复制父进程的线性区,也复制父进程的页表



    从current->mm->mmap指向的地方开始,dup_mmap()函数扫描父进程线性区链表。它复制遇到的每个vm_area_struct线性区描述符,并把复制品插入到子进程的线性区链表中。



copy_thread()



用父进程内核栈中的内容来初始化子进程的内核栈来初始化子进程的内核栈

	childregs = ((struct pt_regs *) (THREAD_SIZE + (unsigned long) p->thread_info)) - 1;
	*childregs = *regs;
	childregs->eax = 0;//注意此处对eax寄存器中值得修改,这就是fork后子进程的返回值为0的原因
	childregs->esp = esp;



唤醒子进程时的特殊情况



当参数clone_flags中的CLONE_VFORK标志位为1时,一定要保证子进程先运行,一直到子进程通过系统调用execv()执行一个新的可执行程序或通过系统调用exit()退出系统时,才可以恢复父进程的执行。



原因:



CLONE_VFORK标志位为1时,就说明父、子进程通过指针共享用户空间(指向相同的mm_struct结构),那也说明父进程写入用户空间的内容也写入了子进程的用户空间,反之亦然。

如果说,在这种情况下,父子进程对数据区的写入可能引起问题的话,那么,对于堆栈区的写入可能就是致命的了。而对子进程函数的调用肯定就是对堆栈的写入。由此可见,在这种情况下,绝不能让两个进程都回到用户空间并发执行,否则,必然导致两个进程的互相“捣乱”或因非法访问而死亡。



解决办法




只能是“扣留”其中的一个进程,而让另外一个进程先回到用户空间,直到两个进程不再共享用户空间或者其中一个进程消亡为止(肯定是先回到用户空间的进程先消亡)。


到此为止,子进程的创建已经完成,该是从内核态回到用户态的时候了。实际上,fork()系统调用执行以后,父子进程返回到用户空间中相同的地址,用户进程根据fork()的返回值分别安排父子进程执行不同的代码。



版权声明:本文为qq_43313035原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。