一、进程替换
为什么要有进程替换呢???比方说我们想用fork创建一个子进程去帮助我们完成一个工作,这个工作我们需要封装成一个函数去使用,但难道我们每次都要自己写一个函数吗?或者说子进程一定要用我父进程的代码吗? 难道不可以是我们之前已经写好的一个可执行程序,当我想去执行的时候直接让子进程用一下不就可以了,但是因为操作系统不相信任何人,所以我们也必须要有一些系统调用接口来完成这个工作。
所以**所谓的进程替换 就是为了让父进程和子进程执行不同的代码!**!
1.1 简单的进程替换引入
为了完成程序替换的工作,让某些可执行程序可以让子进程去使用从而让他实现和父进程执行完全不同的代码,存在一些以exel形式的系统调用接口
**我们会发现这里并没有if eles但是子进程在execl后却没有执行父进程的代码,这说明子进程所执行的代码被替换了!! 这就是发生了进程替换!!**
1.2 进程替换的原理
所以究竟是如何做到在没有if eles的情况下让父子进程执行不同的代码呢??这就涉及到原理层了
问题1:子进程执行了ls这个可执行程序的命令,是有创建新的子进程吗??
——>其实这个过程非常简单粗暴,就是当该进程运行到exec系列的代码时,就会把拿ls这个可执行程序的代码和数据来粗暴地替换自己的代码和数据 ,当然这个过程可能会存在空间的申请和释放。所以并没有创建新的子进程,只是将该子进程的代码和数据替换了,并且内核数据结构PCB并没有释放,而仅仅只是修改了某些字段的内容。
问题2:子进程的代码和数据被替换了,为什么父进程还是执行原来的代码??
——>因为有写时拷贝技术的存在,所以父子进程能够保持自己的独立性,在这个过程中一开始父子进程指向相同的代码和数据,但是当子进程一旦执行了exec系列的函数,操作系统检测到子进程想要修改这些数据,所以发生了写时拷贝,单独开辟了新的物理空间,然后用ls这个可执行程序的代码和数据替换进去。原先的代码和数据就丢失了。 所以程序替换后,子进程跑的是一份全新的代码。****(就有点像你的第二人格出现,但是你已经不记得自己的第一人格做过什么或者说过什么)
问题3:可是数据发生写时拷贝我可以理解,那代码也可以发生写时拷贝吗??
——>没错,代码也可以发生写时拷贝!!因为代码并不如我们想想的那样不可被写入,其实关键是要看谁去写入,比如说是你去写入,你想去修改代码的内容,必然是会拦截你的行为的,但是我们现在使用的是操作系统的execl的系统调用接口,相当于就是操作系统想去写,代码虽然是只读的,但是父子进程共享了,所以子进程要发生替换所以触发写时拷贝是很合理的一件事情。
问题4:如果替换失败了怎么办??
——>如果替换失败了,就只能执行自己原先的代码了!!所以exec系列的函数只有失败的返回值而没有成功的返回值,因为一但成功后跑的就是新的代码和数据了,返回就没有意义了!
问题5:我们常说main函数是告诉操作系统该可执行程序的入口,但是main函数并不一定会写在最开始,那么操作系统是如何找到main函数的呢?
——>Linux中的可执行程序,是有自己的组织形式的,也就是有自己的格式的(有一张表),我们把这个格式叫做ELF ,比方说这个格式将代码段、数据段、只读数据区这些各个区域的其实地址都分好了,而main函数就在表头。所以表头存的就是程序入口的地址!
1.3 探究各个程序替换的接口
一共有七个接口,其实2号手册的这个是系统调用接口,而3号手册的这六个是库函数,他们的区别就有点像exit和_exit的区别 其实库函数是做更进一步的封装,所以其实这六个接口最终都是会转化成调用系统调用接口,所以我们更多的需要关注着3号手册的这六个接口有什么区别。
** execl :l结尾,其实就是list(像链表一样一个个去传,其实就是命令行怎么传就怎么传)**
** execlp:l还是代表list,而p代表的是环境变量path,意思就是你不需要告诉我具体的路径,你就告诉我这个文件的文件名,我会去环境变量里面去找!**
**execv:v结尾,其实就是vector(我们要先创建一个数组然后将参数放进去之后再整体传过去),有一点点像main函数的参数argv[] **
**execvp:就是vector+path **
**execle/execvpe:多个一个envp[ ] 意思就是我们可以自己用一套自己的环境变量,而不是用从父进程继承下来的。 **
1.4 接口总结和加载器理解
总结一下:这些接口其实本质上都是参数的不同——>功能不同所区分的。 他们其实思考了下面的问题:
(1)执行程序的第一件事情,就是找到这个程序,所以我应该去哪里找呢??——>所以有的接口是让你直接传该文件的路径,也有的接口是让你只传文件名,然后他会自动去环境变量里面查找。
(2)找到程序后的下一个问题就是我们要如何去执行这个程序,所以就设计到了要不要涵盖选项,以及这个选项应该以vector的形式传还是list的形式传。
(3)这个程序我一定要用该进程的环境变量吗??我可不可以自己传一套环境变量进去? 所以有就了le系列。
加载器的理解:
argv这个参数会被传递给ls,其实exec系列接口含义也是如此,在命令行参数中,有所的进程都是bash的子进程,所以exec其实就是一个代码级别的加载器,他可以做到将可执行程序的代码和数据导入到内存中,然后再调用main函数的时候将argc参数传递给程序,其实就相当于是你在执行该程序之前,优先给你加载出来一个栈帧结构。
1.5 exec系列执行自己的程序
1.5.1 makefile的写法
.cc .cpp .cxx 都是C++中的文件后缀
我们要执行自己的程序,就需要再搞几个自己写的文件出来。
就是一般来说makefile在不指定的时候,直接make他会找到第一个可执行程序,然后他会沿着这个推导链推导下去,推导结束之后就真的结束了,所以如果我们按照这种写法无法一次编译两个源文件。
——>唯一的方法就是谁都不要放在前面,而是提前建立一个伪目标all放在前面,多一层推导关系,这样两个文件就都会根据推导链的执行而被编译了。
1.5.2 执行其他编译型语言或者是脚本语言
** Shell脚本 本质上就是把Linux命令放在一个文件里面(后缀sh),并且文件的开头都是#!+脚本语言的解释器。 脚本语言不是脚本在跑,而是由解释器来解释执行**
**我们想要执行脚本文件的话,路径传的就不是脚本文件,而是脚本文件的解释器, bash+test.sh则是作为命令行参数**。
——> 所以语言和语言之间是可以相互调用的!!
(1)任何语言都有像exec这类的接口
(2)语言可以互相调用的原因是 无论是什么语言写的程序 在操作系统看来都是进程
1.5.3 将命令行参数和环境变量传递给另一个程序
** 环境变量是在子进程创建的时候就默认继承了,即使没有传环境变变量参数,也可以在地址空间找到。所以进程替换中,环境变量信息不会被替换!**!
1.5.4 子进程环境变量的处理
1、新增环境变量——>putenv
如果我们想给子进程创建新的环境变量,之前我并不想在bash上搞,因为在bash上搞的话所有进程都会被影响。我们只想在我们执行的这个父进程上去导入新的环境变量。
** 就可以用putenv函数,这样的话添加进去的环境变量只和父进程有关 和bash无关。 **
——>所以环境变量是有可以传递得越来越多的!!
** 2、彻底替换环境变量——>execle、execve**
就使用execle系列的接口,然后将myenv(自定义的)传进去 就相当于是覆盖!!
二、自定义Shell
2.1 命令行提示
**首先我们需要有 用户名、主机名、当前路径、命令行提示符 **
(1)用户名+主机名——>用getenv从环境变量获取
(2)当前路径 ——>用getcwd()获取
其实我们也可以用 getenv去获取当前的 PWD 这个环境变量 但是为了后期的cd指令时可以更方便修改(比如回退上级目录的时候还得刷新子串,比较麻烦),所以我们可以用getcwd这个函数来帮助我们直接去获取当前的工作目录,这样后期再cd的时候我们只需要调用getcwd刷新一下就可以。
** (3)整体调用 **
printf具有字符串连接功能
2.2 交互问题——获取命令行
问题1:scanf并不使用于shell,因为他只能读取到空格,但是我们的命令大多数时候是需要带选项的! 所以我们如果用的是C语言的话,可以使用fgets,如果用C++的话,可以使用getline
所以我们可以这样去获取命令行!!!
问题2:fgets一般不会读取失败,因为即使是回车也会被读取进度,但是我们并不希望出现这种情况,所以这种情况可以单独加一个assert断言!
问题3:如果是一些定义了但是没有使用的变量,编译器会给警告,所以当release版本下assert被优化掉了,肯呢个s不会使用,所以我们可以用一个(void)s来表明s已经用过了
问题4: 我们回车的时候,默认/n也是会被读进去的,比如abcd/n 所以我们如果不想要这个回车的话,可以把这个回车的位置给他改成/0
总体代码:
2.3 子串拆分问题——解析命令行
接下来要解决的是分析我们读进来的字符串,然后将该字符串拆分成我们想要的多个字符串,可以有很多方法,比方说用C++的substr来解开,或者是一些字符串相关的分割函数,如果是我们自己去封装的话,我们也可以将各个字符串的起始地址保存起来,然后再把空格位置改成/0
我们可以使用strtok这个函数
** 返回的 i-1 可以用来帮我们检测当前的选项个数 传递给argc**
2.4 普通命令
我们普通命令就是bash让子进程去帮助我们完成,所以我们要做的其实就是 进程创建、进程退出、进程等待、进程替换
问题:子进程要去执行程序的时候,要选择exec系列的那个函数呢??
——>其实最好是选带v的,因为l的话还需要一个个去喂参数,其次还得选带p的,因为我们执行命令的时候默认是不带路径,所以需要他能够根据文件名自动帮助我们去环境变量里面找!!
其次我们可以用lashcode这个全局变量来帮助我们保留退出码——>这个是为了echo 的时候可以帮助我们查看到上一个进程的退出码
但是也是也有一点瑕疵 比方说我们没有ll不支持,因为我们没有起别名,还有就是我们ls出来的东西没有颜色
2.5 内建命令
为什么要有内建命令呢??就是这些工作必须得亲自做!别人做达不到目的!比如说cd 我们想改变的是当前的工作目录,但改变的如果是子进程就没有任何意义。 再比如说 echo $PATH 我们想获取的是父进程的环境变量,而不是子进程的……
2.5.1 cd命令
**chdir接口可以帮助我们修改当前的路径,而用getpwd可以帮助我们重新刷新当前目录 **
** 然后我们要用sprintf将我们的子串给刷新一下(用来打印命令行提示信息)!!**
2.5.2 export命令
export的作用是可以用name=value的形式导入环境变量,但是导环境变量不是将这个字符串信息保存到拷贝到环境变量存储的地方,只是把这个字符串的地址存储在环境变量表里面,所以表里面存的是指针,本质上是一个指针数组, 所以我们如果将字符串信息保留在栈帧中,那么当被释放的时候就找不到这个环境变量了,因此我们必须单独维护一段空间
** 环境变量是你在shell启动的时候从用户目录底下的配置文件读取的,然后变成内存级的文件,如果你修改了环境变量但是想还原,直接重启Shell就可以了!**
2.5.3 echo命令
echo有3种情况,第一种就是直接打印(printf),第二种就是$+环境变量 第三种就是$?(获取错误码 需要维护一个lastcode保存最近一个进程的退出码)
2.6 其他细节
1、ls的颜色变化(特殊处理):
2、shell进程是不断循环的!
3、我们要做指令的判断,所以我们可以设置一个返回值来判断当前的命令是否被当成内建命令来执行,如果不是的话就要执行普通代码的逻辑
2.7 整体代码逻辑
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <assert.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#define LEFT "["
#define RIGHT "]"
#define LABLE "#"
#define DELIM " \t"
#define LINE_SIZE 1024
#define ARGC_SIZE 32
#define EXIT_CODE 44
int lastcode = 0;
int quit = 0;
extern char **environ;
char commandline[LINE_SIZE];
char *argv[ARGC_SIZE];
char pwd[LINE_SIZE];
// 自定义环境变量表
char myenv[LINE_SIZE];
// 自定义本地变量表
const char *getusername()
{
return getenv("USER");
}
const char *gethostname()
{
return getenv("HOSTNAME");
}
void getpwd()
{
getcwd(pwd, sizeof(pwd));
}
void interact(char *cline, int size)
{
getpwd();
printf(LEFT"%s@%s %s"RIGHT""LABLE" ", getusername(), gethostname(), pwd);
char *s = fgets(cline, size, stdin);
assert(s);
(void)s;
// "abcd\n\0"
cline[strlen(cline)-1] = '\0';
}
// ls -a -l | wc -l | head
int splitstring(char cline[], char *_argv[])
{
int i = 0;
argv[i++] = strtok(cline, DELIM);
while(_argv[i++] = strtok(NULL, DELIM)); // 故意写的=
return i - 1;
}
void NormalExcute(char *_argv[])
{
pid_t id = fork();
if(id < 0){
perror("fork");
return;
}
else if(id == 0){
//让子进程执行命令
//execvpe(_argv[0], _argv, environ);
execvp(_argv[0], _argv);
exit(EXIT_CODE);
}
else{
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if(rid == id)
{
lastcode = WEXITSTATUS(status);
}
}
}
int buildCommand(char *_argv[], int _argc)
{
if(_argc == 2 && strcmp(_argv[0], "cd") == 0){
chdir(argv[1]);
getpwd();
sprintf(getenv("PWD"), "%s", pwd);
return 1;
}
else if(_argc == 2 && strcmp(_argv[0], "export") == 0){
strcpy(myenv, _argv[1]);
putenv(myenv);
return 1;
}
else if(_argc == 2 && strcmp(_argv[0], "echo") == 0){
if(strcmp(_argv[1], "$?") == 0)
{
printf("%d\n", lastcode);
lastcode=0;
}
else if(*_argv[1] == '$'){
char *val = getenv(_argv[1]+1);
if(val) printf("%s\n", val);
}
else{
printf("%s\n", _argv[1]);
}
return 1;
}
// 特殊处理一下ls
if(strcmp(_argv[0], "ls") == 0)
{
_argv[_argc++] = "--color";
_argv[_argc] = NULL;
}
return 0;
}
int main()
{
while(!quit){
// 1.
// 2. 交互问题,获取命令行, ls -a -l > myfile / ls -a -l >> myfile / cat < file.txt
interact(commandline, sizeof(commandline));
// commandline -> "ls -a -l -n\0" -> "ls" "-a" "-l" "-n"
// 3. 子串分割的问题,解析命令行
int argc = splitstring(commandline, argv);
if(argc == 0) continue;
// 4. 指令的判断
// debug
//for(int i = 0; argv[i]; i++) printf("[%d]: %s\n", i, argv[i]);
//内键命令,本质就是一个shell内部的一个函数
int n = buildCommand(argv, argc);
// ls -a -l | wc -l
// 4.0 分析输入的命令行字符串,获取有多少个|, 命令打散多个子命令字符串
// 4.1 malloc申请空间,pipe先申请多个管道
// 4.2 循环创建多个子进程,每一个子进程的重定向情况。最开始. 输出重定向, 1->指定的一个管道的写端
// 中间:输入输出重定向, 0标准输入重定向到上一个管道的读端 1标准输出重定向到下一个管道的写端
// 最后一个:输入重定向,将标准输入重定向到最后一个管道的读端
// 4.3 分别让不同的子进程执行不同的命令--- exec* --- exec*不会影响该进程曾经打开的文件,不会影响预先设置好的管道重定向
// 5. 普通命令的执行
if(!n) NormalExcute(argv);
}
return 0;
}
三、Shell的再总结
Shell本身就是一个进程,他首先可以获取用户的输入做分析,并且维护环境变量表、本地变量表、内建命令方法…… 当我们输入的指令执行解析的时候,对于内建命令直接调用函数,非内建命令用子进程执行,执行过程中获取子进程的退出码,父进程等待,然后最后可以将退出码赋予给lastcode,这样方便用户通过echo获取上一个进程的退出码。
版权归原作者 ✿༺小陈在拼命༻✿ 所有, 如有侵权,请联系我们删除。