Linux系统进程创建、可执行文件的加载和进程执行进程切换

328
原创作品转载请注明出处 + https://github.com/mengning/linuxkernel/

一.实验要求
1.阅读理解task_struct数据结构;
2.分析fork函数对应的内核处理过程do_fork,使用gdb跟踪分析一个fork系统调用内核处理,函数do_fork;
3.理解编译链接的过程和ELF可执行文件格式;
4.使用gdb跟踪分析一个execve系统调用内核处理函数do_execve;
5.使用gdb跟踪分析一个schedule()函数;
6.分析switch_to中的汇编代码,理解进程上下文的切换机制,以及与中断上下文切换的关系;

二.实验步骤

1.阅读理解task_struct数据结构

为了管理进程,操作系统必须对每个进程所做的事情进行清楚的描述,为此,操作系统使用数据结构来代表处理不同的实体,这个数据结构就是通常所说的进程描述符或进程控制块(PCB)。
在linux操作系统下这就是task_struct结构 ,所属的头文件#include <sched.h>每个进程都会被分配一个task_struct结构,它包含了这个进程的所有信息,在任何时候操作系统都能够跟踪这个结构的信息。保存进程信息的数据结构叫tast_struct,进程的信息可以通过/proc系统文件夹查看。
下图是tast_struct各个字段的介绍。
Linux系统进程创建、可执行文件的加载和进程执行进程切换
2.分析fork函数对应的内核处理过程do_fork

传统的UNIX中用于复制进程的系统调用是fork。但它并不是Liunx为此实现的唯一的调用,实际上Linux实现了3个:

(1)fork是重量级调用,它建立了父进程的一个完整副本,然后为子进程执行。

(2)vfork类似于fork,但并不创建父进程数据的副本。相反,父子进程之间共享数据。

(3)clone产生线程,可以对父子进程之间的共享、复制进行精确控制。

fork、vfork和close系统调用的入口分别是sys_fork、sys_vfork和sys_clone函数。以上函数从寄存器中取出由用户定义的信息,并调用与体系结构无关的do_fork函数进行进程的复制。

long do_fork(unsigned long clone_flags,
          unsigned long stack_start,
          unsigned long stack_size,
          int __user *parent_tidptr,
          int __user *child_tidptr)
{
    struct task_struct *p;
    int trace = 0;
    long nr;

    // ...

    // 复制进程描述符,返回创建的task_struct的指针
    p = copy_process(clone_flags, stack_start, stack_size,
             child_tidptr, NULL, trace);

    if (!IS_ERR(p)) {
        struct completion vfork;
        struct pid *pid;

        trace_sched_process_fork(current, p);

        // 取出task结构体内的pid
        pid = get_task_pid(p, PIDTYPE_PID);
        nr = pid_vnr(pid);

        if (clone_flags & CLONE_PARENT_SETTID)
            put_user(nr, parent_tidptr);

        // 如果使用的是vfork,那么必须采用某种完成机制,确保父进程后运行
        if (clone_flags & CLONE_VFORK) {
            p->vfork_done = &vfork;
            init_completion(&vfork);
            get_task_struct(p);
        }

        // 将子进程添加到调度器的队列,使得子进程有机会获得CPU
        wake_up_new_task(p);

        // ...

        // 如果设置了 CLONE_VFORK 则将父进程插入等待队列,并挂起父进程直到子进程释放自己的内存空间
        // 保证子进程优先于父进程运行
        if (clone_flags & CLONE_VFORK) {
            if (!wait_for_vfork_done(p, &vfork))
                ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
        }

        put_pid(pid);
    } else {
        nr = PTR_ERR(p);
    }
    return nr;
}


