进程与fork()、wait()、exec函数组

内容简介:本文将引入进程的基本概念;着重学习exec函数组、fork()、wait()的用法;最后,我们将基于以上知识编写Linux shell作为练习。

————————CONTENTS————————


进程与程序

Unix是如何运行程序的呢?这看起来很容易:首先登录,然后shell打印提示符,输入命令并按回车键,程序就开始运行了。当程序结束后,shell会打印一个新的提示符。但是,这些是如何实现的呢?shell在这段时间里做了什么呢?

首先,我们来引入“进程”的概念。

一、进程

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

即使在系统中通常有许多其他的程序在运行,但进程也可以向每个程序提供一种假象,仿佛它在独占地使用处理器。但事实上进程是轮流使用处理器的。我们假设一个运行着三个进程的系统,如下图所示:

三个进程的执行是交错的。进程A运行一段时间后,B开始运行直到完成。然后进程C运行了一会儿,进程A接着运行直到完成。最后,进程C也运行结束了。

通过ps命令与一些参数的组合,可以查看当前状态下的所有进程:

二、上下文切换

内核为每个进程维持一个上下文(context)。上下文就是内核重新启动一个被强占的进程所需的状态。

当内核代表用户执行系统调用时,可能会发生上下文切换。如果系统调用因为等待某个事件而发生阻塞,那么内核可以让当前进程休眠,切换到另一个进程。

下图展示了一对进程A和B之间上下文切换的实例:

在这个例子中,进程A初始运行在用户模式中,直到它通过执行系统调用陷入到内核,在内核模式下执行指令。然后在某一时刻,它开始代表进程B(仍然是内核模式下)执行指令。在切换之后,内核代表进程B在用户模式下执行指令。随后,进程B在用户模式下执行了一会儿,内核判定进程B已经运行了足够长的时间,就执行一个从进程B到进程A的上下文切换,将控制返回给进程A中紧随在刚刚系统调用之后的那条指令。进程A继续运行,直到下一次异常发生。

返回目录


exec函数组

那么问题来了:一个程序如何运行另一个程序呢?

首先我们得搞清楚需要调用什么函数来完成这个过程。如果想使用man -k xxx这个命令进行搜索,必须知道相应的关键字。思考一下,我们想到了process(进程)、execute(执行)、program(程序)等等

我们可以尝试man -k program | grep execute | grep process命令,但发现没有搜到任何相关的内容。扩大搜索范围,我们再试试man -k program | grep execute,这下找到了不少内容:

“execve(2) -execute program”这个解释似乎是我们想要的,再进一步使用man -k execute搜索,通过观察说明,我们找到了一系列相关的函数:

这些函数均以“exec”开头,exec是一组函数的总称,我们可以通过man -k exec来寻找相关信息:

通过描述,我们大概找到了符合要求的几个函数。

查阅资料了解到,exec系列函数共有7个函数可供使用,这些函数的区别在于:指示新程序的位置是使用路径还是文件名,如果是使用文件名,则在系统的PATH环境变量所描述的路径中搜索该程序;在使用参数时使用参数列表的方式还是使用argv[]数组的方式。

如果想了解关于exec函数组的详细信息,可以通过man 3 exec查看:

函数组可简要表示为:

#include <unistd.h>

int execl(const char *pathname, const char *arg0, ... /* (char *)0 */ );
int execv(const char *pathname, char *const argv[]);
int execle(const char *pathname, const char *arg0, .../* (char *)0, char *const envp[] */ );
int execve(const char *pathename, char *const argv[], char *const envp[]);
int execlp(const char *filename, const char *arg0, ... /* (char *)0 */ );
int execvp(const char *filename, char *const argv[]);
int fexecve(int fd, char *const argv[], char *const envp[]); //返回:如果执行成功将不返回,否则返回-1,失败代码存储在errno中。
//前4个函数取路径名作为参数,后两个是取文件名作为参数,最后一个是以一个文件描述符作为参数。

可以见到这些函数名字不同, 而且他们用于接受的参数也不同。

