【Linux系统编程】进程控制(三)——进程程序替换
比execvp多了一个e那它又有什么不同呢?我们看到它在后面又多了一个参数,也是一个char*数组,名为envp我们之前环境变量那篇文章,学习main函数的第三个参数,不就和它一样嘛所以,这里第三个参数,使得进程可以显式传递新环境变量数组,不继承父进程环境那下面就修改代码继续演示这个接口:上面我们演示的都是重新替换执行系统的一些命令,当然程序替换也可以执行我们自己写的程序。比如我们现在新写一个C+
文章目录
在之前我们演示的所有场景中:
fork创建子进程之后(父子进程代码共享),我们都是通过if分流使父子进程分别执行同一份代码中的不同代码块
那如果现在我们想让子进程执行一个全新的程序呢?——进程程序替换就可以完成这件事!
1. 程序替换原理
下面先来简单理解一下原理,然后我们来写代码
初步理解:
程序替换是通过特定的接口,将磁盘上一个全新的程序(代码和数据),覆盖到调用进程的地址空间中。
看下面这张图:
经过前面的学习我们知道:一个程序运行起来变成进程,除了要把启动时所需要的代码和数据加载到内存,操作系统还会在内核中给进程创建对应的进程控制块——PCB(task_struct)
在每个进程的task_struct中,会有一个mm_struct*的结构体指针——mm,指向该进程的进程地址空间/虚拟地址空间(由mm_struct结构体描述)。mm_struct中还有一个指针pgd_t *pgd;指向当前进程的页表,通过页表,可以完成虚拟地址到物理地址的转换。
当一个进程调用exec系列函数(我们后面会讲解这些接口)进行程序替换(需要指明你要替换的程序),则这个新的程序的代码和数据会覆盖到调用进程的地址空间中(地址空间被重新初始化),然后当前进程就会执行新程序的代码(原来的代码不管执行到哪里,后面的都不会再执行了)。程序替换并不会创建新进程,所以替换前后进程的PID不变。
有了上面原理的简单理解,下面我们直接写代码,看一看如何进程程序替换!
2. 替换函数
通过调用exec系列的函数,就可以进行进程的程序替换。
2.1 execl
先来学习第一个接口——execl
int execl(const char *pathname,const char *arg, ...
/* (char *) NULL */);
参数和返回值
介绍一下:
第一个参数pathname,接收你要替换的程序(可执行文件)的路径,即你要替换的新程序是谁。
那后面的参数怎么传呢?...又是啥呢?
比如我现在想进行一个程序替换,让我的进程不再执行原来的代码,而是替换去执行ls命令(本质也是一个可执行程序嘛)
那第一个参数就传ls命令的路径(先找到它),后面的参数:你执行ls命令时在命令行输入什么,你就传什么!(如何执行它),但是最后一个参数必须是NULL(最后的注释就是这个意思),用来表示参数列表结束。
比如:
你要执行ls -l,那参数就传("/usr/bin/ls","ls","-l",NULL)ls -a -l,就传("/usr/bin/ls","ls","-a","-l",NULL)
因为我们执行一个命令/程序时候,是可以带选项的
并且选项的个数可能每次带的是不一样的,所以这里函数的设计采用了可变参数列表。
可变参数列表可能大家有点并不是很了解,其实我们是用过的,C语言中的printf、scanf其实就使用了可变参数列表
当然我这里也写了一个简单的样例,大家可以看一下,简单理解一下可变参数列表即可
下面的样例使用可变参数实现了一个可以求任意数量个整数的最大值:
所以,总结一下:
第一个参数传程序的完整路径,第二个参数通常传程序名,后面你要带什么选项,就传什么,如果不带选项,那就可以不传,但是必须以NULL结尾
返回值:
如果替换成功,则执行新替换程序的代码,不会返回(即替换成功没有返回值,也不需要)
如果替换失败,返回 -1,并设置 errno 指示错误原因。
即今天学习的这些exec系列的函数,只要返回,必然就是失败了。
使用
下面我们就来写个代码演示一下:
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("我是一个进程:%d\n", getpid());
sleep(1);
execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
printf("运行结束\n");
return 0;
}
分析一下:
所以,预期的结果应该是:
程序启动后,先打印“我是一个进程:pid”,然后休眠一秒,休眠结束,则进行程序替换。那进程的代码和数据将被替换成ls命令的代码和数据,所以接着我们就能看到ls -a -l的执行结果,然后进程退出!
那第二个printf不会打印吗?
当然不会,因为一旦进行程序替换,原来执行的代码就被替换掉了,就没啦!
运行看看:
休眠一秒
没有问题,和我们预期的结果一样!
为什么进行了程序替换,但还是执行了原进程代码的第一个printf和sleep休眠呢?
因为执行这两句代码的时候我们还没有调用execl进行程序替换;
为什么第二个printf没有执行?
因为在它之前我们就进行了程序替换。
上面是替换成功的案例,那来一个替换失败的:
如何搞出一种失败的场景呢,很简单
把程序的路径改成一个错误的路径,那就找不到对应的程序,自然就会替换失败了。
既然替换失败,那原进程后面的代码也就会继续执行了,那我们接收一下返回值(就应该是-1),并打印一下
没有问题。
所以其实这样写就行:
不需要接收返回值,也不需要做什么判断。
因为如果替换成功,那就执行替换程序的代码,后面的替换失败也不会打印;
如果替换失败,那就继续执行后面的代码,就会打印替换失败(即只要执行了execl之后的代码,那一定是替换失败了)
既然调用exec系列的函数可以对调用的进程完成程序替换,那当然就可以做到我们一开始提出的,让子进程去执行一个全新的程序,这才是主流的应用场景(上面我们并没有创建子进程,直接对当前进程进行了替换)。
对子进程进行程序替换
首先来思考一个问题:父子进程不是代码共享,数据写时拷贝嘛?那现在如果对子进程进行程序替换,让子进程执行一个全新的程序,那会影响父进程嘛?父进程的代码和数据会变化吗?
当然不会影响,也不应该影响,因为进程之间具有独立性。而且我们上面提到程序替换是将一个全新的程序(代码和数据),覆盖到调用进程的地址空间中。
而每个进程都会有自己独立的进程地址空间(回忆一下上篇文章补充章节的第四点),所以,子进程的代码和数据被替换,并不会影响父进程。
原理
再来谈一谈原理:
通过前面的学习我们知道,fork创建子进程之后,子进程会以父进程的task_struct作为模板,只修改诸如pid,ppid这些属性,大部分直接拷贝父进程task_struct中的属性值。
其中mm指针,上篇文章我们讲的是——结构体级别深拷贝(深拷贝mm指针,父子进程的mm指针指向不同的mm_struct结构体变量,拥有独立的进程地址空间),物理内存级别写时拷贝(如果发生数据写入,则进行物理层面的写时拷贝)
子进程的页表也是以父进程的页表为模板创建的。
所以,刚fork之后,就应该是这样的:
父子进程代码共享,数据写时拷贝(不发生写入也是共享)
那现在如果子进程调用了exec系列的函数进行程序替换,那么新程序的代码和数据就会加载到物理内存,然后通过页表映射覆盖到子进程自己的地址空间中,并不会影响到父进程
加载新的代码和数据到物理内存,然后页表映射到子进程自己的地址空间(mm_struct不会新建,复用之前的,但会彻底清空并重新初始化进程地址空间),不影响父进程!
那讲到这里,我们就可以关联一下我们之前学过的知识了:
第一点:
命令行启动的大部分指令/程序,都是bash创建子进程去执行的(内建命令由bash自己执行)。
比如我们执行一个ls -a -l,那他就是由bash的子进程去执行的,默认父子进程是代码共享的,但是为啥这种情况子进程并没有执行bash的代码,而是执行了ls命令的代码呢?
所以,bash的原理其实就是创建子进程,然后让子进程进行程序替换执行一个指定的新程序/命令,然后bash最后再调用waitpid对其进行回收!
后面我们会模拟实现一个简易版的shell,到时候大家会更加清晰。
第二点:
一个程序要被执行变成进程,必须先加载到内存,这是冯诺依曼体系结构规定的。
要变成进程,首先一定要创建task_struct等内核数据结构,至于代码和数据,可以按需加载(执行一个程序,先只加载目前需要的部分,给他分配物理空间,此时可能并不需要执行所有的代码和数据,其它不需要的就先不加载)
那么问题来了:如何加载呢?
🆗,我们这里学的exec系列的函数,进行程序替换,不就相当于一种“加载器”嘛!(回忆回忆刚才讲的原理)
演示
那下面就来写代码演示一下让子进程进行程序替换:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdlib.h>
int main()
{
pid_t id = fork();
if (id == 0) // 子进程
{
printf("我是一个进程:%d\n", getpid());
sleep(1);
execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
printf("替换失败\n");
exit(1);
}
// 父进程(id=子进程pid)
sleep(3);
pid_t rid = waitpid(id, NULL, 0);
if (rid > 0)
{
printf("等待成功!\n");
}
return 0;
}
看看现象:
没什么问题子进程进行程序替换执行ls命令,然后退出,最后父进程调用waitpid将其回收!
并且,因为父进程在调用waitpid之前休眠了三秒,所以如果我们监控进程状态的话,我们预期能够看到这样的现象:
运行程序之后,刚开始会看到两个进程——父子进程都处于运行状态,然后子进程先运行结束(不论是程序替换成功还是失败),此时父进程还在sleep休眠,所以在父进程休眠结束调用waitpid之前,我们应该能够看到子进程处于僵尸状态,等父进程休眠结束,调用waitpid回收子进程,僵尸状态消失,然后父进程也退出(随之被bash回收)
但是呢,我们实际看到的现象是这样的:
我们会发现,如果子进程重新替换失败,现象符合我们上面的预期;但是替换成功的情况下,我们不会看到子进程变成僵尸状态,会发现子进程一开始是S+运行状态,后面某个时刻子进程的状态信息就直接消失了!
这是怎么回事呢?
🆗,原因在于,重新替换成功,除了会做我们上面提到的那些事情,进程的名字是会变成替换的新进程的名字的!
所以上面的代码,子进程替换成功,它的程序名就由test变成ls了,所以我们使用test过滤就看不到子进程处于僵尸状态了!
如果你把过滤条件中的test换成ls,你就可以看到子进程运行完变成僵尸状态。
而如果替换失败,那子进程的进程名自然也不会改变,所以就直接可以看到!
所以:
上面我们说程序替换并不会创建新进程,所以替换前后进程的PID不变。
但是进程名会变成替换之后的进程名字。
再往下,我们来学习其它的程序替换接口
2.2 execv
参数
这个叫做execv ,上面那个叫做execl,那两者的区别是啥呢?
一图总结:
两个接口第一个参数完全一样,区别在于后面的参数。
所以,使用execv接口,只需把我们使用execl接口的第一个参数后面的所有参数放到一个字符指针数组中,然后把这个数组作为第二个参数传递即可。
使用
所以,现在使用execv来完成上面的重新替换,非常简单
看看结果
没问题!
看到这里的数组名为argv,且类型为字符指针数组,你是否能联想到我们之前讲过的一个知识点?
应该要能够联想到我们之前学习的命令行参数。
所以,下面就可以这样来修改我们的代码:
我们可以获取命令行参数,然后传递给execv,这样我们要替换一个程序,直接在命令行给出要替换的程序路径,如何执行的信息,然后就可以完成这个重新替换。
如果想换一个程序进行替换,直接在命令行输入一个新的程序的相关信息就可以!
改一下代码:
试一下:
现在我们通过命令行输入就可以控制子进程替换不同的程序。
那么现在:
把所有打印信息和sleep休眠注释掉;
并且mv test myload
现在不就相当于我们实现了一个加载器嘛!
当然也可以这样
myargv无需+1,命令行我们不用再传命令/程序名(相当于程序名也复用路径,这当然没问题),直接路径+选项
2.3 execlp
继续学习下一个接口——execlp:
int execlp(const char *file, const char *arg, ...
/* (char *) NULL */);
比
execl多了一个p,首先带l(list)就表示参数以可变参数列表形式传递。
那p是什么意思?p(path):自动在 PATH 环境变量指定的目录中搜索可执行文件,无需写完整路径(给出程序名即可)。
当然:如果是我们自己写的可执行程序(且路径没添加到PATH 中),就不能用PATH搜索到,那就替换失败!
试一下:
下一个
2.4 execvp
int execvp(const char *file, char *const argv[]);
很简单了
那就是在execv的基础上,第一个参数可以不用传完整路径,只需传程序名。
即第一个参数传程序名(要替换哪个程序),后面的参数(如何执行)以v(vector)数组的形式传递。
试一下:
2.5 execvpe
介绍
int execvpe(const char *file, char *const argv[],
char *const envp[]);
比execvp多了一个e(environment):
那它又有什么不同呢?
我们看到它在后面又多了一个参数,也是一个char*数组,名为envp
我们之前环境变量那篇文章,学习main函数的第三个参数,不就和它一样嘛
所以,这里第三个参数,使得进程可以显式传递新环境变量数组(char *envp[]),不继承父进程环境
那下面就修改代码继续演示这个接口:
上面我们演示的都是重新替换执行系统的一些命令,当然程序替换也可以执行我们自己写的程序。
比如我们现在新写一个C++程序:
然后编译生成可执行程序——mycmd。
那现在我们就可以让子进程重新替换执行我们自己这个程序,比如我们先用一下execl:
运行
没问题!
甚至也可以exec系列的函数程序替换执行其它语言的程序(比如python,Java),只要传入正确的路径和执行指令即可。
演示及深入理解
然后再来回到我们的execvpe接口,第三个参数我们该怎么使用呢?
那先把我们上面那个C++的程序改一下
现在执行这个程序就可以打印当前进程的所有命令行参数和环境变量
再往下,我们来正式使用execvpe接口
上面我们说通过第三个参数我们可以给调用进程显式传递环境变量,而不继承父进程的环境变量。
刚才我们直接在命令行执行mycmd程序(那它就是bash的子进程),我们看到它打印了命令行的输入参数和默认继承下来的环境变量。
但是现在,我们通过重新替换执行mycmd,并且我们显式给它传递了命令行参数和环境变量。
那这次的结果会是怎么样呢
我们看到这样mycmd(重新替换成功,mycmd变成子进程了)进程拿到的就是我们exec重新替换是显式传递的命令行参数和环境变量。
那如果现在我们不想自己给他传递环境变量,想让它继承父进程的怎么做呢?
那我们就传environ不就行了
这是啥呀!
我们之前也讲过:environ是一个全局变量,它指向当前进程的环境变量表!
然后再来运行
这次就打印了父进程的环境变量(这不就相当于继承了父进程的环境变量嘛)
所以:
int execvpe(const char *file, char *const argv[],
char *const envp[]);
参数argv传递的内容,就是替换成功后进程的命令行参数;
参数envp传递的内容,就是替换成功后进程的环境变量。
那如果我即想把父进程的环境变量传下去,也想加入自定义的环境变量怎么办呢?
那就先把你自定义的添加到当前的环境变量表中,然后再传。
putenv
如何添加我们之前讲过,可以用export
这里再补充一个用来添加环境变量的接口——putenv
它就可以向当前进程的环境变量表中新增一项
这次再来运行
就打印了父进程原有的+我们自定义的(相当于先添加,再让子进程继承)
2.6 execle
相信讲了上面5个,这个就不需要再多费口舌了
int execle(const char *pathname, const char *arg, ...
/*, (char *) NULL, char *const envp[] */);
即在execl的基础上,后面可以再传环境变量数组,演示一下
结果:
至此:
6个重新替换的库函数全部讲完!
这6个都是库函数,但是它们的底层全部要调用一个系统调用——execve
2.7 execve(系统调用)

