【Linux】进程控制(三) 自定义 Shell 命令行解释器的实现与进程协作实践
文章目录
一、自定义shell命令行解释器
学习了前面进程概念,进程控制的相关知识,我们对进程已经有了理性的认识,下面我们一起来实现一个自定义shell把这些知识串联起来,能对进程概念及进程相关各种用法,函数调用接口有一个更深刻是理解和记忆。
实现自定义shell的目标:能处理普通命令、能处理内建命令、能帮助我们理解内建命令/本地变量/环境变量这些概念、能帮助我们理解shell的运行原理。
构建框架
首先我们把要用到的所有文件创建出来,采用头源分离。未来方便,主要用C++编写。
//myshell.h#ifndef__MYSHELL_H__#define__MYSHELL_H__#include<iostream>voidDebug();#endif//myshell.cc#include"myshell.h"voidDebug(){printf("hello shell!\n");}//makefile myshell:main.cc myshell.cc g++-o $@ $^.PHONY:clean clean: rm -f myshell 输出命令行提示符

实现shell第一步是打印命令行提示符,我们先看xshell的命令行提示符,如上图所示,除了一些符号外有三个主要变量,这三个变量可以直接通过系统调用获取,但是这里我们为了复习一下学过的知识,故采用从环境变量中间接获取,系统的最后一个字符是$,为了区分我们用#。
由于这里我们是C语言和C++混编,所以需要注意一些细节,比如C语言printf字符串的时候不能直接打印string变量,因为它的字符串格式说明符 %s 要求传入的参数是C 风格字符串—— 即一个指向以空字符 ‘\0’ 结尾的字符数组的指针(const char* 类型,所以需要用c_str把string类型变量装换为C 风格字符串再打印。下面是代码示例:
//myshell.ccstatic std::string GetUserName(){ string username =getenv("USER");return username.empty()?"None": username;}static std::string GetHostName(){ string hostname =getenv("HOSTNAME");return hostname.empty()?"None": hostname;}static std::string GetPwd(){ string pwd =getenv("PWD");return pwd.empty()?"None": pwd;}voidPrintCommandPrompt(){ std::string username =GetUserName(); std::string hostname =GetHostName(); std::string pwd =GetPwd();printf("[%s@%s %s]# ", username.c_str(), hostname.c_str(), pwd.c_str());}GetUserName、GetHostName、GetPwd这三个接口我们不想暴露给外部使用,所以可以加static修饰使其只能在myshell.cc文件内部使用。
读取用户输入
因为我们要读取用户输入的整个字符串,所以还需要把空格字符串的空格读进去,所以不能用cin和scanf,因为它们遇到空格都会停止读取,这里我们选用C语言接口fgets,getline也可以,但fgets对于后续一些操作更友好,fgets具体使用介绍如下:

首先需要在main函数里创建一个字符数组commandstr作为参数存放读取用户输入的字符串。
//main.cc#include"myshell.h"#defineSIZE1024intmain(){char commandstr[SIZE];while(true){// 1、打印命令行提示符PrintCommandPrompt();// 2、读取用户输入的命令GetCommandString(commandstr, SIZE);//测试printf("%s\n", commandstr);}return0;}然后实现GetCommandString的内部逻辑,首先要判断传入的参数是否合法。接着通过fgets读取字符串,读取失败返回false,因为至少会读取一个回车键(‘\n’),所以不会读取为空。最后把读取到的最后一个字符’\n’置为’\0’,因为长度为10的字符串最后一个字符下标为9,所以需要strlen(cmdstr_buff) - 1。若删掉’\n’后字符长度为0说明只读取到了’\n’,返回false。
//myshell.ccboolGetCommandString(char cmdstr_buff[],int len){if(cmdstr_buff ==NULL|| len <=0){//参数不合法returnfalse;}char* res =fgets(cmdstr_buff, len,stdin);if(res ==NULL){//读取字符串失败returnfalse;}//把输入的回车也就是'\n'置为'\0' cmdstr_buff[strlen(cmdstr_buff)-1]=0;returnstrlen(cmdstr_buff)==0?false:true;}解析命令字符串
经过前面两步后下一步需要解析用户输入的命令字符串,解析命令字符串本质就是创建命令行参数表,并把用户输入的字符串按空格分开,依次放入命令行参数表中。而且系统中的命令行参数表原本就是由bash创建并维护的,我们自定义shell其实也是在一定程度上在模拟实现一个bash。
1、我们知道系统的命令行参数表是main函数的局部变量,而这里我们自定义shell时希望子进程能继承父进程的命令行参数表,所以我们这里需要把命令行参数表定义在全局。
2、有许多函数可以分割字符串,我们选取一个最简单的来使用:strtok

首次调用时第一个参数传入待分割的字符串,后续调用时传NULL,第二个参数传分隔符字符串,比如空格。切割成功返回分割出的子串的首元素地址,切割失败或者切割完毕返回NULL。它的底层原理是把原字符串里的分隔符字符串全部替换成’\0’。
3、开始提取子串写入命令行参数表gargv,第一次调用strtok得到的子串放入gargv[0],然后循环取子串放入,最后提取子串完毕strtok返回NULL写入gargv最后一个位置,正好命令行参数表要以NULL结尾。
4、但是目前解析命令字符串逻辑还有两个bug,其一因为gargv和gargc都是全局变量,所以在main函数死循环逻辑的开头需要初始化全局变量,gargc直接置为0就行了,gargv数组可以用memset初始化更方便:
void * memset ( void * ptr, int value, size_t num );
第一个参数传待设置的内存空间,因为memset是以字节为单位初始化的,第二个参数是要设置的数值(本质是ASCII码值),第三个参数是要设置的长度。
5、其二是如果用户啥都不输入直接按回车键,那么第二步读取用户命令什么都读取不到,commandstr数组将为空,第三步提取时会把strtok返回的NULL写入gargv[0],所以我们在主逻辑main函数的第二步多加一个判断,如果用户没有输入,直接回车,此时直接continue跳过此轮循环的后续逻辑。
//myshell.cc//全局定义命令行参数表char* gargv[ARGS]={NULL};int gargc =0;voidInitGlobal(){ gargc =0;memset(gargv,0,sizeof(gargv));}boolParseCommandString(char cmd[]){if(cmd ==NULL){//安全检查returnfalse;}//可以在函数内部定义,SEP表示分隔符#defineSEP" "//"ls -a -l" -> "ls" "-a" "-l" //把第一个子串写入gargv[0],然后gargc++ gargv[gargc++]=strtok(cmd, SEP);//把子串全部写入gargv数组里,并且以NULL结尾while(gargv[gargc++]=strtok(NULL, SEP));//循环空语句//回退一次命令行参数的个数--gargc;//条件编译,测试代码//因为gargv,gargc定义在该文件,无法在main.cc里debug//#define DEBUG#ifdefDEBUGprintf("gargc: %d\n", gargc);printf("--------------------------\n");for(int i =0; i < gargc; i++){printf("gargv[%d]: %s\n", i, gargv[i]);}printf("--------------------------\n");for(int i =0; gargv[i]; i++){printf("gargv[%d]: %s\n", i, gargv[i]);}#endifreturntrue;}//main.cc#include"myshell.h"#defineSIZE1024intmain(){char commandstr[SIZE];while(true){// 0、初始化全局变量InitGlobal();// 1、打印命令行提示符PrintCommandPrompt();// 2、读取用户输入的命令//如果用户没有输入,直接回车//会返回false,此时直接continueif(!GetCommandString(commandstr, SIZE))continue;// 3、解析命令行字符串ParseCommandString(commandstr);}return0;}//main.cc#include"myshell.h"#defineSIZE1024intmain(){char commandstr[SIZE];while(true){// 0、初始化全局变量InitGlobal();// 1、打印命令行提示符PrintCommandPrompt();// 2、读取用户输入的命令//如果用户没有输入,直接回车//会返回false,此时直接continueif(!GetCommandString(commandstr, SIZE))continue;// 3、解析命令行字符串ParseCommandString(commandstr);// 4、执行命令ForkAndExec();}return0;}执行命令
首先我们要知道执行命令不能由shell本身来做,因为执行命令会发生程序提花,一但shell被替换那么就无法继续输出命令行提示符和读取用户输入了,所以执行命令需要交由子进程来做。
大体思路是先在main函数逻辑里fork一个子进程,子进程执行程序替换并运行命令,执行完毕后子进程直接退出。父进程等待子进程,不论等待成功还是失败都会继续循环执行shell主逻辑。
然后来选择使用哪个程序替换接口,因为我们要执行的命令没带路径所以要有p,命令行参数已经被我们维护成了表结构,所以要有v,子进程可以通过虚拟地址空间继承到环境变量,所以程序替换时可以不传,那么我们的最佳选择就是execvp。
voidForkAndExec(){ pid_t id =fork();if(id <0){//fork失败perror("fork");return;}elseif(id ==0){//子进程execvp(gargv[0], gargv);exit(0);}else{//父进程 pid_t rid =waitpid(id,nullptr,0);}}内建命令
cd
我们目前已经实现了一个最基本的shell,还有许多优化工作需要我们做。首先当前的shell运行cd指令时无法切换shell进程的当前工作路径,因为cd命令交给子进程去执行了,改变的也只是子进程的工作路径,运行cd指令的子进程退出后不会对父进程有影响,再执行pwd时负责执行pwd的子进程还是继承原先父进程的工作路径,所以我们肉眼看到路径没有变化。而系统的cd路径切换本质是bash自己在切换,切换后创建的子进程继承了父进程的路径,再pwd就会看到切换后的路径。
1、下面我们来实现cd命令的运行逻辑,首先在主逻辑执行命令步骤之前添加一个检查内建命令、若为内建命令则执行的步骤(BuildInCommandExec),如果是内建命令,则执行完该步骤后直接continue,若不是则继续执行后续逻辑。
2、然后编写BuildInCommandExec的内部逻辑,首先判断gargv[0]是不是"cd",注意不能直接比较,直接比gargv[0]和"cd"是比的两个指针是否相同,我们需要比两个字符串是否相同。需要先将其中一方转换为string,然后再比较,这时另一方就会被隐式转换为string,然后就可以调用string的operator==比较两个字符串内容了。
3、接着通过父进程调用chdir改变当前工作路径,chdir 系统调用是 cd 指令的底层实现的一部分,我们要自己实现cd功能就需要让父进程自己调用chdir来切换自己的工作路径。下面是chdir的文档和使用介绍:

参数 path 是目标目录的路径,绝对路径或相对路径均可,调用成功返回 0,调用失败返回 -1。
4、这里小编补充一点,当我们只输入 “cd” 时功能和 “cd ~” 一样,会使当前工作路径返回家目录。所以我们实现时要考虑这两种情况,若为这两种情况,则需要从环境变量中获取家目录并跳转,若不是则跳转到gargv[1]指定的目录下,绝对路径、相对路径均可。
5、最后处理返回值,该接口默认认为提取到的命令不是内建命令返回false,只有是内建命令并且父进程执行了该指令后才返回true。
下面是示例代码:
//myshell.ccstatic std::string GetHostName(){ string hostname =getenv("HOSTNAME");return hostname.empty()?"None": hostname;}boolBuildInCommandExec(){//不能:gargv[0] == "cd" 这样比//这样比是比较指针是否相同,而非字符内容 std::string cmd = gargv[0];bool ret =false;//默认不是内建命令if(cmd =="cd")//这里"cd"会被隐式类型转换为string{if(gargc ==2){ std::string target = gargv[1];if(target =="~"){//"cd ~"返回家目录chdir(GetHomePath().c_str()); ret =true;}else{chdir(gargv[1]); ret =true;}}elseif(gargc ==1){chdir(GetHomePath().c_str()); ret =true;}else{//错误}}return ret;}echo
echo命令也是一个内建命令,因为"echo $?"可以打印出上一个子进程的退出码,而退出码不是环境变量是本地变量,子进程是拿不到父进程的本地变量的,所以echo是由父进程直接执行的。所以echo指令也需要进BuildInCommandExec接口。首先定义一个全局变量lastcode存子进程的退出码,在执行命令接口ForkAndExec的子进程逻辑中获取子进程的退出码写入lastcode中。当用户输入“echo $?"指令时就把lastcode的值打印出来,lastcode里存的就是上一个子进程的退出码,所以InitGlobal不用初始化lastcode。当输入“echo $(环境变量)"时就通过getenv(const char* name)接口查找环境变量并打印。 当打印其它字符串时就把字符串原封不动的打印出来。
//myshell.cc//用于存储上一个子进程的退出码int lastcode;voidForkAndExec(){ pid_t id =fork();if(id <0){//fork失败perror("fork");return;}elseif(id ==0){//子进程execvp(gargv[0], gargv);exit(0);}else{//父进程int status =0; pid_t rid =waitpid(id,&status,0);if(rid >0){//获取子进程退出码 lastcode =WEXITSTATUS(status);}}}boolBuildInCommandExec(){//不能:gargv[0] == "cd" 这样比//这样比是比较指针是否相同,而非字符内容 std::string cmd = gargv[0];bool ret =false;//默认不是内建命令if(cmd =="cd")//这里"cd"会被隐式类型转换为string{if(gargc ==2){ std::string target = gargv[1];if(target =="~"){//"cd ~"返回家目录chdir(GetHomePath().c_str()); lastcode =0; ret =true;}else{chdir(gargv[1]); lastcode =0; ret =true;}}elseif(gargc ==1){chdir(GetHomePath().c_str()); lastcode =0; ret =true;}else{//错误}}elseif(cmd =="echo"){if(gargc ==2){ std::string args = gargv[1];if(args[0]=='$'){if(args[1]=='?'){printf("%d\n", lastcode); lastcode =0; ret =true;}else{//char *getenv(const char *name);constchar* name =&args[1];printf("%s\n",getenv(name)); lastcode =0; ret =true;}}else{printf("%s\n", gargv[1]); lastcode =0; ret =true;}}}return ret;}更新命令行提示符中的当前路径
代码写到这里还有问题,我们cd后再pwd确实看到当前工作路径已经变了,但是为什么输出的命令行提示符的当前路径一直没变呢?为什么pwd后看到的路径却变了呢?

我们一步一步来,先解决命令行提示符的当前路径一直不变的问题。我们在讲环境变量时提到过,环境变量有两个来源,一个是从bash从配置文件中获取,一个是bash启动后自己动态获取并创建,就比如PWD,当用户执行 cd 命令切换目录时,bash 会先通过 chdir() 系统调用修改自身的 cwd,然后立即调用 getcwd() 获取新的 pwd路径,更新到 PWD环境变量中。到目前为止我们自定义的bash已经实现了chdir()的功能,接下来还需要我们实现getcwd()的功能。(getcwd的使用说明:请点击)
命令行提示符的当前路径是通过GetPwd接口获取的,所以我们需要修改原来的GetPwd接口,不再直接getenv获取当前工作路径。
下面是初版代码,并没有更新当前的进程的环境变量中的PWD。
static std::string GetPwd(){//string pwd = getenv("PWD");//return pwd.empty() ? "None" : pwd;char pwd[1024];getcwd(pwd,sizeof(pwd));return pwd;}接下来我们需要更新环境变量表中的PWD,首先需要在全局定义一个字符数组pwd用来存储环境变量表的内容,因为我们知道环境变量表是一个字符指针数组,指向一个一个的字符串或者字符数组,而snprintf可以把拿到的tmp数组格式化输出到字符串中,用法和printf类似,只不过printf是往显示器上输出,而snprintf是往字符串中输出。然后通过putenv环境变量表修改环境变量表,我们之前已经介绍过了。两个接口如下所示:


优化后的代码:
char pwd[1024];static std::string GetPwd(){char tmp[1024];getcwd(tmp,sizeof(tmp));//顺便更新一下自己shell的环境变量snprintf(pwd,sizeof(pwd),"PWD=%s", tmp);putenv(pwd);return pwd;}现在我们已经把基本功能实现完毕,还有最后一步,我们看到xshell的命令行提示符中只打印了一个类似"myshell"的路径,而不是 “/home/fdb/lesson21/myshell” 这样的长路径,所以需要截取子串,步骤如下:
static std::string GetPwd(){char temp[1024];getcwd(temp,sizeof(temp));//顺便更新一下自己shell的环境变量snprintf(pwd,sizeof(pwd),"PWD=%s", temp);putenv(pwd);//命令行提示符中输出单个路径(截取子串) std::string pwd_label = temp;const std::string pathsep ="/";//路径分隔符//查找长路径中最后一个'/'的位置 size_t pos = pwd_label.rfind(pathsep);if(pos == std::string::npos){//整个路径都没有'/',返回Nonereturn"None";}//从pos位置的下一个位置开始截取,相当于跳过pathsep截取后续子串 pwd_label = pwd_label.substr(pos + pathsep.size());//如果此时size为0说明什么都没截取到,说明截取前pwd_label中只有"/"//则返回"/"return pwd_label.size()? pwd_label :"/";}自定义shell源码
main.cc:
#include"myshell.h"#defineSIZE1024intmain(){char commandstr[SIZE];while(true){// 0、初始化全局变量InitGlobal();// 1、打印命令行提示符PrintCommandPrompt();// 2、读取用户输入的命令//如果用户没有输入,直接回车//会返回false,此时直接continueif(!GetCommandString(commandstr, SIZE))continue;// 3、解析命令行字符串ParseCommandString(commandstr);// 4、检查命令,若为内建命令由父进程运行if(BuildInCommandExec())continue;// 5、执行命令ForkAndExec();}return0;}myshell.cc:
#include"myshell.h"//using namespace std不放在头文件中,会污染命名空间usingnamespace std;//全局定义命令行参数表char* gargv[ARGS]={NULL};int gargc =0;//用于存储环境变量PWDchar pwd[1024];//用于存储上一个子进程的退出码int lastcode;voidDebug(){printf("hello shell!\n");}voidInitGlobal(){ gargc =0;memset(gargv,0,sizeof(gargv));}static std::string GetUserName(){ string username =getenv("USER");return username.empty()?"None": username;}static std::string GetHostName(){ string hostname =getenv("HOSTNAME");return hostname.empty()?"None": hostname;}static std::string GetPwd(){//string pwd = getenv("PWD");//return pwd.empty() ? "None" : pwd;char temp[1024];getcwd(temp,sizeof(temp));//顺便更新一下自己shell的环境变量snprintf(pwd,sizeof(pwd),"PWD=%s", temp);putenv(pwd);//命令行提示符中输出单个路径 std::string pwd_label = temp;const std::string pathsep ="/";//路径分隔符//查找长路径中最后一个'/'的位置 size_t pos = pwd_label.rfind(pathsep);if(pos == std::string::npos){//整个路径都没有'/',返回Nonereturn"None";}//从pos位置的下一个位置开始截取,相当于跳过pathsep截取后续子串 pwd_label = pwd_label.substr(pos + pathsep.size());//如果此时size为0说明什么都没截取到,说明截取前pwd_label中只有"/"//则返回"/"return pwd_label.size()? pwd_label :"/";}static std::string GetHomePath(){ std::string home =getenv("HOME");//若环境变量缺失或被篡改home为空,为空则回退到家目录return home.empty()?"/": home;}voidPrintCommandPrompt(){ std::string username =GetUserName(); std::string hostname =GetHostName(); std::string pwd =GetPwd();printf("[%s@%s %s]# ", username.c_str(), hostname.c_str(), pwd.c_str());}boolGetCommandString(char cmdstr_buff[],int len){if(cmdstr_buff ==NULL|| len <=0){//参数不合法returnfalse;}char* res =fgets(cmdstr_buff, len,stdin);if(res ==NULL){//读取字符串失败returnfalse;}//把输入的回车也就是'\n'置为'\0' cmdstr_buff[strlen(cmdstr_buff)-1]=0;returnstrlen(cmdstr_buff)==0?false:true;}boolParseCommandString(char cmd[]){if(cmd ==NULL){//安全检查returnfalse;}//可以在函数内部定义,SEP表示分隔符#defineSEP" "//"ls -a -l" -> "ls" "-a" "-l" //把第一个子串写入gargv[0],然后gargc++ gargv[gargc++]=strtok(cmd, SEP);//把子串全部写入gargv数组里,并且以NULL结尾while(gargv[gargc++]=strtok(NULL, SEP));//循环空语句//回退一次命令行参数的个数--gargc;//条件编译,测试代码//#define DEBUG#ifdefDEBUGprintf("gargc: %d\n", gargc);printf("--------------------------\n");for(int i =0; i < gargc; i++){printf("gargv[%d]: %s\n", i, gargv[i]);}printf("--------------------------\n");for(int i =0; gargv[i]; i++){printf("gargv[%d]: %s\n", i, gargv[i]);}#endifreturntrue;}voidForkAndExec(){ pid_t id =fork();if(id <0){//fork失败perror("fork");return;}elseif(id ==0){//子进程execvp(gargv[0], gargv);exit(0);}else{//父进程int status =0; pid_t rid =waitpid(id,&status,0);if(rid >0){//获取子进程退出码 lastcode =WEXITSTATUS(status);}}}boolBuildInCommandExec(){//不能:gargv[0] == "cd" 这样比//这样比是比较指针是否相同,而非字符内容 std::string cmd = gargv[0];bool ret =false;//默认不是内建命令if(cmd =="cd")//这里"cd"会被隐式类型转换为string{if(gargc ==2){ std::string target = gargv[1];if(target =="~"){//"cd ~"返回家目录chdir(GetHomePath().c_str()); lastcode =0; ret =true;}else{chdir(gargv[1]); lastcode =0; ret =true;}}elseif(gargc ==1){chdir(GetHomePath().c_str()); lastcode =0; ret =true;}else{//错误}}elseif(cmd =="echo"){if(gargc ==2){ std::string args = gargv[1];if(args[0]=='$'){if(args[1]=='?'){printf("%d\n", lastcode); lastcode =0; ret =true;}else{constchar* name =&args[1];printf("%s\n",getenv(name)); lastcode =0; ret =true;}}else{printf("%s\n", gargv[1]); ret =true;}}}return ret;}myshell.h:
#ifndef__MYSHELL_H__#define__MYSHELL_H__#include<stdio.h>#include<iostream>#include<string>#include<cstdlib>#include<cstring>#include<cstdbool>#include<unistd.h>#include<sys/types.h>#include<sys/wait.h>//命令行参数表的大小#defineARGS64voidDebug();//初始化全局变量voidInitGlobal();//输出命令行字符串voidPrintCommandPrompt();//读取用户输入字符串boolGetCommandString(char cmdstr_buff[],int len);//解析命令行字符串boolParseCommandString(char cmd[]);//执行命令voidForkAndExec();//检查是否是内建命令,若为内建命令交由父进程运行boolBuildInCommandExec();#endif二、子进程备份
我们前面实现的自定义shell创建子进程都是让它程序替换后执行与父进程完全不同的代码,下面小编再展示一份让父子进程分工合作的代码,让子进程运行父进程代码的一部分。
代码的业务逻辑是保存随机数据到全局数组并备份到文件中,让父进程负责保存数据,让子进程负责备份父进程的数据,这样就可以使保存数据和备份数据并发执行,提高效率。
因为有写时拷贝的存在,即使父子进程操作的是同一份全局数组,也互不影响。
#include<stdio.h>#include<time.h>#include<stdlib.h>#include<unistd.h>#include<sys/types.h>#include<sys/wait.h>int garray[100]; pid_t backup(constchar* filename){//交由子进程完成备份 pid_t id =fork();if(id ==0){ FILE* pf =fopen(filename,"w");for(int i =0; i <100; i++){fprintf(pf,"%d ", garray[i]);}fclose(pf);exit(0);}return id;}intmain(){srand(time(NULL));for(int i =0; i <100; i++){ garray[i]=rand()%10;} pid_t sub1 =backup("log1.txt");for(int i =0; i <100; i++){ garray[i]=rand()%10;} pid_t sub2 =backup("log2.txt");for(int i =0; i <100; i++){ garray[i]=rand()%10;} pid_t sub3 =backup("log3.txt");waitpid(sub1,NULL,0);waitpid(sub2,NULL,0);waitpid(sub3,NULL,0);return0;}以上就是小编分享的全部内容了,如果觉得不错还请留下免费的赞和收藏
如果有建议欢迎通过评论区或私信留言,感谢您的大力支持。
一键三连好运连连哦~~
