引言:
北京时间:2023/3/22/6:59,一晃3月都要过去了,时间真快,我都不知道自己这个月是怎么过的呢?怎么就要结束了,难受,恍惚自己还在2022年,刚刚晨跑回来,洗完澡,一个字形容,困,昏昏欲睡,可能是昨天没怎么睡好,也可能是睡的时间少了一点,也可能是正常情况,待会就不会了,并且我只知道,早上一睁眼就看见全宿舍都起床了,都在卷,一人独睡,所以咱们起的比别人迟,现在就更不能睡,乘热打铁,算了,铁还没热,乘虚而入,不对,咱不是那种人,乘风而去,算了,这个咱也不行,还是老老实实的乘胜追击、乘其不备的学习一下哈哈哈哈哈哈!所以今天我们就接着上篇博客的内容,继续谈谈什么是进程替换,然后把进程替换玩明白之后,自己实现一个命令行解释器bash
理解加载过程和进程程序替换原理
在上篇博客中,我们了解了什么是进程替换,知道进程替换的本质就是:原进程的内核数据结构不变,把原进程在物理内存中的代码和数据替换为新进程的代码和数据(从磁盘中加载),所以从原进程的角度来看,可以看出进程替换的本质就是代码和数据被替换了,那么此时我们站在替换代码和数据的角度看一看,就又可以理解一个现象,如下:
替换原进程的代码和数据原本是在磁盘之中存储着的,它是被系统调用接口(execl)识别,然后被动的被操作系统加载到内存,然后被动执行,所以由于这个程序是被动的加载到了内存之中,所以我们将这种现象中被动被加载到内存中的程序称之为 加载器
程序如何加载到内存
我相信我们一直都知道一个问题,当然也是一个客观实际,就是程序为什么要加载到内存?这个问题的本质非常好解释,无论是冯诺依曼体系规定的,还是CPU和内存之间规定,我们都知道,一个文件想要被执行,它就一定要加载到内存之中,只有将程序加载到了内存,CPU才可以从内存和该文件对应的进程pcb交互,进而通过进程pcb,来执行该文件中,或者说是该进程中的代码和数据。明白了这个点,此时通过该点,我们接着来谈谈,一个程序如何被加载到内存?
一个程序需要被加载到内存和一个程序如何被加载到内存显然是两个不一样的问题?并且后者比前者更加深入计算机系统,所以此时我们就来聊聊程序是如何被加载到内存之中的,这个问题,就涉及到了我们这篇博客的主要内容,进程替换 ,所以我们明白一个程序被加载到内存是利用了进程替换的方式,并且我们上述也强调了,这种使用进程替换的方式将一个程序加载到内存,此时该程序也叫 加载器 , 并且程序加载到内存的过程就是程序替换的过程,所以我们就有了很强烈的关系,有了很强的扳手,就是程序想要加载到内存之中,就需要程序替换,想要程序替换就需要有一个进程,想要有一个进程首先就需要有一个bash命令行解释器(所有指令和进程的父进程 公式:指令 = 可执行文件 = 进程),并且要知道,此时的bash命令行解释器受操作系统控制, 明白了这一串的联系之后,此时就可以知道,想要进行程序替换,首先要有一个进程,并且该进程中需要使用execl这样的系统调用接口,所以程序加载到内存的本质就是使用 加载器,通过加载器的形式,将一个程序加载到内存。
明白了上述程序是如何别加载到内存之中的之后,此时我们来看看程序被加载到内存的过程中,操作系统做了什么?想要回答这个问题,此时就先要解决,当我们创建进程的时候,是先有进程数据结构,还是先加载代码和数据到内存之中,答案是显然的,我们在学习虚拟地址空间的时候,就有谈到,操作系统是不允许任何资源的浪费,所以它不允许先加载代码和数据,而是先创建该进程对应的pcb,通过pcb来管理该进程,并且在该进程需要被执行的时候,才会将该进程对应的代码和数据加载到内存, 明白了这点之后,此时就可以回答,程序加载到内存,操作系统做了什么工作,或者间接就可以把这个问题改成操作系统如何把程序加载到内存,或者是操作系统同应该如何执行可执行文件?
从程序加载到内存,我们应该先创建进程pcb为落脚点,进而回答上述问题,例如:我们在test.c代码文件中,写了一份C代码,并且利用gcc生成了一个可执行文件(mytest),发现如果想要执行该可执行文件,一定需要在该文件前面添加 ./ 的符号,才能使该可执行文件运行起来,所以此时通过这个最普通的现象,我们可以有一个解释,就是 ./ 就是用来将我们的可执行程序,从磁盘中加载到内存,因为上述说了,创建一个进程的时候,是先创建出该进程的数据结构,所以明白该进程的代码和数据此时还并没有被加载到内存,是需要通过 ./让 操作系统去调用相应的接口, 加载器,来将可执行文件在磁盘中的代码和数据加载到内存,并且此时又通过加载器和程序替换之间的关系,可以知道,./ 的本质就是去调用了像execl这样的程序替换接口;所以当我们的操作系统使用 ./ 加载我们的程序时,此时操作系统就相当于,把当前对应的指令,bash指令(就是命令行指令)加载到了内存,所以操作系统执行进程的方法就是,先在内核中创建一个结构体,在这个结构体上创建子进程,然后直接让这些子进程去调用系统调用接口(execl等函数),就相当于是让我们自己的数据和代码加载到内存(加载器和execl等函数挂钩)所以创建进程时,操作系统一定是先帮我们把进程的数据结构,进程pcb给创建好,然后在需要的时候再通过execl这个接口去把该进程对应的代码和数据加载到内存,然后通过进程pcb来控制或者使用,自然而然这个也就是一个进程的创建过程!
总的来说: 就是操作系统在内核中帮我们创建一个该进程数据结构,此时CPU开始调度,然后操作系统首先就把execl这个指令给给CPU执行,然后把用户想要运行的指令,传递给execl,然后将该指令对应的在磁盘上的代码和数据加载到内存中,这样就变成了一个用户想要执行的进程,完成的就是一个地地道道的进程替换过程(加载器加载过程)
深入进程替换
因为进程替换,把原程序的代码和数据都给替换了,后续的代码是直接被替换,是没有机会执行的,从而证明程序替换是整体替换,不是局部替换
如下图:
可以发现,程序替换,只在子进程中进行是不会影响父进程的,只会影响调用的进程,本质是因为进程具有独立性,但是此时是为什么呢?子进程和父进程它们在物理内存中的代码和数据不是相同的吗,如何理解,代码数据相同,但是又具备进程独立性呢? 所以此时具体的原理,如下:
可以这么理解,由于父进程和子进程在没有进行数据修改之前,也就是没有进行写时拷贝之时,两个进程的代码和数据是相同的,此时如果将子进程进行程序替换,此时就会导致一个问题,就是子进程的代码和数据被替换,是否会导致父进程的代码和数据被替换,从上述的结论可以看出,答案是不会的 ,所以想要搞定这个问题,此时就又涉及到了在进程控制中了解的写时拷贝问题,当我们的子进程或者父进程其中一个进程进行了程序替换或者说是被程序替换,那么此时操作系统检测到之后,就会对子进程或者父进程进行写时拷贝,将子进程或者父进程的代码和数据拷贝两份,其中一份供给程序替换,并且回忆写时拷贝的目的就是:防止资源浪费,不必要是不开辟空间(操作系统的特性);所以,当子进程进行程序替换时,操作系统第一步先是进成写时拷贝,然后才是加载新程序的代码和数据,然后子进程再重新利用页表建立新的映射关系,所以进程在程序替换之后,还能保持进程独立性的本质原因,就是操作系统会进行写时拷贝,并且回忆之前的知识可以发现,写实拷贝不仅可以在父子进程修改数据的时候进行,也可以在代码区发生!
程序替换失败问题
深入理解明白,execl是一个函数接口,它是有可能会调用失败的,也就是无法进行程序替换(进程太多等问题)例如下图:
当我们在execl中给了一个不存在的路径,或者说路径中没有相应的可执行文件之时,此时也就必然会导致execl程序替换失败,所以此时的现象就是程序没有被替换,而是继续执行原程序,所以执行原程序,就只执行了一次父进程中的程序,因为子进程已经被exit退出了,并且父进程也获取了子进程的退出码(-1);
所以得到一个小白白点:就是程序替换成功执行新程序,程序替换失败,继续执行原程序
并且明白程序替换不需要对返回值进行判断(因为只要程序替换函数有返回值,就表示替换失败)
如果execl成功执行新程序,返回值是数据吗 ?这个数据是干嘛的呢?所以此时可以明白,使用了execl,进程替换成功之后,不会有返回值返回,因为代码和数据已经被替换了(去执行新的代码和数据了),但是如果替换失败,那么此时就一定要一个返回值,也就是有返回值,那么程序替换就一定失败,所以该execl函数不需要对该函数进行返回值判断,只要有返回值就是失败,所以只要程序替换失败,此时就可以无脑的exit,直接进行程序终止就行(不需要检查和判断)
所以无论是让子进程执行新的程序还是旧的程序,此时我们在父进程中使用 waitpid 接口,父进程都是可以接收到相应的返回值的,也就是检测到子进程的运行状态的,是正常退出,还是异常退出,退出码正确,还是退出码错误
类似execl的进程替换接口
明白了上述的知识,此时我们就来看看有关程序替换的所有接口,也就是开始熟悉熟悉execl等7个系统调用接口,如下:
(1)int execl(const char *path, const char *arg, ...); (2)int execlp(const char *file, const char *arg, ...); (3)int execle(const char *path, const char *arg, ..., char * const envp[]); (4)int execv(const char *path, char *const argv[]); (5)int execvp(const char *file, char *const argv[]); (6)int execve(const char *filename, char *const argv[], char *const envp[]); (7)int execvpe(const char *file, char *const argv[],char *const envp[]);
注意:execve是真正意思上的系统调用接口,别的都是通过封装它实现
所以此时我们根据执行一个程序的基本步骤来讲讲这些函数的使用
第一步,找到它
第二步,加载它
第三步,执行它
1.execl 第一个参数表示的就是你想执行谁,一个字符指针,所以第一个参数,完成的步骤就是找到它,第二个参数,执行它(想怎么执行它)例:ls -a 、ls -l 等,此时就涉及我想怎么执行它,就怎么传参,(原理:在命令下怎么执行它,我们的参数就怎么一个一个的传给它),最后确定好了我要执行的程序和传递好了相应的指令参数,此时最后一定还要跟上一个NULL结尾,所以具体的使用方法就是:execl("/bin/ls","ls","-a","-l",NULL)
注意: execl,此时最后一个l的意思表示的就是list,表示该接口是一个list实现的接口,支持的是一个数据一个数据的传参
2.execv 第一个参数表示的也是你想要执行的指令,但是区别就在于第二个参数,此时它的第二个参数使用的是一个字符指针数组,这个参数最大的好处就是,传第二个参数的时候,不需要一个一个字符的传,而是可以直接传一个数组,具体使用方法:先建立一个字符指针数组,char* myargv[]={"ls","-a","-l","-n",NULL}; 然后直接使用该字符指针数组进行传参 execv("/bin/ls",myargv);并且从名字上出发,execv,最后的v代表的就是vector
3.execlp,参数(const char* file,const char* arg,……)首先从参数出发,以p结尾的此时的第一个参数是file,不以p结尾的第一个参数就是path(如果第一个参数是path,那么在找到它这个问题上,就需要用户,也就是我们自己,去给给它一个路径),如果带了p,那么此时就不需要给给相对或者绝对路径,只需要把程序名给给它就行,系统会自动在环境变量path中查找(自动查找和手动查找的区别),具体使用方法:execlp("ls","ls","-a","-l",NULL),这个也就是为什么execlp,它的最后是以p结尾的原因,以p结尾就支持自动查找,不带p就不支持
总结: 第一个参数是const char* file就支持自动查找,第一个参数是const char* path就不支持自动查找,需要手动查找
4.execvp(const char* file,char* const argv[]),这个还是按照名字出发,发现它不仅带v而且带p,所以此时它的使用就是不仅可以直接传一个数组,而且可以自动去环境变量中找
5.execle(const char* path,const char* arg,……,char* const envp[])可以发现多了一个参数 char* const envp[],并且该参数此时就是涉及到了环境变量的相关知识,就是我们可以通过该参数,传一个环境变量给给这个接口,然后让这个接口将我们的环境变量传递给那个被调用的程序,此时这个程序就拥有了一个新的环境变量,所以在进行程序替换的时候,如果使用了该接口,那么就是就可以传递一个我们想要传递的环境变量,所以这个接口最重要的一个点,就是理解,自定义的环境变量可以替代系统环境变量的(覆盖式传参),原因就是我们可以手动传递环境变量给被调用的进程,这个点也就涉及到了环境变量的继承问题,下面单独讲解,具体使用方法:extern char** environ;或者char* const myenv[]={"MYENV=you can see me",NULL};定义两个环境变量(一个是自己实现的,一个是系统自带的),execle("./test/a.out","a.out",NULL,myenv); 或者execle("./test/a.out","a.out",NULL,environ); 进行进程替换了
6.execvpe 这个接口跟execle大致相同,都是一个提供了传递环境变量的接口,大致的区别就是这个接口是file接口,支持自动查找相应的路径,而execle不支持自动找路径,一定要手动给路径。
7.execve 这个接口是真正的系统调用接口,也就是操作系统的门户,别的都是通过封装它实现,所以这个接口是最重要的,一切进程替换的源头
搞定了上述的这几个程序替换接口,此时我们就可以从名字上发现一定的规律,如下:
l : 使用链表方式,实现一个一个参数的传递
v:通过构造指针数组的方式,实现数组传参
p:用来区分,你是手动查找还是自动查找
e:多了一个环境变量数组envp[],让我们可以使用新的环境变量代替调用进程的环境变量(覆盖式,但可以使用putenv先保存后覆盖解决)
从execle深入理解环境变量:
一个话题:环境变量,环境变量具有全局属性,可以被子进程继承下去,这是为什么呢?
答案跟我们的execle接口密不可分,因为所有的指令都是bash的子进程,而bash执行所有的指令,都可以调用execle去执行,所以我们想要把bash的环境变量交给子进程,只需要调用execle,然后把我们的环境变量以最后一个参数的形式,传给子进程,子进程就可以拿到环境变量了(environ),所以这个就是环境变量具有全局性的原因。
使用程序替换接口,自己实现简易bash
代码如下:
#include<stdio.h> #include<stdlib.h> #include<unistd.h> #include<sys/types.h> #include<sys/wait.h> #include<string.h> #define MAXLEN 1024 #define LEN 32 int main() { char shell[MAXLEN]={0}; char* DOS[LEN]={0}; while(1) { printf("[dodamce@My_centos dir] "); fgets(shell,MAXLEN,stdin); shell[strlen(shell)-1]='\0'; DOS[0]=strtok(shell," "); int i=1; while(DOS[i]=strtok(NULL," ")) { i++; } pid_t id=fork(); if(id==0) { //child execvp(DOS[0],DOS); exit(1); } int status=0; pid_t wait=waitpid(id,&status,0); if(wait>0) { printf("Exit Code=%d\n",WEXITSTATUS(status)); } } return 0; }