☰
自主实现一个简单的shell命令行
2026/10/7 10:06:49 网站建设 项目流程

自主shell

  • 1、搭建架构
  • 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#include<stdio.h>
// myshell.c#include"myshell.h"voidBash(){while(1){// ...}}
// main.c#include"myshell.h"intmain(){Bash();return0;}

这里我们要明确一点:命令行本质上就是一个死循环的进程,只不过需要用户输入指令,命令行才会执行下一步操作。

2、打印命令行

vscode上的命令行是长这样的:

简单分析,我们就需要以下三个要素:

  • 用户名
  • 主机名
  • 当前的工作目录

这三个要素对应的信息,其实都在环境变量里面。所以我们可以使用getenv获取。

// myshell.c#include"myshell.h"#include<stdlib.h>#include<string.h>#include<unistd.h>staticcharusername[32];staticcharhostname[64];staticcharpwd[256];staticvoidGetUserName(){char*_user_name=getenv("USER");strcpy(username,_user_name?_user_name:"None");}staticvoidGetHostName(){char*_host_name=getenv("HOSTNAME");strcpy(hostname,_host_name?_host_name:"None");}staticvoidGetPwd(){char*_pwd=getenv("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#include<stdio.h>voidBash();

这里有四个细节:

  • 声明与定义分离的多文件项目中,一定要在.h文件中加上声明!!!
  • 本人曾经习惯将所有头文件声明一股脑地放入.h文件中,但这其实是不太好的习惯。建议对于某个文件需要某些头文件声明,而其它文件不需要,我们就把这些头文件声明放在这个文件当中。
  • printf不使用\n时,要使用fflush冲涮缓冲区,否则直到进程结束之前,什么也看不到。
  • 不想外部使用myshell.c里的函数,就加上static。

3、等待用户进行输入

用户输入可能包含空格,我们可以使用库方法fgets。

同时,我们可以用一个static字符数组接收用户输入的字符串。

// myshell.c#include"myshell.h"#include<stdlib.h>#include<string.h>#include<unistd.h>staticcharusername[32];staticcharhostname[64];staticcharpwd[256];staticcharcommandline[256];// ...staticvoidGetCommandLine(){memset(commandline,0,sizeof(commandline));// 清除历史记录if(NULL==fgets(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(NULL==fgets(commandline,sizeof(commandline),stdin)){perror("fgets failed!");exit(10);}else{commandline[strlen(commandline)-1]=0;}}

输入了空串

如果用户什么也没输入,只是敲了一下“enter”,那么我们也就没必要进行下面的操作了,以免浪费CPU资源:

voidBash(){while(1){PrintPrompt();GetCommandLine();if(0==strlen(commandline))continue;}}

清除历史记录

向commandline写入前,先清空commandline里的内容。

4、解析字符串

用户输入了一个字符串,我们需要解析字符串:以空格为分界拆成一个个“小串”,然后传入指针数组argv中做成一个环境变量表。

staticintargc=0;staticchar*argv[64];

这里我们要借助一个C语言处理字符串的库函数strtok:

例如,这里有一个字符串char *string = "ls -a -l",要求将空格作为分界delim,将字符串拆分:

对于strtok中的str参数:

  • 如果输入的是string,那么返回字符串(字符指针)"ls"。
    • 我们可以理解为strtok在返回的时候,内部有一个“指针”从头开始,读到空格就返回了。
    • 以后如果输入的都是string,那么返回的结果都是一样的。
  • 如果输入的是NULL,
    • strtok就会往后读取。比如第二次调用,返回"-a";第三次调用,返回"-l";第四次调用,由于后面没有内容了,就返回NULL。
    • 我们可以理解为,strtok内部存在static全局变量,能够保存最近一次传入字符串的信息。

然后我们就可以完善解析字符串的函数:

