fork() execve()

①fork()系统调用:
进程调用fork()创建一个新的进程,新进程复制了父进程的task_struct(PCB,process control block,进程控制块),以及task_struct中的各个子模块,比如内核堆栈等,然后对各个子模块做了修改。系统调用通过eax寄存器保存返回值,fork()系统调用结束后从内核态返回两次,一次是父进程返回,一次是子进程返回,区分父子进程的方法就是看返回值是否为0,若为0,说明返回的是新进程,不为0返回的是父进程。



②execve()内核函数的作用
解析elf文件,把elf文件装入内存,修改进程的数据段代码段,修改进程的用户态堆栈(主要是把命令行参数和shell上下文加入到用户态堆栈)。修改进程内核堆栈(特别是内核堆栈的ip指针),进程从execve返回到用户态后ip指向ELF文件的main函数地址,用户态堆栈中包含了命令行参数和shell上下文环境。
我们惊奇的发现进程的创建是通过fork()函数完成的,进程的执行代码是execve()加载的,要是把fork()和execve()组和在一起

进程必须的4要点:
a.要有一段程序供该进程运行,就像一场戏剧要有一个剧本一样。该程序是可以被多个进程共享的,多场戏剧用一个剧本一样。
b.有起码的私有财产,就是进程专用的系统堆栈空间。
c.有“户口”,既操作系统所说的进程控制块,在linux中具体实现是task_struct
d.有独立的存储空间。
当一个进程缺少d条件时候,我们称其为线程。
1.fork 创造的子进程复制了父亲进程的资源,包括内存的内容task_struct内容(2个进程的pid不同)。这里是资源的复制不是指针的复制。
2.vfork创建出来的不是真正意义上的进程,而是一个线程,因为它缺少了我们上面提到的进程的四要素的第4项,独立的内存资源



1.fork()



  fork()函数的作用是创建一个新进程,由fork创建的进程称为子进程,fork函数调用一次返回两次,子进程返回值为0,父进程返回子进程的进程ID。我们知道,一个进程的地



址空间主要由代码段,数据段,堆和栈构成,那么p2就要复制相关的段到物理内存。原始的unix系统的实现的是一种傻



瓜式的进程创建,这些复制包括:



(1)  为子进程的页表分配页面,确定页表的位置;



(2)为子进程的页分配页面,确定子进程页面的位置;



(3)初始化子进程的页表;



(4)把父进程的页复制到子进程对应的页中



从图中我们可以看出除了正文段外,子进程的所有其它段都分配了物理空间,并将父进程的相关内容拷贝过来。父进程的task_struct结构中的打开文件描述符,进程组ID,回话ID都进行复制。



但是这种方法的效果非常不好,如果在fork子进程之后,立即调用了exec函数簇,那么原先拷贝的父进程的数据段,栈,堆的相关副本都将变为徒劳。后来人们想到了一种



替代的方式,那就是写时复制(Copy-On-write,COW技术),这种技术允许父进程和子进程共享上面的区域,而且内核将这些段的访问权限变成只读。如果是父进程或是子进程中



的任何一个试图修改这些区域,则内核只为修改区域的那块内存制作一个副本,通常是虚拟存储系统的某一个”页”.下面是这种技术在没有写操作的时候的的详细图解,如果子进



程或父进程的任何一个有写操作的话,那么被写的那一页才需要复制到物理空间;



COW技术在进程调用fork()的时候,并没给子进程的相关段分配内存空间。这种做法在fork()之后调用exec函数簇的过程中效率有很大的提高。这种情况下,内



核只需要为子进程创建一个task_struct,也就是说如果子进程调用exec函数簇执行了另一个可执行文件,那么内核可能只是新建一个task_struct结构体,将父进程的内容拷贝过



,这样就大大节约了盲目拷贝的消耗。



2.vfork()



    vfork()函数也用于创建一个新进程,而该新进程的目的是exec一个新程序



3.clone()
  clone()函数是linux系统中,用来创建轻量级进程。
