【实验二】进程的创建与可执行程序的加载

| 分类 lab  | 标签 course  linux  lab 

李明(164)

实验要求

  • 编程实现fork(创建一个进程实体) -> exec(将ELF可执行文件内容加载到进程实体) -> running program
  • 分析fork和exec系统调用在内核中的执行过程
  • 注意task_struct进程控制块,ELF文件格式与进程地址空间的联系,注意Exec系统调用返回到用户态时EIP指向的位置。
  • 动态链接库在ELF文件格式中与进程地址空间中的表现形式

实验过程

1. 通过下面的程序创建一个进程

#include <stdlib.h>
#include <unistd.h>
int main(){
    pid_t pid;
    pid = fork();
    if (pid == 0)
    {
        execl("/bin/ls","ls","/", NULL);
        exit(1);
        
    }
    else if (pid > 0)
    {
        // printf("Parent process\n");
        wait(NULL);
    }
    else
        printf("error\n");
        
    printf("Parent process\n");
    exit(1);
}

编译执行上面的程序得到下面的结果

arming@Ubuntu:~/course/linux/asm-in-c$ gcc -o execl execl.c 
execl.c: In function ‘main’:
execl.c:19:9: warning: incompatible implicit declaration of built-in function ‘printf’ [enabled by default]
arming@Ubuntu:~/course/linux/asm-in-c$ ./execl 
bin    etc           lib	   opt	 sbin	  tmp	   vmlinuz.old
boot   home	       lost+found  proc  selinux  usr
cdrom  initrd.img      media	   root  srv	  var
dev    initrd.img.old  mnt	   run	 sys	  vmlinuz
Parent process
arming@Ubuntu:~/course/linux/asm-in-c$

2. 实验过程分析

1. 进程的创建

当调用fork时会创建进程,新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份拷贝,包括文本、数据和bss段、堆以及用户栈。子进程还获得与父进程任何打开文件描述符相同的拷贝,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。如上面的程序,当pid == 0时,说明此时是在子进程中运行,若pid > 0,则运行在父进程中,若pid < 0,出错。

在上面的程序中,当子进程创建后,调用execlls可执行文件进行调用,此时ls文件通过execve系统调用进行加载运行。

2. 可执行文件的加载

我们可以把进程定义为“执行上下文”,这就意味着进行特定的计算需要收集必要的信息,包括所访问的页,打开的文件,硬件寄存器的内容等。可执行文件是一个普通的文件,它描述了如何初始化一个新的执行上下文,也就是如何开始一个新的计算。如下图为一个典型的ELF可执行文件。 典型的ELF可执行文件

fork函数在新的子进程中运行相同的程序,新的子进程是父进程的一个拷贝,execve函数在当前进程的上下文中加载并运行一个新的程序,它会覆盖当前进程的地址空间,但并没有创建一个新进程,新的程序仍然有相同的PID,并且继承了调用execve函数时打开的所有文件描述符。下面是execve的函数原型。

#include <unistd.h>

int evecve(const char *filename, const char *argv[],const char *envp[]);
/* Doesn't return if OK, returns -1 on error */

关于exec函数族

exec函数用可执行文件所描述的新上下文代替进程的上下文。每个函数的第一个参数表示被执行文件的路径名。

所有的exec函数(除execve()外)都是C库定义的封装例程,并利用了execve()系统调用,这是Linux所提供的处理程序执行的唯一系统调用。

execve函数加载并运行可执行目标文件filename,且带参数列表argv和环境变量列表envp。只有当出现错误时,才返回调用程序。所以,不像fork会一次调用2次返回,execve调用一次并人不返回。

fork函数被当前进程调用时,内核为新进程创建各种数据结构,并分配给它一个唯一的PID,为了给这个新进程创建虚拟存储器,它创建了当前进程的task_structmm_structvm_rea_struct和页表的原样拷贝,它标记两个进程中的每个页面为只读的,并标记两个进程中的每个区域结构为私有的写时拷贝的。

fork在新进程中返回时,新进程现在的虚拟存储器刚好和调用fork时存在的虚拟存储器相同,当这两个进程中的任何一个后来进行写操作时,写时拷贝机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。

当一个新的程序开始时,用户栈的典型组织结构如图所示:

new_program_start.png 

假设运行在当前进程中的程序执行了如下的execve调用: execve("/bin/ls",argv,environ); execve函数在当前进程中加载并运行包含在可执行目标文件ls中的程序,用ls程序有效地替代了当前程序。加载并运行ls需要以下步骤:

  • 删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
  • 映射私有区域。为新程序的文本、数据、bss和栈区域创建新的区域结构。 elf-process.jpg
  • 映射共享区域。如果ls程序与共享对象链接,那么这些对象是动态链接到这个程序的,并且映射到用户虚拟地址空间中的共享区域内。
  • 设置程序计数器(PC)。execve做的最后一件事就是设置当前进程上下文中的程序计数器,使之指向文本区域的入口点。

用户空间

3. 实验总结

当调用fork创建新的进程时,会对父进程有个拷贝,当在子进程中执行新的程序时,利用execve系统调用来加载可执行程序,此时加载的可执行程序会覆盖当前进程的相应堆栈的数据,来初始化运行上下文,初始化工作完成后子进程即可运行直到结束。

系统中每个程序都运行在一个进程上下文中,该进程上下文有自己的虚拟地址空间,当shell运行一个程序时,父shell进程创建一个子进程,它是父进程的一个复本,子进程通过execve系统调用启动加载器,加载器删除子进程已有的虚拟存储器段,并创建一组新的代码,数据,堆和栈段,新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的组块,新的代码和数据段被初始化为可执行谁的内容。最后,加载器跳转到_start地址,它最终会调用应用的main函数,除了一些头部信息,在加载过程中没有任何从磁盘到存储器的数据拷贝,直到cpu引用一个被映射的虚拟页,才会进行拷贝,此时,操作系统利用它的页面高度机制自动将页面从磁盘传送到存储器。


上一篇     下一篇