前言
本文主要介绍Linux内核中,一个新的进程/线程是如何诞生的。
主要涉及复制父进程各类资源、设置子进程系统堆栈、构造子进程调度时运行点等。
函数原型
Linux系统中,除第一个进程是被捏造出来的,其他进程都是通过do_fork()复制出来的。
int do_fork(unsigned long clone_flags, unsigned long stack_start,struct pt_regs *regs, unsigned long stack_size)
do_fork()根据其参数不同,对父进程的复制过程也不同,主要区别是子进程与父进程共享资源还是单独从父进程复制出来一份。
这里我们介绍复制父进程全部资源的情况,即复制父进程的task_struct结构、进程用户空间等。
我们这里为了叙述do_fork()的主体结构,避免干扰视线,省略了许多细节。
1.创建子进程task_struct结构体
既然是创建新的进程,首先需要申请进程最基本的单位task_struct结构
570 p = alloc_task_struct();
571 if (!p)
572 goto fork_out;
573
574 *p = *current;
然后需要将父进程task_struct结构中的各种参数复制到子进程task_struct中。
2.获取一个空闲的pid
static int get_pid(unsigned long flags)
pid是进程的独一无二的标识,因此必须获取一个。
3.复制各种资源
642 /* copy all the process information */
643 if (copy_files(clone_flags, p)) //复制文件描述符
644 goto bad_fork_cleanup;
645 if (copy_fs(clone_flags, p))
646 goto bad_fork_cleanup_files;
647 if (copy_sighand(clone_flags, p)) //复制信号量
648 goto bad_fork_cleanup_fs;
649 if (copy_mm(clone_flags, p)) //复制虚存空间
650 goto bad_fork_cleanup_sighand;
651 retval = copy_thread(0, clone_flags, stack_start, stack_size, p, regs); //复制系统堆栈
3.1copy_files(clone_flags, p)
父进程中可能打开了一系列文件,因此要复制给子进程,注意这不是通过指针共享。
3.2copy_fs(clone_flags, p)
复制父进程当前目录环境,如当前文件系统,当前目录pwd
3.3复制父进程的用户空间
static int copy_mm(unsigned long clone_flags, struct task_struct * tsk)
这里复制父进程的 vm_area_struct,即用户虚存空间,包括目录表、页面表。事实上do_fork()中并不会真正的复制页面,推迟到访问时通过缺页异常真正申请页面。
copy_mm()首先会申请一个mm_struct结构体,复制父进程的mm_struct内容。我们可以想象即使是复制用户空间,子进程和父进程能够具有相同的目录表吗?显然不能!因此,在申请一个mm_struct后,会调用mm_init(mm)为子进程申请一个新的目录表。
接下来就是二层循环将父进程的页表项填入到子进程相应的页表中,这个过程中包括子进程页表的申请。
总之,子进程复制父进程的用户空间,仅仅是将父进程页面表项内容填到子进程的页表中,因为页面表项的内容才是真正指向页面的地址。
4.copy_thread()
526 #define savesegment(seg,value) \
527 asm volatile("movl %%" #seg ",%0":"=m" (*(int *)&(value)))
528
529 int copy_thread(int nr, unsigned long clone_flags, unsigned long esp,
530 unsigned long unused,
531 struct task_struct * p, struct pt_regs * regs)
532 {
533 struct pt_regs * childregs;
534 //获取子进程系统堆栈顶部指针
535 childregs = ((struct pt_regs *) (THREAD_SIZE + (unsigned long) p)) - 1;
536 struct_cpy(childregs, regs); //从父进程拷贝系统堆栈状态
537 childregs->eax = 0; //子进程返回值设0
538 childregs->esp = esp; //子进程用户堆栈
539
540 p->thread.esp = (unsigned long) childregs; //初次运行时,子进程系统堆栈位置
541 p->thread.esp0 = (unsigned long) (childregs+1); //子进程系统空间堆栈顶部
542
543 p->thread.eip = (unsigned long) ret_from_fork; //下次运行时的切入点
544
545 savesegment(fs,p->thread.fs);
546 savesegment(gs,p->thread.gs);
547
548 unlazy_fpu(current);
549 struct_cpy(&p->thread.i387, ¤t->thread.i387);
550
551 return 0;
552 }
这个函数比较关键。这里拷贝父进程内核堆栈,541行将子进程内核堆栈地址保存到thread.esp0,子进程被调度时,thread.esp0会写入到TSS中的esp0,用于进程堆栈切换。接着543设置子进程初次运行时指令执行起点。
537行将0放到eax中,而do_fork()在返回后,会从eax中读取返回地址,子进程的返回地址就是0。
这时,一个完整的子进程已经诞生了,这时子进程还不在进程可执行队列中,不能接受调度,但是随后就会通过wake_up_process(p)将其加入可执行队列接受调度。子进程系统堆栈空间如下图所示。
而父进程也就是当前进程继续执行,最后,do_fork()将子进程的pid作为返回值返回。
retval = p->pid;
....省略....
return retval;
这样子进程被调度时的返回值和父进程从do_fork()返回时就不一样了。
参考资料:
《Linux内核情景分析》毛德操,胡希明