gdb跟踪:
在 test.c 文件中添加使用 fork 系统调用的函数;

    int testFork(int argc, char *argv[]){
         pid_t fpid; 
         int count=0;  
         fpid=fork();   
         if (fpid < 0)   
             printf("error in fork!");   
         else if (fpid == 0) {  
             printf("i am the child process, my process id is %d\n",getpid());        
             count++;  
         }  
         else {  
             printf("i am the parent process, my process id is %d\n",getpid());   
             count++;  
         }  
         printf("result: %d\n",count);  
         return 0;  
     }   

在 menu 目录下使用 make rootfs 生成文件系统, 然后使用qemu、重新挂载内核;
Linux系统进程创建、可执行文件的加载和进程执行进程切换

Linux系统进程创建、可执行文件的加载和进程执行进程切换
3.理解编译链接的过程和ELF可执行文件格式
源代码:


    //main.c
    int add(int a,int b);
    static int si;//.bss
    extern int buf[];
    int *copy = &buf[0];//.rel.data
    int main()
    {
        int a = 3;
        int b = 5;
        int c = add(a,b);//.rel.text
        char *s = "hello c";//.rodata
        static int si;//.bss
        return 0;
    }

    //add.c
    int buf[2];
    int add(int a,int b)
    {
        return (a+b);
    }

    //makefile(为了简化讨论,makefile文件中没有添加-g选项)
    all:main
    main:main.o add.o
        gcc -o main main.o add.o -m32
    main.o:main.c
        gcc -c main.c -m32
    add.o:add.c
        gcc -c add.c -m32
    clean:
        rm -rf *.o main

编译链接过程:
源代码(.c .cpp .h)经过c预处理器(cpp)后生成.i文件,编译器(cc1、cc1plus)编译.i文件后生成.s文件,汇编器(as)汇编.s文件后生成.o文件,链接器(ld)链接.o文件生成可执行文件。gcc是对cpp、cc1(cc1plus)、as、ld这些后台程序的包装,它会根据不同的参数要求去调用后台程序。以helloworld程序为例,使用gcc -o hello hello.c时加上-v选项可观察到详细的步骤。也可使用gcc分别进行以上四步骤,预编译gcc -E hello.c -o hello.i,编译gcc -S hello.i -o hello.s,汇编gcc -c hello.s -o hello.o,链接gcc -o hello hello.o。

4.使用gdb跟踪分析一个execve系统调用内核处理函数do_execve

利用gdb和qemu工具来跟踪分析,首先给do_execve函数打上断点,当调用新的可执行程序时,会先进入内核态调用do_execve处理函数,并使用堆栈对原来的现场进行保护。然后,根据返回的可执行文件的地址,对当前可执行文件进行覆盖。由于返回地址为调用可执行文件的main函数入口,所以可以继续执行该文件。
Linux系统进程创建、可执行文件的加载和进程执行进程切换
5.使用gdb跟踪分析一个schedule()函数
先对schedule,pick_next_task,context_switch和__switch_to设置断点,在进行进程间的切换时,各处理函数的调用顺序如下:pick_next_task -> context_switch -> __switch_to 。由此可以得出,当进程间切换时,首先需要调用pick_next_task函数挑选出下一个将要被执行的程序;然后再进行进程上下文的切换,此环节涉及到“保护现场”及“现场恢复”;在执行完以上两个步骤后,调用__switch_to进行进程间的切换。
Linux系统进程创建、可执行文件的加载和进程执行进程切换
6.分析switch_to中的汇编代码,理解进程上下文的切换机制,以及与中断上下文切换的关系
源代码:

