慕课18原创作品转载请注明出处 + 《Linux内核分析》MOOC课程http://mooc.study.163.com/course/USTC-1000029000
一、背景知识:
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()函数对父子进程的返回值不同,所以依据返回值推断出回来的是父进程还是子进程,然后分别运行不同的操作。