实际上他们的功能都是差不多的, 因为要用于接受不同的参数所以要用不同的名字区分它们(类似于Java中的函数重载)。

但是实际上它们的命名是有规律的:

exec[l or v][p][e]

exec函数里的参数可以分成3个部分:执行文件部分,命令参数部分,和环境变量部分。

假如要执行:ls -l /etc

  • 执行文件部分就是:"/usr/bin/ls"
  • 命令参数部分就是:"ls","-l","/etc",NULL
  • 环境变量部分:这是1个数组,最后的元素必须是NULL 例如:char * env[] = {"PATH=/etc", "USER=vivian", "STATUS=testing", NULL};

命名规则如下:

  • e:参数必须带环境变量部分,环境变量部分参数会成为执行exec函数期间的环境变量;

  • l:命令参数部分必须以"," 相隔, 最后1个命令参数必须是NULL;

  • v:命令参数部分必须是1个以NULL结尾的字符串指针数组的头部指针。例如char * pstr就是1个字符串的指针, char * pstr[] 就是数组了, 分别指向各个字符串;

  • p:执行文件部分可以不带路径, exec函数会在$PATH中找。

下面我们将以ls -l为例,详细介绍这几个函数:

1、execl()

int execl(const char *pathname, const char *arg0, ... /* (char *)0 *\);

  • execl()函数用来执行参数path字符串所指向的程序,第二个及以后的参数代表执行文件时传递的参数列表,最后一个参数必须是空指针以标志参数列表为空.

程序如下:

#include <unistd.h>  

    int main()
{
execl("/bin/ls","ls","-l","/etc",(char *)0);
return 0;
}

运行结果如下:

2、execv()

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

  • execv()函数函数用来执行参数path字符串所指向的程序,第二个为数组指针维护的程序参数列表,该数组的最后一个成员必须是空指针。

程序如下:


#include <unistd.h> int main()
{
char *argv[] = {"ls", "-l", "/etc"/*,(char *)0*/};
execv("/bin/ls", argv);
return 0;
}

运行结果如下:

3、execle()

int execle(const char *pathname, const char *arg0, .../* (char *)0, char *const envp[] */ );

  • execle()函数用来执行参数path字符串所指向的程序,第二个及以后的参数代表执行文件时传递的参数列表,最后一个参数必须指向一个新的环境变量数组,即新执行程序的环境变量。

程序如下:

 #include <unistd.h>  

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

{  

        execle("/bin/ls","ls","-l","/etc",(char *)0,env);  

        return 0;  

}

运行结果如下:

4、execlp()

int execlp(const char *filename, const char *arg0, ... /* (char *)0 */ );

  • execlp()函数会从PATH环境变量所指的目录中查找文件名为第一个参数指示的字符串,找到后执行该文件,第二个及以后的参数代表执行文件时传递的参数列表,最后一个参数必须是空指针.

程序如下:

#include <unistd.h> 

int main()  

{  

     execlp("ls", "ls", "-l", "/etc", (char *)0);  

     return 0;  

}

运行结果:

5、execvp()

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

  • execvp()函数会从PATH环境变量所指的目录中查找文件名为第一个参数指示的字符串,找到后执行该文件,第二个及以后的参数代表执行文件时传递的参数列表,最后一个成员必须是空指针。

程序如下:

#include <unistd.h>  

int main()  

{  

        char *argv[] = {"ls", "-l", "/etc", /*(char *)0*/};  

        execvp("ls", argv);  

        return 0;  

}

运行结果如下:

6、argv[0]的值对程序运行的影响

以上我们以ls -l示范了exec函数组的使用。如何实现对其他命令的调用呢?很简单,我们只需要修改argv[0]的值。比如:

#include <unistd.h>  

int main()  

{  

        char *argv[] = {"who",(char *)0};  

        execvp("who", argv);  

        return 0;  

}

运行结果为:

7、总结

我们再来看这样一个使用到“execvp()”函数的程序:

#include <unistd.h>  

