Linux进程与程序

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

实验内容

  • 从整体上理解进程创建、可执行文件的加载和进程执行进程切换
  • 重点理解分析fork、execve和进程切换

实验环境

  • 实验楼

实验步骤

  一 、阅读task_struct数据结构

    什么是进程?

    进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。

    计算机为了管理进程,为每个进程都建立了一个结构体来保存与其相关的信息,这个就是PCB。在Linux内核中,PCB是用链式结构存储的,使用一个名为task_struct的结构体来描述。PCB中存了进程的标识符、进程状态、调度优先级、内存指针、上下文数据、信号量、审计信息等诸多关键信息。

  二、分析fork函数对应的内核处理过程do_fork()

    分析do_fork()源码

 1 long do_fork(
 2           unsigned long clone_flags,
 3           unsigned long stack_start,
 4           unsigned long stack_size,
 5           int __user *parent_tidptr,
 6           int __user *child_tidptr)
 8 {
10     struct task_struct *p;
11     int trace = 0;
12     long nr;
14 
15     // 复制进程描述符,返回创建的task_struct的指针
16     p = copy_process(clone_flags, stack_start, stack_size,
17              child_tidptr, NULL, trace);
19     if (!IS_ERR(p)) {
20         struct completion vfork;
21         struct pid *pid;
22         trace_sched_process_fork(current, p);
25         // 取出task结构体内的pid
26         pid = get_task_pid(p, PIDTYPE_PID);
27         nr = pid_vnr(pid);
28         if (clone_flags & CLONE_PARENT_SETTID)
29             put_user(nr, parent_tidptr);
31         // 如果使用的是vfork,那么必须采用某种完成机制,确保父进程后运行
32         if (clone_flags & CLONE_VFORK) {
33             p->vfork_done = &vfork;
34             init_completion(&vfork);
35             get_task_struct(p);
36         }
39         // 将子进程添加到调度器的队列,使得子进程有机会获得CPU
40         wake_up_new_task(p);
42         // 如果设置了 CLONE_VFORK 则将父进程插入等待队列,并挂起父进程直到子进程释放自己的内存空间
43         // 保证子进程优先于父进程运行
44         if (clone_flags & CLONE_VFORK) {
45             if (!wait_for_vfork_done(p, &vfork))
46                 ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
47         }
48         put_pid(pid);
49     } else {
50         nr = PTR_ERR(p);
51     }
52     return nr;
53 }   

    do_fork()都做了什么?

    • 调用copy_process,将当期进程复制一份出来为子进程,并且为子进程设置相应地上下文信息。
    • 初始化vfork的完成处理信息(如果是vfork调用)
    • 调用wake_up_new_task,将子进程放入调度器的队列中,此时的子进程就可以被调度进程选中,得以运行。
    • 如果是vfork调用,需要阻塞父进程,知道子进程执行exec。

    进程如何创建?

    • 通过调用do_fork()来实现进程的创建;
    • 复制父进程PCB–task_struct来创建一个新进程,要给新进程分配一个新的内核堆栈;
    • 修改复制过来的进程数据,比如pid、进程链表等等执行copy_process和copy_thread
    • 成功创建新进程

  三、使用gdb跟踪分析一个fork系统调用内核处理函数do_fork()

    启动实验楼,Linux内核是3.18.6

    • cd LinuxKernel 
    • rm menu -rf
    • git clone https://github.com/mengning/menu.git
    • cd menu
    • mv test_fork.c test.c
    • make rootfs

    

    进入到gbd调式模式

    • gdb
    • file linux-3.18.6/vmlinux
    • target remote:1234  

    设置断点

    • b sys_clone
    • b do_fork
    • b dup_task_struct
    • b copy_process
    • b copy_thread
    • b ret_from_fork

    

    运行后首先停在sys_clone处

     

    再到copy_process

    

    进入copy_thread

    

    do_fork()大概过程fork() -> sys_clone() -> do_fork() -> dup_task_struct() -> copy_process() -> copy_thread() -> ret_from_fork()

       新进程是从哪里开始执行的?

    函数copy_process中的copy_thread()

 1 int copy_thread(unsigned long clone_flags, unsigned long sp,
 2     unsigned long arg, struct task_struct *p)
 3 {
 4     ...
 5     *childregs = *current_pt_regs();
 6     childregs->ax = 0;
 7     if (sp)
 8         childregs->sp = sp;
 9     p->thread.ip = (unsigned long) ret_from_fork;
10     ...
11 }

    childregs->ax = 0;这段代码将子进程的 eax 赋值为0,使得父进程与子进程分开

    为什么从哪里能顺利执行下去?

    子进程执行ret_from_fork

 1 ENTRY(ret_from_fork)
 2     CFI_STARTPROC
 3     pushl_cfi %eax
 4     call schedule_tail
 5     GET_THREAD_INFO(%ebp)
 6     popl_cfi %eax
 7     pushl_cfi $0x0202       # Reset kernel eflags
 8     popfl_cfi
 9     jmp syscall_exit