int execve(const char *pathname, char *const argv[],
char *const envp[]);
相信也不用介绍了,这三个参数大家现在肯定一看就懂!
一图总结:
上面的六个函数,底层都是对execve进行封装,在传参形式上做了些花样!
那现在就有一个问题:
底层都调用execve(有e),那就都要传
char *const envp[],那上面的6个函数中,没e的那几个咋办呢?
🆗,没e,但是底层依然会调用execve。那这种情况在底层调用execve的时候就会默认传environ。
即调用不带e的替换函数,就不能传自定义的环境变量,只能继承父进程的。
3. 完美总结:如何理解子进程可以继承父进程的环境变量
环境变量那篇文章我们给过一个结论:环境变量通常具有全局属性,可被子进程继承。
那今天,我们觉得就可以好好地理解这句话了:为什么子进程可以继承父进程的环境变量?
可以分为两种方式:
继承方式一:
fork之后子进程的地址空间复制自父进程,所以可以继承;
继承方式二:
但是如果进行了程序替换(不带e的接口),进程地址空间会被重新初始化,这时通过调用execve(底层都会调这个系统调用)传递environ参数(如果没有显式传递自定义环境变量数组)完成继承,或者使用带e的接口,但是依然传递environ给参数envp
当然如果程序替换的时候,使用了带e的接口,并且自己传递了自定义的环境变量数组,那子进程的环境变量就是传递的自定义环境变量
更多推荐


























































所有评论(0)