#183 Linux 内核分析 之六:Linux 内核创建进程的过程

说明

欧长坤 原创作品转载请注明出处 《Linux内核分析》MOOC课程http://mooc.study.163.com/course/USTC-1000029000 这学期学校恰好有操作系统的课程,上个学习就开始寻思研究研究Linux内核代码,恰好MOOC有这个课程,遂选了此课。

一、准备工作

这节课的任务依旧很简单,我们来尝试分析一下在一个实际的操作系统下(Linux),它是如何实现操作系统理论中关于进程创建和相关调度的过程。 因此,如果学过操作系统理论的话,我们可以根据相关理论知识来提前预测一下task_struct的应该会存在哪些结构:

  1. 进程状态、将纪录进程在等待、运行、或死锁
  2. 调度信息、由哪个调度函数调度、怎样调度等
  3. 进程的通讯状况
  4. 有插入进程链表的相关操作,因此必须有链表连接指针、当然是task_struct型
  5. 时间信息,比如计算好执行的时间、以便CPU资源的分配
  6. 标号,决定改进程归属
  7. 可以读写打开的一些文件信息
  8. 进程上下文和内核上下文
  9. 处理器上下文
  10. 内存信息等等

我们可以在/linux-3.18.6/include/linux/sched.h中找到tast_struct的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
struct task_struct {
volatile long state; //说明了该进程是否可以执行,还是可中断等信息
unsigned long flags; //进程号,在调用fork()时给出
int sigpending; //进程上是否有待处理的信号
mm_segment_t addr_limit; //进程地址空间,区分内核进程与普通进程在内存存放的位置不同
//0-0xBFFFFFFF for user-thead
//0-0xFFFFFFFF for kernel-thread
//调度标志,表示该进程是否需要重新调度,若非0,则当从内核态返回到用户态,会发生调度
volatile long need_resched;
int lock_depth; //锁深度
long nice; //进程的基本时间片
//进程的调度策略,有三种,实时进程:SCHED_FIFO,SCHED_RR, 分时进程:SCHED_OTHER
unsigned long policy;
struct mm_struct *mm; //进程内存管理信息
int processor;
//若进程不在任何CPU上运行, cpus_runnable 的值是0,否则是1 这个值在运行队列被锁时更新
unsigned long cpus_runnable, cpus_allowed;
struct list_head run_list; //指向运行队列的指针
unsigned long sleep_time; //进程的睡眠时间
//用于将系统中所有的进程连成一个双向循环链表, 其根是init_task
struct task_struct *next_task, *prev_task;
struct mm_struct *active_mm;
struct list_head local_pages; //指向本地页面
unsigned int allocation_order, nr_local_pages;
struct linux_binfmt *binfmt; //进程所运行的可执行文件的格式
int exit_code, exit_signal;
int pdeath_signal; //父进程终止是向子进程发送的信号
unsigned long personality;

int did_exec:1;
pid_t pid; //进程标识符,用来代表一个进程
pid_t pgrp; //进程组标识,表示进程所属的进程组
pid_t tty_old_pgrp; //进程控制终端所在的组标识
pid_t session; //进程的会话标识
pid_t tgid;
int leader; //表示进程是否为会话主管
struct task_struct *p_opptr,*p_pptr,*p_cptr,*p_ysptr,*p_osptr;
struct list_head thread_group; //线程链表
struct task_struct *pidhash_next; //用于将进程链入HASH表
struct task_struct **pidhash_pprev;
wait_queue_head_t wait_chldexit; //供wait4()使用
struct completion *vfork_done; //供vfork() 使用
unsigned long rt_priority; //实时优先级,用它计算实时进程调度时的weight值
…… //后面就不看了 我们不关心
};

可以看到,现在的Linux系统基本上是按照操作系统理论来进行设计的,但是在实现的过程中,理论往往是不够的,为了实现很多实际的需求,tast_struct还定义了很多额外的结构,来方便系统的相关管理,比如后面没有列出来的一些文件操作相关的结构,这些结构一般用于当一个进程没有按照规范来操作文件时,当进程被杀掉后,系统任然可以对这些不规范的操作进行管理。当然,后面还有很多内容也是如此,我们就不一一叙说了,我们只看创建一个进程的相关重点。

二、进程创建分析

好,那么有了进程相关结构作为基础知识,我们来看看fork函数到底如何进行对应的内核处理过程sys_clone。

首先我们需要理解进程究竟是怎么产生的: 进程的创建是基于父进程的,创建出来的进程响应的就被称为紫金城,那么一直追溯上去,总有一个进程是原始的,即没有父进程的。这个进程在Linux中的进程号是0,也就是传说中的0号进程,如果你还不清楚0号进程是什么,请看之前的实验,帮你快速回忆一下零号进程是什么东西

这里简单提一下:子进程可以通过规范的创建进程的函数(如:fork())基于父进程复制创建,那么0号进程就是没有可以复制和参考的对象,也就是说0号进程拥有的所有信息和资源都是强制设置的,不是复制的,这个过程我称为手工设置,也就是说0号进程是“纯手工打造”,这是操作系统中“最原始”的一个进程,它是一个模子,后面的任何进程都是基于0号进程生成的。