1)fork函数会在父进程中创建子进程,子进程的堆,栈,数据段,PC指针都是从父进程中复制过来的,和父进程是独立的,但是内容是一致的。代码段子进程和父进程是共享的。
2)fork()的返回值可能为-1,0,和一个正数。-1表示fork()调用失败,0表示返回子进程执行结果,正数表示返回父进程结果(正数即为子进程ID)。
3)在fork的时候,缓存被复制到了子进程空间。



在进程中调用exec函数启动新的程序。exec函数一共有六个,其中execve为内核级系统调用,其他(execl,execle,execlp,execv,execvp)都是调用execve的库函数。所以只要掌握了execve用法就可以了。



当一个程序使用fork()函数创建了一个子进程时,通过会在该子进程中调用exec()函数簇之一以加载执行另一个新程序。”
这里的“exec()函数簇之一”应该就是指:execve() 函数了,而这个execve()函数是通过宏定义来实现的,宏定义中就是
通过汇编完成“系统中断调用”,对应的中断向量号是0x80,采用的调用功能号是 __NR__EXECVE ,代码如下:



通过宏来完成对execve()函数的定义:
_syscall3 (int, execve, const char *, file, char **, argv, char **, envp)



在Linux帮助手册中关于exec系列函数的说明也说明了这一点:



execve() does not return on success, and the text, data, bss, and stack of the calling process are overwritten by that of the program loaded. (execve函数执行成功后不会返回,而且代码段,数据段,bss段和调用进程的栈会被被加载进来的程序覆盖掉)



execve系统调用的执行过程:





  1. 陷入内核




  2. 加载新的可执行文件并进行可执行性检查




  3. 将新的可执行文件映射到当前运行进程的进程空间中,并覆盖原来的进程数据




  4. 将EIP的值设置为新的可执行程序的入口地址。如果可执行程序是静态链接的程序,或不需要其他的动态链接库,则新的入口地址就是新的可执行文件的main函数地址;如果可执行程序还需要其他的动态链接库,则入口地址是加载器ld的入口地址




  5. 返回用户态,程序从新的EIP出开始继续往下执行。至此,老进程的上下文已经被新的进程完全替代了,但是进程的PID还是原来的。从这个角度来看,新的运行进程中已经找不到原来的对execve调用的代码了,所以execve函数的一个特别之处是他从来不会成功返回,而总是实现了一次完全的变身。





1.预处理
首先在内核空间分配一个物理页面,然后调用do_getname()从用户空间拷贝文件名字符串。



2.调用主体函数do_execve()
2.1我们既然要执行参数中给的二进制文件,首先需要打开文件,获取文件句柄file



2.2然后我们需要一个linux_binprm结构体去保存函数具体的参数信息,包括文件名,argv,envp,还会将文件前128字节读到linux_binprm.buf中。



2.3因为可执行文件的种类很多,比如elf,a.out等格式。我们需要从内核全局linux_binfmt队列中找到一个能够处理参数中所给的可执行文件的linux_binfmt结构,具体就是依次试用linux_binfmt结构中各自的load_binary()函数。



3.可执行文件的装载和投运(a.out为例)
3.1与过去决裂,释放用户空间。
既然是要执行参数中给定的二进制文件,就需要放弃可能从父进程继承下来的用户空间,而使用本进程自己的用户空间。因此,需要检查是否与父进程通过指针共享用户空间,还是之前复制父进程用户空间。如果通过指针共享,说明本进程本身没有自己的用户空间,之前称为“进程”不合适,应该称作线程,就直接申请进程用户空间。如果复制父进程的用户空间,这是就需要全部释放。



3.2装载可执行文件数据段代码段
这时可以将可执行文件装入进程的用户空间了,这时分两种情况:



1.可执行文件不是”纯代码”,需要通过do_brk()扩展数据段+代码段大小的空间,然后通过read()读取文件内容到用户空间



