Linux进程的创建与可执行程序的加载

来源:互联网 发布:淘宝psv游戏 编辑:程序博客网 时间:2024/06/06 08:42

// 研究僧时上实验课的作业,忘了内容有木有啥用了,从另一个账号搬过来的,感觉还有点意思,留着吧就


实验内容:

1.参考进程初探 编程实现fork(创建一个进程实体) -> exec(将ELF可执行文件内容加载到进程实体) -> running program
2.参照C代码中嵌入汇编代码示例及用汇编代码使用系统调用time示例分析fork和exec系统调用在内核中的执行过程
3.注意task_struct进程控制块,ELF文件格式与进程地址空间的联系,注意Exec系统调用返回到用户态时EIP指向的位置。
4.动态链接库在ELF文件格式中与进程地址空间中的表现形式

第一部分:fork() 和 exec()

1.使用fork()

[cpp] view plaincopy
  1. #include<sys/types.h>
  2. #include<unistd.h>
  3. pid_t fork(void);
[cpp] view plaincopy
  1. #include<sys/types.h>  
  2. #include<unistd.h>  
  3. pid_t fork(void);  

这个系统调用复制当前进程,在进程表中创建一个新的表项,新表项中的许多属性与当前进程相同。但是新进程有自己的数据空间(堆和栈),环境和文件描述符。在父进程中的fork调用返回的是新的子进程的PID,而新进程返回的是0.程序代码也靠这一点来区分父子进程。创建失败返回-1.这边在之前看到过有这么一个解释,相当与是一个链状的进程序列,子进程没有儿子了,所以0相当于指向为空

以下为示例

[cpp] view plaincopy
  1. #include<sys/types.h>
  2. #include<unistd.h>
  3. #include<stdlib.h>
  4. #include<stdio.h>
  5. int main()
  6. {
  7. pid_t pid;
  8. pid=fork();
  9. if(0==pid)
  10. {
  11. pid_t cpid=getpid();
  12. printf("this is the child thread,cpid=%d\n",cpid);
  13. }
  14. else if(pid>0)
  15. {
  16. pid_t ppid=getpid();
  17. printf("this is the parent thread,ppid=%d\n",ppid);
  18. }
  19. else
  20. printf("fork error\n");
  21. return 0;
  22. }
[cpp] view plaincopy
  1. #include<sys/types.h>  
  2. #include<unistd.h>  
  3. #include<stdlib.h>  
  4. #include<stdio.h>  
  5.   
  6. int main()  
  7. {  
  8.     pid_t pid;  
  9.     pid=fork();  
  10.     if(0==pid)  
  11.     {  
  12.         pid_t cpid=getpid();  
  13.         printf("this is the child thread,cpid=%d\n",cpid);  
  14.     }   
  15.     else if(pid>0)  
  16.     {  
  17.         pid_t ppid=getpid();  
  18.         printf("this is the parent thread,ppid=%d\n",ppid);  
  19.     }   
  20.     else  
  21.         printf("fork error\n");  
  22.     return 0;  
  23. }  


2.使用exec()

exec() 系列函数有一组相关的函数组成 ,exec函数可以把当前进程替换为另一个新进程,新进程由path 或者file 参数指定。我们可以使用exec函数将程序的执行从一个程序切换到另一个程序。在新的程序启动后,原来的程序就不再运行了。

[cpp] view plaincopy
  1. #include<unistd.h>
  2. char ** environ;
  3. int execl(constchar *path,constchar *arg0,...,(char *)0);
  4. int execlp(constchar *file,constchar *arg0,...,(char *)0);
  5. int execle(constchar *path,constchar *arg0,...,(char *)0,char *const envp[]);
  6. int execv(constchar *path,char *const argv[]);
  7. int execvp(constchar *file,char *const argv[]);
  8. int execve(constchar *path,char *const argv[],char *const envp[]);
[cpp] view plaincopy
  1. #include<unistd.h>  
  2.   
  3. char ** environ;  
  4.   
  5. int execl(const char *path,const char *arg0,...,(char *)0);  
  6. int execlp(const char *file,const char *arg0,...,(char *)0);  
  7. int execle(const char *path,const char *arg0,...,(char *)0,char *const envp[]);  
  8.   
  9. int execv(const char *path,char *const argv[]);  
  10. int execvp(const char *file,char *const argv[]);  
  11. int execve(const char *path,char *const argv[],char *const envp[]);  
这些函数可以分为两大类。execl execlp execle 的参数个数可以变化,参数以一个空指针结束。execv execvp 的第二个参数是一个字符串数组。不管哪种情况,新程序在启动时会把argv数组中给定的参数传递给main函数。