int main()
{
char *argv[] = {"ls", "-l", ".", (char *)0};
printf("*** Begin to Show ls -l\n");
execvp("ls", argv);
printf("ls -l is done! ***");
return 0;
}

运行程序:

竟然只有第一行printf的输出!!execvp后面的那一条printf打印的消息哪里去了???

原因在于:一个程序在一个程序中运行时,内核将新程序载入到当前进程,替代当前进程的代码和数据。如果执行成功,execvp没有返回值。当前程序从进程中清除,新的程序在当前进程中运行。

这使我们联想到“庄周梦蝶”的故事。庄子在梦中化作了蝴蝶,虽然身体是蝴蝶的身体,但思想已换做庄子的思想,蝴蝶的思想已被完全覆盖了。类比execv函数组,系统调用从当前进程中把当前程序的机器指令清除,然后在空的进程中载入调用时指定的程序代码,最后运行这个新的程序。exec调整进程的内存分配使之适应新的程序对内存的要求。相同的进程,不同的内容。

返回目录


fork()

那么问题来了:如果execvp用命令指定的程序代码覆盖了shell的程序代码,然后在命令指定的程序结束之后退出。这样shell就不能再次接受新的命令。那shell如何能做到运行程序的同时还能等待下一个命令呢?

我们设想,如果能创建一个完全相同的新进程就好了,这样就可以在新进程里执行命令程序,且不影响原进程了。

寻找关键词:process(进程)、create(创建)、new(新的)......

使用man -k xxx | grep xxx命令,我们最终找到了这样一个函数:

(注:Unix标准的复制进程的系统调用时fork(即分叉),但是Linux,BSD等操作系统并不止实现这一个,确切的说linux实现了三个:fork,vfork,clone。在这里我们重点讲解fork的使用。)

如何知道更多关于fork函数的细节?参考娄老师别出心裁的Linux系统调用学习法这篇博客,我们可以通过man -k fork命令进行搜索,可以看到,fork函数位于manpages的第二节,与系统调用有关。

使用man 2 fork命令查看fork函数,可以看到关于fork函数的所有信息:

大致将fork()可以总结为:

#include <sys/types.h>
#include <unistd.h> pid_t fork(void); //返回:子进程返回0,父进程返回子进程的PID,如果出错,则返回-1。

一般来说,运行一个C程序直到该程序全部结束,系统只会分配一个PID给这个程序,也就是说,系统里只有一条关于这个程序的进程。但执行了fork函数就不同了。fork()的作用是复制当前进程(包括进程在内存的堆栈数据),然后这个新的进程和旧的进程一起执行下去。而且这两个进程是互不影响的。

例如:调用一次fork()之后的进程如下:

以下面这个程序为例:

    int main(){
printf("it's the main process step 1!!\n\n"); fork();//创建一个新的进程 printf("step2 after fork() !!\n\n"); int i; scanf("%d",&i);//防止程序退出 return 0;
}

运行结果为:

根据上面调用fork()的示意图不难理解,程序在fork()函数之前只有一条主进程,所以只打印一次step 1;而执行fork()函数之后,程序分为了两个进程,一个是原来的主进程,另一个是fork()的新进程,他们都会执行fork()函数之后的代码,所以step 2打印了两次。

此时使用ps -ef | grep fork4命令查看系统的进程,可以发现两条名字相同的进程:

可以看到,4732那个为父进程,4733为子进程(因为由图可知4733的父进程为4732)。

返回目录


wait()

考虑下面这个程序:

void fork2()
{
printf("L0 ");
fork();
printf("L1 ");
fork();
printf("Bye ");
}

程序执行情况的示意图为:

进程图可以帮助我们看清这个程序运行了四个进程,每个都调用了一次printf("Bye "),这些printf可以以任意顺序执行。“L0 L1 Bye Bye L1 Bye Bye ”为一种可能的输出,而“L0 Bye L1 Bye L1 Bye Bye ”这种情况就不可能出现。

通过分析上面的进程图,我们可以发现:一旦子进程建立,父进程与子进程的执行顺序并不固定。这种不确定性有时并不是我们想要的。那么,如何调用一个函数,使得父进程等待子进程结束后,再继续执行呢?

