自主实现一个简单的shell命令行
发布时间:2026/10/7 10:06:50 作者:尧图编辑部 阅读量:1,286

自主shell1、搭建架构2、打印命令行3、等待用户进行输入4、解析字符串5、执行命令6、进一步完善6.1、路径无法切换6.2、完善提示符6.3、echo $?提取退出码6.4、环境变量的获取6.5、环境变量的添加7、其它知识点7.1、进一步理解环境变量 vs 本地变量7.2、一个命令两种身份7.3、当前自主shell不支持的功能自主实现一个简单的shell命令行可以将我们之前学习过的知识串联起来。其核心思想就是创建一个父进程(bash)完成基本的命令行操作对于用户输入的指令如果是普通命令父进程将会创建子进程让子进程执行如果是内建命令父进程将会自己执行。1、搭建架构// myshell.h#pragmaonce#includestdio.h// myshell.c#includemyshell.hvoidBash(){while(1){// ...}}// main.c#includemyshell.hintmain(){Bash();return0;}这里我们要明确一点命令行本质上就是一个死循环的进程只不过需要用户输入指令命令行才会执行下一步操作。2、打印命令行vscode上的命令行是长这样的简单分析我们就需要以下三个要素用户名主机名当前的工作目录这三个要素对应的信息其实都在环境变量里面。所以我们可以使用getenv获取。// myshell.c#includemyshell.h#includestdlib.h#includestring.h#includeunistd.hstaticcharusername[32];staticcharhostname[64];staticcharpwd[256];staticvoidGetUserName(){char*_user_namegetenv(USER);strcpy(username,_user_name?_user_name:None);}staticvoidGetHostName(){char*_host_namegetenv(HOSTNAME);strcpy(hostname,_host_name?_host_name:None);}staticvoidGetPwd(){char*_pwdgetenv(PWD);strcpy(pwd,_pwd?_pwd:None);}staticvoidPrintPrompt(){GetUserName();GetHostName();GetPwd();printf(%s%s:%s# ,username,hostname,pwd);fflush(stdout);// 冲涮缓冲区}voidBash(){while(1){PrintPrompt();sleep(1);// 方便观察避免一下出现一大堆}}// myshell.h#pragmaonce#includestdio.hvoidBash();这里有四个细节声明与定义分离的多文件项目中一定要在.h文件中加上声明本人曾经习惯将所有头文件声明一股脑地放入.h文件中但这其实是不太好的习惯。建议对于某个文件需要某些头文件声明而其它文件不需要我们就把这些头文件声明放在这个文件当中。printf不使用\n时要使用fflush冲涮缓冲区否则直到进程结束之前什么也看不到。不想外部使用myshell.c里的函数就加上static。3、等待用户进行输入用户输入可能包含空格我们可以使用库方法fgets。同时我们可以用一个static字符数组接收用户输入的字符串。// myshell.c#includemyshell.h#includestdlib.h#includestring.h#includeunistd.hstaticcharusername[32];staticcharhostname[64];staticcharpwd[256];staticcharcommandline[256];// ...staticvoidGetCommandLine(){memset(commandline,0,sizeof(commandline));// 清除历史记录if(NULLfgets(commandline,sizeof(commandline),stdin)){perror(fgets failed!);exit(10);}}voidBash(){while(1){PrintPrompt();GetCommandLine();}}这里有三个细节用 sizeof 而不用 strlen对于这句代码if (NULL fgets(commandline, sizeof(commandline), stdin))sizeof()可不能改成strlen()因为用户的指令还没有输入进来commandline肯定是一堆\0。库函数strlen统计字符串的字符个数时遇到\0就停止所以strlen(commandline)的结果为0导致fgets获取输入失败。而操作符sizeof统计的是变量所占的字节数sizeof(commandline)的返回结果就是256因为一个char类型占一个字节256个就占256个字节。多了一个空格事实上我们输入内容时最后还输入了一个回车键那么commandline末尾最终会多一个\n。我们可以将这个转义字符覆盖掉staticvoidGetCommandLine(){memset(commandline,0,sizeof(commandline));if(NULLfgets(commandline,sizeof(commandline),stdin)){perror(fgets failed!);exit(10);}else{commandline[strlen(commandline)-1]0;}}输入了空串如果用户什么也没输入只是敲了一下“enter”那么我们也就没必要进行下面的操作了以免浪费CPU资源voidBash(){while(1){PrintPrompt();GetCommandLine();if(0strlen(commandline))continue;}}清除历史记录向commandline写入前先清空commandline里的内容。4、解析字符串用户输入了一个字符串我们需要解析字符串以空格为分界拆成一个个“小串”然后传入指针数组argv中做成一个环境变量表。staticintargc0;staticchar*argv[64];这里我们要借助一个C语言处理字符串的库函数strtok例如这里有一个字符串char *string ls -a -l要求将空格作为分界delim将字符串拆分对于strtok中的str参数如果输入的是string那么返回字符串(字符指针)ls。我们可以理解为strtok在返回的时候内部有一个“指针”从头开始读到空格就返回了。以后如果输入的都是string那么返回的结果都是一样的。如果输入的是NULLstrtok就会往后读取。比如第二次调用返回-a第三次调用返回-l第四次调用由于后面没有内容了就返回NULL。我们可以理解为strtok内部存在static全局变量能够保存最近一次传入字符串的信息。然后我们就可以完善解析字符串的函数staticvoidParseCommandLine(){// 清除原有的记录argc0;memset(argv,0,sizeof(argv));// 填表argv[argc]strtok(commandline,sep);while((argv[argc]strtok(NULL,sep)));}首先我们要清除上一次输入指令留下的记录其次我们借助strtok只用了两行代码就完成了argc argv的设置甚至还能在argv的末尾加上NULL。值得注意的是while ((argv[argc] strtok(NULL, sep)));中我们多加了一个括号。这是因为括号里面进行的是赋值操作一般赋值操作的返回值都为真很容易导致死循环编译器会认为我们在这里进行了误操作即把 错写成了 所以这里触发了编译器的警告。我们加上括号意在告诉编译器我们确实在做赋值操作并且将赋值作为while循环的条件。5、执行命令执行命令我们可以让父进程创建子进程然后让子进程通过程序替换完成命令的执行。staticvoidExecute(){pid_tidfork();if(id0){perror(fork failed!\n);exit(11);}elseif(0id){// childexecvp(argv[0],argv);// 程序替换exit(12);}else{// parentpid_tridwaitpid(id,NULL,0);(void)rid;// 接收但不处理}}这样一来我们就做好了一个功能简单的shell命令行。6、进一步完善接下来我们将进一步完善自主实现的shell命令行解释器以帮助我们加深理解这些概念shell命令行的运行原理普通命令 vs 内建命令命令行参数 环境变量6.1、路径无法切换子进程做完路径的切换修改的只是子进程的副本子进程直到退出的时候也没能修改父进程(bash)的路径信息。所以路径切换的工作只能由父进程来做。实际上不仅路径切换指令cd查看环境变量指令env、导入环境变量指令export、以及echo……这些命令都必须由父进程执行都是内建命令。所以我们得到一个解析完的字符串还应该先判断一下是否是内建命令if(CheckBuildinAndExecute())continue;我们执行内建命令的大致思路是进入CheckBuildinAndExecute如果是内建命令执行内建命令返回“1”此时命中if条件就转而去等待用户的下一次输入如果不是内建命令返回“0”就不会命中if条件而是继续往下执行普通命令。做路径切换我们借助库函数chdirstaticintCheckBuildinAndExecute(){intret0;if(0strcmp(argv[0],cd)){ret1;if(2argc)chdir(argv[1]);}returnret;}6.2、完善提示符路径切换确实能做了但是又出现一个问题命令行提示符中的路径看起来没有改变实际上我们是从环境变量中获取的当前工作路径。chdir并没有改变环境变量的PWD。在这里我们获取当前工作路径的时候不从环境变量中获取而是借助库函数getcwdvoidGetPwd(){// char *_pwd getenv(PWD);// strcpy(pwd, _pwd ? _pwd : None);getcwd(pwd,sizeof(pwd));}只给出最近的目录名有的平台下的命令行解释器对于当前工作目录只会给出最近的目录名例如10_5。这时我们就可以实现一个简单的算法提取最近的目录名voidGetPwd(){// char *_pwd getenv(PWD);// strcpy(pwd, _pwd ? _pwd : None);//getcwd(pwd, sizeof(pwd));char_cwd[256];getcwd(_cwd,sizeof(_cwd));if(0strlen(_cwd)){strcpy(pwd,None);}elseif(0strcmp(_cwd,/)){strcpy(pwd,_cwd);}else{intendstrlen(_cwd)-1;while(end0){if(/_cwd[end]){strcpy(pwd,_cwd[end1]);break;}--end;}}}注意这里使用的while循环的判断条件不能用while(_cwd[end] ! /)否则遇到/就会结束循环永远也提取不到目录名。6.3、echo $?提取退出码(当前我们只针对普通命令的退出码的提取)首先我们要完善Execute的进程等待以及添加一个static全局变量记录退出码staticexit_code0;// ...staticvoidExecute(){// ...// ...else{// parentintstatus0;pid_tridwaitpid(id,status,0);(void)rid;// 接收但不处理exit_codeWEXITSTATUS(status);}}我们统一将echo设置为内建命令。echo包括两种功能;打印字符串(会换行)打印退出码……staticintCheckBuildinAndExecute(){intret0;if(0strcmp(argv[0],cd)){ret1;if(2argc)chdir(argv[1]);}elseif(0strcmp(argv[0],echo)){ret1;if(1argc){printf(Nothing to do.\n);return1;}elseif($argv[1][0]){if(0strcmp(argv[1],$?)){printf(%d\n,exit_code);exit_code0;}}else{printf(%s\n,argv[1]);}}returnret;}6.4、环境变量的获取对于环境变量我们有两个问题需要回答环境变量由谁维护bash环境变量在哪获取配置文件但是从配置文件中读取我们做不到就只能从真正的bash中获取环境变量// 环境变量staticintenvc;staticchar*env[64];// ...staticvoidLoadEnv()// 从真正的bash中读取环境变量{externchar**environ;for(envc0;environ[envc];envc)env[envc]environ[envc];env[envc]NULL;}值得注意的是LoadEnv可不能放到循环内部。因为下一次等待命令的时候由于又执行了一次LoadEnv之前对环境变量表的修改就会被覆盖。至于执行env查看环境变量表我们可以修改检查内建命令的函数CheckBuildinAndExecute添加env分支。6.5、环境变量的添加即内建命令export的完善。elseif(0strcmp(argv[0],export))// export{if(argc2){env[envc]argv[1];env[envc]NULL;}}在CheckBuildinAndExecute中这样写就可以了吗elseif(0strcmp(argv[0],export))// export{if(argc2){env[envc]argv[1];env[envc]NULL;}}只查到一行空白怎么回是实际上这种添加环境变量的方式我们也不陌生浅拷贝。env表的最后一个有数据的项是直接指向argv[1]指向的内容的当Bash()等待用户的下一次输入时argv会被清空env表的最后一个有效项就指向了一堆\0就出现了上面的情况。其中一个解决的办法就是深拷贝elseif(0strcmp(argv[0],export))// export{if(argc2){// env[envc] argv[1];// env[envc] NULL;char*mem(char*)malloc(sizeof(argv[1]1));strcpy(mem,argv[1]);env[envc]mem;env[envc]NULL;}}7、其它知识点7.1、进一步理解环境变量 vs 本地变量对于环境变量其实就是bash内部隐藏了一个环境变量表接着用一个二级指针指向环境变量表用户定义一个同名指针就可以通过这个同名指针获取环境变量表中的所有信息。至于本地变量其实也是bash内部隐藏了一个本地变量表。只要bash没有识别到export就会写入本地变量表中。voidBash(){// ...staticchar*local[1024];// 本地变量表// ...}7.2、一个命令两种身份上面的学习中我们默认将echo env等命令当作了内建命令从而来完善自主shell。实际上类似于echo envLinux中也有相当一批命令既有在bash中实现的方法又有在磁盘上可随时调用的程序。原因是shell不仅含有解释命令的解释器bash与此同时shell还是一门语言。意味着shell可以像python一样既可以做交互式的命令执行工作又可以对一批写好代码和数据的文件做语法分析与一系列处理。对于同一种功能交互式工作场景下一般都是bash自己的工作就需要bash内部实现方法即内建命令批量化处理场景下bash就可以创建子进程执行就需要磁盘上已经实现好的方法即普通命令。7.3、当前自主shell不支持的功能当前的自主shell不支持权限的管理。例如cat打开文件的请求会不会被拦截 重定向操作管道操作取别名(实际上我们也可以创建一个别名表然后别名实际上也是一个变量)这些不支持的功能未来大家有兴趣的话也可以尝试完善一下。代码演示