有了进程产生是依靠父进程复制才得以出现的概念,我们来看看老师给出的fork一个子进程的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#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 */
fprintf(stderr,&quot;Fork Failed!&quot;);
exit(-1);
}
else if (pid == 0)
{
/* child process */
printf(&quot;This is Child Process!\n&quot;);
}
else
{
/* parent process */
printf(&quot;This is Parent Process!\n&quot;);
/* parent will wait for the child to complete*/
wait(NULL);
printf(&quot;Child Complete!\n&quot;);
}
}

创建一个新进程在内核中的执行过程(sys_clone–>do_fork) fork、vfork和clone三个系统调用都可以创建一个新进程,而且都是通过调用do_fork来实现进程的创建; Linux通过复制父进程来创建一个新进程,那么这就给我们理解这一个过程提供一个想象的框架:

首先:复制一个PCB——task_struct

1
err = arch_dup_task_struct(tsk, orig);

然后,要给新进程分配一个新的内核堆栈

1
2
3
ti = alloc_thread_info_node(tsk, node);
tsk->stack = ti;
setup_thread_stack(tsk, orig); //这里只是复制thread_info,而非复制内核堆栈

要修改复制过来的进程数据,比如pid、进程链表等等都要进行对应的修改,见copy_process内部。

从用户态的代码看fork();函数返回了两次,即在父子进程中各返回一次,父进程从系统调用中返回比较容易理解。 而子进程从系统调用中返回,那它在系统调用处理过程中的哪里开始执行的呢?

这就涉及子进程的内核堆栈数据状态和task_struct中thread记录的sp和ip的一致性问题,这是在哪里设定的? 答案是:copy_thread in copy_process 下面的代码展示了进程数据的修改:

1
2
3
4
5
*childregs = *current_pt_regs();              //复制内核堆栈
childregs->ax = 0; //为什么子进程的fork返回0,这里就是原因!

p->thread.sp = (unsigned long) childregs; //调度到子进程时的内核栈顶
p->thread.ip = (unsigned long) ret_from_fork; //调度到子进程时的第一条指令地址

所以,fork创建一个进程,按流程来说首先会进入do_fork,在do_fork里,对一些情况进行判断。 如果没有什么危险的情况,则开始进入copy_process。

copy_process函数在进程创建的do_fork函数中调用,主要完成进程数据结构,各种资源的初始化。 初始化方式可以重新分配,也可以共享父进程资源,主要根据传入CLONE参数来确定。

随后,调用dup_task_struct()为新进程创建一个内核栈,它的定义在kernel/fork.c文件中。 该函数调用copy_process()函数。然后让进程开始运行。从函数的名字dup就可知,此时,子进程和父进程的描述符是完全相同的。

然后来到了copy_threadcopy_thread的代码类似于在第二周的迷你内核myKernel中的进程调度方法。复制了三次自身,从而创建了四个进程,然后互相循环运行。由fork()产生的则有父子关系。所以这里指明了父进程是如何启动子进程的。

1
2
3
4
5
6
7
8
9
10
*childregs = *current_pt_regs();  
childregs->ax = 0;
if (sp)
childregs->sp = sp;

p->thread.ip = (unsigned long) ret_from_fork;
task_user_gs(p) = get_user_gs(current_pt_regs());

p->thread.io_bitmap_ptr = NULL;
tsk = current;

最后来到ret_from_fork。对于fork来说,父子进程共享同一段代码空间,所以给人的感觉好像是有两次返回,但其实对于调用fork的父进程来说,如果fork出来的子进程没有得到调度,那么父进程从fork系统调用返回,同时分析sys_fork知道,fork返回的是子进程的id。再看fork出来的子进程,由 copy_process函数可以看出,子进程的返回地址为ret_from_fork(和父进程在同一个代码点上返回),返回值直接置为0。所以当子进程得到调度的时候,也从fork返回,返回值为0。关键注意两点: 1.fork返回后,父进程或子进程的执行位置。(首先会将当前进程eax的值做为返回值) 2.两次返回的pid存放的位置。(eax中)

流程上如下图所示:

未完待续。。。。

三、实验过程:跟踪进程创建

2、分析fork函数对应的内核处理过程sys_clone,理解创建一个新进程如何创建和修改task_struct数据结构; 3、使用gdb跟踪分析一个fork系统调用内核处理函数sys_clone ,验证您对Linux系统创建一个新进程的理解,推荐在实验楼Linux虚拟机环境下完成实验。 4、特别关注新进程是从哪里开始执行的?为什么从哪里能顺利执行下去?即执行起点与内核堆栈如何保证一致。

未完待续。。。

四、总结

好吧,按照惯例我们来总结一下: 这次试验我们首先理解了task_struct 这个数据结构,事实上如果我们学过操作系统理论的话,这里的印象是非常深刻的,因为终于看到学以致用的时候了。接着,壳外开发者的角度出发,就是要熟悉fork调用,fork是一个和内核比较紧密的系统调用,掌握它基本上就就掌握了多线程。从源码来看,认识fork就是要认识sysclone函数调用的流程,我们可以看到sys_clone的调用过程是很明确的,随后来到do_fork-->copy_process-->...-->ret_from_fork。最后在验证整个分析过程的时候,gdb对进程创建的过程跟踪将会加深对子进程产生的认识。

如果我的文章对你起到了帮助,你可以选择金额不限的捐助,帮助我写出更多的文章。