lab6:分析Linux内核创建一个新进程的过程

李俊锋 + 原创作品转载请注明出处 + 《Linux内核分析》MOOC课程http://mooc.study.163.com/course/USTC-1000029000

一.实验原理

1.进程的定义

进程是操作系统的概念,每当我们执行一个程序时,对于操作系统来讲就创建了一个进程,在这个过程中,伴随着资源的分配和释放。可以认为进程是一个程序的一次执行过程。

2.进程与程序的区别

 

程序时静态的,它是一些保存 在磁盘上得指令的有序集合,没有任何执行的概念。

进程是一个动态的概念,它是程序执行的过程,包括创建、调度和消亡。
3.linux系统中进程的表示
在linux系统中,进程由一个叫task_struct的结构体描述,也就是说linux中的每个进程对应一个task_struct结构体。该结构体记录了进程的一切。下面我们来看看它的核心字段。
 
struct task_struct
{
 
    //这个是进程的运行状态,-1代表不可运行,0代表可运行,>0代表已经停止。
    volatile long state;
 
    /*
        flags是进程当前的状态标志,具体如下:
 
        0x0000 0002表示进程正在被创建
 
        0x0000  0004表示进程正准备退出
 
        0x0000  0040表示此进程被fork出,但是并没有执行exec
 
        0x0000   0400表示此进程由于其他进程发送相关信号而被杀死
    */
    unsigned int flags;
 
    //表示此进程的运行优先级
    unsigned     int   rt_priority;
 
    //该结构体记录了进程内存使用的相关情况
    struct   mm_struct *mm;
 
    //进程号,是进程的唯一标识
    pid_t   pid;
 
    //进程组号
    pid_t  tgid;
 
    //real_parent是该进程的"亲生父亲",不管其是否被"寄养"
    struct  task_struct  *real_parent;
 
    //parent是该进程现在的父进程,有可能是"继父"
    struct   task_struct  *parent;
 
    //这里children指的是该进程孩子的链表,可以得到所有孩子的进程描述符
    struct   list_head    children;
 
    //同理,sibling该进程兄弟的链表,也就是其父进程的所有孩子的链表
    struct    list_head    sibling;
 
    //这个是主线程的进程描述符,也许你会奇怪,为什么线程用进程描叙符表示,因为linux并没有单独实现线程的相关结构体,只用一            个进程来代替线程,然后对其做一些特殊的处理。
    struct   task_struct  *group_leader;
 
    //这个是该进程所有线程的链表
    struct   list_head   thread_group;
 
    //这个是该进程使用cpu时间的信息,utime是在用户态下执行的时间,stime 是在内核态下执行的时间
    cputime_t   utime,stime;
 
    //comm是保存该进程名字的字符数组,长度最长为15,因为TASK_COMM_LEN为16
    char   comm[TASK_COMM_LEN];
 
    //打开的文件相关信息结构体
    struct  files_struct  *files;
 
 
 
    //信号相关信息的句柄
    struct   signal_struct  *signal;
    struct   sigband_struct  *sighand;
 
};
 
    task_struct结构体非常庞大,我们没必要去了解它的所有字段,只需要对其中比较重要的字段加以关注就可以了。从上面的分析可以看出,一个进程至少有一下东东
 
(1)进程号(pid),就像我们的身份证ID一样,每个人的都不一样。进程ID也是,是其唯一标示。
 
(2)进程的状态,标识进程是处于运行态,等待态,停止态,还是死亡态
 
A.运行态:此时进程 或者正在运行,或者准备运行
 
B.等待态:此时进程在等待一个事件发生或某种系统资源
 
C.停止态:此时进程被终止
 
D.死亡态:这是一个已终止的进程,但还在进程向量数组中,占有一个task_struct结构。
 
(3)进程的优先级和时间片。不同有优先的进程,被调度运行的次序不一样,一般是高优先级的进程先运行。时间片标识一个进程将被处理器运行的时间
 