关键词:wait(等待)、process(进程)......

使用man -k xxx | grep xxx命令,按照关键词进行搜索:

我们了解到,一个进程可以通过调用wait函数来等待它的子进程终止或者停止。

同样地,我们使用man -k wait查看与“wait”相关的信息,从它们的功能说明可以看到,最后几个函数似乎是我们想要的。

再使用man 2 wait命令查看详细信息:

wait()的使用方法为:

#include <sys/types.h>
#include <unistd.h> pid_t wait(int *status); //返回:如果成功,则返回子进程的PID,如果出错,则返回-1。

函数功能是:父进程一旦调用了wait就立即阻塞自己,由wait自动分析是否当前进程的某个子进程已经退出,如果让它找到了这样一个已经变成僵尸的子进程,wait就会收集这个子进程的信息,并把它彻底销毁后返回;如果没有找到这样一个子进程,wait就会一直阻塞在这里,直到有一个出现为止。

需要注意的几点是:

  • 当父进程忘了用wait()函数等待已终止的子进程时,子进程就会进入一种无父进程的状态,此时子进程就是僵尸进程。

  • wait()要与fork()配套出现,如果在使用fork()之前调用wait(),wait()的返回值则为-1,正常情况下wait()的返回值为子进程的PID。

  • 如果先终止父进程,子进程将继续正常进行,只是它将由init进程(PID 1)继承,当子进程终止时,init进程捕获这个状态。

那么,传给函数wait()的参数status是什么呢?

参数status用来保存被收集进程退出时的一些状态,它是一个指向int类型的指针。但如果我们对这个子进程是如何死掉毫不在意,只想把这个僵尸进程消灭掉,(事实上绝大多数情况下,我们都会这样想),我们就可以设定这个参数为NULL,就像下面这样:

pid = wait(NULL);

如果成功,wait会返回被收集的子进程的进程ID,如果调用进程没有子进程,调用就会失败,此时wait返回-1,同时errno被置为ECHILD。

如果参数status的值不是NULL,wait就会把子进程退出时的状态取出并存入其中, 这是一个整数值(int),指出了子进程是正常退出还是被非正常结束的,以及正常结束时的返回值,或被哪一个信号结束的等信息。由于这些信息 被存放在一个整数的不同二进制位中,所以用常规的方法读取会非常麻烦,人们就设计了一套专门的宏(macro)来完成这项工作,以下是其中最常用的两个:

  • 1.WIFEXITED(status) 这个宏用来指出子进程是否为正常退出的,如果是,它会返回一个非零值。

  • 2.WEXITSTATUS(status) 当WIFEXITED返回非零值时,我们可以用这个宏来提取子进程的返回值,如果子进程调用exit(5)退出,WEXITSTATUS(status) 就会返回5;如果子进程调用exit(7),WEXITSTATUS(status)就会返回7。请注意,如果进程不是正常退出的,也就是说, WIFEXITED返回0,这个值就毫无意义。

如果想知道status参数的所有宏,可以先通过grep -nr "wait" /usr/include命令查看与wait相关的头文件的位置:

从结果我们可以得出结论,wait.h的所在位置为:/usr/include/x86_64-linux-gnu/sys/wait.h。接下来只需要执行cat /usr/include/x86_64-linux-gnu/sys/wait.h命令,即可查看到其中包含的所有信息:

下面通过一个实例进一步学习wait()的用法:

void fork9() {
int child_status; if (fork() == 0) {
printf("HC: hello from child\n");
exit(0);
} else {
printf("HP: hello from parent\n");
wait(&child_status);
printf("CT: child has terminated\n");
}
printf("Bye\n");
}

此进程的示意图可表示为:

由于父进程必须等待子进程执行完毕后,才能打印“CT”,所以“HC\nHP\nCT\nBye”为一种可能的输出,而“HP\nCT\nBye\nHC”这种情况就不可能出现。

返回目录

编程练习:myshell

一、思路分析

