引言:
北京时间:2023/3/23/6:34,可能是昨天充分意识到自己的摆烂,所以今天起的比较早一点吧!昨天摆烂的头号原因,笔试强训,加上今天4节课,可以说一整天都是课,所以能不能更新博客,完全取决于,能不能合理的规划好空闲时间,并且今天也还需要完成一份笔试强训,所以说今天想要更新博客可以说是难如登天!哈哈哈,不过不怕,咱是小强吗?就是造,所以今天让我们抓紧来学习一下新知识吧!深入bash自我实现,处理更多细节,完善简易bash,浅谈什么是文件操作,目标不大,内容不多,So,Let’s go!
复盘bash实现
上篇博客我们了解了7个进程程序替换接口,发现execve和其它的6个接口是不一样的,其余的6个接口都是通过封装execve这一个接口实现的,Linux系统本质要实现这么多系统调用接口的原因就是:因为C语言不支持函数重载的,所以为了更好的匹配场景使用,就需要提供更多的接口 ,来满足各种场景的使用,那个场景合适就调用那个接口,不像是C++,可以支持函数重载的方式去对同一个函数进行不同的参数传递,虽然底层的实现不变,但是在函数调用的时候,是比较方便的,因为所有的接口都是同一个名字,你只需要明白自己的参数就行了;所以可以从系统调用接口上的区别,明白,语言之间也是同理的,无论是C++、C、Java、Python,它们也只是在封装方式上不同而已,在操作系统看来,无论什么语言,只要能和操作系统交互,能够调用到操作系统的系统调用,就可以了,所以无论使用什么语言,在编写成代码的时候,它的本质也就不过是一个进程而已(系统调用接口才是本质)
并且在上篇博客中,我们将bash的基础给实现了,但是发现一个问题,就是我们自己实现的bash只能实现一些非常简单的指令(ls,pwd),像cd指令和export等指令都是不支持的,所以这篇博客,我们就来学习一下为什么我们自己实现的bash不支持,系统中的bash就可以支持呢?此时就涉及到了一个叫内置命令/内建命令的概念了,所以想要明白这个问题,就让我们把我们的bash给细节化一下,由浅入深的再来学习一下bash吧!
复习7大函数命名上的特点
bash进阶:
输入的指令需要进行切割的原因是因为,程序替换函数只能一个一个程序指令的识别,不可以一下识别一串,所以要将一串给切割开,然后一个一个选项的识别
进阶第一点:
系统指令是带颜色的,而我们自己的bash指令是不带颜色的
可以通过which查看:which ls 发现是因为系统中的指令是因为有一个 --color = auto的选项
如下图:
所以我们也可以对我们的bash程序进行颜色的设定,如下图:
进阶第二点:
cd指令不能用,更改路径不会起效果
本质原因:路径为什么没有变化呢?原因:目前我们自己实现的bash可执行文件,它本质还是一个可执行程序,本质还是是Linux系统bash进程的子进程,这个身份关系一定要明白,所以我们在代码中执行想要执行相应的指令时,我们就不能给子进程去执行,而是要让父进程(bash)自己去执行,因为只有父进程才有权利执行,所以像cd这种只有父进程可以执行,而子进程无法执行的命令,我们就叫做内建命令/内置命令
但是我们会发现一个新的问题,就是应该如何让bash自己执行内置命令呢?
所以此时为了可以让bash自己执行内置命令,所以此时操作系统就为我们提供了一个接口,chdir 就一个参数const char* path;所以只要把路径字符串给给它就行了,它就会自己去调用,然后进行相应的路径切换,这样就很好的完成了cd指令的实现(在自己的bash文件中),成功返回0,失败返回-1;所以上述我们的理解都是正确的,只有系统的bash可以执行想cd这样的内置命令,就算是我们自己的bash文件中的父进程也是没有这个权利的,更别谈,bash文件中的子进程(也就是系统bash的孙子进程),如下图:
深入内置命令:
例如:我们的export指令
同理,此时这个export肯定是bash设置的,别的进程都是子进程,所以想要使用,就必须只能是继承bash,所以同理,如果想要在我们自己的bash文件中使用export指令,就一定需要使用系统调用的接口,getenv ,只有这样,我们才可以使用它在我们自己的bash文件中间接的完成export功能
但是最后发现,就算我们在自己的bash文件中使用了putenv把export导给bash执行,此时我们的bash文件也不可以使用export,第一个猜测不行的原因:我们最后导入环境变量的时候,使用的是execvp这个接口,导致无法把父进程(bash)的环境变量继承给bash文件中的子进程,所以,在使用bash文件中的子进程的使用,应该要去调用execvpe这个接口,因为这个接口,不仅可以传execvpe对应的参数,此时最重要的就是它还可以传一个环境变量environ给给调用进程,当然此时也就是我们的bash文件中的子进程(所以此时这个子进程,就可以使用系统bash对应的所有环境变量),自然而然,由于上述我们已经把export给导入过了,所以此时就可以利用export接口往系统bash的环境变量导入数据,并且拿到系统bash的所有环境变量,所以就可以拿到我们想要导入的环境变量了,但是此时发现,我们还是不能成功,原因就是: execvpe这个接口和execvp本质上是一样的,因为系统bash的环境变量无论是使用哪个接口,此时它都是会被传递的,所以我们有没有传,其实本质都是一样的,所以无论是使用execvpe还是execvp都是一样的
所以其实问题的本质就是:我们把export通过putenv传给bash的时候,此时的这个putenv中的内容是变化的,会一直覆盖我导入的环境变量,导致出问题,所以,一般用户自定义的环境变量,要用户自己进行维护,不要用一个经常被覆盖的缓冲区来保存环境变量,所以需要我们自己来维护一段缓冲区,如下图:
所以综合上述的现象,此时就告诉了我们一个道理,就是,当我们导出环境变量的时候,我们就不能把这个环境变量放在一个会变化的字符串中,一定要让它可以被持久保存(涉及指针指向的地址问题),
并且这边要记住,使用env指令,就可以访问我们系统中的所有环境变量;并且明白一个点,就是子进程只能执行部分程序,有的程序只有父进程可以执行,例如:export
总结:其实我们之前学习到的所有的(几乎)环境变量命令,都是内置命令,需要让系统bash自己去执行这个操作,因为这些操作都是需要调用真正的系统调用接口才可以完成的,例如上述所说的:putenv/chdir或者是我们的自定义函数,showEnv(),都是bash自己维护的环境变量,只有bash把这些环境变量维护好了,以后在bash执行指令,也就是子进程的时候(例如:我们自己实现的bash文件),才会将自写环境变量通过execve接口等的方式,把这个环境变量继承下去,所以想要在我们自己实现的bash文件中使用系统bash的所有环境变量,就必须利用好系统调用接口
进阶第三点:
echo命令的实现,同理,如下图:
总结:通过mybash的编写,此时我们充分理解了什么是环境变量和父子之间的继承关系是怎么样的,并且可以更加合理的去使用那7大函数,并且更加的明白了什么是内置命令,因为我们会明白,如果想要执行普通的命令,让我们的子进程 去执行就行了,但是如果想要执行内置命令,此时就必须要让父进程,也就是bash自己去执行才行,所以内置命令也就是相当于就是bash的函数
bash细节化实现
#include<assert.h> 2 #include<iostream> 3 #include<stdlib.h> 4 #include<stdio.h> 5 #include<unistd.h> 6 #include<string.h> 7 #include<sys/wait.h> 8 #include<sys/types.h> 9 10 11 #define MAX 1024 12 #define ARGC 64 13 #define SEP " " 14 int split(char* commandstr,char* argv[])//注意,此时是不需要使用二级指针的,因为数组传参的时候降维了,所以此时这样写就是等于在使用下面那个数组 15 { 16 assert(commandstr && argv); 17 argv[0]=strtok(commandstr,SEP);//切割成功,此时就是会把相应的子串保存在argv指针数组中,但是要注意strtok的目的就是为了把空格替换成\0 18 if(argv[0]==NULL) 19 { 20 return -1; 21 } 22 int i =1; 23 while(argv[++i] = strtok(NULL,SEP));//最后切失败了,argv[i]就是0,while自然就不满足了,strtok把NULL赋值给argv[i],转换一下argv[i]就是等于0 24 // int i = 1; 25 // while(1) 26 // { 27 // argv[i]=strtok(NULL,SEP); 28 // if(argv[i]==NULL) 29 // { 30 // break; 31 // } 32 // ++i; 33 // } 34 return 0; 35 } 36 37 void debugPrint(char** argv) 38{ 39 for(int i =0;argv[i];++i) 40 { 41 printf("%d:%s\n",i,argv[i]); 42 } 43 } 44 45 void showEnv() 46 { 47 extern char** environ; 48 for(int i =0;environ[i];++i) 49 { 50 printf("%d:%s\n",i,environ[i]); 51 } 52 } 53 54 int main() 55 { 56 int last_exit=0; 57 char myenv[32][256]; 58 int env_index = 0; 59 // extern char** environ;//声明环境变量(系统中的所有) 60 while(1)//第一步理解:进程一定是一个死循环,因为要一直支持输入数据 61 { 62 char commandstr[MAX]={0}; 63 char* argv[ARGC]={NULL};//这个就是环境变量的知识了,就是用来存储参数选项的 64 printf("[wuweixin@mymachine 我的bash实现]# ");//这边由于我们没有写\n,所以本身是还在输出缓冲区的,所以需要我们刷新一下 65 fflush(stdout);//不能有\n,不然不规范,所以刷新标准输出缓冲区就行 66 char* str = fgets(commandstr,sizeof(commandstr) - 1,stdin);//此时使用C语言实现,所以在获取一行字符串的时候,我们使用的是fgets函数(如果想要知道fgets,可以去查一下) 67 assert(str);(void)str;//防止获取字符串失败,所以检查一下,并且为了防止在release版本也能使用,所以就加上后面这句 68 //此时上述的那个减一的目的是,防止不知道使用的C语言接口,还是系统接口,因为若果是C接口,它就会自己补\0,如果是系统接口,此时就不会补\0 69 //printf("%s\n",commandstr);//注意,此时的这个如果我们家了\n,它就会换行两次,因为,我们在输入字符的时候,它默认是把我们回车那下也算进去了 70 //所以解决方法如下: 71 commandstr[strlen(commandstr)-1]='\0';//例:获取的是,abcd\n\0 ,所以此时的目的就是要把\n用\0覆盖掉,但是要注意,下标是从0开始的,所以想要覆盖\n,此时需要减一一下 72 //所以此时我们自己的命令行就很健康了 73 //此时就是第二步:切割字符串(因为指令选项之间是按照空格间隔的) 74 pid_t id = fork();//但是要注意:子进程只能跑部分的指令,不是所有指令都可以通过子进程实现的,例如:export,向进程中写入环境变量(此时子进程就完成不了) 75 assert(id>=0);(void)id; 76 //例:"ls -a -l" 需要切割成 "ls""-a""-l" 就是要让它在输入的时候,可以按照空格区分, "ls"\0"-a"\0"-l"\0 77 //实现原理,使用指针数组搞定,以下标原理 78 //所以这边你可以使用strtok,也可以直接使用C++中的find函数,找到空格,然后把空格替换成\0 79 //strtok使用原理:strtok(str," ");切割之后,就传 strtok(NULL," ") 80 int n =split(commandstr,argv);//所以此时执行完这个函数,此时就切割好了 81 if(n != 0) 82 { 83 continue;//切失败了就继续切,反正一定要切成功 84 } 85 // debugPrint(argv); 86 if(strcmp(argv[0],"cd")==0) 87 { 88 if(argv[1]!=NULL)//因为此时实现的是cd指令,所以第二个参数一定是要有的,cd不是ls这种功能性的,是目的性的 89 { 90 chdir(argv[1]);//此时就是这个系统接口的特性,可以直接到达我们传的这个路径,当然此时的argv[1]也就是我们想要返回的那个路径 91 continue;//目的拦截在父进程,不执行fork函数,导致子进程也会执行 92 } 93 } 94 else if(strcmp(argv[0],"export")==0) 95 { 96 if(argv[1]!=NULL) 97 {//此时我们有了自己的自定义env,此时就不要使用putenv了,因为putenv中的内容会被fgets中的内容给覆盖 98 // putenv(argv[1]);//此时就是将我们的export使用putenv导入到系统的bash进程进行处理,让真正的bash去完成这个工作,间接导致我们的bash文件完成这个工作 99 strcpy(myenv[env_index],argv[1]);//此时就是现将argv中保存的字符给拷贝一份,然后就不怕被覆盖了 100 putenv(myenv[env_index++]);//因为此时这个下标是单独保存,所以就不会因为gets,导致被commandstr给覆盖掉了 101 continue; 102 } 103 } 104 else if(strcmp(argv[0],"env")==0) 105 { 106 showEnv(); 107 continue; 108 } 109 else if(strcmp(argv[0],"echo")==0) 110 { 111 const char* target_env=NULL; 112 if(argv[1][0]=='$')//因为使用echo后面跟的一定是$才行,但是此时要注意数据类型,此时的argv代表的是一个指针数组,所以想要用里面的数据进行判断就一定要加上解引用 113 { 114 if(argv[1][1]=='?')//因为此时输入$?的时候是不需要空格的,所以可以看做是一个字符,然后就可以看成是一个二维数组 115 { 116 printf("%d\n",last_exit); 117 continue; 118 } 119 else 120 { 121 target_env=getenv(argv[1]+1);//使用getenv来获取特定的环境变量,此时因为这些都是在调用函数,并且这些函数的参数本质都是指针,所以直接传地址没关系 122 123 } 124 } 125 if(target_env!=NULL) 126 { 127 printf("%s=%s\n",argv[1]+1,target_env); 128 } 129 continue; 130 } 131 132 if(strcmp(argv[0],"ls")==0)//此时这句代码就是识别到,如果指令是ls,就特殊处理一下,目的:加个颜色 133 { 134 int pos = 0; 135 while(argv[pos]) 136 { 137 ++pos; 138 } 139 argv[pos++]= (char*)"--color=auto"; 140 argv[pos]=NULL; 141 } 142 143 if(id == 0) 144 { 145 //child 146 execvp(argv[0],argv);//直接将环境变量传给子进程,让子进程去替换系统中argv环变量中的指令 147 exit(1); 148 } 149 else 150 { 151 //parent 152 int status = 0; 153 pid_t ret = waitpid(id,&status,0); 154 if(ret>0) 155 { 156 last_exit=WEXITSTATUS(status);//目的每次获取进程退出码 157 } 158 } 159 160 } 161 162 return 0; 163 }
文件操作
文件操作我们肯定是非常的熟悉的,因为我们在学习C语言的时候,我们就学习过如何进行文件操作,例:如何使用和文件有关的相关函数接口,所以此时我们学习成本也是不高的,但是如果在系统层面看待文件操作,那么就需要结合之前有关进程的知识来结合理解,所以和系统调用一般,文件操作也是同理,不是语言问题,而是系统问题,所有的语言,无论是C++、java、C、Pathon,这些语言都是有文件操作的,但是这些文件操作想的方法都是不一样的,本质上是因为对操作系统的文件程序驱动的系统调用接口的封装不一样,但是底层都是取调用操作系统的系统调用接口,所以在语言层面和操作系统层面,文件操作是有区别的
从语言层面看文件操作
首先明白 文件=内容+属性 ,是针对文件的操作,对内容的操作,对属性的操作,并且要明白,一个文件是需要被提前加载到内存中的,那么此时加载的是文件的内容,还是文件的属性呢?
所以打开文件的本质就是,将我们需要的文件属性加载到内存中,并且操作系统内部一定会同时存在大量的被打开文件,所以此时就有一个问题:那么操作系统需不需要把这些被打开的文件进行管理呢?简简单单还是那句话,先描述,再组织,
所以先描述再组织的本质也就是构建一个合适的结构体出来(所以管理的本质也就是构建一个结构体出来),并且文件结构体中就有文件的属性,所以此时的结构体就是通过文件的属性构成的,例如:此时操作系统就是通过一个叫struct file的结构体进行对加载到内存的文件进行管理,并且加载文件时,一定是文件的属性先被加载到内存(文件=内容+属性),所以此时struct file结构体中用于存储的就是各个文件的属性,通过struct file* next;指针的形式,所以可以初步了解,文件结构体大致是一个链表形式的数据结构,所以总,操作系统对内存中文件的管理,就类似于是对链表的增删查改
所以上述虽然是一个推导出来理论,但本质也就是一个对文件对象管理的建模
明白了每一个被打开的文件(被加载到内存的文件),都要在OS内对应文件对象的struct结构体,可以将所有的struct file结构体使用某种数据结构链接起来,在os内部,对被打开文件进行管理,就被转换成为了对链表的增删查改
结论:文件被打开,OS要为被打开的文件,创建对应的内核数据结构 struct file (跟操作系统创景进程pcb一个道理)
并且明白, 什么是被打开的文件(是谁在打开呢?)当然是操作系统,因为只有它有这个能力,那么是谁让它打开的呢?可以说是用户,准确的说是进程,用户写了一个代码,生成进程,最后操作系统打开,执行文件,首先就是生成进程
所以文件操作的本质都是被打开文件和进程之间的关系,我们学习的目标也就是进程和被打开文件之间的关系,所以按照内存来看,就是struct task_struct;和struct file;之间的关系,也就是进程控制块和文件对象之间的关系
语言层面看文件操作
C语言中最基本的文件操作函数,fopen,fclose,fput
简简单单证明一个点:如果默认只是打开一个文件,文件中的内容是会自动被清空的,同时,每次进行写入的时候,都会从最开始进行写入,如下图:
所以打开一个文件第一步先清空文件中的内容,然后第二步才是对文件进行写入(当然这是因为我们写入的方式是“w”)
所以如果我们使用的是“a”的话,那么此时表示的就是追加,此时就不会对文件内容进行清空,而是不断的对文件内容进行追加,所以具体的文件操作是可以按照我们自己的需求来实现的
下图就是C语言中部分的文件操作:
系统层面看文件操作
打开文件接口如下:
第一个注意点,为什么返回值是整形(int)
来一个话题: 操作系统一般是如何让用户给自己传递标志位的
首先操作系统有一个接口,int=32个比特位,我们可以调用一个比特位表示一个标志位,一个整数就可以同时传递32个标志位,所以我们就可以使用比特位的方式向一个系统接口传递标志位,本质也就是我们之前学的位图结构起的作用,