1. 单进程的程序替换
int execl(const char *path, const char *arg, ...);
path:要替换的目标可执行程序的路径
其中的 ... 为可变参数列表,不同函数的参数列表可能是不一样的,有了可变参数,即可传递不同的参数个数
而不会被固定的形参列表所限制传递的实参个数。
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int main()
{
printf("before: I am a process, pid: %d, ppid: %d\n", getpid(), getppid());
// 标准写法
execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
printf("after: I am a process, pid: %d, ppid: %d\n", getpid(), getppid());
return 0;
}
现象:excel 进行程序替换之后,execl 后续的代码并没有被执行。而类似 execl 这种调用我们就称为 进程替换。
2. 进程替换的基本原理
当一个程序执行起来,操作系统为其创建一个独有的 PCB、进程地址空间、页表等内核数据结构,并且把存储在磁盘中的该程序代码和数据加载到内存之后,这个进程的创建就完成了。而 execl 是一个系统调用函数,在调用这个系统调用之前,还是我们这个程序,执行 execl 时,由于替换目标也是一个可执行程序也是程序,那么它就一定也存储在磁盘中,在运行起来之前也必须加载到内存中。而替换发生时,操作系统简单粗暴的直接将原本程序的代码和数据 替换成 目标程序的代码和数据(单进程情况),这就是进程替换!
所以也就不难理解,为什么进程替换之后,位于 execl 系统调用之后的代码都不再被执行。进程替换,就相当于被夺舍!替换之后,就是另一个程序了,哪还有你原来程序的代码和数据,早就被踢出门了!替换后新的程序是看不到,也没办法看到你原本程序的代码和数据的!替换之后,进程的 PCB、进程地址空间,包括页表的虚拟地址这些都不需要变动,只需要修正一下映射的物理地址即可(因为每个程序的大小不一定都是一致的)。之后再从新程序的起始地址开始执行!
3. 多进程的进程替换
int main()
{
pid_t id = fork();
if(id == 0)
{
printf("before: I am a process, pid: %d, ppid: %d\n", getpid(), getppid());
sleep(3);
execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
printf("after: I am a process, pid: %d, ppid: %d\n", getpid(), getppid());
}
pid_t ret = waitpid(id, NULL, 0);
if(ret > 0) printf("wait success, ppid: %d, ret_id: %d\n", getppid(), ret);
sleep(3);
return 0;
}
现象:fork 创建出一个子进程之后,父子进程同时运行,子进程进行进程替换,父进程照样执行,不受影响。并且父进程对子进程的进程等待也不受影响。而在进程监控中,执行 excel 进程替换之后,waitpid 返回的等待的子进程的 pid 并没有发生改变!换言之,进程替换不会创建出新的进程,只进行进程的代码和数据的替换工作!
-
为什么子进程进程替换的时候不会影响父进程呢??
因为进程直接具有独立性!进程替换的本质,就是对子进程的代码和数据进行修改,而子进程存在 写时拷贝 的策略。父子进程代码是共享的,数据层面上,必要时子进程会对数据进行写时拷贝。但是进程替换,不仅仅是数据的替换,代码也被替换成新程序的代码了。 -
这么说的话,是不是代码也可以被写时拷贝??
没错,代码也可以被写实拷贝!这与我们之前的认知有点不同,代码不是常量区的区域吗,怎么可以被修改了。其实,在物理内存中,没有所谓的只读不可写的区域,这都是进程地址空间上划分的,而之所以我们一直认为代码不可写,是因为有进程地址空间的存在,我们是用户,操作系统不让我们对代码常量区进行写入操作。但我们可以通过调用 execl 这样的函数,让操作系统帮我们做。换言之,你做不到,操作系统做得到,在它的世界里,它就是 root!
简言之,单进程的进程替换,新程序的代码和数据之间替换;多进程的进程替换时,代码和数据写时拷贝,代码和数据就都互相独立了,就算子进程进程替换了,父子进程也依旧互不影响,保持着进程之间的独立性!
- 补充:
- 程序替换成功之后,exec* 后续的代码不会被执行。只有替换失败了才有可能执行后续代码。所以这也就决定了,exec*函数,调用失败了才有返回值,调用成功是没有返回值的(成功了就代表进程替换了,原程序都被踢出门了,能返回给谁呢??)
- Linux中形成的可执行程序,是有格式的,ELF, 可执行程序的表头,可执行程序的入口地址就记录在表头中。
4. 验证各种进程替换接口
在正式介绍各种进程替换的接口参数之前,我们需要先有一个前置知识。
- 执行一个程序的第一件事,是找到这个程序
- 找到程序之后,才是执行这个程序,而怎么执行,取决于涵盖哪些选项去执行。
int execl(const char *path, const char *arg, ...);
path: 进程替换的目标程序的路径(找到程序)
arg: 执行哪个程序
...: 可变参数列表,即如何执行该程序(如何在命令行中执行的,就如何传递参数即可)
示例:
int execl("/usr/bin/ls", "ls", "-a", "-l", NULL); // 一定要以 NULL 结尾,以示命令行参数的结尾
int execlp(const char *file, const char *arg, ...);
execlp 其中的 p 代表的 PATH 环境变量的意思,execlp 自己会在默认的 PATH 环境变量中查找
所有的子进程都会继承父进程的环境变量列表,因此进程替换后,也可以通过 PATH 环境变量找到相应的程序
与上面不同的是,第一个参数可以不用写路径(写了也能正常运行),只需要写可执行程序的程序名称即可
示例:
int execlp("ls", "ls", "-a", "-l", NULL);
int execv(const char *path, char *const argv[]);
execv 其中的 v 可以理解为 vector 向量的意思,
第一个参数还是传递路径
可变参数不再需要一个一个传递,可以使用字符串指针数组传递
示例:
char* const myargv[] = {"ls", "-a", "-l", NULL};
int execv("/usr/bin/ls", myargv);
- 拓展:ls 也是一个程序,c/c++ 编译后的可执行程序,它也有 main 函数,其 main 函数也有命令行参数,其命令行参数就是通过 execv 系统调用中的 myargc 参数传递进来的。而类似 execl 这类进程替换函数的可变参数列表,最终都是转换成 myargc 这样的指针数组,再传入给指定的程序中。这也就是诸如 ls 这样的命令有命令行参数的原因。而在命令行中,所有的进程都是 bash 的子进程啊,换言之,所有的程序启动都是通过 exec* 这类函数启动的! 所以 ecec* 系列函数承担的是一个加载器的角色!
int execvp(const char *file, char *const argv[]);
这个可以理解为是 execv 和 execlp 的结合体
既可以直接传递替换目标程序的名称,也可以使用数组传递
示例:
char* const myargv[] = {"ls", "-a", "-l", NULL};
int execvp("ls", myargv);
所以,exec* 系列的系统调用能够执行系统命令,那自然也可以执行我们自己的可执行程序。(该demo采用 c 调 c++编译生成的可执行程序)
// test.c
int main()
{
char* const myargc[] = {"ls", "-a", "-l", NULL};
pid_t id = fork();
if(id == 0)
{
printf("before: I am a process, pid: %d, ppid: %d\n", getpid(), getppid());
execl("./otherExe", "otherExe", NULL);
printf("after: I am a process, pid: %d, ppid: %d\n", getpid(), getppid());
}
pid_t ret = waitpid(id, NULL, 0);
if(ret > 0) printf("wait success, ppid: %d, ret_id: %d\n", getppid(), ret);
return 0;
}
// otherExe.cpp
int main()
{
for(int i = 0; i < 5; ++i)
cout << "hello C++" << i << "\n";
}
# makefile
.PHONY:all
all:test otherExe
test:test.c
gcc -o $@ $^ -std=c99
otherExe:otherExe.cpp
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f test otherExe
不仅可以 C 语言调用 C++ 编写的可执行程序,诸如 shell 脚本,python 等解释器语言都可以调用。换言之,运行起来,能被 cpu 调度的肯定是进程,所以不管是什么语言,运行起来最终在系统中都会变成进程,就能够被 exec* 类的函数调用。
换言之,我们自己写的可执行程序能被 exce* 调用,那么命令行参数、环境变量这些也能够通过一个程序传给另一个程序。
// test.c 核心调用代码
char* const myargv[] = {"otherExe", "-a", "-b", "-c", NULL};
execv("./otherExe", myargv);
// otherExe.cpp
int main(int argc, char* argv[])
{
cout << "这是命令行参数:\n";
for(int i = 0; argv[i]; ++i)
cout << "i: " << argv[i] << "\n";
cout << "这是环境变量: \n";
for(int i = 0; env[i]; ++i)
cout << "i: " << env[i] << "\n";
}
命令行参数在另一个程序中拿到了,没问题,因为 test 这个程序中调用了 exce* 系统调用,并且传递了一个 argv 数组。但是环境变量为什么也能获取呢??我可没有向另一个程序传递环境变量啊。
4.1 再谈环境变量
这就需要回归到一个问题,环境变量是何时传递给进程的?
在 C 库中有一个全局变量 *environ,在父进程的时候就已经被初始化,并且指向环境变量表了,当一个子进程被创建出来时,它是会以父进程为模板,拷贝其进程地址空间,页表等内核数据结构的,包括父进程的数据(环境变量也是数据)。而进程地址空间是由记录命令行参数、环境变量等信息的。换言之,即便不传参环境变量表,子进程也能通过继承下来的进程地址空间找到环境变量表,只要不发生写时拷贝,子进程指向的就是与父进程同一张表。并且在进程替换之后,环境变量信息不会被替换!
假设今天我有两种想要给子进程传递环境变量的场景,我该如何传递?
-
新增环境变量:
可以直接在 shell 中 export 添加环境变量,因为环境变量具有全局属性,因此子进程也会继承来自 bash 的环境变量(包括新增的环境变量),而代码中 fork() 创建出来的子进程一样会继承父进程的环境变量,也包括新增的。也可以通过
int putenv(char *string);
在代码层面上给进程新增环境变量,哪个进程调用,就给哪个进程 put 一个环境变量。putenv("PRIVATE_ENV=777");
现象:通过 putenv 往指定进程新增的环境变量,是该子进程 “独有” 的!其父进程是看不到这个新增的环境变量的。
-
彻底替换环境变量
int execle(const char *path, const char *arg, ..., char * const envp[]); execle 其中的 e 即代表 env 环境变量的意思 envp 不仅可以传递C库中的全局变量envrion,还可以传递自定义的环境变量 并且在传递 envrion 时,程序中调用的 putenv 新增的环境变量也依旧有效 而在传递自己定义的环境变量表时,则是采用直接覆盖的方式。 示例: 1. int execle("/usr/bin/ls", "ls", "-a", "-l", NULL, environ); 2. char* myenv = {"ENV1=11111", "ENV2=2222",NULL}; execle("./otherExe", "otherExe", "-a", "-b", NULL, myenv);
int execvpe(const char *file, char *const argv[], char *const envp[]);
execvp 再带一个环境变量的参数
示例:
char* = {"ENV1=11111", "ENV2=2222",NULL};
char* const myargv[] = {"ls", "-a", "-l", NULL};
int execvpe("ls", myargv, myenv);
5. execve 系统调用
上述讲的所有 exec* 类函数,都属于库函数,只有 exceve 是系统调用,而其它的 exec* 类函数与 exceve 这个系统调用唯一的不同点只是传数的不同,其它都是一样的。并且在底层,exec* 类函数最终都是调用的 execve 系统调用。
如果感觉该篇文章给你带来了收获,可以 点赞👍 + 收藏⭐️ + 关注➕ 支持一下!
感谢各位观看!