进程控制
文章目录
- 进程控制
- 一、进程创建
- 1.fork函数认识
- 2.写时拷贝
- 3.fork常规用法
- 4.fork调用失败的原因
- 二、进程终止
- 1.进程退出场景
- 2.进程退出码
- 3.进程退出的方式
- 三、进程等待
- 1.进程等待是什么?
- 2.进程等待的必要性
- 3.进程等待的方法
- 3.1.wait函数
- 3.2.waitpid函数
- 4.如何获取子进程status
- 4.1.如何理解status参数?
- 4.2.获取退出码和退出信号
- 3.阻塞等待和非阻塞等待
- 四、进程替换
- 4.1 替换原理
- 4.2 替换函数
- 4.4 exec*接口介绍
- 4.5.替换函数使用实例
一、进程创建
1.fork函数认识
在Linux中fork函数非常的重要,它的作用是在一个已经存在的进程中创建一个新进程。新进程叫做子进程,原来的进程叫做父进程。
函数名称 | fork |
---|---|
函数功能 | 创建子进程 |
头文件 | #include<unistd.h> |
函数原型 | pid_t fork(void); |
参数 | 无 |
返回值 | >-1:成功(其中子进程返回0,父进程返回子进程的id) =-1:失败 |
进程调用fork,当控制转移到内核中的fork代码后,内核要做的是:
- 分配新内存和数据结构给子进程
- 将父进程部分数据结构内容拷贝至子进程中
- 添加子进程到系统进程列表中
- fork返回,调度器开始调度
当一个进程调用了fork之后,父子进程代码是共享的,虽然他们都运行到了相同的地方,但是每个进程都可以开始自己的旅程:
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/types.h>
int main()
{
printf("Before:pid is: %d\n",getpid());
pid_t pid = fork();
if(pid == -1)
{
perror("fork()");
exit(1);
}
printf("after:pid is: %d,return is %d\n",getpid(), pid);
sleep(1);
return 0;
}
结果展示:
我们可以看到,第一行输出是fork之前,只有父进程在执行,打印了before信息,fork创建子进程后,打印了两行after信息,分别由父子进程打印,注意到,进程29404打印了before的pid,而另外一个after却没有打印,这是为啥呢?
所以,fork之前父进程独立执行,fork之后,两个父子进程执行流分别执行。
注意:fork之后谁先执行,完全由调度器决定。(父子都有可能先执行)
2.写时拷贝
通常,父子代码共享,父子不再写入时,数据也是共享的,当任意一方试图写入时,便以写时拷贝的方式各自一份副本(在物理内存中)。
3.fork常规用法
- 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
- 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。
4.fork调用失败的原因
以下两种原因可能会导致fork调用失败:
- 系统中有太多的进程
- 实际用户的进程数超过了限制
我们写一个死循环创建子进程的程序测试我们当前操作系统最多能创建多少个进程:
注意:上面这个程序可能会导致服务器或虚拟机直接挂掉,虚拟机的话,大家只需要使用shotdown命令关机重启即可,服务器则需要去服务器控制台进行重启。
二、进程终止
1.进程退出场景
- 代码运行完毕,结果正确
- 代码运行完毕,结果不正确
- 代码异常终止(vs下叫做程序崩溃)
2.进程退出码
我们先前写C/C++代码的时候,都会在入口函数main函数开始写,我们总是喜欢在结尾的时候给上一个return 0,继而引发出了如下的两个问题:
- return 0,给谁return?
- 为何是0?其它值可以吗?
下面一次解决:
- 1、return 0,给谁return?
给父进程,具体理由在下面会有讲解。
- 2、为何是0?其它值可以吗?
返回值代表的是进程代码跑完,结果是否正确,如果是0,则成功,非零则失败。所以我们在写一个程序的时候,如果测试结果正确,这里我们可以给上return返回值0,可如果不正确,我们return的应该是其他值以此表示结果失败,只不过我们平时都无脑return 0了,准确说是不太正确的。
此外,失败虽是用非零值表示,可也是有讲究的,结果成功都是用0表示,结果失败反倒用不同的数字来表示,以此表示失败的不同原因。所以我们把main函数的return返回值称之为进程退出码!!进程退出码表征了进程退出的信息,此信息是要给父进程去读取的。
示例:
我们可以通过如下的指令查看退出码:
echo $?
$?表示在bash中,最近一次执行完毕时,对应进程的退出码!(说的简单点就是上一条指令执行完毕后的退出码)
再比如我们平时在命令行输入的指令,诸如ls、cd……类的,其退出码均为0,表示结果正确,可是当你随便输入一条错误指令的时候,其退出码则是某一数字表示结果错误:
问:一般而言,失败的的非零值我该如何设置呢?以及默认表达的含义?
- C语言当中的strerror函数可以通过错误码,获取该错误码在C语言当中对应的错误信息。
- **总结:**错误码退出码可以对应不同的错误原因,方便定位问题!
- 这里就可以提出我们退出码的意义?
它能够表示结果的正确与否,正确用0表示,因为那么多个数字,0只有一个,但是错误却有多个,用非0数字表示,错误的原因也是有多种可能的
退出码也是不能够随意乱写的,每一个退出码对应的数字,代表不同的错误,我们可以利用函数接口strerror
观察有哪些错误码
#include<stdio.h>
#include<string.h>
int main()
{
for(int i=0;i<150;i++)
{
printf("%d:%s\n",i,strerror(i));
}
return 0;
}
结果展示部分错误码对应的错误原因:
3.进程退出的方式
正常终止:
-
1、
main
函数中return返回,代表退出进程;而非main
函数中return返回表示的就是普通的函数返回/结束调用。 -
2、调用
exit
函数,它在程序的任意地方调用都是代表终止进程,参数是退出码,exit函数会完成一些收尾工作,例如资源的清理和释放,刷新缓冲区等。 -
3、调用
_exit
函数,它的作用是强制终止进程,不要进行后续收尾工作,比如刷新缓冲区(用户级别的缓冲区!)
异常终止:
- 【ctrl + c】信号终止
介绍return退出、exit函数和_exit函数
return退出
return退出是一种最为常见的一种退出进程的方法,执行return n等于执行exit(n),因为调用main函数运行时,会将main的返回值当做exit的参数。
exit函数
exit函数是标准C库中的一个库函数。
函数名称 | exit |
---|---|
函数功能 | 正常终止一个进程 |
头文件 | #inlcude<stdlib.h> |
函数原型 | void exit(int status) |
参数 | status:程序退出的状态 |
返回值 | 无 |
执行并查看退出码:
在调用exit之前,还会做一些其他的工作,
- 执行用户通过atexit或者on_exit定义的清理函数。
- 关闭所有打开的流,所有的缓冲数据均被写入。
- 调用_exit函数。
结果展示:
_ exit函数
_ exit也是标准C库中的一个库函数,它和_ Exit函数调用同义。
函数名称 | _exit |
---|---|
函数功能 | 正常终止一个进程 |
头文件 | #include<unistd.h> |
函数原型 | void _exit(int status) |
参数 | status 定义了进程的终止状态,父进程通过wait来获取该值 |
返回值 | 无 |
_exit是强制退出进程,并不进行后续的收尾工作!
注意:status
定义了进程的终止状态,父进程通过wait来获取该值,虽然status是int,但是仅有低八位可以被父进程所用。所以_exit(-1)
时,在终端执行echo $?
时,发现返回值是255。
_exit和exit的作用都是终止进程,但是还是有点区别的,区别如下:
结果演示:
exit
和_exit
函数的区别:
exit函数退出进程前,exit函数会执行用户定义的清理函数、刷新缓冲区,关闭流等操作,然后再终止进程,而_exit函数会直接终止进程,不会做任何收尾工作。
三、进程等待
1.进程等待是什么?
让父进程fork之后,需要通过wait
或者waitpid
等待子进程退出,父进程想要知道子进程完成的任务情况如何了。
2.进程等待的必要性
- 之前讲过,子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏问题,需要通过父进程
wait
,释放子进程占用的资源。 - 另外,进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程。
- 最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对,或者是否正常退出。父进程需要获得子进程的退出状态。
- 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息(status),能够得知子进程的执行结果。
- 可以保证“时序问题”,那就是保证子进程先退出,父进程后退出。
3.进程等待的方法
利用系统级别的函数wait
和waitpid
3.1.wait函数
函数名称 | wait |
---|---|
函数功能 | 暂停当前进程,直至子进程结束,并取回子进程结束时的状态 |
头文件 | #include<sys/wait.h> |
函数原型 | pid_t wait(int *status) |
参数 | status:子进程终止状态的地址 |
返回值 | 等待成功返回被等待进程pid,失败返回-1 |
说明:输出型参数status,获取子进程状态,不关心时可以设置为NULL
作用:等待任意子进程
我们写一段代码来看一看,fork之后我们先让子进程运行5秒,之后子进程退出,而让父进程一直在等待(调用wait函数),我们就能看到进程等待的现象。
#include<stdio.h>
#include<string.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
pid_t id = fork();
if (id == 0)
{
//child
int cnt = 5;
while (cnt)//5秒后子进程退出
{
printf("child is running!,pid:%d, cnt:%d\n", getpid(), cnt);
cnt--;
sleep(1);
}
exit(0);//退出子进程
}
printf("father wait begin!\n");
sleep(5);//父进程休眠5秒
pid_t ret = wait(NULL);
if (ret > 0)
{
printf("father wait:%d,sucess\n", ret);
}
else
{
printf("father wait failed!\n");
}
sleep(5);//子进程被回收之后,让父进程再运行5秒钟
return 0;
}
我们可以使用以下监控脚本对进程进行实时监控:
while :; do ps axj | head -1 && ps axj | grep proc1 | grep -v grep;echo "—————————————————————————————————————————————————————————————————";sleep 1;done
运行结果如下:
这时我们可以看到,当子进程退出后,父进程读取了子进程的退出信息,子进程也就不会变成僵尸进程了。 由此得知我们可以通过wait()的方案解决回收子进程Z状态,让子进程进入X。
3.2.waitpid函数
函数名称 | waitpid |
---|---|
函数功能 | 获取子进程结束时的状态 |
头文件 | #include<sys/wait.h> |
函数原型 | pid_t waitpid(pid_t pid, int *status, int options) |
参数 | pid:指定的子进程PID status:子进程终止状态的地址 options:控制操作方式的选项 |
返回值 | 1、> 0 等待子进程成功,当正常返回的时候waitpid返回收集到的子进程的进程ID; 2、 如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0; 3、如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在; |
对于三个参数进一步说明:
pid:
1、pid<-1等待进程组识别码为pid绝对值的任何子进程.
2、pid=-1 等待任何子进程,相当于wait().
3、pid=0 等待进程组识别码与目前进程相同的任何子进程.
4、pid>0 等待任何子进程识别码为pid的子进程.
status:
用下面的常用的两个宏:
WIFEXITED(status)
: 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)WEXITSTATUS(status)
: 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
options:
- 0:默认行为,阻塞等待(父进程什么都不做,就是等待子进程退出)
WNOHANG
:设置等待方式为非阻塞等待
注意:当一个进程非正常退出时,说明该进程是被信号所杀,那么该进程的退出码也就没有意义了
- 如果子进程已经退出,调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获得子进程退出信息。
- 如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞。
- 如果不存在该子进程,则立即出错返回。
下面再来强调下第二个参数:status
status是一个输出型参数,通过调用该函数,从函数内部拿出来特定的数据,也就是从操作系统拿到特定数据。
子进程退出的时候会将自己的退出信息写入自己的task_struct,随后变成Z状态,随后父进程调用wait / waitpid接口,通过status把子进程的退出码拿到。
4.如何获取子进程status
4.1.如何理解status参数?
- wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。
- 如果传递NULL,表示不关心子进程的退出状态信息。
- 否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
- status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status低16比特位)
解释上图:
- 在status的低16比特位当中,高8位表示进程的退出状态,即退出码。进程若是被信号所杀,则低7位表示终止信号,而第8位比特位是core dump标志。
我们通过一系列位操作,就可以根据status得到进程的退出码和退出信号。
exitCode = (status >> 8) & 0xFF; //退出码
exitSignal = status & 0x7F; //退出信号
对于此,系统中提供了两个宏来获取退出码和退出信号。
- WIFEXITED(status):用于查看进程是否是正常退出,本质是检查是否收到信号。
- WEXITSTATUS(status):用于获取进程的退出码。
exitNormal = WIFEXITED(status);//是否正常退出
exitCode = WEXITSTATUS(status);//获取退出码
- 需要注意的是,当一个进程非正常退出时,说明该进程是被信号所杀,那么该进程的退出码也就没有意义了。
4.2.获取退出码和退出信号
我们通过位运算可以,根据status得到退出码和退出信号:
#include<stdio.h>
#include<string.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
pid_t id=fork();
if(id==0)
{
//child
int cnt=5;
while(cnt)
{
printf("child is running! pid:%d, cnt:%d\n",getpid(), cnt);
cnt--;
sleep(1);
}
exit(10);
}
printf("father wait begin!\n");
sleep(10);
//pid_t ret =wait(NULL);
int status=0;
pid_t ret=waitpid(id,&status,0);
if(ret>0)
{
printf("father wait:%d sucess,status wait code:%d,status exit signal:%d\n",ret,(status>>8)&0xFF,status&0x7F);
}
else
{
printf("father wait failed!\n");
}
sleep(10);
return 0;
}
结果如下:
status的最低7位表示进程退出时收到的信号,进程如果异常退出,是因为这个进程收到了特定的信号,我们先前kill -9 pid就是在进程异常时退出而发出的信号。下面来模拟下进程的异常退出:
#include<stdio.h>
#include<string.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
pid_t id=fork();
if(id==0)
{
//child
while(1)
{
printf("child is running! pid:%d, cnt:%d\n",getpid(), cnt);
sleep(1);
}
exit(10);
}
printf("father wait begin!\n");
sleep(10);
//pid_t ret =wait(NULL);
int status=0;
pid_t ret=waitpid(id,&status,0);
if(ret>0)
{
printf("father wait:%d sucess,status wait code:%d,status exit signal:%d\n",ret,(status>>8)&0xFF,status&0x7F);
}
else
{
printf("father wait failed!\n");
}
sleep(10);
return 0;
}
这里子进程是在无限循环的,父进程只能阻塞等待,现在我们把子进程kill掉,结果如下:
如果kil -3 pid,退出信号就是3……当进程收到信号的时候,就代表进程异常了。
综上:退出信号代表进程是否异常,退出码代表程序跑完后的结果正确与否。
问:一个进程退出的时候,父进程会拿到退出码和退出信号,那到底先看谁呢?
- 一旦进程出现异常,只关心退出信号,退出码没有任何意义。
强调系统中的两个宏:
status:
WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
我们对于位操作是不是有点太过复杂和麻烦了,我们可以用上文提到的宏来代替位操作:
#include<stdio.h>
#include<string.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
pid_t id = fork();
if (id == 0)
{
//child
int cnt = 5;
while (cnt)
{
printf("child[%d] is running! cnt is %d\n", getpid(), cnt);
cnt--;
sleep(1);
}
exit(11);
}
printf("father wait begin!\n");
sleep(10);
//pid_t ret =wait(NULL);
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if (ret>0)
{
if (WIFEXITED(status))//没有收到任何退出信号
{ //正常结束,获取对应的退出码
printf("exit code:%d\n", WEXITSTATUS(status));
}
else
{
printf("error get a signal!\n");
}
}
/*if (ret > 0)
{
printf("father wait:%d sucess,status wait code:%d,status exit signal:%d\n", ret, (status >> 8) & 0xFF, status & 0x7F);
}
else
{
printf("father wait failed!\n");
}*/
sleep(10);
return 0;
}
结果展示:
3.阻塞等待和非阻塞等待
再来强调下waitpid函数中的最后一个参数options,当其值为0时,就是阻塞等待,当为WNOHANG时,就是非阻塞等待。下面展开讨论:
阻塞等待:
-
如果子进程就是不退出(如死循环),怎么办呢?我的父进程只能阻塞等待。
-
当我们调用某些函数的时候,因为条件不就绪(可能是任意的软硬件条件),需要我们阻塞等待,本质就是当前进程自己变成阻塞状态,当条件就绪的时候再被唤醒。
详细说明:
- 就是一个进程在系统层面上因为要等待某种事件发生,如果这个事件并没有发生,那么当前进程的代码和数据将无法运行,此时就要进入阻塞状态,也就是将父进程的task_struct的状态由R->S,从运行队列投入到等待队列,等待子进程退出,当子进程退出了,本质就是条件就绪,那么就会逆向执行上述操作,将进程从等待队列搬到运行队列,并将状态由S->R。
举例:
- 假设你叫李四,是个大混子,天天不学习,马上就要考数据结构了,为了能够及格,你打电话给了你班的一位学霸朋友张三来让他教我,整个过程我就是一个进程,打电话的过程就是在调用接口,张三就是所谓的OS操作系统,当电话接通了,但是张三说他在忙,于是我让张三别挂电话,我在电话这头一直等待你,此时这个等待过程中我什么也没干,只是等待张三,也就是说父进程在等待期间不做任何事情,这个过程就是阻塞等待。
非阻塞等待:
举例:
- 此时重复上述场景,当电话接通后,张三表示还在忙,那么我直接挂电话,此时我做些自己的事情,忙了一会后又给张三打了个电话,张三还在忙,那么我又挂电话继续做自己的事情,while(1)重复循环直至张三说自己ok了。
详细说明:
- 整个过程我依旧是用户,张三是OS操作系统,打电话就等价于调用waitpid函数,相当于是用户问操作系统子进程是否退出,当OS回应没有时,此时waitpid直接返回,此时用户不会调用wait而将自己阻塞住,此时用户在空闲时间段内做自己的事情,做一会之后再去问OS操作系统好了没,这个过程就叫做非阻塞等待。这种多次调用非阻塞接口,就是轮询检测。
总结:
1、阻塞等待:死等,就是上述的情况,父进程一直等待子进程,父进程不做任何事情。
2、非阻塞等待:我们可以不要让父进程死等,而是在等待期间,父进程去做自己的事情,等子进程退出时再来检测子进程的运行状态
代码示例:
#include<stdio.h> #include<unistd.h> #include<stdlib.h> #include<sys/types.h> #include<sys/wait.h> int main() { pid_t id = fork(); assert(id != -1); if (id == 0) { //子进程 while (1) { printf("我是子进程,我的pid:%d, 我的ppid:%d\n", getpid(), getppid()); sleep(3); } exit(10); } else if (id > 0) { //父进程 //基于非阻塞的轮询等待方案 int status = 0; while (1) { pid_t ret = waitpid(id, &status, WNOHANG);// WNOHANG:非阻塞->子进程没有退出,父进程检测时候,立即返回 if (ret > 0) { //waitpid调用成功,子进程退出了 printf("等待成功,%d,退出信号是:%d,退出码是:%d\n", ret, status & 0x7F, (status >> 8) & 0xFF); break; } else if (ret == 0) { //(waitpid调用成功了)等待成功了,但是子进程没有退出 //子进程没有退出,我的waitpid没有等待失败,仅仅是检测到了子进程没有退出 printf("子进程完成没?还没有,那么我父进程就做其他事情啦...\n"); sleep(1); } else { //waitpid调用失败 printf("waitpid调用失败了\n"); } } } else { //do nothing } return 0; }
运行结果就是,父进程每隔一段时间就去查看子进程是否退出,若未退出,则父进程先去忙自己的事情,过一段时间再来查看,直到子进程推出后读取子进程的退出信息。
现在增加点设计,模拟让父进程在等待的过程中做些自己的事情,如下的代码:
#include<stdio.h> #include<unistd.h> #include<stdlib.h> #include<sys/types.h> #include<sys/wait.h> #define NUM 10 typedef void (*func_t)(); //函数指针 func_t handlerTask[NUM]; //样例任务 void task1() { printf("handler task1\n"); } void task2() { printf("handler task2\n"); } void task3() { printf("handler task3\n"); } void loadTask() { memset(handlerTask, 0, sizeof(handlerTask)); handlerTask[0] = task1; handlerTask[1] = task2; handlerTask[2] = task3; } int main() { pid_t id = fork(); assert(id != -1); if (id == 0) { //子进程 while (1) { printf("我是子进程,我的PID:%d, 我的PPID:%d\n", getpid(), getppid()); sleep(3); } exit(10); } else if (id > 0) { //父进程 //基于非阻塞的轮询等待方案 loadTask(); int status = 0; while (1) { pid_t ret = waitpid(id, &status, WNOHANG);// WNOHANG:非阻塞->子进程没有退出,父进程检测时候,立即返回 if (ret > 0) { // waitpid调用成功,子进程退出了 printf("等待成功,%d,退出信号是:%d,退出码是:%d\n", ret, status & 0x7F, (status >> 8) & 0xFF); break; } else if (ret == 0) { //(waitpid调用成功了)等待成功了,但是子进程没有退出 //子进程没有退出,我的waitpid没有等待失败,仅仅是检测到了子进程没有退出 printf("子进程完成没?还没有,那么我父进程就做其他事情啦...\n"); for(int i = 0; handlerTask[i] != NULL; i++) { handlerTask[i](); // 采用回调的方式,执行我们想让父进程在空闲的时候做的事情 } sleep(1); } else { //waitpid调用失败 printf("waitpid调用失败了\n"); } } } else { //do nothing } return 0; }
过程描述:父进程每隔一秒就去看看自己进程退出没有,若没有就继续等待,若退出了,就获取退出码。
四、进程替换
4.1 替换原理
程序替换的概念
- 用fork创建子进程后,子进程执行的是和父进程相同的程序(但有可能执行不同的代码分支),若想让子进程执行另一个程序,这就需要用到程序替换,而完成程序替换需要用到exec函数。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,并从新程序的启动李成开始执行。
为什么要完成程序替换
我们一般在服务器设计(linux变成)的时候,往往需要子进程干两件种类的事情:
- 让子进程执行父进程的代码片段(服务器代码)
- 让子进程执行磁盘中一个全新的程序(shell,想让客户端执行对应的程序,通过我们的进程。执行其他人写的进程代码等待),如C/C++(自己写的) -> C/C++/Python/Shell/Php/java……(别人写的)
上述的第二点需求就是用我自己写的C/C++代码调用别人不同语言的代码程序,完成这一项需求就需要用到程序替换。
程序替换的原理
如下图所示:
- 前面我们学习到,当fork创建子进程的时候,子进程的PCB、虚拟地址空间都以父进程为模板,页表中的代码段指向的是父进程中的代码段,数据也以写时拷贝的方式来和父进程进行共享,如果现在有一个全新的程序b.exe,并且我现在不想让子进程执行任何父进程相关的代码以及访问父进程的数据,并执行的是b.exe程序,此时把b.exe的程序加载到物理内存上,让子进程重新调整自己的页表映射,使其指向新的b程序的代码和数据,这种过程就叫做程序替换。
总结程序替换的原理:
- 将磁盘中的程序,加载入内存结构
- 重新建立页表映射,谁执行程序替换,就重新建立谁的映射,最终达到的效果就是让父进程和子进程彻底分离,并让子进程执行一个全新的程序!!!
问1:当进行程序替换时,有没有创建新的进程?
- 进程程序替换后,该进程对应的PCB、进程地址空间以及页表等数据结构均没有发生改变,只是重新建立了一下物理内存中的映射关系罢了,它的内核对应的数据结构没有发生变化,他的pid也没有发生变化,也就没有创建新的进程,只不过是让进程执行不同的程序罢了!!!
问2:子进程进行进程程序替换后,会影响父进程的代码和数据吗?
- 子进程刚被创建时,与父进程共享代码和数据,但当子进程需要进行进程程序替换时,也就意味着子进程需要对其数据和代码进行写入操作,这时便需要将父子进程共享的代码和数据进行写时拷贝,此后父子进程的代码和数据也就分离了,因此子进程进行程序替换后不会影响父进程的代码和数据。
问3:为什么要加载到内存中呢?
因为数据是存储在外设磁盘上的,CPU距离内存最近,读取是最方便的!
代码演示:
#include<stdio.h>
#include<unistd.h>
int main()
{
printf("I am a process! pid:%d\n", getpid());
execl("/usr/bin/ls","ls","-a","-l",NULL);
//本该执行下面的hello程序的,但是调用execl替换了代码和数据,将会执行ls等命令
printf("process done, pid:%d\n", getpid());
return 0;
}
结果展示:
程序替换的本质:就是把程序的代码和数据,加载到特定的进程的上下文中!
我们平时写的C/C++程序要运行,必须先加载到内存中!如何加载呢?——加载器!(封装的底层就是exec系列的程序替换函数)
现在我们想让子进程也进行程序替换:
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/wait.h>
int main()
{
if(fork()==0){//child
printf("command begin...\n");
execl("/usr/bin/ls","ls","-a","-l","-i",NULL);
printf("command end...\n");
exit(1);
}
//father
waitpid(-1,NULL,0);//等待任意一个子进程
printf("father wait success!\n");
return 0;
}
结果展示:
当我们在执行子进程的替换时,我们的父进程照常执行自己的任务,两者之间互不影响呢?那是因为进程之间具有独立性!
4.2 替换函数
其实有6种以exec
开头的函数,统称为exec函数
六种替换函数(实际上七种,换汤不换药):
- 上述过程中的程序替换是我们通过调用接口(函数)来让OS操作系统帮助我们完成的,此接口就是替换函数,替换函数有六种以exec开头的函数,他们统称为exec函数:
#include <unistd.h>`
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ...,char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);
只要程序替换成功,就不会执行后续的代码,意味着exec*
系列的函数,执行成功的时候就不需要返回值检测!只要exec*
返回了,那就意味着替换失败,调用函数也失败了!(返回-1)所以exec函数只有出错的返回值而没有成功的返回值!
4.4 exec*接口介绍
函数名 | 参数格式 | 是否带路径 | 是否使用当前环境变量 |
---|---|---|---|
execl | 列表 | 不带 | 是 |
execlp | 列表 | 带 | 是 |
execle | 列表 | 不带 | 不是,需要自己组装环境变量 |
execv | 数组 | 不带 | 是 |
execvp | 数组 | 带 | 是 |
execve | 数组 | 不带 | 不是,需要自己组装环境变量 |
函数解释:
- 这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回
- 如果调用出错则返回-1
- 所以exec函数只有出错的返回值而没有成功的返回值
命名理解:
这六个exec系列函数的函数名都以exec开头,其后缀的含义如下:
- l(list):表示参数可用列表的形式,一一列出
- v(vector):表示参数采用数组的形式
- p(path):表示能自动搜索环境变量PATH,进行程序查找
- e(env):表示可以传入自己设置的环境变量
事实上,只有execve是真正的系统调用,其它五个函数最终都是调用的execve,所以execve在man手册的第2节,其它五个函数在man手册的第3节,也就是说其它五个函数实际上是对系统调用execve进行了封装,以满足不同用户的不同调用场景的需求。
下图为exec系列函数族之间的关系:
4.5.替换函数使用实例
我们如果要执行一个全新的程序,需要做2件事情:
-
先找到这个程序在哪里
-
程序可能携带选项进行执行(也可以不携带),要明确告诉OS操作系统,我想怎么执行这个程序?要不要带选项?
下面几个替换函数均是按照上述逻辑执行的。
1、execl
int execl(const char *path, const char *arg, ...);
- 第一个参数是要执行程序的路径
- 第二个参数是可变参数列表(我们可以按照用户的意愿传入数量大小不等的参数),表示你要如何执行这个此程序,命令行怎么写(ls -l -a),这个参数就怎么填 “ls”,“-l”,“-a”,并以NULL结尾。
示例:
假设我执行的是ls -l -a程序:
当我make之后,会生成myexec可执行程序,现在运行此程序:
当我执行top、pwd命令也亦是同样的操作:
- 此时一个现象就产生了,当我调用execl替换程序函数成功后,后面的printf语句并没有执行,原因就在于一旦execl替换成功,是将当前进程的代码和数据全部替换了,后面的printf自然就被替换了,即该代码就不存在了。
问:程序替换用不用判断返回值?
- 程序替换不用判断返回值,这就是我们前面提到的,因为一旦程序替换成功了,就不会有返回值,而失败的时候,必然会向后执行,最多通过返回值得到什么原因导致的替换失败。
下面演示进程替换失败的场景(只需随便执行一个不存在的程序即可)
我们的上述操作是一个单进程程序,我没有创建子进程,相当于是把父进程的代码给替换了,现在想让子进程完成替换操作,如下:
上述操作就完成了让子进程执行全新的程序,以前是执行父进程的代码片段,运行结果如下:
问:子进程执行程序替换,会不会影响父进程呢?
- 答案是不会的,这个我上面详细讲解过,总结就是进程具有独立性,当程序替换的时候,代码和数据都发生了写时拷贝完成父子的分离。
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("我是一个进程,我的pid:%d\n", getpid());
// ls -l -a;
execl("usr/bin/ls", "ls", "-l", "-a", NULL);
printf("我执行完了,我的pid:%d\n", getpid());
return 0;
}
2、execv
int execv(const char *path, char *const argv[]);
- 第一个参数是要执行程序的路径
- 第二个参数是一个字符指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾
例如,要执行的是ls -l -a -i
char *const argv_[] = { (char*)"ls", (char*)"-a", (char*)"-l", (char*)"-i", NULL };
execv("/usr/bin/ls", argv_);
前面的execl是一个一个参数传过去的,这里execv是直接传一个指针数组,示例如下:
补充:vim批量化注释、取消注释小技巧
- 注释:小写,ctrl+v,hjkl选中区域,切换大写,输入I,//,esc
- 取消注释:小写,ctrl+v,hjkl选中区域(注释区域),输入d
3、execlp
int execlp(const char *file, const char *arg, ...);
- 第一个参数是要执行程序的名字
- 第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾
例如,要执行的是ls -a -l
execlp("ls", "ls", "-a", "-l", NULL );
它和execl唯一的区别就是多了一个p,在我们执行指令的时候,默认的搜索路径是在环境变量PATH搜索的,而execl中的p就是此PATH环境变量,前面我们的execl和execv都是要指明路径的,而这里execlp命名带p了,因此就可以不带路径,只说出你要执行哪一个程序即可。示例:
- 这里出现了两个ls,含义是不一样的,第一个参数是供系统去找你要执行谁的,第二个是你想怎么执行它。
4、execvp
int execvp(const char *file, char *const argv[]);
- 第一个参数是要执行程序的名字
- 第二个参数是一个字符指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾
例如,要执行的是ls -a -l -i
char *const argv_[] = { (char*)"ls", (char*)"-a", (char*)"-l", (char*)"-i", NULL };
execvp("ls", argv_);
此替换函数同样是带了p,所以它是从PATH路径里头找,我们只需要程序名即可,还带了v,我们就可以将命令行参数字符串统一放入数组中即可完成调用。示例:
5、execle
在正式讲解此替换函数execle前,先来看这样一个问题:
- 目前我们执行的程序,全部都是系统命令,如果我们要执行自己写的C/C++程序呢?如果我们要执行其它语言写的程序呢?
首先,一次makefile默认只能生产一个可执行程序,但是按如下修改一次可生产多个可执行程序:
现在有两个可执行程序,我现在的目标是让myexec把mycmd调起来,代码如下:
execl("/home/xzy/dir/date16/mycmd", "mycmd", NULL);//绝对路径 //execl("./mycmd", "mycmd", NULL);相对路径
详情如下:
上述实现了C可执行程序调C++可执行程序,现在来实现C可执行程序调用python可执行程序,如下一个python小脚本:
现在想让C可执行程序myexec来调用python,只需如下操作:
execl("/usr/bin/python3", "python3", "test.py", NULL);
解决了刚刚那个问题,现在再回过头来看execle替换函数:
int execle(const char *path, const char *arg, ..., char * const envp[]);
- 第一个参数是要执行程序的路径
- 第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾
- 第三个参数是你自己设置的环境变量
以我设置的"MYPATH"环境变量为例:
char* const env_[] = { (char*)"1314520", NULL };
execle("./mycmd", "mycmd", NULL, env_);
execle的第三个参数表示的是如果你想给你的程序传入对应的环境变量信息,也是一个字符指针数组,那就可以传入对应的环境变量参数,示例:
如下我们的mycmd.cpp文件:
如上我输出的是系统PATH中的环境变量,现在我想自定义环境变量MYPATH并交给子进程,我的myexec.c程序如下:
这里有个错误:
execle("./mycmd", "mycmd", NULL, env_);
当我make后并运行myexec可执行程序会发现有个问题:
当我自定义一个环境变量时,运行myexec,我PATH环境变量就输出不了了,当我把mycmd.cpp中的PATH语句那块给注释掉,再运行myexec,此时就能看到我自定义的环境变量MYPATH了:
为什么我把PATH的注释取消了,却输出不了我的PATH环境变量?相反的,把PATH注释掉才能打印我的MYPATH?
- 根本原因:自己添加环境变量给目标进程,是覆盖式的!!!
解决办法如下:
使用environ(系统环境变量的指针声明),利用export将MYPATH添加到系统环境变量中:
6、execve
int execve(const char *filename, char *const argv[], char *const envp[]);
- 第一个参数是要执行程序的路径
- 第二个参数是一个指针数组,表示你要如何执行这个程序,数组以NULL结尾
- 第三个参数是你自己设置的环境变量
例如,你设置了MYPATH环境变量,在myexec程序内部就可以使用该环境变量:
char* my_argv[] = { "myexec", NULL };
char* my_envp[] = { "MYPATH=helloWorld", NULL };
execve("./myexec", my_argv, my_envp);
7、execvpe
int execvpe(const char *file, char *const argv[], char *const envp[]);
- 第一个参数是要执行程序的路径
- 第二个参数是一个指针数组,表示你要如何执行这个程序,数组以NULL结尾
- 第三个参数是你自己设置的环境变量
此替换函数在我们前面讲解的基础上,已经不难理解了,就不给出测试用例了。
问:为什么会有这么多接口?execve为什么是单独的?
- 其实替换函数一共有7个,只不过execve是单独的:
有多个接口的原因在于调用替换函数的场景各不相同,所以有不同的接口,execve是单独的原因其实我在命名理解那也说明了,这里再强调下:
- 事实上,只有execve是真正的系统调用,其它五个函数最终都是调用的execve,所以execve在man手册的第2节,其它五个函数在man手册的第3节,也就是说其它五个函数实际上是对系统调用execve进行了封装,以满足不同用户的不同调用场景的需求。