进程替换是什么
fork之后,父子进程各自执行父进程的代码的一部分,父子代码共享,数据写时拷贝各自一份。
但是,如果子进程不想执行父进程的代码,就想执行一个全新的代码呢?
这就需要用到 进程程序替换
所谓的程序替换,就是某进程通过特定的接口,加载磁盘上的一个全新的程序(代码和数据),加载到调用进程的地址空间中
从而达到 去执行其他程序的目的
下面的图解释了进程程序替换的基本过程
当然,上面的替换过程是 用操作系统的相关接口即可完成
两个问题:
- 进程替换有没有创建新的子进程 ?
没有!因为这里仅仅是加载,重新建立映射关系,进程的pcb、优先级、状态这种内核结构根本没有发生变化,因此并没有创建新进程 - 如何理解所谓的将程序 放入 内存中?
这个过程就是加载, 加载也有加载器(就像链接有链接器一样),操作系统帮你把数据从一个硬件(磁盘)搬到另一个硬件(内存),这个过程并不是从硬件层面上加载,而是操作系统提供了相关接口的!因此所谓的exec系列函数,本质就是如何加载程序的函数
基本操作
最简单的execl函数
execl函数
int execl(const char* path,const char* arg, ...)
首先要找到程序,path就是程序所在的路径 + 目标文件名。 注意path既可以是相对路径,也可以是绝对路径。
其中 ...
表示的是: 可变参数列表,即可以传入多个不定个数参数
除了path之外,后面的一串参数怎么传入呢? 这一串参数表示你找到的这个程序,你想要怎么执行他 👇
在命令行上怎么执行,就在这里参数一个一个怎么填
比如: ls -a -l
这条命令, 在这里就是分别传入/usr/bin/ls
,ls
,-a
,-l
这四个参数 注意,都是字符串形式!
如果不知道ls
所在的位置,就可以利用 which ls
找到其所在位置
但是,必须注意的是,最后一个参数必须以 NULL
结尾!表示参数传递完毕
即传入:/usr/bin/ls
,ls
,-a
,-l
,NULL
当然上面这样运行的ls命令是没有颜色的, 如果需要颜色,就可以在加一个参数: --color=auto
int main(){
cout <<"当前进程的开始代码"<<endl; //成功打印
execl("usr/bin/ls","ls","-l","-a","--color=auto",NULL);
cout <<"当前进程的结束代码"<<endl; //不打印
}
为什么 main函数中的 执行完execl函数之后,后面代码不打印呢??
因为,execl是程序替换,调用该函数成功之后,会将当前进程的所有代码和数据都进行替换,包括已经执行的和没有执行的!
执行完execl,代码和数据都换成了 usr/bin/ls
的代码和数据,原来的代码都没了,去哪里执行呢?
excel返回值:
- 调用成功:没有返回值
- 调用失败:返回-1
因为excel本身也属于原本进程的代码,替换成功之后,原本进程的代码和数据被替换掉,即excel本身和其返回值自然也被替换掉了
所以execl的调用: - 调用成功: 代码和数据全部被替换,后续所有代码,全都不会被执行!
- 调用失败:(比如调用不存在的命令),代码和数据不会被替换,会继续执行后续代码。
因此excel根本不需要函数返回值的判断,直接在excel后面加一个exit(1)即可,如果excel执行失败,后续代码就会执行,因此直接异常退出即可!
调用execl函数有两种方式:
- 不创建子进程
不创建子进程就是在本进程中调用execl(上面那种),然后本进程的代码和数据被替换为目标进程的代码和数据,该进程后面的代码就无法执行了。但是,如果在本进程进行替换,那么本进程后面的代码就失效了,因此常常使用创建子进程的方式让其进行程序替换! - 创建子进程
让子进程去执行其他的代码,把子进程的程序做替换,子进程的程序和代码发生变化不会影响到父进程(独立性),子进程完成任务之后进行回收即可,父进程可以继续执行后面的代码并做收尾工作。
为什么要采用创建子进程的方式? 这里有一个通俗的例子:
如果我这样做:
while(1)
{
1. 显示一个提示行:root@localhost#
2. 获取用户输入的字符串,fgets,scanf 。 -> ls -a -l
3. 对字符串进行解析
4. 然后利用execl()进行程序替换,执行得到的字符串对应的程序
}
那么上面这段伪代码,得到的是什么? – 其实就是一个简单的shell
因此如果不创建子进程,替换的进程只能是父进程
如果创建了子进程:
- 替换的进程就是子进程,而不影响父进程
- 另一方面我们想让父进程聚焦在:读取数据、解析数据、指派进程执行代码的功能
注意
加载新程序之前,父子进程的数据和代码的关系: 代码共享,数据写时拷贝
当子进程加载新程序的时候,子进程要把自己的代码和数据进行替换,但是子进程的代码和数据都是继承自父进程。
数据进行替换的时候发生写时拷贝,可以做到。
但是,代码是和父进程共享的。 如果要把新的代码替换子进程的代码,即对代码进行写入。
也就意味着,必须将父子代码分离! 如果不分离,替换之后立马会影响父进程! 因此代码也会发生写时拷贝
这样,父子进程在代码和数据上就彻底分开了(虽然曾经并不冲突)
所以一般而言,父子进程代码数据共享,数据写时拷贝
但是在程序替换这种场景,代码和数据都要写时拷贝
其他函数
execv
int execv(const char* path, char* const argv[])
execl可以想象成是 exec + list,即后面的 l 我们看作list。 即他的参数像list的节点一样,一个一个往后跟
而execv可以看作 exec + vector,所以第二个参数开始传入参数的时候,把要传入的命令构建出一个 指针数组 (char*argv[]
)
当然同样需要以NULL结尾。 和execl其实没有本质区别,只是参数传递的方式有所不同。
char* const argv[] = {"ls","-a","-l",NULL};
execv("/usr/bin/ls",argv);
execlp
int execlp(const char* file,const char* arg, ...)
这个函数相对于execl来说,名字多了一个p,第一个参数从path
变成file
也就是只需要给文件名,不需要给文件路径
但是要执行程序,必须先找到程序。 如果不带路径,如何找到程序呢?
— 利用环境变量可以找到
因此,execlp会自己在系统环境变量PATH中进行查找,不用告诉他要执行的程序在哪里。
(除此之外,带p的函数一般就是会自动去PATH中查找程序)
//用法:
execlp("ls","ls","-a","-l",NULL);
这里不免会产生一个问题
execlp("ls","ls","-a","-l",NULL)
, 第一个已经传递了一个"ls", 为什么还要传递一个 “ls” ??
因为这里的两个ls表示的意义并不一样, 第一个 ls 表示 文件名
- 第一个ls 表示文件名,要执行谁,是为了找到程序
- 第二个ls 表示如何执行文件,即程序能找到,要传递什么选项
多知道一些
程序替换所用的后面的一系列参数,非常像main函数的命令行参数,他也是以NULL结尾的。
所以目标程序才能获得这些参数来执行
实际上,就是exec系列函数,把参数传递给 目标要替换程序的命令行参数
在这个例子里, 就是"ls" "-a" "-l" NULL
这些参数,通过execlp函数,传递给ls
这个可执行程序的命令行参数来执行程序。
execvp
int execvp(const char*file , char* const argv[]
这个函数就要对比execv函数来看了,因为带了p,所以就是会自己在环境变量PATH中查找,其他的用法和execv一样
```c
char* const argv[] = {
(char*)"ls",
(char*)"-a",
(char*)"-l",
NULL
};
execvp("ls",argv);
为什么是 char* const
类型 而不是 const char*
? 或者 const char* const
或者char*
看笔者这一片文章:👉戳我
如何执行其他程序自己写的C、C++程序
我想用我写的程序A,调用我写的程序B,如何实现?
以一个代码作为例子:
mycmd.cpp:
//mycmd.cpp
#include<iostream>
#include<string.h>
#include<stdlib.h>
using namespace std;
// 假设只有 mycmd -a/-b/-c 选项
// argc为命令行参数数量, argv是传递的命令行参数
int main(int argc,char* argv[]){
if(argc!=2) {
cout <<"can not execute"<<endl;// 如果一个选项都不传递,就无法执行
exit(1);
}
//如果传递的第一个参数是a,就执行这个
if(strcmp(argv[1],"-a") == 0){
cout <<"hello a!"<<endl;
}
//如果传递的第一个参数是b
else if(strcmp(argv[1],"-b") == 0){
cout <<"hello b!"<<endl;
}
else{
cout <<"default"<<endl;
}
return 0;
}
exec.cpp:
#include<iostream>
using namespace std;
#include<unistd.h>
#include<stdlib.h>
#include<sys/wait.h>
const char* path = "./mycmd";//相对路径
const char* path1= "/home/ky/process_replace/execute_other_cpp/mycmd"; //绝对路径
int main(){
pid_t id = fork();
//子进程进行程序替换
if(id == 0){
cout <<"子进程开始运行"<<endl;
execl(path,"mycmd","-a",NULL); //传递mycmd.cpp生成的可执行程序 mycmd
exit(1);
}
//父进程阻塞等待
else{
cout <<"父进程开始运行,pid: " << getpid() <<endl;
int status = 0;
pid_t ret = waitpid(-1,&status,0);
//等待子进程成功,子进程退出
if(ret > 0){
cout<<"wait success! the exit code is:"<<WEXITSTATUS(status) <<endl;
}
}
return 0;
}
这里因为要执行另一个cpp程序即:mycmd
因此要用到mycmd.cpp编译好的mycmd可执行文件,因此可以利用makefile同时编译两个cpp文件 makefile一次形成两个可执行
如何执行其他语言的程序,如python
方法1:利用程序替换
因为相比于C/C++这种纯编译性语言,python、shell、java、javac这样的语言都是有对应的解释器的。解释器就是一个程序,python test.py 其实就是把test.py作为一个参数的形式,传递给python解释器这个可执行程序,然后再python程序内部直接去执行解释这个文件。
如下面是一个test.py文件
#! /usr/bin/python3.6
print("hello python")
# [root]$ python test.py -> 命令行执行py文件
即python test.py
就对应ls -al
用到exec系列函数,那么path就对应的python解释器的文件路径,而后面就是在命令行执行的参数
# 子进程中:
execlp("python","python","test.py",NULL);
下面是一个test.sh即shell脚本
#! /usr/bin/bash
echo "hello shell"
执行 shell test.sh
用到exec系列函数,同理:
execl("/usr/bin/ bash","bash","test.sh",NULL);
补充知识
这里注意到 #! /usr/bin/bash
这样的注释,是linux下对于脚本语言指定特定的脚本解释器。
linux系统下脚本特殊注释指定解释器
方法2:利用更改执行权限
以python文件为例,可以给文件添加执行权限, 然后利用./test.py
即可运行
chmod +x test.py
: 给test.py添加x权限
这样在执行这个python脚本,实际上它是会自动找到python的解释器,直接执行代码
这样如果带了可执行权限,就等价于:
execlp("./test.py","test.py",NULL)
也就是说,本身exec系列函数就可以执行任何程序(因为是系统调用)
注意,必须在test.py即脚本程序中指定解释器,不然就会出现下面的错误:
execle
int execle(const char* path, const char* arg, ... , char* const envp[]
对于基础的exec, l
表示想怎么执行这个程序,就把程序一个一个的传递,所以第二个参数是一个可变参数列表
对于e
表示环境变量,但是并没有带p
,因此就要带全路径path,e
则带了一个char* const envp[]
的参数,这是一个环境变量。即,自己主动传递环境变量。
因为最终目标要替换的进程的程序路径、参数选项、环境变量,是由execle这个系统接口,传给要替换的程序的main函数的
execle的使用场景:
execle用在需要传递环境变量的场景,如果期望替换后的程序需要使用原进程(或者原进程的父进程)的环境变量,那么可以使用execle函数传递环境变量
环境变量具有全局属性,可以被子进程继承下去
环境变量是一个指针数组,并且是kv模型, 每个元素都"key=value"
父进程的环境变量,子进程可以直接用
示例: exec调用mycmd, mycmd获取指定的环境变量,exec程序替换时传递环境变量给mycmd
// mycmd.cpp,获取 MY_VAR这个环境变量
#include<iostream>
#include<string.h>
#include<stdlib.h>
#include<stdio.h>
using namespace std;
// 假设只有 mycmd -a/-b/-c 选项
// argc为命令行参数数量, argv是传递的命令行参数
int main(int argc,char* argv[]){
if(argc!=2) {
cout <<"can not execute"<<endl;// 如果一个选项都不传递,就无法执行
exit(1);
}
//获取环境变量
printf("获取环境变量:%s\n",getenv("MY_VAR"));
//如果传递的第一个参数是a,就执行这个
if(strcmp(argv[1],"-a") == 0){
cout <<"hello a!"<<endl;
}
//如果传递的第一个参数是b
else if(strcmp(argv[1],"-b") == 0){
cout <<"hello b!"<<endl;
}
else{
cout <<"default"<<endl;
}
return 0;
}
//exec.cpp ,调用execle函数,进程替换的同时,传递环境变量
#include<iostream>
using namespace std;
#include<unistd.h>
#include<stdlib.h>
#include<sys/wait.h>
const char* path = "./mycmd";//相对路径
int main(){
pid_t id = fork();
// 定义环境变量
char* const env[]={
(char*)"MY_VAR=Process replace",
(char*)"mode=easy",
NULL
};
//子进程进行程序替换
if(id == 0){
cout <<"子进程开始运行"<<endl;
execle(path,"mycmd","-a",NULL,env); //进程替换
exit(1);
}
//父进程阻塞等待
else{
cout <<"父进程开始运行,pid: " << getpid() <<endl;
int status = 0;
pid_t ret = waitpid(-1,&status,0);
//等待子进程成功,子进程退出
if(ret > 0){
cout<<"wait success! the exit code is:"<<WEXITSTATUS(status) <<endl;
}
}
return 0;
}
execvpe
int execvpe(const char *file, char *const argv[],char *const envp[]);
p表示程序会在环境变量中找
v表示命令行参数的传递不是采用可变参数列表,而是采用一个char*数组
来接受。简单
char* const env[]={
(char*)"MY_VAR=123456789",
NULL
};
char* const argv[]={(char*)"mycmd",(char*)"-a",NULL};
execvpe("mycmd",argv,env); //自动在PATH中找mycmd
前提是:要先把mycmd所在的文件夹导入到系统的环境变量PATH中才可以,因为这个mycmd是你自己写的程序。
echo $PATH #输出当前环境变量
export PATH=$PATH:. # 配置环境变量为之前的环境变量 + 当前目录
# : 是环境变量的分隔符
不用担心,这是更改的用户环境变量,退出窗口下次登录又回到默认了
总结
exec* 系列函数: 功能实际上就是加载器的底层接口,用这样的接口实际上就可以把任何程序加载进来。
区分execve和其他函数
- execl
- execlp
- execle
- execv
- execvp
- execvpe
上面这六个,严格意义上来讲,都不是系统直接提供给我们的!准确的说他们是C库函数,而系统直接给我们提供的类似程序替换的接口其实只有一个,而是 execve
int execve(const char* filename,char* const argv[],char* const envp[])
这个函数是系统内核可调用函数,即系统调用
如何验证呢? 上面的6个使用man 3查询,而execve是用man 2查询,这点就可以看出
注意:这里的execve没有带p,因此filename也是需要提供全路径的
当然在程序里也可以直接使用execve这个系统调用,而不使用上面的C库函数
但上面的6个接口,底层实际上都调用的 execve这个函数,他们会把接收到的参数做合并和其他处理,最后处理成符合 execve 接口参数的要求。
之所以要提供这些封装,是因为应用程序替换接口时的场景不一样,有时候知道程序的路径,有时候只知道程序名,有时候参数是一个个可以传,有时候必须使用数组,有时候要带环境变量,有时候不用带。
封装这些的最终目的就是满足上层调用的不同场景。
为什么要进行程序替换(总结)
一定和应用场景有关,我们有时候,必须让子进程来执行新的程序!!