在上面的学习中,我们知道了如何在应用程序中创建和操作进程,以及如何通过Linux系统调用来使用多个进程。事实上,像Unix shell和Web服务器这样的程序大量使用了fork()和execve()函数,现在我们通过调用以上学习的函数,自己写一个类似于shell的程序。

一个shell的主循环执行下面的4步:

  • 用户键入a.out;
  • shell建立一个新的进程来运行这个程序;
  • shell将程序从磁盘载入;
  • 程序在它的进程中运行直到结束。

二、伪代码

shell由下面的循环组成:

while(!end_of_input)
get command
execute command
wait for command to finish

以时间为参考,shell的主循环可以由下图来表示:

shell读入一个新的一行输入,建立一个新进程,在这个程序中运行程序并等待这个进程结束。当shell检测到输入结束时,它就退出。

因此,要写一个shell,需要学会:

  • 运行一个程序——exec函数组;
  • 建立一个进程——fork()函数;
  • 等待进程结束——wait()函数。

学习了以上内容,我们就可以实现自己的shell了。

三、产品代码

有了以上的分析之后,我们可以根据伪代码写出详细的代码,以下程序可作为参考:

#include <stdio.h>
#include <unistd.h>
#include <wait.h>
#include <stdlib.h>
#include <string.h>
#define MAX 128 void eval (char *cmdline); //对用户输入的命令进行解析
int parseline (char *buf, char **argv);
int builtin_command(char **argv); int main()
{
char cmdline[MAX]; while(1){
printf("vivian@vivian-VirtualBox:~/20155303/week5/myshell$ ");
fgets(cmdline,MAX,stdin);
if(feof(stdin))
{
printf("error");
exit(0);
}
eval(cmdline);
}
} void eval(char *cmdline)
{
char *argv[MAX];
char buf[MAX];
int bg;
pid_t pid;
strcpy(buf,cmdline);
bg = parseline(buf,argv);
if(argv[0]==NULL)
return;
if(!builtin_command(argv))
{
if((pid=fork()) == 0)
{
if(execvp(argv[0],argv) < 0) {
printf("%s : Command not found.\n",argv[0]);
exit(0);
}
} if(!bg){
int status;
if(waitpid(-1,&status,0) < 0)
printf("waitfg: waitpid error!");
}
else
printf("%d %s",pid, cmdline);
return;
}
} int builtin_command(char **argv)
{
if(!strcmp(argv[0], "quit"))
exit(0);
if(!strcmp(argv[0],"&"))
return 1;
return 0;
} int parseline(char *buf,char **argv)
{
char *delim;
int argc;
int bg; buf[strlen(buf)-1]=' ';
while(*buf && (*buf == ' '))
buf++; argc=0;
while( (delim = strchr(buf,' '))){
argv[argc++] = buf;
*delim= '\0';
buf = delim + 1;
while(*buf && (*buf == ' '))
buf++;
} argv[argc] = NULL;
if(argc == 0)
return 1;
if((bg=(*argv[argc-1] == '&')) != 0)
argv[--argc] = NULL;
return bg;
}

运行结果如下:

返回目录

参考资料

