进程与fork()、wait()、exec函数组
进程与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 */ );
grep命令查看进程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函数的细节?参考的这篇博客,我们可以通过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函数来等待它的⼦进程终⽌或者停⽌。