目录
- 一、进程程序替换
-
- [1.1 直接看程序替换的现象](#1.1 直接看程序替换的现象)
- [1.2 介绍原理](#1.2 介绍原理)
- [1.3 fork 程序替换版本](#1.3 fork 程序替换版本)
- [1.4 程序替换函数](#1.4 程序替换函数)
-
- [1.4.2 execlp](#1.4.2 execlp)
- [1.4.3 execv](#1.4.3 execv)
- [1.4.4 execvp](#1.4.4 execvp)
- [关于 程序替换](#关于 程序替换)
- [1.4.5 execvpe](#1.4.5 execvpe)
- [1.4.6 总结](#1.4.6 总结)

个人主页:矢望
个人专栏:C++、Linux、C语言、数据结构、Coze-AI
一、进程程序替换
1.1 直接看程序替换的现象
首先上一段代码:
cpp
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
printf("我是一个进程,pid: %d\n", getpid());
// 执行另一个程序的代码
execl("/usr/bin/ls", "ls", "-a", "-l", NULL); // 程序替换函数
printf("我正在执行我的代码~\n");
printf("我正在执行我的代码~\n");
printf("我正在执行我的代码~\n");
printf("我正在执行我的代码~\n");
printf("我正在执行我的代码~\n");
return 0;
}
先将execl那一行注释掉运行程序,再将execl那一行放开再运行程序,查看结果。

让我的进程去执行其它程序的代码,这就是程序替换。
1.2 介绍原理
我们在上面./myexec的时候,OS就会给这个进程创建 PCB、虚拟地址空间、页表,并且加载 程序的代码和数据。当我们注释掉execl的时候,程序就会顺着加载好的代码依次向后运行。
而当我们执行到execl的时候,是调用了另一个程序,只要是程序,就会有自己相应的代码和数据,而execl就是将它要执行的程序的代码和数据加载到老进程的代码段和数据段中,将老进程的给覆盖掉 。所以我们看到的现象是程序直接运行到一半去执行ls的命令去了,老进程的后半截代码和数据被覆盖了。如下图所示。

补充:exec* 系列的函数,调用成功的时候,没有返回值,也没办法返回。
那么程序替换有没有创建新的进程呢? 没有创建新进程,PCB还是之前的。
程序替换的本质是将磁盘中的代码和数据拷贝到内存中 。那么加载到内存是怎么做到的呢?加载到内存的工作只有OS有资格做这个IO的过程,所以OS必须提供对应的系统调用 ,来完成加载到内存的工作。而这个系统调用就叫做程序替换。
更深层次 ,Linux启动时最早开始运行的就是加载器,这个加载器调用程序替换就将自己转化成了进程 。一切都是进程,进程通过fork和exec创建,连系统启动也不例外!
1.3 fork 程序替换版本
我们上面写的程序替换版本没有意义,它确实发生了程序替换,但是进程本身的代码被覆盖了,原来的代码无法正常执行了,所以正常在使用时,都是fork创建子进程,让子进程执行程序替换,完成子任务。
cpp
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main()
{
printf("我是一个进程,pid: %d\n", getpid());
pid_t id = fork();
if(id == 0)
{
// 执行另一个程序的代码
execl("/usr/bin/ls", "ls", "-a", "-l", NULL); // 程序替换函数
exit(0);
}
wait(NULL);
printf("我正在执行我的代码~\n");
printf("我正在执行我的代码~\n");
printf("我正在执行我的代码~\n");
printf("我正在执行我的代码~\n");
printf("我正在执行我的代码~\n");
return 0;
}
运行结果 :

在这个过程中,fork之后,父子进程的虚拟地址空间和页表一样,当子进程执行execl时,新的代码段和数据段被加载到内存中,子进程就会指向新的代码段和数据段,此时子进程的代码段和数据段就和父进程的完全不同了。至此往后,父子彻底分离了,如下图所示。

fork版本的程序替换最重要的应用场景就是bash,你在命令行输入一串字符串,bash就会将它给exec*,这样bash就可以以子进程的方式,执行你要执行的命令。
1.4 程序替换函数
有六种以exec开头的函数,统称为exec函数 。

我们要执行任何程序的流程,首先要找到程序,并且加载它 ,也就是需要程序的路径+程序名, 其次就是你要怎样执行这个程序,这就由你要执行的程序和选项决定。
以第一个函数execl为例,我们之前使用过它了。

关于可变参数,没有了解过的可以看这个博客:点击跳转。
C++11可变参模板不需要使用NULL来标记结束,但C语言的早期设计是需要加NULL的,所以即使是C++编译的环境下调用execl系列函数,需要以NULL结尾。
所以path那里写入程序的路径,执行程序部分,你平时怎么执行,你就怎么填写。程序替换函数的部分参数确实是可以省略的,但是不推荐。
execl中的l表示传参以列表形式传。
我们所有的exec系列函数,执行成功的时候,都没有返回值 。那么它执行失败呢?

如上图,当执行失败的时候,返回-1,并且错误码被设置。
那么我们需要设置一个变量接收它的返回值吗? 回顾我们之前学习的知识,execl执行成功之后,子进程的代码段和数据段都被覆盖了,也就是说execl执行成功的时候,execl这一行后面的代码都不会被执行,也就是执行成功的时候,下一行的exit(0),根本没机会执行。
反过来讲当执行失败的时候,就会继续执行后面的代码,等到执行exit的时候,就已经证明execl执行失败了,所以我们的exit里面直接设置为!0即可,不需要用一个变量接收它的值。
那么它什么时候执行失败呢? 也就是找不到程序,或者程序执行的命令错误的时候,代码示例:
cpp
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main()
{
printf("我是一个进程,pid: %d\n", getpid());
pid_t id = fork();
if(id == 0)
{
// 执行另一个程序的代码
printf("子进程执行任务中~\n");
execl("/usr/bin/XXX", "ls", "-a", "-l", NULL); // 程序替换函数
exit(1); // 直接设置为 !0 值即可
}
int status = 0;
int rid = waitpid(id, &status, 0);
if(rid > 0)
{
printf("我是父进程,等待子进程成功! rid: %d, exit_code: %d\n", rid, WEXITSTATUS(status));
}
return 0;
}
运行结果 :

如上图,接收到了设置的错误码1。
1.4.2 execlp

它和execl的区别在于它的第一个参数是一个file,其它的和execl完全一样,它的名字多了一个p(path)。
这个函数第一个参数只要传递你要执行的程序名即可,它会自动到环境变量PATH所表明的路径下找这个程序。后面的所有参数和execl一样,表明你想怎么执行这个程序。
代码样例:
cpp
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main()
{
printf("我是一个进程,pid: %d\n", getpid());
pid_t id = fork();
if(id == 0)
{
// 执行另一个程序的代码
printf("子进程执行任务中~\n");
execlp("ls", "ls", "-a", "-l", NULL);
exit(1); // 直接设置为 !0 值即可
}
int status = 0;
int rid = waitpid(id, &status, 0);
if(rid > 0)
{
printf("我是父进程,等待子进程成功! rid: %d, exit_code: %d\n", rid, WEXITSTATUS(status));
}
return 0;
}
运行结果 :

上面的运行符合预期。
那么它执行的时候,传的第一个和第二个参数一样,它重复吗? 不重复,第一个参数是表明你想执行谁,而后面的所有合起来是你要如何执行它 。

其实当你只写一个的时候,你会发现代码也能运行,但不建议这样写,这样会增加记忆成本,当你在执行exec系列其它函数的时候,你又要纠结那些可以省略那些不可以省略了。
1.4.3 execv

接下来,我们来看这个函数,一看到看到它的第二个参数,再看它的整体就有一种茅塞顿开的感觉,和之前的博客一串联,感觉更深了,【Linux】环境变量。
依旧那它和execl做对比,这个函数不是可变参数,它的第二个参数要求传递一个指针数组。execv中的v就是vector的意思。需要注意的是这个指针数组的最后一个参数必须以NULL结尾。它和execl的区别就是第二个参数传参形式的不同。
我们来使用一下它。
cpp
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main()
{
printf("我是一个进程,pid: %d\n", getpid());
pid_t id = fork();
if(id == 0)
{
// 执行另一个程序的代码
printf("子进程执行任务中~\n");
char *argv[] = {
"ls", // argv[0] → 指向字符串常量"ls"
"-l", // argv[1] → 指向字符串常量"-l"
"-a", // argv[2] → 指向字符串常量"-a"
NULL // argv[3] → 空指针
};
execv("/usr/bin/ls", argv);
exit(1); // 直接设置为 !0 值即可
}
int status = 0;
int rid = waitpid(id, &status, 0);
if(rid > 0)
{
printf("我是父进程,等待子进程成功! rid: %d, exit_code: %d\n", rid, WEXITSTATUS(status));
}
return 0;
}
如上,指针数组传递过去之后,execv的第二个参数char *const argv[]中的const是用于保证传过来的数组元素的指向不被修改。
运行结果 :

那么传递的这一批参数最终是给谁了呢? ls命令本身也是使用C语言、C++写的,它的内部也有自己的main函数int main(int argv, char* argv[]),将来传递的选项就直接传给了ls命令所对应的命令行参数!
1.4.4 execvp

它和execv的区别就是第一个选项只需要告诉它程序名,它会自动到环境变量PATH所表明的路径下找这个程序。
使用样例:
cpp
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main()
{
printf("我是一个进程,pid: %d\n", getpid());
pid_t id = fork();
if(id == 0)
{
// 执行另一个程序的代码
printf("子进程执行任务中~\n");
char *argv[] = {
"ls", // argv[0] → 指向字符串常量"ls"
"-l", // argv[1] → 指向字符串常量"-l"
"-a", // argv[2] → 指向字符串常量"-a"
NULL // argv[3] → 空指针
};
execvp(argv[0], argv);
exit(1); // 直接设置为 !0 值即可
}
int status = 0;
int rid = waitpid(id, &status, 0);
if(rid > 0)
{
printf("我是父进程,等待子进程成功! rid: %d, exit_code: %d\n", rid, WEXITSTATUS(status));
}
return 0;
}
运行结果 :

关于 程序替换
学了这么多,我们之前替换的都是系统命令,那么程序替换能不能替换我们自己写的程序?
当然可以! 我会使用两个程序为例子,一个是C++编译的程序,一个是脚本语言。
C++程序:
cpp
#include <iostream>
int main()
{
std::cout << "自己实现的 C++ 代码!" << std::endl;
std::cout << "自己实现的 C++ 代码!" << std::endl;
std::cout << "自己实现的 C++ 代码!" << std::endl;
std::cout << "自己实现的 C++ 代码!" << std::endl;
std::cout << "自己实现的 C++ 代码!" << std::endl;
return 0;
}

接下来,进行程序替换。
cpp
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main()
{
printf("我是一个进程,pid: %d\n", getpid());
pid_t id = fork();
if(id == 0)
{
// 执行另一个程序的代码
printf("子进程执行任务中~\n");
execl("./cmd/mycmd", "./mycmd", NULL);
exit(1); // 直接设置为 !0 值即可
}
int status = 0;
int rid = waitpid(id, &status, 0);
if(rid > 0)
{
printf("我是父进程,等待子进程成功! rid: %d, exit_code: %d\n", rid, WEXITSTATUS(status));
}
return 0;
}
替换结果 :

脚本语言:
bash
#!/bin/bash
# 内核执行时会读取这一行,确定用哪个解释器
echo "hello world!"
printf "hello shell!\n"
echo "hello world!"
printf "hello shell!\n"
echo "hello world!"
printf "hello shell!\n"
echo "hello world!"
printf "hello shell!\n"

脚本语言的运行是先将bash启动起来,然后bash读到脚本的第一行时,就知道如何执行它,然后它就会逐行解释脚本语言。
进行程序替换:
cpp
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main()
{
printf("我是一个进程,pid: %d\n", getpid());
pid_t id = fork();
if(id == 0)
{
// 执行另一个程序的代码
printf("子进程执行任务中~\n");
execl("/usr/bin/bash", "bash", "./cmd/test.sh",NULL);
exit(1); // 直接设置为 !0 值即可
}
int status = 0;
int rid = waitpid(id, &status, 0);
if(rid > 0)
{
printf("我是父进程,等待子进程成功! rid: %d, exit_code: %d\n", rid, WEXITSTATUS(status));
}
return 0;
}
替换结果 :

程序替换的本质是替换虚拟地址空间的内容,所有拥有虚拟地址空间的进程都可以进行程序替换。
由上面我们知道程序替换可以替换各种语言写的进程,所以程序替换是系统级别的概念。
那么有了上面bash运行脚本语言的例子,有了上面的理解。那么现在你如何看待vs2022 IDE编译?
你在这个vs2022上面写了程序,然后按了一下运行按钮,它就把代码给你编译好了。今天,你就可以理解:vs2022这个进程正在运行,然后你要编译源代码,vs2022这个进程就fork创建子进程,然后在子进程内部exec程序替换执行vs2022内部的编译器组件,然后这个组件就是子进程了,接着vs2022就将你的源代码丢给编译器编译!
你可以把vs2022想象成一个图形化界面的程序,它自己不做任何事。然后在它里面配有文本编辑器、编译器、调试器等组件(它们都是命令),接着你想打开代码,它fork加程序替换文本编辑器;你想调试代码,它fork加程序替换调试器等等。你想要做某些工作,它就fork加程序替换XXX,帮你做这个工作。
1.4.5 execvpe

上面出现了第七个函数execve。
: l(list) : 表示参数采用列表
v(vector) : 参数用数组
p(path) : 有 p自动搜索环境变量 PATH
e(env) : 表示自己维护环境变量

好,知道上面的区别之后,这三个程序替换函数我们就挑一个典型execvpe讲一讲,讲完之后其它两个按照命名理解你也就大概知道怎么用了。
接下来,为了讲解execvpe函数,将我们之前的mycmd.cpp程序修改一下。
cpp
#include <iostream>
#include <stdio.h>
int main(int argc, char *argv[], char *env[])
{
int i = 0;
for(; i < argc; i++)
{
printf("argv[%d]: %s\n", i, argv[i]);
}
std::cout << std::endl;
for(i = 0; env[i]; i++)
{
printf("env[%d] -> %s\n", i, env[i]);
}
std::cout << "自己实现的 C++ 代码!" << std::endl;
return 0;
}

此时我的程序替换代码:
cpp
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main()
{
printf("我是一个进程,pid: %d\n", getpid());
pid_t id = fork();
if(id == 0)
{
// 执行另一个程序的代码
printf("子进程执行任务中~\n");
char *argv[] = {
"mycmd",
"-a",
"-b",
"-c",
"-d",
NULL
};
char *env[] = {
"PATH=/home/wuhu/study/day_30/cmd",
NULL
};
execvpe("./cmd/mycmd", argv, env); //覆盖式的使用全新的环境变量表
exit(1); // 直接设置为 !0 值即可
}
int status = 0;
int rid = waitpid(id, &status, 0);
if(rid > 0)
{
printf("我是父进程,等待子进程成功! rid: %d, exit_code: %d\n", rid, WEXITSTATUS(status));
}
return 0;
}
如上,我自己定义了环境变量表,并传递给了子进程,接下来查看运行结果。

如上图,子进程获得了全新的环境变量表。

由程序的运行结果,我们可以知道,命令行参数和环境变量表都是父进程通过exec*系列函数给进程传递的。
除了获得全新的,还可以获得父进程的环境变量表:
cpp
extern char **environ;
execvpe("./cmd/mycmd", argv, environ); // 使用父进程的环境变量表
运行结果 :

如果既想保留老的,又想有新的,那就新增。

作用:直接将传入的字符串指针放入环境变量数组。
cpp
putenv((char*)"HI=HELLO");
extern char **environ;
execvpe("./cmd/mycmd", argv, environ); // 使用父进程的环境变量表
运行结果 :

这就是在父进程的环境变量上新增环境变量。并且putenv不会修改父进程的环境变量只会在传递给子进程的时候体现。
exec系列函数,无论带不带e,默认情况下子进程都可以拿到父进程的环境变量表,只不过带e之后,你可以选择传递给子进程全新的环境变量表。
1.4.6 总结
我们上面看到了很多的程序替换函数,那么为什么系统要给出这么多的程序替换函数呢?其实,这里面只有execve是系统调用,其它的都是库函数 。系统只提供了execve这一个系统调用。

假如你要执行execl函数,它会在底层调用execve函数,execl的path会传给execve的filename,而它后面的所有参数都会传给execve的argv数组,由于execl没有环境变量这一参数,所以execve默认会使用environ里面的环境变量,这也是为什么environ和这六个函数放一起的原因,如果是有环境变量的程序替换函数调用execve,那么它就会使用新的环境变量表 。

总结:
以上就是本期博客分享的全部内容啦!如果觉得文章还不错的话可以三连支持一下,你的支持就是我前进最大的动力!
技术的探索永无止境! 道阻且长,行则将至!后续我会给大家带来更多优质博客内容,欢迎关注我的CSDN账号,我们一同成长!
(~ ̄▽ ̄)~