【Linux】进程控制(三) 自定义 Shell 命令行解释器的实现与进程协作实践
文章目录
- 一、自定义shell命令行解释器
- 构建框架
- 输出命令行提示符
- 读取用户输入
- 解析命令字符串
- 执行命令
- 内建命令
- cd
- echo
- 更新命令行提示符中的当前路径
- 自定义shell源码
- 二、子进程备份
一、自定义shell命令行解释器
学习了前面进程概念,进程控制的相关知识,我们对进程已经有了理性的认识,下面我们一起来实现一个自定义shell把这些知识串联起来,能对进程概念及进程相关各种用法,函数调用接口有一个更深刻是理解和记忆。
实现自定义shell的目标:能处理普通命令、能处理内建命令、能帮助我们理解内建命令/本地变量/环境变量这些概念、能帮助我们理解shell的运行原理。
构建框架
首先我们把要用到的所有文件创建出来,采用头源分离。未来方便,主要用C++编写。
//myshell.h
#ifndef __MYSHELL_H__
#define __MYSHELL_H__#include <iostream>void Debug();#endif
//myshell.cc
#include "myshell.h"void Debug()
{printf("hello shell!\n");
}
//makefile
myshell:main.cc myshell.ccg++ -o $@ $^
.PHONY:clean
clean:rm -f myshell
输出命令行提示符
实现shell第一步是打印命令行提示符,我们先看xshell的命令行提示符,如上图所示,除了一些符号外有三个主要变量,这三个变量可以直接通过系统调用获取,但是这里我们为了复习一下学过的知识,故采用从环境变量中间接获取,系统的最后一个字符是$,为了区分我们用#。
由于这里我们是C语言和C++混编,所以需要注意一些细节,比如C语言printf字符串的时候不能直接打印string变量,因为它的字符串格式说明符 %s 要求传入的参数是C 风格字符串—— 即一个指向以空字符 ‘\0’ 结尾的字符数组的指针(const char* 类型,所以需要用c_str把string类型变量装换为C 风格字符串再打印。下面是代码示例:
//myshell.cc
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;
}void PrintCommandPrompt()
{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"#define SIZE 1024int main()
{char commandstr[SIZE];while(true){// 1、打印命令行提示符PrintCommandPrompt();// 2、读取用户输入的命令GetCommandString(commandstr, SIZE);//测试printf("%s\n", commandstr);}return 0;
}
然后实现GetCommandString的内部逻辑,首先要判断传入的参数是否合法。接着通过fgets读取字符串,读取失败返回false,因为至少会读取一个回车键(‘\n’),所以不会读取为空。最后把读取到的最后一个字符’\n’置为’\0’,因为长度为10的字符串最后一个字符下标为9,所以需要strlen(cmdstr_buff) - 1。若删掉’\n’后字符长度为0说明只读取到了’\n’,返回false。
//myshell.cc
bool GetCommandString(char cmdstr_buff[], int len)
{if(cmdstr_buff == NULL || len <= 0){//参数不合法return false;}char* res = fgets(cmdstr_buff, len, stdin);if(res == NULL){//读取字符串失败return false;}//把输入的回车也就是'\n'置为'\0'cmdstr_buff[strlen(cmdstr_buff) - 1] = 0;return strlen(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;void InitGlobal()
{gargc = 0;memset(gargv, 0, sizeof(gargv));
}bool ParseCommandString(char cmd[])
{if(cmd == NULL){//安全检查return false;}//可以在函数内部定义,SEP表示分隔符
#define SEP " "//"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#ifdef DEBUGprintf("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]);}#endifreturn true;
}
//main.cc
#include "myshell.h"#define SIZE 1024int main()
{char commandstr[SIZE];while(true){// 0、初始化全局变量InitGlobal();// 1、打印命令行提示符PrintCommandPrompt();// 2、读取用户输入的命令//如果用户没有输入,直接回车//会返回false,此时直接continueif(!GetCommandString(commandstr, SIZE))continue;// 3、解析命令行字符串ParseCommandString(commandstr);}return 0;
}
//main.cc
#include "myshell.h"#define SIZE 1024int main()
{char commandstr[SIZE];while(true){// 0、初始化全局变量InitGlobal();// 1、打印命令行提示符PrintCommandPrompt();// 2、读取用户输入的命令//如果用户没有输入,直接回车//会返回false,此时直接continueif(!GetCommandString(commandstr, SIZE))continue;// 3、解析命令行字符串ParseCommandString(commandstr);// 4、执行命令ForkAndExec();}return 0;
}
执行命令
首先我们要知道执行命令不能由shell本身来做,因为执行命令会发生程序提花,一但shell被替换那么就无法继续输出命令行提示符和读取用户输入了,所以执行命令需要交由子进程来做。
大体思路是先在main函数逻辑里fork一个子进程,子进程执行程序替换并运行命令,执行完毕后子进程直接退出。父进程等待子进程,不论等待成功还是失败都会继续循环执行shell主逻辑。
然后来选择使用哪个程序替换接口,因为我们要执行的命令没带路径所以要有p,命令行参数已经被我们维护成了表结构,所以要有v,子进程可以通过虚拟地址空间继承到环境变量,所以程序替换时可以不传,那么我们的最佳选择就是execvp。
void ForkAndExec()
{pid_t id = fork();if (id < 0){//fork失败perror("fork");return;}else if (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.cc
static std::string GetHostName()
{string hostname = getenv("HOSTNAME");return hostname.empty() ? "None" : hostname;
}bool BuildInCommandExec()
{//不能: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;}}else if(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;void ForkAndExec()
{pid_t id = fork();if(id < 0){//fork失败perror("fork");return;}else if(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);}}
}bool BuildInCommandExec()
{//不能: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;}}else if(gargc == 1){chdir(GetHomePath().c_str());lastcode = 0;ret = true;}else{//错误}}else if(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);const char* 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"#define SIZE 1024int main()
{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();}return 0;
}
myshell.cc:
#include "myshell.h"//using namespace std不放在头文件中,会污染命名空间
using namespace std;//全局定义命令行参数表
char* gargv[ARGS] = {NULL};
int gargc = 0;//用于存储环境变量PWD
char pwd[1024];//用于存储上一个子进程的退出码
int lastcode;void Debug()
{printf("hello shell!\n");
}void InitGlobal()
{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;
}void PrintCommandPrompt()
{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());
} bool GetCommandString(char cmdstr_buff[], int len)
{if(cmdstr_buff == NULL || len <= 0){//参数不合法return false;}char* res = fgets(cmdstr_buff, len, stdin);if(res == NULL){//读取字符串失败return false;}//把输入的回车也就是'\n'置为'\0'cmdstr_buff[strlen(cmdstr_buff) - 1] = 0;return strlen(cmdstr_buff) == 0 ? false : true;
}bool ParseCommandString(char cmd[])
{if(cmd == NULL){//安全检查return false;}//可以在函数内部定义,SEP表示分隔符
#define SEP " "//"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#ifdef DEBUGprintf("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]);}#endifreturn true;
}void ForkAndExec()
{pid_t id = fork();if(id < 0){//fork失败perror("fork");return;}else if(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);}}}bool BuildInCommandExec()
{//不能: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;}}else if(gargc == 1){chdir(GetHomePath().c_str());lastcode = 0;ret = true;}else{//错误}}else if(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{const char* 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>//命令行参数表的大小
#define ARGS 64void Debug();
//初始化全局变量
void InitGlobal();
//输出命令行字符串
void PrintCommandPrompt();
//读取用户输入字符串
bool GetCommandString(char cmdstr_buff[], int len);
//解析命令行字符串
bool ParseCommandString(char cmd[]);
//执行命令
void ForkAndExec();
//检查是否是内建命令,若为内建命令交由父进程运行bool BuildInCommandExec();#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(const char* 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;
}int main()
{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);return 0;
}
以上就是小编分享的全部内容了,如果觉得不错还请留下免费的赞和收藏
如果有建议欢迎通过评论区或私信留言,感谢您的大力支持。
一键三连好运连连哦~~