staticvoidParseCommandLine(){// 清除原有的记录argc=0;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_tid=fork();if(id<0){perror("fork failed!\n");exit(11);}elseif(0==id){// childexecvp(argv[0],argv);// 程序替换exit(12);}else{// parentpid_trid=waitpid(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条件,而是继续往下,执行普通命令。

做路径切换,我们借助库函数chdir:

staticintCheckBuildinAndExecute(){intret=0;if(0==strcmp(argv[0],"cd")){ret=1;if(2==argc)chdir(argv[1]);}returnret;}

6.2、完善提示符

路径切换确实能做了,但是又出现一个问题:

命令行提示符中的路径,看起来没有改变?

实际上,我们是从环境变量中获取的当前工作路径。chdir并没有改变环境变量的PWD。

在这里,我们获取当前工作路径的时候,不从环境变量中获取,而是借助库函数getcwd:

voidGetPwd(){// 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(0==strlen(_cwd)){strcpy(pwd,"None");}elseif(0==strcmp(_cwd,"/")){strcpy(pwd,_cwd);}else{intend=strlen(_cwd)-1;while(end>=0){if('/'==_cwd[end]){strcpy(pwd,&_cwd[end+1]);break;}--end;}}}

注意这里使用的while循环的判断条件,不能用while(_cwd[end] != '/'),否则遇到'/'就会结束循环,永远也提取不到目录名。

6.3、echo $?提取退出码

(当前我们只针对普通命令的退出码的提取)

首先我们要完善Execute的进程等待,以及添加一个static全局变量记录退出码:

staticexit_code=0;// ...staticvoidExecute(){// ...// ...else{// parentintstatus=0;pid_trid=waitpid(id,&status,0);(void)rid;// 接收但不处理exit_code=WEXITSTATUS(status);}}

我们统一将echo设置为内建命令。echo包括两种功能;

  1. 打印字符串(会换行)
  2. 打印退出码
    ……
staticintCheckBuildinAndExecute(){intret=0;if(0==strcmp(argv[0],"cd")){ret=1;if(2==argc)chdir(argv[1]);}elseif(0==strcmp(argv[0],"echo")){ret=1;if(1==argc){printf("Nothing to do.\n");return1;}elseif('$'==argv[1][0]){if(0==strcmp(argv[1],"$?")){printf("%d\n",exit_code);exit_code=0;}}else{printf("%s\n",argv[1]);}}returnret;}

6.4、环境变量的获取

对于环境变量,我们有两个问题需要回答:

  • 环境变量由谁维护?bash
  • 环境变量在哪获取?配置文件

但是从配置文件中读取,我们做不到,就只能从真正的bash中获取环境变量:

// 环境变量staticintenvc;staticchar*env[64];// ...staticvoidLoadEnv()// 从真正的bash中读取环境变量{externchar**environ;for(envc=0;environ[envc];++envc)env[envc]=environ[envc];env[envc]=NULL;}

值得注意的是,LoadEnv可不能放到循环内部。因为下一次等待命令的时候,由于又执行了一次LoadEnv,之前对环境变量表的修改就会被覆盖。

至于执行env查看环境变量表,我们可以修改检查内建命令的函数CheckBuildinAndExecute,添加env分支。

6.5、环境变量的添加

即内建命令export的完善。

elseif(0==strcmp(argv[0],"export"))// export{if(argc==2){env[envc++]=argv[1];env[envc]=NULL;}}

在CheckBuildinAndExecute中这样写,就可以了吗?

elseif(0==strcmp(argv[0],"export"))// export{if(argc==2){env[envc++]=argv[1];env[envc]=NULL;}}


只查到一行空白,怎么回是?

实际上,这种添加环境变量的方式,我们也不陌生:浅拷贝。env表的最后一个有数据的项是直接指向argv[1]指向的内容的,当Bash()等待用户的下一次输入时,argv会被清空,env表的最后一个有效项就指向了一堆\0,就出现了上面的情况。

其中一个解决的办法,就是深拷贝:

elseif(0==strcmp(argv[0],"export"))// export{if(argc==2){// 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 env,Linux中也有相当一批命令,既有在bash中实现的方法,又有在磁盘上可随时调用的程序。

原因是,shell不仅含有解释命令的解释器bash:

与此同时,shell还是一门语言。

意味着shell可以像python一样,既可以做交互式的命令执行工作,又可以对一批写好代码和数据的文件,做语法分析与一系列处理。

对于同一种功能,

  • 交互式工作场景下,一般都是bash自己的工作,就需要bash内部实现方法,即内建命令;
  • 批量化处理场景下,bash就可以创建子进程执行,就需要磁盘上已经实现好的方法,即普通命令。

7.3、当前自主shell不支持的功能

当前的自主shell不支持:

  • 权限的管理。例如cat打开文件的请求会不会被拦截
  • > < >>重定向操作
  • 管道操作
  • 取别名(实际上我们也可以创建一个别名表,然后别名实际上也是一个变量)

这些不支持的功能,未来大家有兴趣的话也可以尝试完善一下🤝。

代码演示

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询