schedule()函数选择一个新的进程来运行,并调用context_switch进行上下文的切换,这个宏调用switch_to来进行关键上下文切换
next = pick_next_task(rq, prev);//进程调度算法都封装这个函数内部
context_switch(rq, prev, next);//进程上下文切换
switch_to利用了prev和next两个参数:prev指向当前进程,next指向被调度的进程
31#define switch_to(prev, next, last)                    \
32do {                                 \
 /*                              \
  * Context-switching clobbers all registers, so we clobber  \
  * them explicitly, via unused output variables.     \
  * (EAX and EBP is not listed because EBP is saved/restored  \
  * explicitly for wchan access and EAX is the return value of   \
  * __switch_to())                     \
  */                                \
 unsigned long ebx, ecx, edx, esi, edi;                \
                                 \
 asm volatile("pushfl\n\t"      /* save    flags */   \ 
          "pushl %%ebp\n\t"        /* save    EBP   */ \ 当前进程堆栈基址压栈
          "movl %%esp,%[prev_sp]\n\t"  /* save    ESP   */ \ 将当前进程栈顶保存prev->thread.sp
          "movl %[next_sp],%%esp\n\t"  /* restore ESP   */ \ 讲下一个进程栈顶保存到esp中
          "movl $1f,%[prev_ip]\n\t"    /* save    EIP   */ \ 保存当前进程的eip
          "pushl %[next_ip]\n\t"   /* restore EIP   */    \ 将下一个进程的eip压栈,next进程的栈顶就是他的的起点
          __switch_canary                   \
          "jmp __switch_to\n"  /* regparm call  */ \ 
          "1:\t"                        \
          "popl %%ebp\n\t"     /* restore EBP   */    \ 
          "popfl\n"         /* restore flags */  \ 开始执行下一个进程的第一条命令
                                 \
          /* output parameters */                \
          : [prev_sp] "=m" (prev->thread.sp),     \
            [prev_ip] "=m" (prev->thread.ip),        \
            "=a" (last),                 \
                                 \
            /* clobbered output registers: */     \
            "=b" (ebx), "=c" (ecx), "=d" (edx),      \
            "=S" (esi), "=D" (edi)             \
                                      \
            __switch_canary_oparam                \
                                 \
            /* input parameters: */                \
          : [next_sp]  "m" (next->thread.sp),        \
            [next_ip]  "m" (next->thread.ip),       \
                                      \
            /* regparm parameters for __switch_to(): */  \
            [prev]     "a" (prev),              \
            [next]     "d" (next)               \
                                 \
            __switch_canary_iparam                \
                                 \
          : /* reloaded segment registers */           \
         "memory");                  \
77} while (0)

通过系统调用,用户空间的应用程序就会进入内核空间,由内核代表该进程运行于内核空间,这就涉及到上下文的切换,用户空间和内核空间具有不同的地址映射,通用或专用的寄存器组,而用户空间的进程要传递很多变量、参数给内核,内核也要保存用户进程的一些寄存器、变量等,以便系统调用结束后回到用户空间继续执行,所谓的进程上下文,就是一个进程在执行的时候,CPU的所有寄存器中的值、进程的状态以及堆栈中的内容,当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的进程上下文,以便再次执行该进程时,能够恢复切换时的状态,继续执行。

同理,硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理,中断上下文就可以理解为硬件传递过来的这些参数和内核需要保存的一些环境,主要是被中断的进程的环境。

Linux内核工作在进程上下文或者中断上下文。提供系统调用服务的内核代码代表发起系统调用的应用程序运行在进程上下文;另一方面,中断处理程序,异步运行在中断上下文。中断上下文和特定进程无关。

运行在进程上下文的内核代码是可以被抢占的(Linux2.6支持抢占)。但是一个中断上下文,通常都会始终占有CPU(当然中断可以嵌套,但我们一般不这样做),不可以被打断。正因为如此,运行在中断上下文的代码就要受一些限制。

三.总结

schedule()在需要的时候重新获得大内核锁、重新启用内核抢占、并检查是否一些其他的进程已经设置了当前进程的tlf_need_resched标志,如果是,整个schedule()函数重新开始执行,否则,函数结束。linux调度的核心函数为schedule,schedule函数封装了内核调度的框架。细节实现上调用具体的调度类中的函数实现。当切换进程已经选好后,就开始用户虚拟空间的处理,然后就是进程的切换switch_to()。所谓进程的切换主要就是堆栈的切换,这是由宏操作switch_to()完成的。