10     CFI_ENDPROC
11 END(ret_from_fork)

    p->thread.ip = (unsigned long) ret_from_fork;这句代码将子进程的 ip 设置为 ret_form_fork 的首地址,因此子进程是从 ret_from_fork 开始执行的。因此,函数copy_process中的copy_thread()决定了子进程从系统调用中返回后的执行。

    执行起点与内核堆栈如何保证一致?

    在ret_from_fork之前,也就是在copy_thread()函数中:*childregs = *current_pt_regs(); 该句将父进程的regs参数赋值到子进程的内核堆栈,*childregs的类型为pt_regs,里面存放了SAVE ALL中压入栈的参数。故在之后的RESTORE ALL中能顺利执行下去。

  四、理解编译链接的过程和ELF可执行文件格式

    ELF文件格式包括三种主要的类型:可执行文件、可重定向文件、共享库。

    • 可执行文件(应用程序)可执行文件包含了代码和数据,是可以直接运行的程序。
    • 可重定向文件(.o)可重定向文件又称为目标文件,它包含了代码和数据(这些数据是和其他重定位文件和共享的object文件一起连接时使用的)。 .o文件参与程序的连接(创建一个程序)和程序的执行(运行一个程序),它提供了一个方便有效的方法来用并行的视角看待文件的内       容, 这些.o文件的活动可以反映出不同的需要。Linux下,我们可以用gcc -c编译源文件时可将其编译成.o格式。
    • 共享文件(*.so)也称为动态库文件,它包含了代码和数据(这些数据是在连接时候被连接器ld和运行时动态连接器使用的)。动态连接器可能称为ld.so.1,libc.so.1或者 ld-linux.so.1。在linux下输入“man elf”即可查看其详细的格式定义。

    静态链接与动态链接
    静态链接
      在编译链接时直接将需要的执行代码复制到最终可执行文件中,有点是代码的装在速度块,执行速度也比较快,对外部环境依赖度低。编译时它会把需要的所有代码都链接进去,应用程序相对较大。
    动态链接
      动态链接是在程序运行时由操作系统将需要的动态库加载到内存中。动态链接分为装载时动态链接和运行时动态链接。

  五、使用gdb跟踪分析一个execve系统调用内核处理函数do_execve()

    exec函数主要工作是根据指定的文件名找到可执行的文件,并用他取代调用进程的内容。换句话说,就是在调用进程内部执行一个可执行文件这里的可执行文件既可以是二进制文件,也可以是任何Linux下可执行的脚本文件。

    

    

    现在我们给出采用exec*库中提供的函数创建简单的程序,先介绍一下exec家族中的函数:

    

 1     (1)int execl(const char *path, const char *arg, ......);
 2 
 3   (2)int execle(const char *path, const char *arg, ...... , char * const envp[]);
 4 
 5   (3)int execv(const char *path, char *const argv[]);
 6  
 7   (4)int execve(const char *filename, char *const argv[], char *const envp[]);
 8 
 9   (5)int execvp(const char *file, char * const argv[]);
10 
11   (6)int execlp(const char *file, const char *arg, ......);

 

    他们之间的区别是:

    • 前四个取路径名做为参数,后两个取文件名做为参数,如果文件名中不包含 “/” 则从PATH环境变量中搜寻可执行文件, 如果找到了一个可执行文件,但是该文件不是连接编辑程序产生的可执行代码文件,则当做shell脚本处理。
    • 前两个和最后一个函数中都包括“ l ”这个字母 ,而另三个都包括“ v ”, " l "代表 list即表 ,而" v "代表 vector即矢量,也是是前三个函数的参数都是以list的形式给出的,但最后要加一个空指针,如果用常数0来表示空指针,则必须将它强行转换成字符指针,否则有可能出错。,而后三个都是以矢量的形式给出,即数组。
    • 与向新程序传递环境变量有关,如第二个和第四个以e结尾的函数,可以向函数传递一个指向环境字符串指针数组的指针。即自个定义各个环境变量,而其它四个则使用进程中的环境变量。
 1 #include <stdio.h>
 2 #include <stdlib.h>
 3 #include <unistd.h>
 4 #include <string.h>
 5 #include <errno.h>
 6  
 7 
 8 int main(int argc, char *argv[])
 9 {
10   //以NULL结尾的字符串数组的指针,适合包含v的exec函数参数
11   char *arg[] = {"ls", "-a", NULL};
12 
13   /**
14    * 创建子进程并调用函数execl
15    * execl 中希望接收以逗号分隔的参数列表,并以NULL指针为结束标志
16    */
17 
18   if( fork() == 0 )
19   {
20     // in clild 
21     printf( "1------------execl------------\n" );
22     if( execl( "/bin/ls", "ls","-a", NULL ) == -1 )
23     {
24       perror( "execl error " );
25       exit(1);
26     }
27   }
28 
29 if( fork() == 0 )//创建子进程,并调用execv函数,execv希望接收到一个以NULL结尾的字符串数组的指针
30 
31   {
32     // in child 
33     printf("2------------execv------------\n");
34     if( execv( "/bin/ls",arg) < 0)
35     {
36       perror("execv error ");
37       exit(1);
38     }
39   }
40 return 0;
41 }
42 
43 //执行结果
44 1------------execl------------
45 .  ..  .deps  exec  exec.o  .libs  Makefile
46 2------------execv------------
47 .  ..  .deps  exec  exec.o  .libs  Makefile

  六、总结

    • 在调度时机方面,内核线程可以直接调用schedule()进行进程切换,也可以在中断处理过程中进行调度,也就是说内核线程作为一类的特殊的进程可以主动调度,也可以被动调度。
    • schedule()函数实现进程调度,context_ switch完成进程上下文切换,switch_ to完成寄存器的切换。
    • 用户态进程无法实现主动调度,仅能通过陷入内核态后的某个时机点进行调度,即在中断处理过程中进行调度

  
    

 

    

    

posted on 2019-03-26 00:02  我想做个好人  阅读(264)  评论(0编辑  收藏  举报