进程与fork()、wait()、exec函数组的更多相关文章

  1. APUE8进程控制 fork vfork exec

  2. 【转】Linux下Fork与Exec使用

    Linux下Fork与Exec使用 转自 Linux下Fork与Exec使用 一.引言 对于没有接触过Unix/Linux操作系统的人来说,fork是最难理解的概念之一:它执行一次却返回两个值.for ...

  3. fork和exec函数

    #include<unistd.h> pid_t fork(void); 返回:在子进程中为0,在父进程中为子进程IO,若出错则为- fork最困难之处在于调用它一次,它却返回两次.它在调 ...

  4. Linux下Fork与Exec使用

    Linux下Fork与Exec使用   一.引言 对于没有接触过Unix/Linux操作系统的人来说,fork是最难理解的概念之一:它执行一次却返回两个值.fork函数是Unix系统最杰出的成就之一, ...

  5. fork和exec

    fork pid_t fork(void); 它在调用进程(成为父进程)中返回一次,返回值为新派生进程(成为子进程)的进程ID号 在子进程中又返回一次,返回值为0.因此,返回值本身告知当前进程是子进程 ...

  6. linux c语言 fork() 和 exec 函数的简介和用法

    linux c语言 fork() 和 exec 函数的简介和用法   假如我们在编写1个c程序时想调用1个shell脚本或者执行1段 bash shell命令, 应该如何实现呢? 其实在<std ...

  7. Linux进程理解与实践(三)进程终止函数和exec函数族的使用

    进程的几种终止方式(Termination) (1)正常退出 从main函数返回[return] 调用exit 调用_exit或者_Exit 最后一个线程从其启动处返回 从最后一个线程调用pthrea ...

  8. linux进程之fork 和 exec函数

    ---恢复内容开始--- fork函数 该函数是unix中派生新进程的唯一方法. #include <unistd.h> pid_t   fork(void); 返回: (调用它一次, 它 ...

  9. 进程控制fork与vfork

    1. 进程标识符 在前面进程描述一章节里已经介绍过进程的两个基本标识符pid和ppid,现在将详细介绍进程的其他标识符. 每个进程都有非负的整形表示唯一的进程ID.一个进程终止后,其进程ID就可以再次 ...

随机推荐

  1. Coins HDU - 2844

    Whuacmers use coins.They have coins of value A1,A2,A3...An Silverland dollar. One day Hibix opened p ...

  2. 前端测试框架对比(js单元测试框架对比)

    前端测试框架对比(js单元测试框架对比) 本文主要目的在于横评业界主流的几款前端框架,顺带说下相关的一些内容. 测试分类 通常应用会有 单元测试(Unit tests) 和 功能测试(Function ...

  3. Apace Ignite剖析

    1.概述 Apache Ignite和Apache Arrow很类似,属于大数据范畴中的内存分布式管理系统.在<Apache Arrow 内存数据>中介绍了Arrow的相关内容,它统一了大 ...

  4. JVM介绍&自动内存管理机制

    1.介绍JVM(Java Virtual Machine,Java虚拟机) JVM是Java Virtual Machine的缩写,通常成为java虚拟机,作为Java可以进行一次编写,到处执行(Wr ...

  5. Python--urllib3库详解1

    Python--urllib3库详解1 Urllib3是一个功能强大,条理清晰,用于HTTP客户端的Python库,许多Python的原生系统已经开始使用urllib3.Urllib3提供了很多pyt ...

  6. HDU1166敌兵布阵(线段树,树状数组)

    题面 C国的死对头A国这段时间正在进行军事演习,所以C国间谍头子Derek和他手下Tidy又开始忙乎了.A国在海岸线沿直线布置了N个工兵营地,Derek和Tidy的任务就是要监视这些工兵营地的活动情况 ...

  7. centos7下搭建 MongoDB -01

    距离上次写的一篇mongoDB搭建已经有一年多的时间了,刚好这次在公司搭建好在centos7下的mongodb搭建,简单的做一个记录吧 mongo 是一个基于分布式文件存储的数据库,数据主要存储在磁盘 ...

  8. 小程序input输入框获取焦点时,文字会出现闪动

    最近在开发小程序时,发现一个有趣的现象.input里面设置了placeholder,随后当输入框获取焦点时,文字会出现一瞬间的抖动,随后正常. 猜想可能是设置的font-family不同引起的抖动,但 ...

  9. java 向上转型与向下转型

    转型是在继承的基础上而言的,继承是面向对象语言中,代码复用的一种机制,通过继承,子类可以复用父类的功能,如果父类不能满足当前子类的需求,则子类可以重写父类中的方法来加以扩展. 向上转型:子类引用的对象 ...

  10. ajax+struts2 实现省份-城市-地区三级联动

    1.需求分析 2.js部分(通过ajax异步请求实现) 省份-->城市联动 城市-->地区 3.struts部分 struts.xml action部分 4.service部分 5.总结 ...