💦 为什么要进程替换 && 什么是进程替换
创建子进程的目的:
-
执行父进程的部分代码。
我们之前所写的代码都属于这种情况。
-
执行其它程序的代码。
不要父进程的代码和数据。所以我们要学习进程替换。
所以进程替换是为了子进程能够执行其它程序的代码;进程替换就是以写时拷贝的策略,让第三方进程的代码和数据替换到父进程的代码和数据,给子进程用,因为进程间具有独立性,所以不会影响父进程。以前我们说数据是可写的,代码是不可写的,现在看来,确实如此。但是接下来要把其它程序的代码通过进程替换放在内存里让子进程与之关联,此时就要给代码进行写时拷贝。99% 的情况是对数据进行写时拷贝,1% 的情况是代码依旧是只读,本质就是对父进程不可写,子进程后续调用某些系统调用,实际给子进程重新开辟空间把新进程的代码加载,不让子进程执行父进程的代码。
💦 替换原理
我们想让子进程里执行新的程序,可以一步到位在内存里重新开辟两块空间以加载新程序的代码和数据,再修改子进程页表的映射关系,之后父子就彻底脱离了。
系统是如何做到重新建立映射关系的呢 ???
当子进程里要加载新进程时,操作系统可以设置一些特殊信号让该进程对全部代码和数据的写入,子进程会自动触发写时拷贝,重新开辟空间,再重新把代码和数据加载。
在进行进程替换时,有没有创建新的进程 ??
我们并不需要重新开辟新的 PCB、地址空间、页表,没有创建新进程的最有力证据是 pid 没变。所以我们曾经说过,程序要运行起来,必须先加载到内存,这句话当然没问题。但是反过来,程序只要加载到内存了,一定是变成一个进程,这句话有纰漏,因为进程是否是新创建是不一定的。不过大部分情况下是创建新进程的,进程替换是属于少数。
所以进程替换不会改变进程内核的数据结构,只会修改部分页表数据,然后把新进程的代码和数据加载至内存,重新构建页表映射关系,和父进程彻底脱离。
💦 替换函数
其实严格来说有7种以exec开头的系列函数,统称exec函数:
#include<unistd.h>
int execl(const chaar* path, const char* arg, ...);
int execlp(const char* file, const chr* arg, ...);
int execv(const char* path, char* const argv[]);
int execvp(const char* file, char* const argv[]);
int execle(const chra* path, const char* arg, ..., char* const envp[]);
int execve(const char* path, char* const argv[], char* const envp[]);
int execvpe(const char* file, char* const argv[], char* const envp[]);
这些函数的功能都是一样的,如果用 C++ 去设计这样的接口,一定是重载。这里是使用 C 去设计的,函数名的命名也有区分。下面我们会对这些接口进行演示,但实际在后面常用的也只是部分而已。
为什么 execve 要单独拎出来 ❓
虽然头文件都是 <unistd.h>,但实际上真正是系统提供函数只有 execve,其余的 6 个都是封装的,最后底层调用的依旧是 execve,这样做的原因是需要根据不同的用户来定制不同的使用场景。
好比,大家最后吃的米饭都会转换成能量,但是有的人喜欢蛋炒饭、有的人喜欢肉丝炒饭。
💦 函数解释及使用
- 这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回。
- 如果调用出错则返回 -1。
- 所以 exec 函数只有出错的返回值而没有成功的返回值。
✔ 测试用例一:
单进程,父进程亲自干活。
#include<stdio.h>
#include<unistd.h>
int main()
{
printf("my process begin!\n");
execl("/usr/bin/ls", "ls", "-a", "-l", "-i", NULL);
printf("my process end!\n");
return 0;
}
多进程,父进程创建子进程干活。
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
printf("I am child, pid: %d, ppid: %d\n", getpid(), getppid());
execl("/usr/bin/ls", "ls", "-a", "-l", "-i", NULL);
exit(1);
}
//father
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if(ret > 0)
{
printf("child status -> sig: %d, code: %d\n", status & 0x7F, (status >> 8) & 0xFF);
}
else
{
printf("wait error!\n");
}
return 0;
}
💨运行结果:
为什么图一没有输出 my process end ! && 图二的退出码是 0 ❓
因为在这之前 execl 已经程序替换了,所以 execl 后面的代码已经不是当前进程的代码了,所以图二获取到的退出码 0 是 ls 的退出码。换言之,一旦程序替换,你到底执行正确与否是取决于 ls 程序。
所以 exec 系列的函数不用考虑返回值,只要返回了,一定是这个函数调用或程序替换失败了。
注意编程规范是父进程创建子进程干活。
加载器 ❓
一个完整的集成开发环境的组件肯定包括编辑器、编译器、调试器、加载器等。一个软件被加载到内存里,肯定是运行起来,形成进程,进程再调用 exec 系列的函数就可以完成加载的过程。所以 exec 可以理解成一种特殊的加载器。
✔ 测试用例二:
execv 与 execl 较为类似,它们的唯一差别是,如果需要传多个参数,那么:execl 是以可变参数的形式进行列表传参;execv 是以指针数组的形式传参。
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
//const char* const my_argv[] = {"ls", "-l", "-a", "-i", NULL};//err,注意要与函数原型的参数类型匹配
char* const my_argv[] = {"ls", "-l", "-a", "-i", NULL};
printf("I am child, pid: %d, ppid: %d\n", getpid(), getppid());
execv("/usr/bin/ls", my_argv);
exit(1);
}
//father
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if(ret > 0)
{
printf("child status -> sig: %d, code: %d\n", status & 0x7F, (status >> 8) & 0xFF);
}
else
{
printf("wait error!\n");
}
return 0;
}
💨运行结果:
✔ 测试用例三:
execlp 相比 execl 在命名上多了 1 个 p,且参数只有第 1 个不同:不同点在于 execlp 不需要带路径,execlp 在执行时,它会拿着你要执行的程序自动的在系统 PATH 环境变量中查找你要执行的目标程序。
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
printf("I am child, pid: %d, ppid: %d\n", getpid(), getppid());
execlp("ls", "ls", "-a", "-i", "-l", NULL);
exit(1);
}
//father
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if(ret > 0)
{
printf("child status -> sig: %d, code: %d\n", status & 0x7F, (status >> 8) & 0xFF);
}
else
{
printf("wait error!\n");
}
return 0;
}
💨运行结果:
带 p 的含义就是不用带路径,系统会自动搜索你要执行的程序,不带 p 则相反。所以你要执行哪个程序,背后的含义是 a) 你在哪 b) 你是谁。可见 execlp 就是 b,execl 就是 ab。当然这里的搜索默认只有系统的命令才能找到,如果需要执行自己的命令,需要提前把自己的命令与 PATH 关联。
✔ 测试用例四:
所以 execvp 无非就是不带路径,使用指针数组传参。
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
char* const my_argv[] = {"ls", "-l", "-a", "-i", NULL};
printf("I am child, pid: %d, ppid: %d\n", getpid(), getppid());
execvp("ls", my_argv);
exit(1);
}
//father
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if(ret > 0)
{
printf("child status -> sig: %d, code: %d\n", status & 0x7F, (status >> 8) & 0xFF);
}
else
{
printf("wait error!\n");
}
return 0;
}
💨运行结果:
✔ 测试用例五:
e 表示传入默认的或者自定义的环境变量给目标可执行程序。
子进程跑自己的程序 mycmd.c。
makefile 里需要 make 时一次生成 2 份可执行程序。
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
int main(int argc, char* argv[], char* env[])
{
pid_t id = fork();
if(id == 0)
{
char* const my_env[] = {"MYENV=hellobit!", NULL};
printf("I am child, pid: %d, ppid: %d\n", getpid(), getppid());
//测试1
//execle("./mycmd", "mycmd", NULL, my_env);
//测试2
//char* const my_argv[] = {"mycmd", NULL};
//execve("./mycmd", my_argv, my_env);
//测试3
//execvpe("mycmd", my_argv, my_env);
//测试4
execle("./mycmd", "mycmd", NULL, env);
exit(1);
}
//father
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if(ret > 0)
{
printf("child status -> sig: %d, code: %d\n", status & 0x7F, (status >> 8) & 0xFF);
}
else
{
printf("wait error!\n");
}
return 0;
}
💨运行结果:
-
测试 1 && 测试 2
-
测试 3
execvpe 并没有替换成功,因为它带了 p,所以它会在环境变量里查找目标程序,但是此时目标程序就在当前路径。所以当前场景是不适合使用带 p 的函数的。如果想让 execvpe 查找到目标程序,就只能将当前路径添加到环境变量中,或将目标程序添加到任意环境变量的路径下。
-
测试 4
main 函数可以获得环境变量,环境变量再传给子进程。所以现在我们就能理解环境变量是怎么被子进程继承的,本质是通过 exec 函数将环境变量传入的。
💦 命名理解
这些函数原型看起来很容易混淆,但只要掌握了规律就很好记。
- l(list),表示参数采用列表。
- v(vector),表示参数使用数组。
- p(path),自动搜索环境变量 PATH。
- e(env),表示自己维护环境变量。
💦 简单模拟shell解释器
子进程执行新程序的需求 ❓
在之前,我们举过 1 个例子:你是村长家的儿子,是程序员,你不擅长和女生打交道,所以你去通过王婆去找如花表达你的爱意,村里人都知道如花已经心有所属了,而你又是村长家的儿子。王婆心想,这趟浑水我可不不趟,万一搞砸了,可能会影响到自己以后的职业发展,但又碍于你是村长家的儿子,不敢得罪。所以,机智的王婆说:呀!最近的活太多了,这样吧,我给你找我们公司的销冠(其实是实习生,比较好欺负),你自己跟销冠对接。就算谈不成,王婆也可以周旋(再给你找业务好的实习生)。王婆会根据工作的难易程度,简单的自己做,复杂的交给其它人。
这里你是用户;王婆是命令行解释器中的 bash;销冠(实习生)是子进程;如花是操作系统;销冠(实习生)谈砸了,不影响王婆就如子进程崩了不会影响父进程。
✔ 测试用例一:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#define NUM 128
#define SIZE 32
char command_line[NUM];
char* command_parse[SIZE];
int main()
{
while(1)
{
//1.获取命令
memset(command_line, '\0', sizeof(command_line));
printf("[DanceBit@myhost myshell]$ ");
fflush(stdout);
if(fgets(command_line, NUM - 1, stdin))
{
command_line[strlen(command_line) - 1] = '\0';//\n = '\0'
//2.加工命令
int index = 0;
command_parse[index] = strtok(command_line, " ");//strtok以空格分割"ls -a -l"于指针数组,strtok可以适用于"ls -a -l"。
while(1)
{
index++;
command_parse[index] = strtok(NULL, " ");
if(command_parse[index] == NULL)
{
break;
}
}
//3.执行非内置命令
if(fork() == 0)
{
//child
execvp(command_parse[0], command_parse);//我们选择v、p。
exit(1);//父进程拿到1,说明execvp替换失败。
}
//father
int status = 0;
pid_t ret = waitpid(-1, &status, 0);
if(ret > 0 && WIFEXITED(status))
{
printf("Exit Code: %d\n", WEXITSTATUS(status));
}
}
}
return 0;
}
💨运行结果:
可以看到我们自己模拟的 shell 可以支持大部分命令,但有部分命令是不支持的,如 ll、>、| 。不支持的原因也很好理解,重定向和管道是需要我们理解了它的原理,然后才能实现的,后面我们再对 myshell 进行完善。
myshell 和 mini_shell 中使用的 echo 是同一个 echo 吗 ❓
不一定,如果感觉有些抽象的话,可以这么理解:有些命令实际让子进程去运行,子进程是不影响父进程的,此时有可能就会出现一些奇怪的现象,如。
为什么 ???
-
ps ajx | head -1 && ps ajx | grep mini_shell查看 mini_shell 进程信息。 -
ls /proc/20339 -al查看 mini_shell 20339 进程相关资源。其中 cwd 是当前所处的目录,exe 是执行程序的名字。我们发现在 myshell 中
cd ../../../执行是成功的,但路径并没有任何变化。这里的 cd 更改的是父进程所在路径还是子进程所在路径 ??
我们需要明确一件事,在 cd.. 时,是期望更改子进程还是父进程的路径呢。子进程是目标程序,父进程是 shell。实际上我们想改的并不是子进程,因为子进程的路径一改,子进程就退出了,改就没有意义了。所以我们要改的是父进程的路径,换言之,你要改父进程的路径的前提是不能创建子进程执行 cd。父进程也不能执行 cd,因为父进程一旦替换就会把父进程的代码替换成 cd 的代码,父进程不仅越俎代庖,自己的本职工作也没做好。
其实虽然 cd 像命令,但实际上在 shell 中就压根不能使用程序替换执行,它使用系统接口来完成 “ 命令的执行 ”,这个接口是 chdir。
#include<unistd.h> int chdir(const char* path);
✔ 测试用例二:
支持 cd。
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#define NUM 128
#define SIZE 32
char command_line[NUM];
char* command_parse[SIZE];
int main()
{
while(1)
{
//1.获取命令
memset(command_line, '\0', sizeof(command_line));
printf("[DanceBit@myhost myshell]$ ");
fflush(stdout);
if(fgets(command_line, NUM - 1, stdin))
{
command_line[strlen(command_line) - 1] = '\0';//\n = '\0'
//2.加工命令
int index = 0;
command_parse[index] = strtok(command_line, " ");//strtok以空格分割"ls -a -l"于指针数组,strtok可以适用于"ls -a -l"。
while(1)
{
index++;
command_parse[index] = strtok(NULL, " ");
if(command_parse[index] == NULL)
{
break;
}
}
//3.执行第三方命令(内置命令)
if(strcmp(command_parse[0], "cd") == 0 && chdir(command_parse[1]) == 0)
{
continue;
}
//4.执行非内置命令
if(fork() == 0)
{
//child
execvp(command_parse[0], command_parse);//我们选择v、p。
exit(1);//父进程拿到1,说明execvp替换失败。
}
//father
int status = 0;
pid_t ret = waitpid(-1, &status, 0);
if(ret > 0 && WIFEXITED(status))
{
printf("Exit Code: %d\n", WEXITSTATUS(status));
}
}
}
return 0;
}
💨运行结果:
可以看到这样的 cd 命令并没有创建子进程去执行,本质 cd 是内置命令,它是 shell 内的一个函数调用。所以这里简单的工作,shell 自己做,复杂的就交给子进程做。
我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。