(4)虚拟内存    大多数进程有一些虚拟内存(内核线程和守护进程没有) ,并且Linux必须跟踪内存如何映射到系统物理内存。
 
(5)处理器相关上下文      一个进程可以被认为是系统当前状态的总和。每当一个进程运行时,它要使用处理器的寄存器、栈等,这是进程的上下文(context)。并且,每当一个进程被暂停时,所有的CPU相关上下文必须保存在该进程的task_struct中。当进程被调度器重新启动时其上下文将从这里恢复。
 
4.linux进程中的文件
linux操作系统中每个进程有两个数据结构描叙文件相关信息。
第一个:fs_struct,它包含此进程当前工作目录和根目录、umask。umask是新文件被 创建的缺省模式,它可以通过系统调用来改变。
 
第二个:files_struct,包含此进程正在使用的所有文件的信息。f_mode字段描述该文件是以什么模式创建的:只读、读写、还是只写。f_pos保存文件中下一个读或写将发生的位置。f_inode描叙文件的VFS索引节点,而f_ops是一个例程向量的指针,每个代表一个想施加于文件的操作的函数。
 
 每次一个文件被打开时,files_struct中的空闲file指针之一就被用来指向新的file结构。Linux进程在启动时有三个文件描叙符被打开了,他们是标准输入设备、标准输出设备和标准错误设备,并且通常是从创建此进程的父进程继承得来的。所有对文件的访问时通过传递或返回文件描叙符的标准系统调用进行的。这些描述符是进程fd向量的索引,所以标准输入设备、标准输出设备和标准错误设备分别对应文件描述符0、1和2。
 
5.进程中的虚拟内存
在Linux操作系统中,当我们运行一个二级制可执行文件时,操作系统将创建一个进程。此时如果将这个可执行二进制文件的全部代码和数据装入物理内存将是浪费的。因为他们不可能同时使用。随着系统中进程数的增多,这种浪费将被成倍的扩大,系统将非常低效地运行。事实上,linux使用一种称为请求调页(demand-paging)的技术:只有当进程要使用时其虚拟内存时,其对应的数据才装入物理内存。所以,不是直接把代码和数据装入物理内存。linux内核只修改进程的页表,标识虚拟内存页存在但其对应的数据不在内存中。当进程想要访问代码或数据时,系统硬件将产生页故障并把控制交给Linux内核来解决。因此,对于进程地址空间中的每一个内存区,Linux都需要知道该虚拟内存来自何处,以及如何把它装入内存以解决故障。
    
当一个进程分配虚拟内存时,Linux并不真正为它保留物理内存。它只是创建一个新vm_area_struct数据结构来描叙虚拟内存,这个结构被链入进程的虚拟内存列表。当进程试图写一个位于新分配虚拟内存区域的虚拟地址时,系统将产生页故障。处理器试图转换该虚拟地址,但是因为没有此内存的页表项,它将放弃并产生一个页故障异常,留给Linux内核来解决。Linux查看被引用的虚拟地址是否是位于当前进程的虚拟内存地址空间。如果是Linux创建适当的PTE并为此进程分配一页物理内存。代码或数据可能需要从文件系统或交换硬盘上读入物理内存。然后进程可以从引起页故障的那条指令处重启,并且因为这次内存物理地址存在,所以它可以继续执行。如果不是,就是大家常常见到的"段错误"。
 

二.实验步骤

1.在test.c中添加如下代码:

 1         pid_t fpid;
 2     printf("going to create a process.....\n");
 3         asm volatile(
 4         "mov $0x2,%%eax\n\t"
 5         "int $0x80\n\t"
 6         "mov %%eax,%0\n\t"
 7         :"=m"(fpid)
 8         );
 9         printf("have created a process\n");
10         if(fpid < 0)
11         {
12                 printf("error in fork!\n");
13         }
14         else if(fpid == 0)
15         {
16                 printf("i am child,process id :%d.\n",getpid());
17         }
18         else
19         {
20                 printf("i am parent,process id :%d.\n",getpid());
21         }
22         return 0;

