> 🍃 本系列为Linux的内容,如果感兴趣,欢迎订阅🚩
> 🎊个人主页:【小编的个人主页】
>小编将在这里分享学习Linux的心路历程✨和知识分享🔍
>如果本篇文章有不足,还请多多包涵!🙏
> 🎀 🎉欢迎大家点赞👍收藏⭐文章
> ✌️ 🤞 🤟 🤘 🤙 👈 👉 👆 🖕 👇 ☝️ 👍
目录
🐼前言
在上一节,我们学习了进程退出,进程退出无非就是两个数字,退出信号和退出码,以及相关场景,并给出最佳实践。我们同时分享了进程等待的相关话题解决"僵尸"问题,包括wait,waitpid相关的使用方式,以及非阻塞等待。这一节我们来谈一谈进程控制的下一个话题,进程程序替换,如果把进程比作一个“小工人”,那么原来用fork()创建的子进程,就像是“小工人”复制了一个“小分身”,它们做一样的工作,代码共享,数据各用各的。但如果想让子进程去做完全不同的新工作,就需要给它换一套全新的“工具”和“任务指南”,这就是我们今天要分享的内容,话不多说,直接开冲。
🐼进程程序基本替换原理
我们在之前调用fork()创建出子进程,都是是为了帮助父进程一起完成任务的,其中代码是共享的,而数据可以通过写实拷贝各自私有。但是如果我们想让子进程执行一个全新的程序呢?也就是子进程需要有自已的代码和数据,成为一个全新("独立")的进程,这时候我们就需要进程程序替换了。
是怎么完成替换的呢?
在创建一个进程时,会创建它的task_struct,虚拟地址空间,页表,以及会会加载它的代码和数据到物理内存等,然后进程就开始执行了。那如果我们不想让该进程执行原本的代码和数据了,而是将磁盘上的另一个代码和数据把他换了,就跟"狸猫换太子"一样,无非数据多一点,只需要稍微修改页表的映射关系等,并没有创建新的进程,那他是不是就能执行我们新的代码和数据,成为一个全新的进程了。就像下图一样:
那是怎么替换的?需要调用系统调用exec函数,将该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。
有六种以exec开头的函数,统称exec函数:
🐮见一见进程程序替换"庐山真面目"
1️⃣ 我们先来用一个替换方式execl,来看一看进程程序替换长什么样,替换后的效果
execl的参数形式是以可变参数的形式给我们的,我们可以这么理解:
- 第一个参数:表示程序的路径+文件名,也就是我们想替换谁,因为程序也是文件,比如/usr/bin/ls
- 第二个参数:是我们要执行程序的程序命令比如"ls"
- 第三个参数:是可变参数,是给程序传递的命令的选项,比如-a -l -n等命令选项,但最后必须以NULL结尾
我们把后两个参数简单理解为,我们在命令行怎么传递参数的,这里就怎么写,但是最后要以NULL结尾!
如果替换失败返回-1,成功不需要返回值(为什么读者继续看)
下面我们写一个程序调用execl来替换我们在命令行中的ls -a -l -n命令
#include<stdio.h>
#include<unistd.h>
int main()
{
printf("我们的进程开始了\n");
execl("/usr/bin/ls","ls","-a","-l","-n",NULL);
printf("进程运行结束\n");
return 0;
}
输出:
观察结果,这里为什么会先输出"我们的进程开始了"?因为代码是从上往下执行的,在执行execl之前 ,执行的都是我们自已进程的代码。执行到execl,发生程序替换,将ls的代码和数据把我们的进程替换了,我们的进程就执行ls -a -l -n命令了。
那为什么execl之后不执行后续语句了呢?
因为,我们的进程已经被替换了,执行一个新的程序的代码了,我自已原本的代码和数据,已经没有了!被替换了!
所以,如果进程替换成功,execl之后的代码都不会被执行,因为被替换了,反之,只有程序替换失败,才会执行后续代码。我们可以得出结论,exe系列的函数,如果有返回值,必定失败,如果成功,不需要也不会有返回值。所以我们默认exe后的语句都是程序替换失败
所以这些函数如果调用成功则加载新的程序从启动代码开始执行不再返回。
如果调用出错则返回-1
所以exec函数只有出错的返回值而没有成功的返回值。
那现在有一个问题,就是fork创建子进程后,可以从磁盘拿代码和数据替换子进程的代码和数据吗???当子进程执行一个全新的程序时,会影响到父进程吗?
因为进程具有独立性,所以,当然不会影响到父进程!我们之前说父子进程代码共享,数据在写入时发生写实拷贝。为了保证进程的独立性,当发生程序替换时,我们可以简单理解为,代码也要写实拷贝,也就是父子进程的代码和数据都要写实拷贝,各自私有,有各自的代码段和数据段,父子进程只有父子的关系了,父子进程各自执行自已的代码和数据!如图:
所以我们现在能否理解我们在命令行上执行ls -a -l -n时,由fork()后创建的出来的子进程,通过exec将子进程的代码和数据替换(加载ls 的代码和数据了),然后父进程通过waitpid等待子进程完成任务情况。
现在我们也能理解我们的程序是怎么被加载到内存的?这个道理和将磁盘上的代码和数据加载到我们的子进程一样,不就是发生了程序替换吗?通过创建一个子进程,调用接口exec,将我们程序的代码和数据把子进程原有的代码和数据替换了,所以我们可以将exec理解为"加载器",所以操作系统内核本身就是一个“加载器”帮助把我们的程序加载到内存。这个道理就好比,创建一个子进程来服务我们,但是它的代码和数据我们不用,非把它换成我们的,这个过程就是加载我们的代码和数据。
所以我们在windows鼠标点击某个图形化界面,这个动作就可以被解释成,某个进程,创建了个子进程,发生了程序替换。
下面我们不直接将父进程程序替换了,不然父进程的代码和数据都消失了。通过fork创建子进程,让子进程发生程序替换,执行全新的代码,那父进程呢,要waitpid等待回收子进程.代码如下:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<wait.h>
#include<stdlib.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
//子进程进行程序替换
sleep(1);
printf("子进程%d 开始程序替换了\n",getpid());
execl("/usr/bin/ls","ls","-a","-l","-n",NULL);
printf("程序替换失败\n");
exit(0);
}
int status = 0;
pid_t rid = waitpid(id,&status,0);
if(rid>0)
{
printf("父进程等待 %d 子进程成功\n",id);
if(WIFEXITED(status))
{
printf("正常运行结束,exit_code: %d\n",WEXITSTATUS(status));
}else
{
printf("进程异常了\n");
}
}
return 0;
}
运行结果:
🐼程序替换其他接口
只要我们懂了execl的使用,其他接口就好懂了
2️⃣我们先看execv
其中第一个参数依旧为我要执行谁,是文件路径。第二个参数是一个指针数组,就是将我们所要执行的命令名称+命令选项放到这个指针数组中即可。这个数组等同于execl的后两个参数,我们可以简单记忆为我们在命令行中怎么写,这个数组就怎么填,支持可变,最后一个元素依旧以NULL结尾,和execl后两个参数没区别。只不过 execv的v表示vector的意思,而execl的l表示list参数列表的意思。
所以我们想把我们的代码替换成execv,执行程序替换,可以基于上面代码这么写:
[lsg@VM-12-2-centos exec]$ cat myexec.c
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<wait.h>
#include<stdlib.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
//子进程进行程序替换
sleep(1);
printf("子进程%d 开始程序替换了\n",getpid());
char* const myargv[] = {"ls","-a","-l","-n",NULL};//改动在这两行!!!!
execv("/usr/bin/ls",myargv);
//execl("/usr/bin/ls","ls","-a","-l","-n",NULL);
printf("程序替换失败\n");
exit(0);
}
int status = 0;
pid_t rid = waitpid(id,&status,0);
if(rid>0)
{
printf("父进程等待 %d 子进程成功\n",id);
if(WIFEXITED(status))
{
printf("正常运行结束,exit_code: %d\n",WEXITSTATUS(status));
}else
{
printf("进程异常了\n");
}
}
return 0;
}
程序替换结果如图:
那写完这个我们自然就发现,这个char *const myargv数组怎么和我们之前的命令行参数表argv有点像啊!只不过execv的第一个参数要命令所在路径罢了。那如果我们将我们的命令行参数表argv给execv进行程序替换会有什么效果,我们验证下看看:
int main(int argc,char* argv[])
{
pid_t id = fork();
if(id == 0)
{
//子进程进行程序替换
sleep(1);
printf("子进程%d 开始程序替换了\n",getpid());
// char* const myargv[] = {"ls","-a","-l","-n",NULL};
// execv("/usr/bin/ls",myargv);
//execl("/usr/bin/ls","ls","-a","-l","-n",NULL);
char** myargv = &argv[1];
execv(myargv[0],myargv);
printf("程序替换失败\n");
exit(0);
}
int status = 0;
pid_t rid = waitpid(id,&status,0);
if(rid>0)
{
printf("父进程等待 %d 子进程成功\n",id);
if(WIFEXITED(status))
{
printf("正常运行结束,exit_code: %d\n",WEXITSTATUS(status));
}else
{
printf("进程异常了\n");
}
}
return 0;
}
假设我们此时在命令行执行:./myexec /usr/bin/ls -a -l -n。我们让二级指针指向命令行参数表的第二个位置(/usr/bin/ls),此时execv第二个参数数组的内容恰好就是myargv数组的内容,如图:
这样我们就可以将命令行的命令用我么创建的子进程替换执行.因为子进程可以共享父进程的argv。 我们的可执行程序不就变成了个加载器吗?可以加载我们命令行的任何命令,比如我们输入:
这样我们就理解了,我们的操作系统(bash)不就是个加载器,通过fork创建子进程+exev程序替换,执行我们的命令。
3️⃣execlp(名字上比execl多了个p,这个p我们可以理解为环境变量PATH)
第一个参数和execl不同的是,它不再是文件路径,而是直接告诉它我想执行谁,不需要再带路径。也就是执行指定的命令时,需要让execlp默认从系统环境变量PATH中查找执行的程序。后面的参数和execl的用法是一致的。所以基于上面的代码,同样可以用execlp进程程序替换:
运行结果:
4️⃣那当然我们可以用另个程序替换接口:execvp,参数的使用我们已经知道啦
基于上面的代码我们用execvp:
运行结果:
这些函数原型看起来很容易混,但只要掌握了规律就很好记。我们这里可以浅浅总结一下:
那我们上述子进程都执行的是系统的命令,那么子进程能不能执行我们自已的命令???
答案是可以的,只要可以找到。
比如我们一个C++程序让子进程程序替换执行。假设C++mycmd.cpp是这样的
#include<iostream>
int main()
{
std::cout<<"hello C++"<<std::endl;
return 0;
}
让我们的子进程调用系统调用execl替换它执行:
甚至我们可以用我们的C语言调用系统调用接口执行其他语言,比如python,现在我们有一个mycmd.py:
1 #!/usr/bin/python3
2
3 print("hello python")
为什么能这么调啊?因为不管是C++,python,java,PHP,在这个世界的所有语言,最后都要转换成进程,只要是进程,就能通过系统调用exec进行替换执行!而exec想要替换,只需要找到你就行了!
5️⃣那我们再看多一个参数的execvpe
与execvp不同的是,多了一个参数envp,表示环境变量。如过我们想把环境变量传进去,就可以用exec**e的程序替换接口。
假设我们现在有自已的命令行参数和环境变量表;
并且想让子进程用execvp替换帮我们执行这个mycmd.cc代码:
//mycmd.cc
#include<iostream>
#include<cstdio>
int main(int argc,char* argv[],char* env[])
{
int i=0;
for(;argv[i];i++)
printf("argv[%d]: %s\n",i,argv[i]);
for(i =0;env[i];i++)
printf("env[%d]: %s\n",i,env[i]);
return 0;
}
我们将myargv和myenv传给他,并告诉他我要执行谁即可:
输出结果:
那如果我们把系统环境变量交给它,它是不是也能帮助我们完成替换执行呢?是的!就相当于我们把系统的数据加载给我们创建的子进程,通过系统调用的方式,把环境变量表和命令行参数表传递给我们的子进程!把它原有的替换掉(现在是系统的数据,如系统环境变量表),帮助我们执行! 用系统环境变量表如图:
运行结果:
也就是传递环境变量表时,默认是摒弃老的环境变量表,使用你自已全新的环境变量表,如果想使用系统的环境变量表,就需要显示传入系统的环境变量表!
那如果我们既想使用系统的环境变量表,又想使用自定义我们自已的环境变量怎么办?根据以前的知识和上述的知识。那我们就天然需要在环境变量表中添加我们的环境变量
我们调用putenv在环境变量表中新增一项:
执行结果:
6️⃣我们再来看execle,只是将execvpe的vector参数形式改成list,并且默认不是在环境变量中查找,要带路径
7️⃣其实还存在一个程序替换接口,execve,它在二号手册,作为系统调用。所以我们以上讨论的六种函数调用方式最后都是对execve的封装,最后都调用了系统调用(因为库和系统调用是上下层关系),只是参数表示形式不同,为了实现不同的功能需求。说白了,只有execve是真正的系统调用,其它五个函数最终都调用execve,所以execve在man手册 第2节,其它函数在man手册第3节,他们的关系如图:
最后区别一下这exec系列程序替换接口参数使用:
感谢你耐心地阅读到这里,你的支持是我不断前行的最大动力。如果你觉得这篇文章对你有所启发,哪怕只是一点点,那就请不吝点赞👍,收藏⭐️,关注🚩吧!你的每一个点赞都是对我最大的鼓励,每一次收藏都是对我努力的认可,每一次关注都是对我持续创作的鞭策。希望我的文字能为你带来更多的价值,也希望我们能在这个充满知识与灵感的旅程中,共同成长,一起进步。如果本篇文章有错误,还请大佬多多指正,再次感谢你的陪伴,期待与你在未来的文章中再次相遇!⛅️🌈 ☀️