2.否则,如果文件系统提供mmap(),并且数据段和代码段长度与页面大小对齐,直接通过文件映射读取到用户空间,否则,通过1方法读取。



3.2装载可执行文件堆栈段和bss段
用户空间堆栈区顶部当然是用户虚存空间顶部,即TASK_SIZE,为3GB,虚存地址为0xC000 0000的位置。



这里主要是设置用户堆栈区,包括envp[],argv[]以及argc



4.start_thread()



exec族的任一函数都不创建一个新的进程,而是在调用进程里面去执行新的程序。所以进程id不变,还是调用exec函数前的进程id,但是用户空间的代码和数据都更新了,变为新程序的代码和数据了。



extern char **environ;	//全局环境变量,导入到本文件即可直接使用




  1. int execl(const char *path, const char *arg, …);



    功能:通过路径+文件名来加载一个进程;path文件路径;arg文件名称;…可变参数,至少一个NULL



    附:l即list



    返回值:成功的情况下是没有返回的,失败时返回-1 。



    举例说明:



     execl("/bin/ls", "ls", "-a", "-l", NULL);	//path绝对路径,如/bin/ls;文件名称ls;后面三个可变参数,最后必须以NULL结束



  2. int execlp(const char *file, const char *arg, …);



    功能:借助PATH环境变量加载一个进程,file要加载的程序的名称



    附:l即list;p即path



    该函数需要配合PATH环境变量来使用,当PATH中所有目录搜索后没有参数file,则出错返回。



    该函数通常用来调用系统程序。如:ls、cp、cat等命令。



    返回值:成功的情况下是没有返回的,失败时返回-1 。



    举例说明:



     execlp("ls", "ls", "-a", "-l", NULL);	//第一个ls是指查看PATH环境变量里的ls;第二个ls是名称文件;后面是可变参数,NULL结束



  3. int execle(const char *path, const char *arg, …, char * const envp[]);



    功能:加载指定路径的程序,并为新程序复制最后一个环境变量





附:l即list;e即environment

举例说明:

char* envp[] = {NULL};

execlp("ls", "ls", "-a", "-l", NULL, envp);




  1. int execv(const char *path, char *const argv[]);



    功能:加载指定路径的程序



    附:v即vector,命令行参数列表



    举例说明:



     char* argv[] = {"ls", "-a", "-l", NULL}; 

    execl("/bin/ls",argv);



  2. int execvp(const char *file, char *const argv[]);



    功能:加载path环境变量里的名称为file的程序



    附:v即命令行参数列表,p即path



    int main(int argc, char *argv[]) {



     pid_t pid = fork();

    if (pid == 0) { //子进程里加载ls程序

    char* argvv[] = {"ls", "-a", "-l", NULL};

    execvp("ls", argvv);

    perror("execlp"); exit(1); //只有execl函数执行失败的情况下才有机会执行这两句代码,执行的成功话就有去无回了。

    } else if (pid > 0) {

    sleep(1); printf("parent\n");

    }

    return 0;


    }




  3. int execve(const char *filename, char *const argv[], char *const envp[]);



    功能:加载指定的程序;filename必须是一个可执行程序或者一个以#! interpreter [optional-arg] 开始的脚本。



     上面的五个exec函数是库函数,这个是系统函数;上面的五个exec函数最终都是调用这个函数实现的。 




总结:exec族函数的规律



exec函数一旦调用成功就有去无回了,去执行新的程序去了。只有失败时才有返回,返回值为-1。所以我们直接在exec函数调用后直接调用perror()和exit(),不需要if判断,因为失败的情况才会执行。

函数名的意义的理解:

l (list) 命令行参数列表

p (path) 环境变量,环境变量搜素文件名称file

v (vector) 命令行参数数组

e (environment) 环境变量数组,给新加载程序设置指定的环境变量

函数的相似性:

execlp——>execvp

|

execl ——>execv

|

execle——>execve

从左往右,可变参数转为以NULL结尾的指针数组;从左往右, 从上往下,最后归根结底都是调用execve函数实现的。

Category linux