2.重新编译运行,结果如下图所示:

3.运行fork,结果如下图所示:

 

4.使用gdb调试运行系统,如下图所示:

5.在如下位置下断点,如下图所示:

 

 

6.调试运行程序,观察程序运行过程:

 

 

三.实验总结

实验作业题目的理解如下所示:

  • 阅读理解task_struct数据结构http://codelab.shiyanlou.com/xref/linux-3.18.6/include/linux/sched.h#1235

    1. state:运行状态
    2. stack:内核堆栈
    3. tasks:进程链表
    4. mm:内存管理
    5. task_state:任务的状态
    6. pid:进程PID
    7. real_parent children:进程的父子关系
    8. files:文件描述符列表
    9. signal:信号处理相关
    10. splice_pipe:管道相关
  • 分析fork函数对应的内核处理过程sys_clone,理解创建一个新进程如何创建和修改task_struct数据结构;

    1. fork、vfork和clone三个系统调用都可以创建一个新进程,都是通过调用do_fork来实现进程的创建
    2. 创建新进程需要先复制一个PCB:task_struct
    3. 再给新进程分配一个新的内核堆栈 
      ti = alloc_thread_info_node(tsk, node);
      tsk->stack = ti;
      setup_thread_stack(tsk, orig); //这里只是复制thread_info,而非复制内核堆栈
    4. 再修改复制过来的进程数据,比如pid、进程链表等等,见copy_process内部
    5. 从用户态的代码看fork();函数返回了两次,即在父子进程中各返回一次
      1 *childregs = *current_pt_regs(); //复制内核堆栈
      2 childregs->ax = 0; //为什么子进程的fork返回0,这里就是原因!
      3  
      4 p->thread.sp = (unsigned long) childregs; //调度到子进程时的内核栈顶
      5 p->thread.ip = (unsigned long) ret_from_fork; //调度到子进程时的第一条指令地址
    6. do_fork完成了创建中的大部分工作,该函数调用copy_process()函数,然后让进程开始运行。copy_process()函数工作如下:

      • 1、调用dup_task_struct()为新进程创建一个内核栈、thread_info结构和task_struct,这些值与当前进程的值相同
      • 2、检查
      • 3、子进程着手使自己与父进程区别开来。进程描述符内的许多成员被清0或设为初始值。
      • 4、子进程状态被设为TASK_UNINTERRUPTIBLE,以保证它不会投入运行
      • 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()做扫尾工作并返回一个指向子进程的指针
  • 使用gdb跟踪分析一个fork系统调用内核处理函数sys_clone ,验证您对Linux系统创建一个新进程的理解

    1. 下断点的函数:sys_clone do_fork dup_task_struct copy_struct copy_process copy_thread ret_from_fork
    2. dup_task_struct()为新进程创建一个内核栈
    3. copy_process()主要完成进程数据结构,各种资源的初始化
  • 特别关注新进程是从哪里开始执行的?为什么从哪里能顺利执行下去?即执行起点与内核堆栈如何保证一致。

    1. ret_from_fork;决定了新进程的第一条指令地址
    2. copy_thread()函数中的语句p->thread.ip = (unsigned long) ret_from_fork;决定了新进程的第一条指令地址
    3. 在ret_from_fork之前,也就是在copy_thread()函数中*childregs = *current_pt_regs();该句将父进程的regs参数赋值到子进程的内核堆栈
    4. *childregs的类型为pt_regs,里面存放了SAVE ALL中压入栈的参数 故在之后的RESTORE ALL中能顺利执行下去

 

 本次实验主要是对fork系统调用的调试,难度并不是很大,但是需要啊记住的东西很多,希望自己能够全部理解,加油!(*^__^*) 

posted on 2016-04-03 21:30  crowpurple  阅读(279)  评论(0编辑  收藏  举报

导航