李明(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
,出错。
在上面的程序中,当子进程创建后,调用execl
对ls
可执行文件进行调用,此时ls
文件通过execve
系统调用进行加载运行。
2. 可执行文件的加载
我们可以把进程定义为“执行上下文”,这就意味着进行特定的计算需要收集必要的信息,包括所访问的页,打开的文件,硬件寄存器的内容等。可执行文件是一个普通的文件,它描述了如何初始化一个新的执行上下文,也就是如何开始一个新的计算。如下图为一个典型的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_struct
、mm_struct
、vm_rea_struct
和页表的原样拷贝,它标记两个进程中的每个页面为只读的,并标记两个进程中的每个区域结构为私有的写时拷贝的。
当fork
在新进程中返回时,新进程现在的虚拟存储器刚好和调用fork
时存在的虚拟存储器相同,当这两个进程中的任何一个后来进行写操作时,写时拷贝机制就会创建新页面,因此,也就为每个进程保持了私有地址空间的抽象概念。
当一个新的程序开始时,用户栈的典型组织结构如图所示:
假设运行在当前进程中的程序执行了如下的execve
调用:
execve("/bin/ls",argv,environ);
execve
函数在当前进程中加载并运行包含在可执行目标文件ls中的程序,用ls程序有效地替代了当前程序。加载并运行ls需要以下步骤:
- 删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
- 映射私有区域。为新程序的文本、数据、bss和栈区域创建新的区域结构。
- 映射共享区域。如果ls程序与共享对象链接,那么这些对象是动态链接到这个程序的,并且映射到用户虚拟地址空间中的共享区域内。
- 设置程序计数器(PC)。
execve
做的最后一件事就是设置当前进程上下文中的程序计数器,使之指向文本区域的入口点。
3. 实验总结
当调用fork
创建新的进程时,会对父进程有个拷贝,当在子进程中执行新的程序时,利用execve
系统调用来加载可执行程序,此时加载的可执行程序会覆盖当前进程的相应堆栈的数据,来初始化运行上下文,初始化工作完成后子进程即可运行直到结束。
系统中每个程序都运行在一个进程上下文中,该进程上下文有自己的虚拟地址空间,当shell运行一个程序时,父shell进程创建一个子进程,它是父进程的一个复本,子进程通过execve系统调用启动加载器,加载器删除子进程已有的虚拟存储器段,并创建一组新的代码,数据,堆和栈段,新的栈和堆段被初始化为零,通过将虚拟地址空间中的页映射到可执行文件的页大小的组块,新的代码和数据段被初始化为可执行谁的内容。最后,加载器跳转到_start地址,它最终会调用应用的main函数,除了一些头部信息,在加载过程中没有任何从磁盘到存储器的数据拷贝,直到cpu引用一个被映射的虚拟页,才会进行拷贝,此时,操作系统利用它的页面高度机制自动将页面从磁盘传送到存储器。