黑暗中的我们都没有说话,你只想回家,不想你回家...............................................................
文章目录
前言
一、【shell工作过程】
二、【命令行参数】
2.1、【获取命令行参数】
1、【输出命令行提示符】
2、【输入命令行参数】
2.2、【解析命令行参数】
1、【分割命令行参数】
2、【判断指令类型】
3、【外部命令的执行】
4、【内建命令的执行】
cd命令:
export指令:
echo指令:
5、【实现重定向】
三、【总结说明以及完整代码】
1、【使shell循环工作】
2、【代码中的全局变量说明】
3、【完整代码及效果演示】
总结
前言
shell也就是命令行解释器,其运行原理就是:当有命令需要执行时,shell创建子进程,让子进程执行命令,而shell只需等待子进程退出即可,学习了进程的概念及其控制以后,我们也可以上手做一个简易的shell。
一、【shell工作过程】
我们一般见到的shell一般是下面这个样子:
然后我们可以在光标之后输入一些命令来完成相应的操作:
那么我们来想一下,shell是如何进行工作的呢?
首先我们看到的命令行提示符,光标,这些我们只需要做一些打印工作即可。
但是那些命令是如何执行的呢?
我们知道那些命令实际上也是一个个存放在磁盘中的可执行程序,我们通过在shell界面上输入那些命令名,实际上就是将其从磁盘加载到内存进而进行调用,也就是说,我们要在shell进程里创建子进程,然后通过子进程去调用那些命令的可执行程序,才能完成使用命令的操作。
因此shell需要执行的逻辑其实非常简单,其只需循环执行以下步骤:
- 获取命令行。
- 解析命令行。
- 创建子进程。
- 替换子进程。
- 等待子进程退出。
其中,创建子进程使用fork函数,替换子进程使用exec系列函数,等待子进程使用wait或者waitpid函数。
下面我们具体来看一看。
二、【命令行参数】
2.1、【获取命令行参数】
1、【输出命令行提示符】
我们首先将shell界面中的命令行提示符打印出来,首先我们先来看看这些命令行提示符都是什么:
不难发现这些都是我们shell环境变量中的值,所以我们可以通过函数getenv(),来获取他们具体如下:
#include<stdio.h> #include<stdlib.h> #include<unistd.h> #define NUM 1024 //用来存放输入的数组大小 //获取用户名 const char* getUsername() { const char* name=getenv("USER"); if(name) { return name; } else { return "none"; } } //获取主机名 const char* getHostname() { const char* hostname=getenv("HOSTNAME"); if(hostname) { return hostname; } else { return "none"; } } //获取工作目录 const char* getCwd() { const char* cwd=getenv("PWD"); if(cwd) { return cwd; } else { return "none"; } } int main() { char usercommand[NUM]; printf("[%s@%s %s]$ ",getUsername(),getHostname(),getCwd()); scanf("%s",usercommand); return 0; }
打印效果:
2、【输入命令行参数】
但是这样编写的代码会存在一些问题:
如果我们用scanf读取输入的命令行参数,只会读到一个不含空白符的字符串,因为scanf会以空白符为结尾。我们可以看看:
所以我们不能用scanf读取命令行参数,可以用fgets:【fgets - C++ Reference】
注意:
puts遇到空字符停止输出,在输出字符串时会自动在字符串末尾(\0)加一个换行符。
gets()丢弃输入中的换行符,puts()在输出中添加换行符。fgets()保留输入中的换行符,fputs()不在输出中添加换行符。
所以更改如下:
最后我们对其进行封装:
2.2、【解析命令行参数】
1、【分割命令行参数】
用户输入参数(指令)后,shell会对该行参数进行解析,一般会将字符串按空格进行分割,然后分割好的命令行参数就可以作为程序替换函数exec*的参数,从而实现程序替换,并调用对应的命令程序,而我们这里用strtok函数【strtok - C++ Reference】分割字符串。
这里我们直接进行封装:
最终达到下面的效果即可:
命令行提示符、用户与命令行交互、解析参数的功能实现了,现在我们需要根据用户输入的参数(指令),执行程序。但在执行程序之前,我们必须对指令进行判断。
2、【判断指令类型】
我们之前学习过指令,Linux系统的指令一般可以分为两类 :
- 一类是内建指令(builtin shell command),内部指令是指内建在shell中的指令,但我们执行该类指令时,不需要额外创建进程,所以内部指令执行的效率高,内建命令就是bash自己执行的,类似于自己内部的一个函数【SHELL编程之内建命令 | Zorro’s Linux Book】
另一类是外部指令(external shell command)。外部指令是指非内建于shell的指令,我们执行该类指令时,会额外创建一个进程。
我们可以通过指令type判断一个指令是否是内建指令,type命令来自英文单词“类型”,其功能是用于查看命令类型,如需区分某个命令是Shell内部指令还是外部命令,则可以使用type命令进行查看。
【参考资料】:type命令 – 查看命令类型 – Linux命令大全(手册)
显示出文件路径的一般是外部命令,显示“is a shell builtin”是内建命令,内建命令不是存放在磁盘中的可执行程序,我们不能简单的创建子进程并进行程序替换,来实现内建命令的调用,对于内建命令我们需要作特殊处理。
3、【外部命令的执行】
对于外部命令的执行,我们不需要考虑太多,只需要在我们的shell进程中创建子进程,使用分割好的命令行参数,进行程序替换,从而完成对应命令的调用。
具体如下:
4、【内建命令的执行】
由于内建命令不同于外部命令只进行简单的程序替换即可完成,内建命令需要特殊的处理,所以对于不同的内建命令我们有不同的处理措施,下面我们看几个例子:
cd命令:
我们知道cd + 目标路径,就可以将我们当前的工作目录改为cd命令后面的目标路径,所以我们可以通过chdir系统调用,将当前的工作路径换为“目标路径”
将工作目录改变以后我们可以通过getcwd函数来进行查看:
需要注意的是,这里我们仍然是创建子进程来实现cd命令所以我们会在子进程中使用chdir函数,而chdir只会进程的当前工作目录,只会影响当前进程及其子进程的工作目录,不会影响环境变量中的PWD,PWD 是由 shell 自动维护,而不是由内核直接管理。
对于典型的 shell,PWD 在你使用命令(如 cd)改变目录时会被更新,但是,直接用系统调用 chdir 不会自动更新 PWD。所以我们仅仅使用chdir函数就会出现下面的场景:
所以当我们调用chdir更改当前工作目录后,还要对环境变量PWD进行修改。先通过调用getcwd()获取当前工作目录的绝对路径,并将其存放到临时空间tmp中,之后通过函数sprintf将tmp的值格式化给全局变量cwd,最后再使用函数putenv将全局变量cwd覆盖环境变量PWD,最后就可以完成修改如下:
export指令:
像cd指令一样,一旦我们识别到对应指令为export,我们就要对其进行特殊的处理,我们知道
export
命令可用于显示或设置环境变量。所以我们可以使用putenv函数来实现:echo指令:
当我们使用echo指令打印字符串,都没什么问题,可当我们像下面这样使用时:
按理说这里应该会为我们打印PATH环境变量,但是echo好像将其当成了字符串进行输出,所以我们要来解决一下这个问题,与前面一样我们对其进行针对性特殊处理即可:
5、【实现重定向】
在myshell当中添加重定向功能的步骤大致如下:
- 对于获取到的命令进行判断,若命令当中包含重定向符号
>
、>>
或是<
,则该命令需要进行处理。- 设置type变量,type为0表示命令当中包含输出重定向,type为1表示命令当中包含追加重定向,type为2表示命令当中包含输入重定向。
- 重定向符号后面的字段标识为目标文件名,若type值为0,则以写的方式打开目标文件;若type值为1,则以追加的方式打开目标文件;若type值为2,则以读的方式打开目标文件。
- 若type值为0或者1,则使用dup2接口实现目标文件与标准输出流的重定向;若type值为2,则使用dup2接口实现目标文件与标准输入流的重定向。
请看下面的实现过程:
最后注意每次进行新指令的输入时把我们定义的redir和filename两个全局变量进行清理:
三、【总结说明以及完整代码】
1、【使shell循环工作】
到这里就算把大部分常见的功能实现完全了,但是细心的你一定会发现,我们的shell还存一个致命的缺陷,就是我们好像只能执行一次命令,所以我们要对其进行完善,是我们的shell能够像正常shell一样,能够连续工作:
2、【代码中的全局变量说明】
我们可以看到我们的代码中用了很多全局变量,那么它们有什么作用呢?
我们先来看lastcode,该全局变量表示程序的退出码,这样我们在父进程等待子进程,或者程序退出的时候获得退出信息:
下面再让我们看看enval和cwd:
我们会发现,这两者都是在调用函数putenv时使用的,也就是说当我们需要添加换将变量时需要定义它们两个定义为全局变量,那么是为什么呢?
首先我们假设没有这两个全局变量,而是将enval和cwd定义在了函数中:
我们要知道当进程启动时,会专门开辟一块空间用来存储命令行参数和环境变量,同时用一个字符串指针数组管理这些环境变量,这个管理环境变量的字符串指针数组就叫做环境变量表(char* envrion[]),当我们用putenv新增一个环境变量时,这时环境变量表会分配一个元素,也就是字符串指针指向这个新增的环境变量,这个新增的环境变量并没有添加到专门存储环境变量的内存空间中(也就是environ指向的空间),而是在栈区,这是因为我们使用argv[1],作为路径path,去当作新传入的环境变量(这里在cd指令中path是目标路径,我们要通过改变环境变量来实现命令行提示符中路径部分的持续变化,而在export中path是我们要导入的环境变量),大致如下:
所以我们通过putenv函数将path添加到环境变量表中,实际上是在environ中创建了一个字符串指针指向了path,而path本身存放在栈区中:
而当我们输入重新在命令行输入指令时,就会刷新命令行参数表argv,如果argv可以分割成两个字符串(也就是argv[0],argv[1]均存在),第二个字符串就会覆盖原来的argv[1],“这样环境变量表environ就找不到原来新增的环境变量了因为被覆盖了。
所以,我们要自己维护一个存放环境变量的空间myenv或cwd,将新增的环境变量存放在myenv和cwd中:
这样就不会覆盖了。 (但再添加一个新的环境变量会覆盖旧的环境变量,大家也可以把自己维护的环境变量设置成二维数组的形式,在堆上申请空间)。
3、【完整代码及效果演示】
为了将系统的shell和我们自己写的shell进行区分,我们可以通过之前写进度条的颜色方案为我们的命令行提示符部分进行上色:
同时也应使用Makefile进行管理:
完整代码:
#include<stdio.h> #include<stdlib.h> #include<unistd.h> #include<string.h> #include<sys/types.h> #include<sys/wait.h> #include<sys/stat.h> #include<fcntl.h> #include<ctype.h> #define NUM 1024 //用来存放输入的数组大小 #define SEP " " #define SIZE 64 char cwd[1024]; char enval[1024]; // for test int lastcode=0; //重定向类别 #define NoneRedir 0// 不是重定向 #define OutputRedir 1//输出重定向 #define AppendRedir 2 //追加重定向 #define InputRedir 3//输入重定向 int redir=NoneRedir;//全局变量,redir,用来标识 char* filename=NULL;// 文件名 //获取用户名 const char* getUsername() { const char* name=getenv("USER"); if(name) { return name; } else { return "none"; } } //获取主机名 const char* getHostname() { const char* hostname=getenv("HOSTNAME"); if(hostname) { return hostname; } else { return "none"; } } //获取工作目录 const char* getCwd() { const char* cwd=getenv("PWD"); if(cwd) { return cwd; } else { return "none"; } } //获取命令行参数 int getUserCommand(char *command, int num) { printf("\033[35;1m[%s@\033[32;1m%s \033[34;1m%s]\033[31;1m %c\033[0m ", getUsername(), getHostname(), getCwd(),'$'); char *r = fgets(command, num, stdin); if(r == NULL) return -1; command[strlen(command) - 1] = '\0'; return strlen(command); } //分割命令行参数 void commandSplit(char *in, char *out[]) { int argc = 0; out[argc++] = strtok(in, SEP); while( out[argc++] = strtok(NULL, SEP)); } //执行外部命令 int execute(char *argv[]) { // pid_t id = fork();//创建子进程 if(id < 0) return -1;//创建失败 else if(id == 0) //child { // 程序替换会不会影响曾经的重定向呢??不会!! 为什么?如何理解?? int fd = 0; if(redir == InputRedir) { fd = open(filename, O_RDONLY); // 差错处理我们不做了 dup2(fd, 0); } else if(redir == OutputRedir) { fd = open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0666); dup2(fd, 1); } else if(redir == AppendRedir) { fd = open(filename, O_WRONLY | O_CREAT | O_APPEND, 0666); dup2(fd, 1); } else { //do nothing } //程序替换 execvp(argv[0], argv); // argv[0]-->ls,argv[1]-->-a,argv[2]-->-l,argv[3]-->NULL exit(1);//子进程结束后使用exit函数退出 } else // father { //父进程等待子进程 int status = 0; pid_t rid = waitpid(id, &status, 0); if(rid > 0) { lastcode = WEXITSTATUS(status); } } return 0; } void cd(const char *path) { char tmp[1024];//定义临时空间存放当前工作目录 getcwd(tmp, sizeof(tmp));//获取当前工作目录 printf("当前工作目录:%s\n",tmp); printf("环境变量:%s\n",getenv("PWD")); chdir(path);//改变工作目录 getcwd(tmp, sizeof(tmp));//更新工作目录,并将其存放到tmp sprintf(cwd, "PWD=%s", tmp); //使用tmp覆盖环境变量,cwd的值被格式化为"PWD=tmp" putenv(cwd);//覆盖PWD环境变量 printf("当前工作目录:%s\n",tmp); printf("环境变量:%s\n",getenv("PWD")); } char *homepath() { char *home = getenv("HOME"); if(home) return home; else return (char*)"."; } extern char **environ; //使用全局变量environ打印所有环境变量 //内建命令 int doBuildin(char *argv[]) { if(strcmp(argv[0], "cd") == 0)//判断是否是cd命令 { char *path = NULL; if(argv[1] == NULL) path=homepath();//cd后为空就设置为家目录 else path = argv[1];//不为空就设置为目标路径 cd(path);//执行cd逻辑 return 1; } else if(strcmp(argv[0], "export") == 0) { if(argv[1] == NULL) return 1;//若未传入环境变量则返回 strcpy(enval, argv[1]);//否则将传入的环境变量拷贝到全局变量enval中 putenv(enval); // 使用putenv函数将enval添加到环境变量中 return 1; } else if(strcmp(argv[0], "echo") == 0) { if(argv[1] == NULL) { printf("\n"); return 1; } if(*(argv[1]) == '$' && strlen(argv[1]) > 1)//判断是否是类似与$PATH,$?这种参数 { char *val = argv[1]+1; // 对于不同的参数进行不同的处理,$?就要打印退出码 if(strcmp(val, "?") == 0) { printf("%d\n", lastcode); lastcode = 0; } else//其他的使用getenv函数进行获取并打印即可 { const char *enval = getenv(val); if(enval) printf("%s\n", enval); else printf("\n"); } return 1; } else//不是$*这种参数就当作字符串即可 { printf("%s\n", argv[1]); return 1; } } else if(0) { //有其他内建指令,再进行特殊处理 } return 0; } #define SkipSpace(pos) do{ while(isspace(*pos)) pos++; }while(0)//这是一个宏函数,作用是跳过空格,里面使用了函数isspace,该函数作用是判断pos位置是否为空格 void checkRedir(char usercommand[], int len) { // ls -a -l > log.txt // ls -a -l >> log.txt char *end = usercommand + len - 1;//从后遍历 char *start = usercommand; while(end>start) { if(*end == '>')//可能是输入重定向,也可能是追加重定向 { if(*(end-1) == '>')//输入重定向 { *(end-1) = '\0'; filename = end+1; SkipSpace(filename); redir = AppendRedir; break; } else//追加重定向 { *end = '\0'; filename = end+1; SkipSpace(filename); redir = OutputRedir; break; } } else if(*end == '<')//输出重定向 { *end = '\0'; filename = end+1; SkipSpace(filename); // 如果有空格,就跳过 redir = InputRedir; break; } else//说明未找到“<,<<,>”三个中的一个,继续向前遍历 { end--; } } } int main() { while(1) { redir=NoneRedir; filename=NULL; char usercommand[NUM]; char *argv[SIZE]; //获取 int n = getUserCommand(usercommand, sizeof(usercommand)); if(n<=0)//查看getUserCommand函数返回值,n<=0说明其输入的是空串 { continue;//空串直接跳过循环,重新输入 } //获得字符串以后,首先检查是否进行了重定向 //ls -a -l > log.txt ?> "ls -a -l" [redir_type] "log.txt" checkRedir(usercommand,sizeof(usercommand)); //分割字符串 commandSplit(usercommand, argv); n = doBuildin(argv); if(n) { continue;//n不为0说明为内建命令,就不向下继续执行了。 } n= execute(argv); } return 0; }
演示效果:
总结
到这里我们就完了一个简易shell的实现,其中我们我们使用了,进程替换,字符分割,重定向,以及文件的打开及关闭,甚至包括dup2函数,等等知识,本篇博客到这里也就结束了,希望对你有所帮助!
......................................................................是否沉默就是你的回答,我们都别挣扎,去爱他
————《爱我还是他》