用户态--fork函数创建进程
我们一般使用Shell命令行来启动一个程序,其中首先是创建一个子进程。但是由于Shell命令行程序比较复杂,为了便于理解,我们简化了Shell命令行程序,用如下一小段代码来看怎样在用户态创建一个子进程。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char * argv[])
{
int pid;
/* fork another process */
pid = fork();
if (pid < 0)
{
/* error occurred */
}
else if (pid == 0)
{
/* child process */
}
else
{
/* parent process */
}
}
库函数fork
库函数fork是用户态创建一个子进程的系统调用API接口。对于判断fork函数的返回值,我们可能会很迷惑,因为fork在正常执行后,if条件判断中除了if (pid < 0)异常处理没被执行,else if (pid == 0)和else两段代码都被执行了。
实际上fork系统调用把当前进程又复制了一个子进程,也就一个进程变成了两个进程,两个进程执行相同的代码,只是fork系统调用在父进程和子进程中的返回值不同。其实是if语句在两个进程中各执行了一次,由于判断条件不同,输出的信息也就不同。父进程没有打破if else的条件分支的结构,在子进程里面也没有打破这个结构,只是在Shell命令行下好像两个都输出了,好像打破了条件分支结构,实际上背后是两个进程。fork之后,父子进程的执行顺序和调度算法密切相关,多次执行有时可以看到父子进程的执行顺序并不是确定的。
通过以上那段fork代码程序,我们可以在用户态创建一个子进程,就是调用系统调用fork。
首先来回顾系统调用是怎样工作的,并讨论创建进程和其他常见的系统调用有哪些不同。
系统调用回顾
在正常触发系统调用时,对于X86 Linux系统来说用户态有一个int $0x80或syscall指令触发系统调用,CPU跳转到系统调用入口的汇编代码执行。int $0x80指令触发entry_INT80_32并以iret返回系统调用,syscall指令触发entry_SYSCALL_64并以sysret或iret返回系统调用。
对于ARM64 Linux系统来说,用户态程序会执行svc指令触发系统调用,CPU会跳转到异常向量表(vectors)中执行,然后进入异常处理入口,即svc指令之后跳转到el0_sync和el0_svc,执行完系统调用,以eret指令返回系统调用。
系统调用从用户态陷入内核态时,所使用的的函数调用堆栈也从用户态堆栈转换到内核态堆栈,然后把相应的CPU关键的现场栈顶寄存器、指令指针寄存器、标志寄存器等保存到内核堆栈,保存现场。系统调用入口的汇编代码还会通过系统调用号执行系统调用内核处理函数,最后恢复现场和系统调用返回,将CPU关键现场栈顶寄存器、指令指针寄存器、标志寄存器等从内核堆栈中恢复到对应寄存器中,并回到用户态int $0x80/syscall或svc指令之后的下一条指令的位置(系统调用返回地址)继续执行。
fork系统调用
fork也是一个系统调用,和前述一般的系统调用执行过程大致是一样的。尤其从父进程的角度来看,fork的执行过程与前述描述完全一致,但问题是:fork系统调用创建了一个子进程,子进程复制了父进程中所有的进程信息,包括内核堆栈、进程描述符等,子进程作为一个独立的进程也会被调度,当子进程获得CPU开始运行时,它是从哪里开始运行的呢?
从用户态空间来看,就是fork系统调用的下一条指令。但fork系统调用在子进程当中也是返回的,也就是说fork系统调用在内核里面变成了父子两个进程,父进程正常fork系统调用返回到用户态,fork出来的子进程也要从内核里返回到用户态。那么对于子进程来讲,fork系统调用在内核处理程序中是从何处开始执行的呢?一个新创建的子进程是从哪行代码开始执行的,这是一个关键问题。下面带着这个问题来仔细分析fork系统调用的内核处理过程,解决这个疑问相信会更深入地理解Linux内核源代码。
进程创建的主要过程
我们先来看如何正确建立一个进程的框架。我们前面了解了创建一个进程是复制当前进程的信息,就是通过_do_fork函数来创建了一个新进程。因为父进程和子进程的绝大部分信息是完全一样的,但是有些信息是不能一样的,比如 pid 的值和内核堆栈。还有将新进程链接到各种链表中,要保存进程执行到哪个位置,有一个thread数据结构记录进程执行上下文的关键信息也不能一样,否则会发生问题。
可以想象出来这样一个框架,父进程创建一个子进程,应该会有一个地方复制了父进程的进程描述符task_struct结构体变量,并有很多地方来修改复制出来的进程描述符task_struct结构体变量。因为父子进程各自都有很多自己独立之处,子进程应该有很多地方修改内核堆栈里的信息,因为内核堆栈里的很多数据是从父进程复制来的,而fork系统调用在父子进程中分别返回到用户态,父子进程的内核堆栈中可能某些信息也不完全一样。还有thread,根据子进程复制的父进程的内核堆栈的状况,肯定要设定好指令指针和栈顶寄存器,即设定好子进程开始执行的位置。
需要特别说明的是,fork一个子进程的过程中,复制父进程的资源时采用了Copy On Write(写时复制)技术,不需要修改的进程资源父子进程是共享内存存储空间的。
有了这个框架思路之后,就可以追踪具体代码执行过程,找到这个框架思路中需要了解的相关信息。为了避免重复,在此就不再赘述触发fork系统调用的过程,而直接从_do_fork函数来跟踪分析代码,具体代码如下见kernel/fork.c。
_do_fork函数
_do_fork函数主要完成了调用copy_process()复制父进程、获得pid、调用wake_up_new_task将子进程加入就绪队列等待调度执行等。
long _do_fork(struct kernel_clone_args *args)
{
//复制进程描述符和执行时所需的其他数据结构
p = copy_process(NULL, trace, NUMA_NO_NODE, args);
wake_up_new_task(p);//将子进程添加到就绪队列
return nr;//返回子进程pid(父进程中fork返回值为子进程的pid)
}
copy_process()函数是如何复制父进程的
copy_process()是创建一个进程的主要的代码,如下的copy_process()函数代码做了删减并添加了一些中文注释,完整代码见kernel/fork.c。
static __latent_entropy struct task_struct *copy_process(
struct pid *pid,
int trace,
int node,
struct kernel_clone_args *args)
{
//复制进程描述符task_struct、创建内核堆栈等
p = dup_task_struct(current, node);
/* copy all the process information */
shm_init_task(p);
…
// 初始化子进程内核栈和thread
retval = copy_thread_tls(clone_flags, args->stack, args->stack_size, p,
args->tls);
…
return p;//返回被创建的子进程描述符指针
}
copy_process函数主要完成了调用dup_task_struct复制当前进程(父进程)描述符task_struct、信息检查、初始化、把进程状态设置为TASK_RUNNING(此时子进程置为就绪态)、采用写时复制技术逐一复制所有其他进程资源、调用copy_thread_tls初始化子进程内核栈、设置子进程pid等。其中最关键的就是dup_task_struct复制当前进程(父进程)描述符task_struct和copy_thread_tls初始化子进程内核栈。接下来具体看dup_task_struct和copy_thread_tls。
dup_task_struct复制当前进程(父进程)描述符task_struct
static struct task_struct *dup_task_struct(struct task_struct *orig, int node)
{
…
//实际完成进程描述符的拷贝,具体做法是*tsk = *orig
err = arch_dup_task_struct(tsk, orig);
…
tsk->stack = stack;
...
//实际完成进程描述符的拷贝,具体做法是*tsk = *orig
setup_thread_stack(tsk, orig);
clear_user_return_notifier(tsk);
clear_tsk_need_resched(tsk);
set_task_stack_end_magic(tsk);
...
return ts
}
还有copy_thread_tls是一个关键,在早期版本3.18.6该函数叫copy_thread,它负责构造fork系统调用在子进程的内核堆栈,也就是fork系统调用在父子进程各返回一次,父进程中和其他系统调用的处理过程并无二致,而在子进程中的内核函数调用堆栈需要特殊构建,为子进程的运行准备好上下文环境。另外还有线程局部存储TLS(thread local storage) 则是为支持多线程编程引入的,我们不去深究。
在看copy_thread_tls之前我们需要重点看一下fork子进程的内核堆栈和进程描述符的最后一个成员struct thread_struct thread。
task_struct数据结构的最后是保存进程上下文中CPU相关的一些状态信息的关键数据结构thread。struct thread_struct在进程描述符最后定义的结构体变量thread代码如下:
/* CPU-specific state of this task: */
struct thread_struct thread;
这个struct thread_struct数据结构内部的东西还比较多,其中最关键的是sp和ip。在x86下32位Linux内核3.18.6中,sp用来保存进程上下文中的ESP寄存器状态,ip用来保存进程上下文中的EIP寄存器状态;数据结构中还有很多其他和CPU相关的状态。
需要特别说明的是在5.4.34代码中struct thread_struct数据结构中没有了ip,而是将ip通过内核堆栈来保存,比如fork创建的子进程内核堆栈中会有一个ret_addr。
了解了fork子进程的内核堆栈和进程描述符的最后一个成员struct thread_struct thread,我们需要重点看一下copy_thread_tls(以5.4.34为例)和copy_thread(以3.18.6为例)。
copy_thread_tls vs. copy_thread
int copy_thread_tls(unsigned long clone_flags, unsigned long sp,
unsigned long arg, struct task_struct *p, unsigned long tls)
{
frame->ret_addr = (unsigned long) ret_from_fork;
p->thread.sp = (unsigned long) fork_frame;
*childregs = *current_pt_regs();
childregs->ax = 0;
...
/*
* Set a new TLS for the child thread?
*/
if (clone_flags & CLONE_SETTLS) {
err = do_arch_prctl_64(p, ARCH_SET_FS, tls);
......
int copy_thread(unsigned long clone_flags, unsigned long sp,
unsigned long arg, struct task_struct *p)
{
p->thread.sp = (unsigned long) childregs;
//复制内核堆栈(复制父进程的寄存器信息,即系统调用int指令和SAVE_ALL压栈的那一部分内容)
*childregs = *current_pt_regs();
childregs->ax = 0; //将子进程的eax置0,所以fork的子进程返回值为0
...
//ip指向 ret_from_fork,子进程从此处开始执行
p->thread.ip = (unsigned long) ret_from_fork;
...
子进程创建好了进程描述符、内核堆栈等,就可以通过wake_up_new_task(p)将子进程添加到就绪队列,使之有机会被调度执行,进程的创建工作就完成了,子进程就可以等待调度执行,子进程的执行从这里设定的ret_from_fork开始。
值得注意的是进程关键上下文的ip和sp,linux-5.4.34与早期版本有所不同,主要是指令指针ip在3.18.6版本是存放在thread.ip中,而5.4.34中则是通过frame->ret_addr直接存储在内核堆栈中。
_do_fork总结
总结来说,进程的创建过程大致是父进程通过fork系统调用进入内核_do_fork函数,如下图所示复制进程描述符及相关进程资源(采用写时复制技术)、分配子进程的内核堆栈并对内核堆栈和thread等进程关键上下文进行初始化,最后将子进程放入就绪队列,fork系统调用返回;而子进程则在被调度执行时根据设置的内核堆栈和thread等进程关键上下文开始执行。
以上内容为中科大软件学院《Linux操作系统分析》课后总结,感谢孟宁老师的倾心教授,老师讲的太好啦(^_^)
参考资料:《庖丁解牛Linux内核分析》 孟宁 编著