这些函数通常都是用execve实现的。我们来看下面的一个例子,在这个例子当中,直接指定了各个变量,并没有从shell中读入。

[cpp] view plaincopy
  1. #include<stdlib.h>
  2. #include<unistd.h>
  3. #include<stdio.h>
  4. int main(int argc,char *argv[],char *envp[])
  5. {
  6. pid_t pre_pid=getpid();
  7. printf("Before Running ps with execlp,pre_pid=%d\n",pre_pid);
  8. execlp("ps","ps","-l",0);
  9. pid_t after_pid=getpid();
  10. printf("After Runing ps wiht execlp,after_pid=%d\n",after_pid);
  11. exit(0);
  12. }
[cpp] view plaincopy
  1. #include<stdlib.h>  
  2. #include<unistd.h>  
  3. #include<stdio.h>  
  4.   
  5. int main(int argc,char *argv[],char *envp[])  
  6. {  
  7.     pid_t pre_pid=getpid();  
  8.     printf("Before Running ps with execlp,pre_pid=%d\n",pre_pid);  
  9.     execlp("ps","ps","-l",0);  
  10.     pid_t after_pid=getpid();  
  11.     printf("After Runing ps wiht execlp,after_pid=%d\n",after_pid);  
  12.     exit(0);  
  13. }  


我们发现一个很有趣的现象, 

printf("After Runing ps wiht execlp,after_pid=%d\n",after_pid);

并没有被执行。这是由于exec函数取代了原先的进程,一般情况下,exec函数是不会返回的,除非发生错误。出现错误时,exec函数返回-1,并设置错误变量errno。

特别要注意的一点,在原进程中已打开的文件描述符在新进程中仍将保持打开,除非它们的执行时关闭标志被置位。


第二部分:fork和exec系统调用在内核中的执行过程

对fork函数进行反汇编

汇编的时候要注意设置断点

如下:

[cpp] view plaincopy
  1. gcc -g forktest.c -o forktest
  2. gdb forktest
  3. b fork
  4. r
  5. disas fork
[cpp] view plaincopy
  1. gcc -g forktest.c -o forktest  
  2. gdb forktest  
  3. b fork  
  4. r  
  5. disas fork  


fork()函数在系统调用中,会执行do_fork()函数,其关键步骤如下:
1)查找pidmap_array位图,为子进程获取一个新的PID;
2)调用copy_process()函数,这个函数会将子进程的各个数据结构进行分配并初始化,它返回子进程描述符,即task_struct结构的指针;
3)返回并返回子进程的PID;
copy_process()函数的执行过程:
1)为子进程分配一个task_struct结构,将其指针暂存在局部变量tsk中,并继续分配一个thread_info结构,将其指针暂存在局部变量ti中;
2)将current进程描述符地址复制到tsk指向的task_struct结构,将ti描述父地址复制到tsk指向的thread_info结构,并将当前进程的thread_info结构内容拷贝到ti指向结构中;(子进程复制父进程的进程描述符以及thread_info描述符)
3)用子进程PID更新task_struct中的pid字段;
4)分别创建并复制父进程的打开文件列表、文件系统描述符、信号描述符、内存描述符、命名空间等结构;
5)初始化子进程的内核栈,并将eax寄存器对应字段的值设为0(这样子进程返回时,系统调用返回值便为0);
6)结束并返回子进程描述符指针tsk;


对exec函数进行反汇编


execlp分析
系统中存在一个formats链表,其链表结构分别对应一种可执行文件的执行方法,execlp()函数对应的系统调用sys_exece()函数会分配一个linux_binprm数据结构并将可执行文件的数据拷贝到其中,并依次扫描formats链表试图执行这个可执行文件,一旦找到了就执行链表结构中的load_binary方法,其主要步骤为:
1)将可执行文件的首部拷贝至内存;
2)根据动态链接程序路径名将共享库对应函数映射到内存;
3)释放原进程的内存描述符、线性区描述符、所有页框;
4)选择线性区的布局;
5)为可执行文件的代码段、数据段以及动态链接程序的代码段、数据段分别进行内存映射;
6)修改内核态堆栈中eip、esp寄存器的值,使其分别指向程序的入口点以及新的用户态堆栈顶并返回;

