【Linux】模拟实现Shell(上)
1.能处理普通命令
1.1 打印命令行提示符
在我们自己的命令行中首先会显示用户名、主机名、当前所在的路径。
得到这些变量的方法很简单,通过环境变量获得,获取环境变量的函数就是getenv。
#include <stdio.h>
#include <string.h>
#include <stdlib.h>const char* UserName() //获取用户名
{const char* user_name = getenv("USER");return user_name == nullptr ? "None" : user_name;
}const char* HostName() //获取主机名
{const char* host_name = getenv("HOSTNAME");return host_name == NULL? "None" : host_name;
}const char* GetPwd() //获取当前路径
{const char* pwd = getenv("PWD");return pwd == NULL ? "None" : pwd;
}
都获取到之后把这些内容打印出来,为了和原来的Shell命令行做一个区分,这里最后一个字符$用#代替。
int main()
{//打印命令行printf("%s@%s:%s# ",UserName(), HostName(),GetPwd());return 0;
}
1.2 获取命令行参数
当我们打印完命令行之后,就是等待用户输入,等待用户输入时我们不能选取scanf函数,因为这个函数是以空格作为分隔符的,比如说我们要输入ls -l -a这个命令,在Shell看来其实是"ls -l -a"这个字符串。
获取字符串有很多方法,这里我选择使用fgets函数。
fgets就是从指定的文件流中按行获取指定信息,缓冲区大小是size,获取成功时返回值的就是s的地址,失败是就是NULL,FILE*就是标准输入。
#define COMMAND_SIZE 1024
void GetArguments() //获取命令行参数
{char commandline[COMMAND_SIZE];char* arg = fgets(commandline, sizeof(commandline), stdin);if(arg == NULL) //获取失败 return;printf("%s\n", commandline);
}
我们把我们输入的内容用printf回显一下,然后看结果。
int main()
{//打印命令行printf("%s@%s:%s# ",UserName(), HostName(),GetPwd());//获取命令行参数GetArguments();return 0;
}
这里多打了一个换行,因为我们在输入结束的时候自己按了回车键,其实输入的是"ls -a -l\n", 如果不想让这里多一个换行,直接让commandline的最后一个字符设为0。
void GetArguments()
{char commandline[COMMAND_SIZE];char* arg = fgets(commandline, sizeof(commandline), stdin);if(arg == NULL) //获取失败 return;commandline[strlen(commandline)-1] = 0; //清理多余的\n printf("%s\n", commandline);
}
当我们什么都没输入的时候这种写法会有越界问题吗?比如访问-1的位置?答案是不会,因为我们什么都没输入的时候,至少会按一次回车,按了回车commandline长度就是1,再-1就是0,把0位置赋值成0,此时这个字符串就是一个空串。
现在就没有多余的\n了,但是在Shell中我们直接按回车是什么都没有的,我们现在的Shell按回车会换行,而且我们的Shell只能执行一次,解决方法如下
- 用while让我们自己的Shell一直执行
- 让GetArguments函数返回值为bool类型,根据返回中在函数外部进行判断
#define COMMAND_SIZE 1024
bool GetArguments() //获取命令行参数
{char commandline[COMMAND_SIZE];char* arg = fgets(commandline, sizeof(commandline), stdin);if(arg == NULL) //获取失败 return false;commandline[strlen(commandline)-1] = 0; //清理多余的\nif(strlen(commandline) == 0) //如果是直接回车的情况return false;return true;
}int main()
{while(1){//打印命令行提示符PrintCmdPrompt();//获取命令行参数if(!GetArguments()) //获取失败continue;printf("%s\n", commandline); //获取成功就回显}return 0;
}
但是此时的commandline是函数里的一个局部变量,我们要把它放在全局,以参数的形式传给GetArguments函数,这样外面就能拿到了。
#define COMMAND_SIZE 1024
bool GetArguments(char* commandline, int size) //获取命令行参数
{char* arg = fgets(commandline, size, stdin);if(arg == NULL) //获取失败 return false;commandline[strlen(commandline)-1] = 0; //清理多余的\nif(strlen(commandline) == 0) //如果是直接回车的情况return false;return true;
}int main()
{while(1){//打印命令行提示符PrintCmdPrompt();//获取命令行参数char commandline[COMMAND_SIZE];if(!GetArguments(commandline, sizeof(commandline))) //获取失败continue;printf("%s\n", commandline); //获取成功就回现}return 0;
}
现在我们只按回车就什么也没显示了,并且可以多次对我们自己的Shell进行操作。
1.3 解析命令行参数
我们输入的是一个字符串,比如 "ls -l -a",但是我们要把这个字符串分解成"ls" "-l" "-a"的形式,并且存放在一个数组里,这个数组以NULL结尾,它就是我们自己的命令行参数表。
这里我选择用strtok函数分割这个字符串,str是要切割的字符串,delim是要分割的字符,这里我们就是以空格做分隔符。
- 首次调用时,它会从传入的字符串中分割出第一个子字符串,并返回指向该子字符串的指针
- 后续调用时,第一个参数传入 NULL,它会继续从上次分割的位置向后处理,返回下一个子字符串的指针
- 当没有更多子字符串可分割时,返回 NULL
例如,用 strtok("a,b,c", ",")逗号为分隔符,首次调用会返回指向 "a" 的指针,再次调用(第一个参数为 NULL)返回指向 "b" 的指针,第三次调用返回指向 "c" 的指针,第四次调用返回 NULL。
定义全局的argc和argv,因为命令函参数表以NULL结尾,而strtok分割完之后的返回值就是NULL,并且以NULL结尾还可以作为while循环退出的条件。
#define DELIM " " //分隔符要用双引号
char *g_argv[128];
int g_argc = 0;
bool CommandParse(char *commandline)
{g_argc = 0;//每次都要置0,否则argc会被累加g_argv[g_argc++] = strtok(commandline, DELIM);while( g_argv[g_argc++] = strtok(nullptr, DELIM) );g_argc--; //后置++导致argc多加了一次,这里要-1return g_argc > 0 ? true : false; //返回时判断一下
}
如果argc的数量等于0的话,就证明我们什么也没输入,就是按了一个回车,这种情况下也就不用往后执行了,返回false,如果大于0返回true,再往后执行。
这样命令就被我们分割并且放在以NULL结尾的argv数组里了,我们可以把argv和argc都打印出来看看。
void PrintArg()
{for(int i = 0; g_argv[i]; i++){printf("argv[%d]:%s\n", i, g_argv[i]);}printf("argc:%d\n", g_argc);
}int main()
{while(1){//打印命令行提示符PrintCmdPrompt();//获取命令行参数char commandline[COMMAND_SIZE];if(!GetArguments(commandline, sizeof(commandline))) //获取失败continue;//解析命令行参数if(!CommandParse(commandline))continue;PrintArg();}return 0;
}
1.4 执行命令
我们输入了命令就要执行,但是不能直接在这个进程里执行,我们要创建子进程,然后进行进程的替换,程序替换相关的函数如下。
因为我们前面已经得到参数列表的数组,而我们的程序又是不带路径的,让函数自己从环境变量里找,目前我们也不需要传环境变量,所以这里要选择execvp函数。
进程等待的函数选择waitpid
- 当正常返回的时候waitpid返回收集到的⼦进程的进程ID;
- 如果设置了选项WNOHANG,⽽调⽤中waitpid发现没有已退出的⼦进程可收集,则返回0;
- 如果调⽤中出错,则返回-1,这时errno会被设置成相应的值以指⽰错误所在;
- pid: Pid=-1,等待任⼀个⼦进程。与wait等效,Pid>0.等待其进程ID与pid相等的⼦进程。
- wstatus:(是输出型参数):WIFEXITED(status): 若为正常终⽌⼦进程返回的状态,则为真(查看进程是否是正常退出) ;WEXITSTATUS(status): 若WIFEXITED⾮零,提取⼦进程退出码(查看进程的退出码)
- options:默认为0,表⽰阻塞等待,WNOHANG非阻塞等待
int last_exit_code = 0;
int Execute()
{pid_t id = fork();if(id == 0) //子进程:程序替换{execvp(g_argv[0], g_argv);}//父进程:阻塞等待int status;pid_t rid = waitpid(id, &status, 0);if(rid > 0){if(WIFEXITED(status)) //正常退出{last_exit_code = WEXITSTATUS(status); //获取退出码}elselast_exit_code = 100;}return 0;
}
int main()
{while(1){//1.打印命令行提示符PrintCmdPrompt();//2.获取命令行参数char commandline[COMMAND_SIZE];if(!GetArguments(commandline, sizeof(commandline))) //获取失败continue;//3.解析命令行参数if(!CommandParse(commandline))continue;//4.执行命令Execute();}return 0;
}
这样一个能执行普通命令的Shell就初步实现好了,随便输入几个命令试试。
但是我们执行cd命令切换路径的时候,发现并不起作用。
因为每个进程都有自己的当前路径,此时cd是子进程在执行,无法改变父进程的路径,我们要影响的是父进程。
2.能处理内建命令
2.1 cd
像cd这样的命令,就是内建命令,所以我们在执行命令之前还要检测这个命令是否为内建命令。检查的方法就是if判断一下。
bool CheckAndExeBuiltinCmd()
{std::string cmd = g_argv[0];if(cmd == "cd")return true;return false;
}
cd这个命令后面可以加相对路径也可以加绝对路径,也可以什么都不跟,什么都不跟的时候就是去到家目录。
这里的cd命令要父进程执行,需要用到一个函数chdir,更改进程的工作路径,谁调用它就更改谁的。
- cd什么都不加的话,argc就为1,我们就要将路径改到家目录,而家目录我们也要获取,直接通过环境变量获得即可
- cd后面跟路径,就直接chdir到这个路径
const char* GetHome() //获取家目录
{const char* home = getenv("HOME");return home == NULL ? "None" : home;
}
void Cd()
{if(g_argc == 1) //cd后什么都不加,回到家目录{std::string home = GetHome();if(home == "None") //没获取到家目录直接返回return; chdir(home.c_str());}else //cd后加路径{std::string targ_dir = g_argv[1];chdir(targ_dir.c_str()); //直接去目标路径}return;
}bool CheckAndExeBuiltinCmd() //检查并执行内建命令
{std::string cmd = g_argv[0];if(cmd == "cd"){Cd();return true;}return false;
}
内建命令在父进程执行,不用再往后进行Execute的步骤了,因为Execute里是创建子进程执行的,如果是内建命令,执行完直接continue就好了。
int main()
{while(1){//1.打印命令行提示符PrintCmdPrompt();//2.获取命令行参数char commandline[COMMAND_SIZE];if(!GetArguments(commandline, sizeof(commandline))) //获取失败continue;//3.解析命令行参数if(!CommandParse(commandline))continue;//4.检测是否为内建命令,是内建命令就执行if(CheckAndExeBuiltinCmd())continue; //5.执行命令Execute();}return 0;
}
我们再用cd命令时,就可以进行路径的切换了。
但是我们会发现新问题出现了,命令行提示符的路径怎么没变?并且思考一个问题,环境变量先变还是当前进程的工作路径先变?
肯定是进程的路径先变了,然后shell更新环境变量时,环境变量才改变,这个更新的工作要shell来做,但是我们的这个shell没有这个步骤,所以我们在打印命令行提示符的时候,拿环境变量获取的都是旧的路径数据。
所以此时获取路径要用系统调用的函数getcwd获取当前工作路径。
char cwd[1024]; //先定义一个全局的cwd
const char* GetPwd() //获取当前路径
{//const char* pwd = getenv("PWD"); //环境变量获取const char* pwd = getcwd(cwd, sizeof(cwd)); //系统调用获取return pwd == NULL ? "None" : pwd;
}
此时我们命令行提示符的路径也就跟着改变了。
但是我们查看环境变量的时候,环境变量依旧没有更新。
所以我们需要再更新一下环境变量,这里会用到两个函数snprintf和putenv。
- snprintf:用于将格式化的数据写入字符串。这个函数会根据指定的格式将数据转换成字符串,并将结果保存到提供的缓冲区中。
- putenv:如果环境变量name已经存在,则其值会被更新为value;如果name不存在,则会在环境中新建这一环境变量
先用snprintf对字符串进行格式化,放进cwdenv里,然后用putenv把这个格式化好的内容导入到环境变量里。
char cwd[1024]; //先定义一个全局的cwd
char cwdenv[1024]; //全局的cwd环境变量
const char* GetPwd() //获取当前路径
{//const char* pwd = getenv("PWD"); //环境变量获取const char* pwd = getcwd(cwd, sizeof(cwd)); //系统调用获取if(pwd != NULL){snprintf(cwdenv, sizeof(cwdenv), "PWD=%s", cwd); //格式化putenv(cwdenv);}return pwd == NULL ? "None" : pwd;
}
现在我们切换路径后,再看这个环境变量就会跟着变了。
2.2 echo
echo这个命令也是内建命令,echo可以打印字符串,可以打印最近一个程序的退出码,也可以打印环境变量。
bool CheckAndExeBuiltinCmd() //检查并执行内建命令
{std::string cmd = g_argv[0];if(cmd == "cd"){Cd();return true;}else if(cmd == "echo"){//echo实现逻辑return true;}return false;
}
先实现用 echo $? 打印程序退出码的代码。
int last_exit_code = 0;
void Echo()
{//只输入了echo,不做处理直接返回 if(g_argc == 1) return;std::string opt = g_argv[1];if(opt == "$?") //打印最近一个程序的退出码{std::cout << last_exit_code << std::endl;last_exit_code = 0; //置0}return;
}
bool CheckAndExeBuiltinCmd() //检查并执行内建命令
{std::string cmd = g_argv[0];if(cmd == "cd"){Cd();return true;}else if(cmd == "echo"){Echo();return true;}return false;
}
用 echo $环境变量名,可以打印环境变量,要先获取一下环境变量,获取环境变量用函数getenv。
void Echo()
{//只输入了echo,不做处理直接返回 if(g_argc == 1) return;std::string opt = g_argv[1];if(opt == "$?") //打印最近一个程序的退出码{std::cout << last_exit_code << std::endl;last_exit_code = 0; //置0}else if(opt[0] == '$') //如 echo $PATH ,打印环境变量PATH{std::string env_name = opt.substr(1); //env_name里存的就是$后的内容const char* env_value = getenv(env_name.c_str());if(env_value) std::cout << env_value << std::endl; //打印环境变量的值}else //直接打印字符串{std::cout << opt << std::endl;}return;
}
echo就实现好了,如果还想设计别的功能就直接else if添加就行。
还有一些别的内建命令,在函数CheckAndExeBuiltinCmd里直接添加就行了。
3.环境变量表
shell有两张表,一张命令行参数表,一张环境变量表,命令行参数表就是argv,现在要把环境变量表在整理出来。
在shell启动时,需要从系统的配置文件中获取环境变量,但是我们现在实现的shell做不到这一点,我们就直接从我们自己实现的shell的父进程里拿环境变量,拿到环境变量表之后还要维护这个表,还要导到自己的环境变量空间中。
char *g_env[100]; //指向环境变量的指针数组
int g_env_num = 0; //环境变量的个数
void EnvInit()
{//1.先获取环境变量//2.把得到的环境变量导到自己的进程地址空间里}
先定义一个大小为100的指针数组,从父进程获取环境变量,要用到一个全局的外部变量environ,通过计算每一个环境变量的长度,动态的申请空间。
char *g_env[100]; //指向环境变量的指针数组
int g_env_num = 0; //环境变量的个数
void EnvInit()
{//1.先获取环境变量extern char ** environ; //外部全局变量memset(g_env, 0, sizeof(g_env)); //清0,可有可无for(int i = 0; environ[i]; i++){g_env[i] = (char*)malloc(strlen(environ[i]) + 1); //申请空间if(g_env[i] == NULL) {perror("malloc fail!\n");return;}}//2.把得到的环境变量导到自己的进程地址空间里
}
然后边申请空间边拷贝父进程的环境变量到g_env里,环境变量表以NULL结尾,所以最后一个位置要为NULL。
char *g_env[100]; //指向环境变量的指针数组
int g_env_num = 0; //环境变量的个数
void EnvInit()
{//1.先获取环境变量extern char ** environ; //外部全局变量memset(g_env, 0, sizeof(g_env)); for(int i = 0; environ[i]; i++){g_env[i] = (char*)malloc(strlen(environ[i]) + 1); //申请空间if(g_env[i] == NULL) {perror("malloc fail!\n");return;}strcpy(g_env[i], environ[i]); //拷贝父进程的环境变量g_env_num++;}g_env[g_env_num] = NULL; //环境变量表以NULL结尾//2.把得到的环境变量导到自己的进程地址空间里
}
然后通过putenv把环境变量导到自己的进程地址空间里。
char *g_env[100]; //指向环境变量的指针数组
int g_env_num = 0; //环境变量的个数
void EnvInit()
{//1.先获取环境变量extern char ** environ; //外部全局变量memset(g_env, 0, sizeof(g_env)); for(int i = 0; environ[i]; i++){g_env[i] = (char*)malloc(strlen(environ[i]) + 1); //申请空间if(g_env[i] == NULL) {perror("malloc fail!\n");return;}strcpy(g_env[i], environ[i]); //拷贝父进程的环境变量g_env_num++;}g_env[g_env_num] = NULL; //环境变量表以NULL结尾//2.把得到的环境变量导到自己的进程地址空间里for(int i = 0; g_env[i]; i++){putenv(g_env[i]);}}
这样我们就得到了一张环境变量表。
int main()
{EnvInit(); //初始化环境变量while(1){//1.打印命令行提示符PrintCmdPrompt();//2.获取命令行参数char commandline[COMMAND_SIZE];if(!GetArguments(commandline, sizeof(commandline))) //获取失败continue;//3.解析命令行参数if(!CommandParse(commandline))continue;//PrintArg();//4.检测是否为内建命令,是内建命令就执行if(CheckAndExeBuiltinCmd())continue; //5.执行命令Execute();}return 0;
}
本次分享就到这里,我们下篇见~