自主shell
- 1、搭建架构
- 2、打印命令行
- 3、等待用户进行输入
- 4、解析字符串
- 5、执行命令
- 6、进一步完善
- 6.1、路径无法切换?
- 6.2、完善提示符
- 6.3、`echo $?`提取退出码
- 6.4、环境变量的获取
- 6.5、环境变量的添加
- 7、其它知识点
- 7.1、进一步理解:环境变量 vs 本地变量
- 7.2、一个命令,两种身份?
- 7.3、当前自主shell不支持的功能
自主实现一个简单的shell命令行,可以将我们之前学习过的知识串联起来。其核心思想就是:
- 创建一个父进程(bash),完成基本的命令行操作;
- 对于用户输入的指令,
- 如果是普通命令,父进程将会创建子进程,让子进程执行;
- 如果是内建命令,父进程将会自己执行。
1、搭建架构
// myshell.h#pragmaonce#include<stdio.h>// myshell.c#include"myshell.h"voidBash(){while(1){// ...}}// main.c#include"myshell.h"intmain(){Bash();return0;}这里我们要明确一点:命令行本质上就是一个死循环的进程,只不过需要用户输入指令,命令行才会执行下一步操作。
2、打印命令行
vscode上的命令行是长这样的:
简单分析,我们就需要以下三个要素:
- 用户名
- 主机名
- 当前的工作目录
这三个要素对应的信息,其实都在环境变量里面。所以我们可以使用getenv获取。
// myshell.c#include"myshell.h"#include<stdlib.h>#include<string.h>#include<unistd.h>staticcharusername[32];staticcharhostname[64];staticcharpwd[256];staticvoidGetUserName(){char*_user_name=getenv("USER");strcpy(username,_user_name?_user_name:"None");}staticvoidGetHostName(){char*_host_name=getenv("HOSTNAME");strcpy(hostname,_host_name?_host_name:"None");}staticvoidGetPwd(){char*_pwd=getenv("PWD");strcpy(pwd,_pwd?_pwd:"None");}staticvoidPrintPrompt(){GetUserName();GetHostName();GetPwd();printf("%s@%s:%s# ",username,hostname,pwd);fflush(stdout);// 冲涮缓冲区}voidBash(){while(1){PrintPrompt();sleep(1);// 方便观察,避免一下出现一大堆}}// myshell.h#pragmaonce#include<stdio.h>voidBash();这里有四个细节:
- 声明与定义分离的多文件项目中,一定要在
.h文件中加上声明!!! - 本人曾经习惯将所有头文件声明一股脑地放入
.h文件中,但这其实是不太好的习惯。建议对于某个文件需要某些头文件声明,而其它文件不需要,我们就把这些头文件声明放在这个文件当中。 printf不使用\n时,要使用fflush冲涮缓冲区,否则直到进程结束之前,什么也看不到。- 不想外部使用
myshell.c里的函数,就加上static。
3、等待用户进行输入
用户输入可能包含空格,我们可以使用库方法fgets。
同时,我们可以用一个static字符数组接收用户输入的字符串。
// myshell.c#include"myshell.h"#include<stdlib.h>#include<string.h>#include<unistd.h>staticcharusername[32];staticcharhostname[64];staticcharpwd[256];staticcharcommandline[256];// ...staticvoidGetCommandLine(){memset(commandline,0,sizeof(commandline));// 清除历史记录if(NULL==fgets(commandline,sizeof(commandline),stdin)){perror("fgets failed!");exit(10);}}voidBash(){while(1){PrintPrompt();GetCommandLine();}}这里有三个细节:
用 sizeof 而不用 strlen
对于这句代码if (NULL == fgets(commandline, sizeof(commandline), stdin)),sizeof()可不能改成strlen(),因为用户的指令还没有输入进来,commandline肯定是一堆\0。
库函数strlen统计字符串的字符个数时,遇到\0就停止,所以strlen(commandline)的结果为0,导致fgets获取输入失败。
而操作符sizeof统计的是变量所占的字节数,sizeof(commandline)的返回结果就是256,因为一个char类型占一个字节,256个就占256个字节。
多了一个空格?
事实上,我们输入内容时,最后还输入了一个回车键,那么commandline末尾最终会多一个\n。
我们可以将这个转义字符覆盖掉:
staticvoidGetCommandLine(){memset(commandline,0,sizeof(commandline));if(NULL==fgets(commandline,sizeof(commandline),stdin)){perror("fgets failed!");exit(10);}else{commandline[strlen(commandline)-1]=0;}}输入了空串
如果用户什么也没输入,只是敲了一下“enter”,那么我们也就没必要进行下面的操作了,以免浪费CPU资源:
voidBash(){while(1){PrintPrompt();GetCommandLine();if(0==strlen(commandline))continue;}}清除历史记录
向commandline写入前,先清空commandline里的内容。
4、解析字符串
用户输入了一个字符串,我们需要解析字符串:以空格为分界拆成一个个“小串”,然后传入指针数组argv中做成一个环境变量表。
staticintargc=0;staticchar*argv[64];这里我们要借助一个C语言处理字符串的库函数strtok:
例如,这里有一个字符串char *string = "ls -a -l",要求将空格作为分界delim,将字符串拆分:
对于strtok中的str参数:
- 如果输入的是
string,那么返回字符串(字符指针)"ls"。- 我们可以理解为
strtok在返回的时候,内部有一个“指针”从头开始,读到空格就返回了。 - 以后如果输入的都是
string,那么返回的结果都是一样的。
- 我们可以理解为
- 如果输入的是
NULL,strtok就会往后读取。比如第二次调用,返回"-a";第三次调用,返回"-l";第四次调用,由于后面没有内容了,就返回NULL。- 我们可以理解为,
strtok内部存在static全局变量,能够保存最近一次传入字符串的信息。
然后我们就可以完善解析字符串的函数:
staticvoidParseCommandLine(){// 清除原有的记录argc=0;memset(argv,0,sizeof(argv));// 填表argv[argc]=strtok(commandline,sep);while((argv[++argc]=strtok(NULL,sep)));}首先,我们要清除上一次输入指令留下的记录;其次,我们借助strtok,只用了两行代码,就完成了argc argv的设置,甚至还能在argv的末尾加上NULL。
值得注意的是,while ((argv[++argc] = strtok(NULL, sep)));中我们多加了一个括号。这是因为括号里面进行的是赋值操作,一般赋值操作的返回值都为真,很容易导致死循环;编译器会认为我们在这里进行了误操作,即把 == 错写成了 = ,所以这里触发了编译器的警告。我们加上括号,意在告诉编译器,我们确实在做赋值操作,并且将赋值作为while循环的条件。
5、执行命令
执行命令,我们可以让父进程创建子进程,然后让子进程通过程序替换完成命令的执行。
staticvoidExecute(){pid_tid=fork();if(id<0){perror("fork failed!\n");exit(11);}elseif(0==id){// childexecvp(argv[0],argv);// 程序替换exit(12);}else{// parentpid_trid=waitpid(id,NULL,0);(void)rid;// 接收但不处理}}这样一来,我们就做好了一个功能简单的shell命令行。
6、进一步完善
接下来,我们将进一步完善自主实现的shell命令行解释器,以帮助我们加深理解这些概念:
- shell命令行的运行原理
- 普通命令 vs 内建命令
- 命令行参数 & 环境变量
6.1、路径无法切换?
子进程做完路径的切换,修改的只是子进程的副本;子进程直到退出的时候,也没能修改父进程(bash)的路径信息。所以路径切换的工作只能由父进程来做。
实际上,不仅路径切换指令cd,查看环境变量指令env、导入环境变量指令export、以及echo……这些命令,都必须由父进程执行,都是内建命令。
所以我们得到一个解析完的字符串,还应该先判断一下是否是内建命令:
if(CheckBuildinAndExecute())continue;我们执行内建命令的大致思路是:进入CheckBuildinAndExecute,
- 如果是内建命令,执行内建命令,返回“1”,此时命中
if条件,就转而去等待用户的下一次输入; - 如果不是内建命令,返回“0”,就不会命中
if条件,而是继续往下,执行普通命令。
做路径切换,我们借助库函数chdir:
staticintCheckBuildinAndExecute(){intret=0;if(0==strcmp(argv[0],"cd")){ret=1;if(2==argc)chdir(argv[1]);}returnret;}6.2、完善提示符
路径切换确实能做了,但是又出现一个问题:
命令行提示符中的路径,看起来没有改变?
实际上,我们是从环境变量中获取的当前工作路径。chdir并没有改变环境变量的PWD。
在这里,我们获取当前工作路径的时候,不从环境变量中获取,而是借助库函数getcwd:
voidGetPwd(){// char *_pwd = getenv("PWD");// strcpy(pwd, _pwd ? _pwd : "None");getcwd(pwd,sizeof(pwd));}只给出最近的目录名?
有的平台下的命令行解释器,对于当前工作目录,只会给出最近的目录名,例如10_5。
这时我们就可以实现一个简单的算法,提取最近的目录名:
voidGetPwd(){// char *_pwd = getenv("PWD");// strcpy(pwd, _pwd ? _pwd : "None");//getcwd(pwd, sizeof(pwd));char_cwd[256];getcwd(_cwd,sizeof(_cwd));if(0==strlen(_cwd)){strcpy(pwd,"None");}elseif(0==strcmp(_cwd,"/")){strcpy(pwd,_cwd);}else{intend=strlen(_cwd)-1;while(end>=0){if('/'==_cwd[end]){strcpy(pwd,&_cwd[end+1]);break;}--end;}}}注意这里使用的while循环的判断条件,不能用while(_cwd[end] != '/'),否则遇到'/'就会结束循环,永远也提取不到目录名。
6.3、echo $?提取退出码
(当前我们只针对普通命令的退出码的提取)
首先我们要完善Execute的进程等待,以及添加一个static全局变量记录退出码:
staticexit_code=0;// ...staticvoidExecute(){// ...// ...else{// parentintstatus=0;pid_trid=waitpid(id,&status,0);(void)rid;// 接收但不处理exit_code=WEXITSTATUS(status);}}我们统一将echo设置为内建命令。echo包括两种功能;
- 打印字符串(会换行)
- 打印退出码
……
staticintCheckBuildinAndExecute(){intret=0;if(0==strcmp(argv[0],"cd")){ret=1;if(2==argc)chdir(argv[1]);}elseif(0==strcmp(argv[0],"echo")){ret=1;if(1==argc){printf("Nothing to do.\n");return1;}elseif('$'==argv[1][0]){if(0==strcmp(argv[1],"$?")){printf("%d\n",exit_code);exit_code=0;}}else{printf("%s\n",argv[1]);}}returnret;}6.4、环境变量的获取
对于环境变量,我们有两个问题需要回答:
- 环境变量由谁维护?bash
- 环境变量在哪获取?配置文件
但是从配置文件中读取,我们做不到,就只能从真正的bash中获取环境变量:
// 环境变量staticintenvc;staticchar*env[64];// ...staticvoidLoadEnv()// 从真正的bash中读取环境变量{externchar**environ;for(envc=0;environ[envc];++envc)env[envc]=environ[envc];env[envc]=NULL;}值得注意的是,LoadEnv可不能放到循环内部。因为下一次等待命令的时候,由于又执行了一次LoadEnv,之前对环境变量表的修改就会被覆盖。
至于执行env查看环境变量表,我们可以修改检查内建命令的函数CheckBuildinAndExecute,添加env分支。
6.5、环境变量的添加
即内建命令export的完善。
elseif(0==strcmp(argv[0],"export"))// export{if(argc==2){env[envc++]=argv[1];env[envc]=NULL;}}在CheckBuildinAndExecute中这样写,就可以了吗?
elseif(0==strcmp(argv[0],"export"))// export{if(argc==2){env[envc++]=argv[1];env[envc]=NULL;}}只查到一行空白,怎么回是?
实际上,这种添加环境变量的方式,我们也不陌生:浅拷贝。env表的最后一个有数据的项是直接指向argv[1]指向的内容的,当Bash()等待用户的下一次输入时,argv会被清空,env表的最后一个有效项就指向了一堆\0,就出现了上面的情况。
其中一个解决的办法,就是深拷贝:
elseif(0==strcmp(argv[0],"export"))// export{if(argc==2){// env[envc++] = argv[1];// env[envc] = NULL;char*mem=(char*)malloc(sizeof(argv[1]+1));strcpy(mem,argv[1]);env[envc++]=mem;env[envc]=NULL;}}7、其它知识点
7.1、进一步理解:环境变量 vs 本地变量
对于环境变量,其实就是bash内部隐藏了一个环境变量表,接着用一个二级指针指向环境变量表;用户定义一个同名指针,就可以通过这个同名指针,获取环境变量表中的所有信息。
至于本地变量,其实也是bash内部隐藏了一个本地变量表。只要bash没有识别到export,就会写入本地变量表中。
voidBash(){// ...staticchar*local[1024];// 本地变量表// ...}7.2、一个命令,两种身份?
上面的学习中,我们默认将echo env等命令当作了内建命令,从而来完善自主shell。实际上,类似于echo env,Linux中也有相当一批命令,既有在bash中实现的方法,又有在磁盘上可随时调用的程序。
原因是,shell不仅含有解释命令的解释器bash:
与此同时,shell还是一门语言。
意味着shell可以像python一样,既可以做交互式的命令执行工作,又可以对一批写好代码和数据的文件,做语法分析与一系列处理。
对于同一种功能,
- 交互式工作场景下,一般都是bash自己的工作,就需要bash内部实现方法,即内建命令;
- 批量化处理场景下,bash就可以创建子进程执行,就需要磁盘上已经实现好的方法,即普通命令。
7.3、当前自主shell不支持的功能
当前的自主shell不支持:
- 权限的管理。例如
cat打开文件的请求会不会被拦截 > < >>重定向操作- 管道操作
- 取别名(实际上我们也可以创建一个别名表,然后别名实际上也是一个变量)
这些不支持的功能,未来大家有兴趣的话也可以尝试完善一下🤝。
代码演示