上一章学习了进程的创建,在用户空间可以使用fork接口来创建一个用户进程,或者使用clone接口来创建一个用户线程,它们在内核空间都会调用do_fork函数来实现,但是我们有两个疑问未得到解答
- fork接口,它可以是父、子进程都会返回,那么它会返回两次,其中父进程的返回值是子进程的PID,而子进程返回0,这个过程是如何的呢?
- 子进程第一次返回用户空间时,它的返回在哪里呢?
- 进程如何完成终止
1. fork的执行过程
当调用_do_fork()函数创建子进程后,子进程会加入到内核的调度器中,在调度器中参与调度。那么子进程在稍后的某一时刻得到调度和执行,因此fork函数也会有两次返回,一次是父进程的返回,另外一次是子进程被调度后执行的返回。
我们以copy_process为例,下面是在里面有一个copy_thread的线程
static __latent_entropy struct task_struct *copy_process(
unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *child_tidptr,
struct pid *pid,
int trace,
unsigned long tls,
int node)
{
...
retval = copy_thread_tls(clone_flags, stack_start, stack_size, p, tls);
...
}
在Linux4.2后增加了CONFIG_HAVE_COPY_THREAD_TLS宏和copy_thread_tls函数,这个函数使一个特定于体系结构的函数,用于复制进程中特定的线程的数据,重要的是填充task_struct->thread的各个成员,其对于ARM64的定义如下:
int copy_thread(unsigned long clone_flags, unsigned long stack_start,
unsigned long stk_sz, struct task_struct *p)
{
struct pt_regs *childregs = task_pt_regs(p);
memset(&p->thread.cpu_context, 0, sizeof(struct cpu_context));
fpsimd_flush_task_state(p);
//创建子进程时用户进程的情况
if (likely(!(p->flags & PF_KTHREAD))) {
//将当前寄存器信息复制给子进程
*childregs = *current_pt_regs();
childregs->regs[0] = 0;//子进程 X0寄存器 0,因此fork 在子进程返回0
*task_user_tls(p) = read_sysreg(tpidr_el0);
if (stack_start) {
if (is_compat_thread(task_thread_info(p)))
childregs->compat_sp = stack_start;
else
childregs->sp = stack_start; //创建线程时设置用户栈起始地址
}
if (clone_flags & CLONE_SETTLS)
p->thread.tp_value = childregs->regs[3];
} else {//处理子进程是内核线程的情况
memset(childregs, 0, sizeof(struct pt_regs));
childregs->pstate = PSR_MODE_EL1h;//设置子进程的处理器状态为 PSR_MODE_EL1h ,异常等级为el1使用sp_el1
if (IS_ENABLED(CONFIG_ARM64_UAO) &&
cpus_have_cap(ARM64_HAS_UAO))
childregs->pstate |= PSR_UAO_BIT;
p->thread.cpu_context.x19 = stack_start;//设置内核线程执行函数地址
p->thread.cpu_context.x20 = stk_sz;//设置传递给函数的参数
}
//设置子进程的进程硬件上下文中的PC和SP成员的值
p->thread.cpu_context.pc = (unsigned long)ret_from_fork;
p->thread.cpu_context.sp = (unsigned long)childregs;
ptrace_hw_copy_thread(p);
return 0;
}
- childregs->regs[0] = 0子进程被调度返回用户空间的时候,fork的返回值为0,这就是为何fork返回值为0表示是子进程的原因
- 如果创建的是子进程,那么就直接和父进程写时复制方式共享用户栈,而栈不需要在进行设置,直接使用父进程的
- 进程切换时,子进程的pc和sp,当子进程第一次被调度的时候,从ret_from_fork开始执行指令,栈指针指向childregs,即为设置后pt_regs
在copy_thread函数中会复制父进程struct pt_regs栈的全部内容到子进程,包括描述内核栈上保持的寄存器的全部信息,如X0-X30寄存器,栈指针寄存器,PC寄存器以及PSTATE寄存器信息等。同时还会修改子进程X0的值,该值在返回用户空间时子进程的返回值就是该值。
由此可见,copy_thread这个函数对于进程调度很重要,决定了进程第一次被调度的时候执行哪个代码,决定了fork函数的返回值。 pt_regs描述的发生异常的时候保存的现场信息,主要是一些通用寄存器,我们这里称为异常现场:
struct pt_regs {
union {
struct user_pt_regs user_regs;
struct {
u64 regs[31];
u64 sp;
u64 pc;
u64 pstate;
};
};
u64 orig_x0;
u64 syscallno;
u64 orig_addr_limit;
u64 unused; // maintain 16 byte alignment
};
- 当异常发生,异常的现场,通用寄存器的内容,如X0-X30,sp,pc,pstate会被压入内核栈,通过pt_reg结构来描述。
- 当异常处理结束时候,需要恢复异常前的现场,会将这些保持的值恢复到通用寄存器中
pu_context描述的是进程调度的时候需要保存的进程上下文,我们这里成为调度现场:
struct cpu_context {
unsigned long x19;
unsigned long x20;
unsigned long x21;
unsigned long x22;
unsigned long x23;
unsigned long x24;
unsigned long x25;
unsigned long x26;
unsigned long x27;
unsigned long x28;
unsigned long fp;
unsigned long sp;
unsigned long pc;
};
当进程切换的时候,会将处理器当前需要保持的寄存器保存到前一个进程的tsk的thread.cpu_context中,并将后一个即将要调度的进程的上下文信息从该tsk的thread.cpu_context中恢复到相应的寄存器中,就完成了处理器状态的切换。
所以对该过程pt_regs表明发生异常时,处理器现场,而cpu_context发生调度时,当前进程的处理器现场。
2. 子进程开始执行
子进程时如何开始执行呢?由上面代码,copy_thread函数会使子进程的入口地址PC指向ret_from_fork,该过程主要是通过子进程硬件上下文中PC成员来实现,那么子进程执行就会跳转到该汇编函数中
/*
* This is how we return from a fork.
*/
ENTRY(ret_from_fork)
bl schedule_tail
cbz x19, 1f // not a kernel thread
mov x0, x20 //赋值内核线程的参数
blr x19 //执行内核线程函数
1: get_thread_info tsk
b ret_to_user //返回用户空间
ENDPROC(ret_from_fork)
在第2行中,判断X19寄存器的值是否为空,如果为空,说明这是一个用户进程,则跳转到第5行代码中,调用ret_to_user汇编函数,直接返回用户空间。如果X19寄存器的值不为空,说明这是一个内核线程,直接执行X19寄存器中保存的内核线程回调函数。这个章节在后面进程上下文中单独学习。
3. 进程的终止
系统有源源不断的进程的诞生,同时,也会有进程不断的终止。进程的终止有两种方式
- 资源地终止,包括调用exit系统调用或者从某个程序的主函数返回
- 被动地收到终止信号或者异常终止
进程主动终止主要有以下两种途径:
- 从main函数返回,链接程序会自动添加exit()系统调用
- 主动调用exit()系统调用
进程被动终止主要有以下途径:
- 进程收到一个自己不能处理的信号
- 进程在内核态执行时发生了一个异常
- 进程收到SIGKILL等终止信号
当一个进程终止时,Linux内核会释放所占用的所有资源,并把这个消息告诉给父进程,而一个进程终止时可能又有以下情况
- 它先于父进程终止,那么子进程会变成僵尸进程,直到父进程调用wait()才能最终消亡
- 它也在父进程之后终止,那么Init进程将成为子进程的新父进程
4. 僵尸进程
一个进程通过exit()系统调用终止之后,就会处于僵尸状态。在僵尸状态中,除了进程描述符依然保留外,进程的其他资源已经归还给内核。
Linux内核这么做是为了让系统可以得到子进程的终止原因,父进程可以通过wait()系统调用来获取已终结的子进程的信息之后,内核才会释放子进程的task_strcut数据结构。
但是如果父进程先于子进程消亡,那么子进程就变成孤儿进程。Linux内核会把它托孤给init进程(1号进程),init进程就成为子进程的父进程。