学习Linux到目前为止,我们都知道命令是由shell执行的,但是具体如何执行的我们看不到,因此我们今天来自己写一个shell来执行我们的指令,让大家对shell的底层有一个进阶的理解,文章的最后会给出完整代码喔~
目录
一、打印命令行提示符
二、获取键盘输入
三、解析字符串
四、执行指令
五、增加内建命令
1.cd 路径的改变
2.echo 退出码
六、总结与源码
一、打印命令行提示符
知识前置:shell本质上是一个死循环,因为要不断地处理一条又一条指令,因此编写的shell功能全部都要放进一个死循环中,直到我们主动退出才结束
命令行提示符的格式为 [用户名@主机名 路径],因此要打印出来就必须获取这三个数据,很显然它们都属于环境变量,分别对应USER、HOSTNAME和PWD,要获取环境变量,用getenv函数即可,为了方便管理,我们将打印命令行提示符封装成一个函数
//1.打印命令行提示符 PrintCommandLine();void PrintCommandLine() { printf("[%s@%s %s]# ", GetUserName(), GetHostName(), GetPwd()); //用户名@主机名 当前路径 }const char *GetUserName() { char *name = getenv("USER"); if(name == NULL) { return "None"; } return name; } const char *GetHostName() { char *hostname = getenv("HOSTNAME"); if(hostname == NULL) { return "None"; } return hostname; } const char *GetPwd() { char *pwd = getenv("PWD"); if(pwd == NULL) { return "None"; } return pwd; }至此命令行提示符的打印就完成了,但是shell本质是一个死循环,它还得需要一个等待我们输入指令的功能,否则就会一直打印,导致满屏的命令行提示符
二、获取键盘输入
键盘输入本质就是输入一个字符串,需要用一个字符数组来接收
#define MAXSIZE 128 //... char command_line[MAXSIZE] = {0};对于指令的输入我们通常存在两种情况,一种是输入指令后回车,一种是啥也不输入直接回车,因此,要获取键盘输入,我们需要用到系统调用 fgets ,啥也不输入的时候返回NULL, 我们依旧对获取键盘输入的功能封装一个函数,且函数的返回值为输入的指令字符串长度,当返回0的时候,命中我们刚刚说的第二种情况,直接continue即可,否则我们尝试打印出刚刚输入的指令做一个验证测试,看看是否输出的与我们输入的一致
但同时要注意的是,无论哪一种情况,我们都要至少输入一次回车键,回车键相当于换行符\n,因此为了保证字符串和输出的正确性,我们需要将最后的换行符改成'\0'
//2.获取键盘输入 if(GetCommand(command_line, sizeof(command_line)) == 0) continue; printf("%s\n", command_line);int GetCommand(char commandline[], int size) { if(fgets(commandline, size, stdin) == NULL) return 0; //用户输入的时候,至少会按一次回车\n,改'\0' commandline[strlen(commandline)-1] = '\0'; return strlen(commandline); }运行结果如下:
三、解析字符串
前面我们输入进去的指令是一整个字符串,我们要把它们拆分(“ls -a -l” -> “ls” “-a” "-l" ),并放入命令行参数表中
对于字符串的拆分,C语言中有一个封装好的函数,strtok
第一个参数为要拆分的字符串,第二个参数为拆分符号,遇到该符号就进行拆分,对于同一个字符串的第二次拆分,则将第一个参数设为NULL,否则会一直拆分第一个而后面的不拆分,具体代码演示如下:
#include<stdio.h> #include<string.h> int main() { char str[] = "aaa bbb ccc ddd"; const char* sep = " "; char *p = strtok(str, sep); printf("%s\n", p); while(p) { p = strtok(NULL, sep); if(p == NULL) { break; } printf("%s\n",p); } return 0; }拆分的字符串,我们要放到全局的环境变量表中,这是shell内部要维护的第一张表,同时设置一下切割分隔符
#define MAXARGS 32 //shell内部维护的第一张表:命令行参数表 char *gargv[MAXARGS]; int gargc = 0; const char *sep = " ";将解析字符串封装函数
//3.解析字符串 ParseCommand(command_line);int ParseCommand(char commandline[]) { //输入新的指令要重置命令行参数表 gargc = 0; memset(gargv, 0, sizeof(gargv)); //分割字符串 gargv[0] = strtok(commandline, sep); while((gargv[++gargc] = strtok(NULL, sep))); //打印测试 printf("gargc: %d\n", gargc); int i = 0; for(; gargv[i]; i++) printf("gargv[%d]: %s\n", i, gargv[i]); return 0; }我们打印出分割结果测试一下效果
四、执行指令
前面我们完成了指令的输入和解析,还差一个执行,要执行指令,就需要fork子进程来进行程序替换
//4.执行指令 ExcuteCommand();int ExcuteCommand() { pid_t id = fork(); if(id < 0) return -1; else if(id == 0) { //子进程 程序替换 execvp(gargv[0], gargv); exit(1); } else{ //父进程 int status = 0; pid_t rid = waitpid(id, &status, 0); if(rid > 0) { printf("wait child process success!\n"); } } }有了命令行参数表gargv,我们就可以用程序替换函数execvp了,对于程序替换有问题的可以回看博主的文章《进程程序替换》
【Linux】进程控制(三)——进程程序替换-CSDN博客
我们来看看运行结果
结果如我们所料,我们把所有的测试打印全部注释再试试看
到这里为止,shell的基本框架搭成了
五、增加内建命令
刚刚我们输入的指令都如期由子进程替换执行成功了,但是我们来看下面这种情况
这里演示了两种无法成功执行的情况,echo $?是输出上一个程序执行结束的退出码,这里没有打印出来;cd ..是返回上一个目录,但是我们用pwd查看,却发现路径没有变化。
原因就是它们都属于内建命令,内建命令的特点是由Shell自身解析执行,不需要创建新进程再替换,接下来一一解决这两个问题
执行命令前先写一个函数判断是否是内建命令,如果是则直接执行并返回1,如果不是则返回0,并创建子进程替换执行命令
1.cd 路径的改变
要改变路径,可以使用chdir函数
直接将gargv[1]放入参数即可,因为gargv[0]是cd,后面一个必跟路径
//4.执行指令 if(CheckBuiltinExcute() > 0) continue; ExcuteCommand();int CheckBuiltinExcute() { if(strcmp(gargv[0], "cd") == 0) { //内建命令 if(gargc == 2) { //新的目标路径 chdir(gargv[1]); } return 1; } return 0; }试一下执行结果
可以发现路径改变了,但是为什么命令行提示符的路径却没有改变呢?
回看我们之前获取路径的函数
const char *GetPwd() { char *pwd = getenv("PWD"); if(pwd == NULL) { return "None"; } return pwd; }会发现我们获取的是环境变量的PWD记录,但是环境变量的这个值是静态的,即使我们用chdir切换了目录但是没更新PWD环境变量,它依旧会返回旧路径,不准
要实时改变这个路径,就不能依赖环境变量,而需要一个能直接与内核交互的系统调用 getcwd!
这个系统调用的第一个参数属于典型的输出型参数,我们提供数组参数,它会将实时路径给我传递到数组中,那我们就创建一个接收的数组
//我们shell所处的工作路径 char cwd[MAXSIZE];利用getcwd系统调用优化Getpwd函数
const char *GetPwd() { //char *pwd = getenv("PWD"); char *pwd = getcwd(cwd, sizeof(cwd)); if(pwd == NULL) { return "None"; } return cwd; }试试优化后的效果
达到了我们的预期
但到这里还剩下最后一个问题,Linux命令行提示符中的路径只保留了最后一个,而我们是直接显示出了一长串的绝对路径,这是过于冗余的,我们接下来就是要解决这个问题,只取它的最后一个“/”后的路径,这里我用c++来实现
static std::string rfindDir(const std::string &p) { if(p == "/") return p; const std::string psep = "/"; auto pos = p.rfind(psep); if(pos == std::string::npos) { return std::string(); } return p.substr(pos+1); }在打印的位置将本来要传的绝对路径先传入这个函数当中,最后截取结束后再打印出来
void PrintCommandLine() { printf("[%s@%s %s]# ", GetUserName(), GetHostName(), rfindDir(GetPwd()).c_str()); //用户名@主机名 当前路径 }记得对rfindDir的返回值还要加一个c_str(),这是C++为了兼容C语言的打印而设计的接口
来看看成果,为了区分原shell和我们自己写的shell,我们的分隔符是不一样的,前者是$,我的是#
到这里就解决了cd路径的改变问题,所以其实真正的shell还是非常复杂的,我这仅仅是极简版本,还有很多内建命令和其他各种快捷键功能没实现,我们接下来再解决一下echo问题
2.echo 退出码
echo依旧是内建命令,承接实现“cd”的代码继续扩充。
首先先解决获取退出码的问题,先定一个全局变量Last_Exitcode
//上一个进程结束的退出码 int Last_ExitCode = 0;编写输入指令为echo的情况,并在每个指令执行结束的地方重置退出码
int CheckBuiltinExcute() { if(strcmp(gargv[0], "cd") == 0) { //内建命令 if(gargc == 2) { //新的目标路径 chdir(gargv[1]); Last_ExitCode = 0; } return 1; } else if(strcmp(gargv[0], "echo") == 0) { if(gargc == 2) { if(gargv[1][0] == '$') { if(strcmp(gargv[1]+1, "?") == 0) { printf("lastcode:%d\n", Last_ExitCode); } Last_ExitCode = 0; } return 1; } } return 0; }//父进程 int status = 0; pid_t rid = waitpid(id, &status, 0); if(rid > 0) { //获取退出码 Last_ExitCode = WEXITSTATUS(status); //printf("wait child process success!\n"); }来看看实现成果
六、总结与源码
至此,一个简易的shell被我们手搓出来了,独立完成其实非常考验知识储备和代码能力,可作为一个教学意义极高的训练典例,其实除了命令行参数表以外,shell内部还管理了另一张表就是环境变量表,它也承担着非常重要的角色,我将手搓的源码放在下面供大家自行在此基础拓展
#include<stdio.h> #include<stdlib.h> #include<string.h> #include<unistd.h> #include<sys/types.h> #include<sys/wait.h> #include<iostream> #include<string> #define MAXSIZE 128 #define MAXARGS 32 //shell内部维护的第一张表:命令行参数表 char *gargv[MAXARGS]; int gargc = 0; const char *sep = " "; //我们shell所处的工作路径 char cwd[MAXSIZE]; //上一个进程结束的退出码 int Last_ExitCode = 0; static std::string rfindDir(const std::string &p) { if(p == "/") return p; const std::string psep = "/"; auto pos = p.rfind(psep); if(pos == std::string::npos) { return std::string(); } return p.substr(pos+1); } const char *GetUserName() { char *name = getenv("USER"); if(name == NULL) { return "None"; } return name; } const char *GetHostName() { char *hostname = getenv("HOSTNAME"); if(hostname == NULL) { return "None"; } return hostname; } const char *GetPwd() { //char *pwd = getenv("PWD"); char *pwd = getcwd(cwd, sizeof(cwd)); if(pwd == NULL) { return "None"; } return cwd; } void PrintCommandLine() { printf("[%s@%s %s]# ", GetUserName(), GetHostName(), rfindDir(GetPwd()).c_str()); //用户名@主机名 当前路径 } int GetCommand(char commandline[], int size) { if(fgets(commandline, size, stdin) == NULL) return 0; //用户输入的时候,至少会按一次回车\n,改'\0' commandline[strlen(commandline)-1] = '\0'; return strlen(commandline); } int ParseCommand(char commandline[]) { //输入新的指令要重置命令行参数表 gargc = 0; memset(gargv, 0, sizeof(gargv)); //分割字符串 gargv[0] = strtok(commandline, sep); while((gargv[++gargc] = strtok(NULL, sep))); // printf("gargc: %d\n", gargc); // int i = 0; // for(; gargv[i]; i++) // printf("gargv[%d]: %s\n", i, gargv[i]); return 0; } int CheckBuiltinExcute() { if(strcmp(gargv[0], "cd") == 0) { //内建命令 if(gargc == 2) { //新的目标路径 chdir(gargv[1]); Last_ExitCode = 0; } return 1; } else if(strcmp(gargv[0], "echo") == 0) { if(gargc == 2) { if(gargv[1][0] == '$') { if(strcmp(gargv[1]+1, "?") == 0) { printf("lastcode:%d\n", Last_ExitCode); } Last_ExitCode = 0; } return 1; } } return 0; } int ExcuteCommand() { pid_t id = fork(); if(id < 0) return -1; else if(id == 0) { //子进程 程序替换 execvp(gargv[0], gargv); exit(1); } else{ //父进程 int status = 0; pid_t rid = waitpid(id, &status, 0); if(rid > 0) { //获取退出码 Last_ExitCode = WEXITSTATUS(status); //printf("wait child process success!\n"); } } } int main() { char command_line[MAXSIZE] = {0}; while(1) { //1.打印命令行提示符 PrintCommandLine(); //2.获取键盘输入 if(GetCommand(command_line, sizeof(command_line)) == 0) continue; // printf("%s\n", command_line); //3.解析字符串 ParseCommand(command_line); //4.执行指令 if(CheckBuiltinExcute() > 0) continue; ExcuteCommand(); } return 0; }