动态链接执行程序的过程
在传统的静态链接中,程序中用到的每个库函数,都会在链接器的链接过程中,将全部代码复制到文本段中,这种方式的原理十分简单,但是会使得程序的文本段过于庞大,对于紧缺的内存资源来说,是一种巨大的浪费。
而动态链接的过程,并不需要将各个库函数的代码分别进行复制,只需要在程序中静态指明其链接的目标库函数在库文件的位置就可以了,在程序真正开始执行之前,动态链接器会根据文件中的重定位信息将其链接的库函数映射到内存中。而库文件因为是被“映射”到内存中的,所以每个库只有一个库文件,且可以同时被几个不同的程序进行映射。其过程基本如下:
1)参照图五中各个段的信息,其中.interp段存放了动态链接器的路径,程序运行之前,会首先通过这里找到动态链接器,并加载和运行这个动态链接器。
2).got中存放了全局偏移量表GOT,每个被此程序链接到的全局数据都有一个对应的条目,静态编译时会在其中存放各个重定位记录,在加载过程中,链接器会对其中的各个记录依次进行重定位,使其含有固定的地址,从此不再变化。
3).plt存放了过程链接表PLT,每个被此程序链接到的全局函数都有一个对应的条目,此外,每个全局函数在GOT表中也有一个对应条目。执行过程中,每当初次调用其中的某个函数,则会通过PLT表跳转到GOT表,计算出函数地址后,会重定位GOT表中的条目。之后便不再计算函数地址,直接跳转得到函数地址。
在链接器加载程序运行时,会在一个3GB的用户虚拟内存空间中进行内存映射(3GB-4GB为内核空间),链接器会根据ELF文件的头部与段头部表中的信息,分别对各个段进行处理,并将程序的代码段从地址0x08048000开始向上映射,紧随其后的是数据段,堆紧随数据段之后,以供malloc进行动态内存分配,而用户态堆栈从最大合法用户态空间地址向下增长,在堆栈与堆之间是共享库的链接函数内存映射空间,此时内存空间的影响基本如下所示:

图六
之后加载器转到程序的入口点,即.text中的_start的位置,这里的几行汇编代码在所有程序加载过程中都是一样的,它们分别会进行一些初始化例程,并注册一些退出程序时应执行的例程,最后会执行callmain命令,此时开始执行程序正文。
 
第三部分 实验总结 

1)进程控制块task_struct:

    task_struct,就是进程描述符(process descriptor),该数据结构中包含了与一个进程相关的所有信息,比如包含众多描述进程属性的字段,以及指向其他与进程相关的结构体的指针。其中有指向mm_struct结构体的指针mm,这个结构体是对该进程用户空间的描述;也有指向fs_struct结构体的指针fs,这个结构体是对进程当前所在目录的描述;也有指向files_struct结构体的指针files,这个结构体是对该进程已打开的所有文件进行描述;另外还有一个小型的进程描述符thread_info,结构如下图所示。


2)ELF文件格式与进程地址空间的联系:

    当子进程调用exec时,启动加载器,加载器删除子进程已有的虚拟存储器段,按照path路径所指向的可执行文件段头表的指导,将ELF可执行文件的相关内容加载到了当前子进程的上下文中(代码段和数据段等),它会覆盖当前子进程的地址空间,从而实现文件组块与进程空间地址的映射。


3)动态链接库在ELF文件格式中与进程地址空间中的表现形式:

    应用程序通常都需要使用动态链接库,当在 shell 中敲入一个命令要执行时,内核会创建一个新的进程,它在往这个新进程的进程空间里面加载进可执行程序的代码段和数据段后,也会加载进动态连接器(在Linux里面通常就是 /lib/ld-linux.so 符号链接所指向的那个程序,它本省就是一个动态库)的代码段和数据。在这之后,内核将控制传递给动态链接库里面的代码。动态连接器接下来负责加载该命令应用程序所需要使用的各种动态库。加载完毕,动态连接器才将控制传递给应用程序的main函数。如此,应用程序才得以运行。

    为了让动态连接器能成功的完成动态链接过程,在前面运行的连接编辑器需要在应用程序可执行文件中生成数个特殊的 sections,比方 .dynamic、.dynsym、.got和.plt等等。

    ELF文件里面,每一个 sections 内都装载了性质属性都一样的内容,比方:

(1) .text section 里装载了可执行代码;

(2) .data section 里面装载了被初始化的数据;

(3) .bss section 里面装载了未被初始化的数据;

(4) 以 .rec 打头的 sections 里面装载了重定位条目;

(5) .symtab 或者 .dynsym section 里面装载了符号信息;

(6) .strtab 或者 .dynstr section 里面装载了字符串信息;

(7) 其他还有为满足不同目的所设置的section,比方满足调试的目的、满足动态链接与加载的目的等等。

   动态链接库在ELF文件格式中与进程地址空间中的表现形式,如下如所示:

 

0 0
原创粉丝点击