myShell:Linux Shell 的简单实现

这是之前写的博客,那时因为内容涉及学校正在上的课程的作业,经同学提醒,删除了,现在恢复,供有需要的同学参考。

实验目的

综合利用进程控制的相关知识,结合对shell功能的和进程间通信手段的认知,编写简易shell程序,加深操作系统的进程控制和shell接口的认识。

实验内容

  • 可以使用Linux或其它Unix类操作系统;
  • 全面实践进程控制、进程间通信的手段;
  • 编写简易shell程序。
    1. 尝试自行设计一个C语言小程序,完成最基本的shell角色:给出命令行提示符、能够逐次接受命令;对于命令分成三种,内部命令(例如help命令、exit命令等)、外部命令(常见的ls、cp等,以及其他磁盘上的可执行程序HelloWrold等)以及无效命令(不是上述三种命令)。
    2. 参考“综合预备(3)”中的4.1.1小节内容将上述shell进行扩展,使得你编写的shell程序具有支持管道的功能,也就是说你的shell中输入“dir || more”能够执行dir命令并将其输出通过管道将其输入传送给more作为标准输入。
    3. 可以将步骤1和2直接合并完成。
    4. 设计标准的参考。1)提示符最低标准是固定字符串。提升标准是使用含当前路径的信息为提示符。2)接受命令的最低标准是一次接受一个命令就推出shell程序,提升标准是在shell内部循环读取和执行命令。

实验步骤与结果

shell的基本功能分析

Shell是用户和系统内核之间的一个接口程序,shell可以较好地保护系统内核免受非法操作的破坏,也能让用户能方便地完成自己的任务。它是一个命令解释command-language interpreter,我们提交的每条命令都会通过shell的解释,然后再提交给内核执行。
图1:启动终端
当我们如图1一样,启动一个终端时,会打印出“用户@主机:~$”的提示符,这是由bash打印的,bash是shell的一种,我们可以手动指定自己要使用的shell。

shell包含内部命令和外部命令,比如打印目录的pwd命令就是一个内部命令,而压缩文件时用的tar命令则是一个外部命令。对于用户来讲,内部命令和外部命令使用起来并没有差别,当然我们也并不关心这条命令究竟是外部命令还是内部命令。

对于Shell来说,当用户需要执行一条命令时,它会先看看这条命令是否为内部命令,如果不是,再去环境变量$PATH中去找一下这是不是一个可执行的程序,如果都不是,shell会返回错误,提示没找到这条命令:
图2:未找到命令

图3:shell内部命令
图3展示了几个shell内部命令,echo用来指定的字符串;cd 用来切换目录;help用来输出帮助信息。

shell外部命令包括linux提供的一些使用的命令,比如ls,grep等,和我们自行安装的一些软件。

预计实现的功能

  • 命令提示符:如图1一样,输出命令提示符(并设置对应的颜色);
  • 常用外部命令:ls,cp,cat;
  • 可执行程序运行支持:允许通过./helloworld 这样的方式来执行可执行程序
  • 常用内部命令:help,echo,cd,exit
  • 无效命令提示:如图2一样提示未找到命令

设计及实现过程

shell我们一直都在用,其实很容易直观地想到它的运行机制就是:
图4:shell运行流程
按照图4的运行流程,我们可以设计出我们自己的shell程序的大致框架:

while(TRUE){
print_prompt();
get_command();
if command valid
deal_command();
else
    print_error();
}

图5:Shell 基本框架(Modern Operating Systems)

图5中的shell框架来自《现代操作系统》一书,和我们刚开始自己想的框架没有太大差别,说明我们的大体思路是没有问题的。只是我们的框架太“抽象”了些,没有多少指导意义,而这里则比较清楚地展示了shell执行中需要fork命令子进程,以及调用exec族函数来执行命令等细节。

图6:echo SHELL
图6展示了我们正在使用的shell版本,本实验中模仿的shell将以bash为标准。

根据搜索的资料,为了达到较好的命令交互效果,我们需要使用一个开源的库,realine。
从官网下载源码,解压后进入readline所在目录,使用./configure进行必要的配置,然后make,再make install,我们就完成了readline库的配置工作。

命令提示符:

如图1一样,输出命令提示符(并设置对应的颜色);

此部分关键的函数为void print_prompt(),它用来输出命令提示符。
图7:命令提示符分析
如图7所示,典型的命令提示符应该包含图中的6个元素:
1:用户名;
2:“@”符号;
3:主机名;
4:“:”符号
5:当前目录
6:用户权限符号(ubuntu系统下,在需要执行管理员权限命令时,使用sudo 来提供管理员权限,通常此提示符号均是$,并不如其他linux系统一样,用“#”来表示管理员)
图8:定义输出颜色
如图8,为了达到打印不同颜色的字体的要求,我们定义一些基本的颜色。

此部分比较关键的几个函数为:
gethostname(),获取主机名;
getcwd(),获取当前目录;
getpwuid(),根据用户id获取用户信息;
getuid(),获取用户id;
要打印不同颜色字体时,使用类似下面的打印语句即可:
printf(L_GREEN“hello world\n”);
可以看到只需要在要打印的字符串前面加上我们之前定义颜色的宏即可。

为了和真正的命令提示符看起来更像些,我们还需要要做一件工作:当用户处在自己的家目录下时,用“~”来替代“/home/*”,这个路径。这里需要用到字符串处理的一些手段。使用函数strtok()可以很方便地完成字符串的工作,它具有两个参数,第一个为要处理的字符串,第二个为分割字符,第一次调用这个函数时,需要指定要分割的字符,之后调用时,将此参数设置为“NULL”以继续完成分割。
图9:家目录处理
我们尝试运行一下,查看一下效果:
图10:命令提示符部分效果
图10中目录为红色(为了避免调试时和原本的shell提示符混淆)的命令提示符为myShell打印出来,看起来似乎很正常。

上面的命令提示符使我们直接使用printf打印的,还不是真正的“命令提示符”,为了使用到我们之前提到的readline库,我们需要对prompt再进行一些处理。

使用字符串格式化函数sprintf来将命令提示符中的几部分(目录,主机名等)合到一个字符串中,于是我们有如下的代码

sprintf(prompt,”\e[1;32m%s@%s\e[0m:\e[1;31m%s\e[0m%c”,pwp->pw_name,hostname,cwd,super);

其中\e[1;32m为颜色的控制信息。readline.h头文件中readline函数表示读入一行字符串,它包含一个char *prompt参数,传入的prompt将会作为命令提示符来处理。

当用到这个函数时,编译时,需要再链接一个库文件, -lncurses,否则会出错。有可能会出现无法找到libreadline.so.7这样的错误,我们需要去ld.so.conf中添加一行,/usr/local/lib(libreadline.so.7所在的位置)。最后,为了不用每次都敲长长的编译命令,我们可以尝试写个makefile来帮助我们完成这项工作。
图11:makefile
需要注意的是,两个库readline,ncurses的链接顺序,不可以调换。

解决完这些问题,我们终于可以使用readline()了。
图12:错误的prompt
从图12的结果来看,确实输出了不同颜色的字符,但是,我们的命令提示符完全被打乱了,而且光标跑到一个错误的位置(应该在命令提示符末尾)。

很自然的想法是去readline源码中看看出了什么问题。
readline
根据搜索资料的提示,我们找到readline.h如上图的宏定义,上面的宏定义的意思是,prompt中的转义用\001***\002这样的方式来括起来。
修改我们的prompt,把颜色部分全部用这样的方式:
\001\e[1;32m\002
来表示,修改完后,make一下,再次执行myShell。
图13:不同目录下的命令提示符效果
可以从图13中看到,命令提示符显示正常。把myShell放到不同目录下测试一下,暂时没有太大的问题了,可以开始下一步工作。

用户命令分析

通过readline(),我们可以获得用户输入的字符串,根据图5,Shell的基本框架,我们接着要做的就是对用户输入的字符串进行分析。

首先回想我们自己使用shell时的场景:
图14:用户输入命令的一些可能情况
从图14可以看到,用户通常输入命令的一般格式为:
命令 参数选项

在myShell中我们如果只是为了简单的达到执行命令的效果,可以使用system(“command”)函数,但是这个函数不太安全,很容易出错,所以用户命令分析还是有必要的,通过分析输入的命令,拿到命令和参数,然后调用exec函数来执行它。

此部分的关键函数为analysis_command()它用来分析用户输入的命令,并将其进行拆分。

由readline()获得用户输入的字符串,使用之前提到过的strtok函数进行进行分割。
图15:分割用户命令字符串
这里用的分割字符为空格符,将分割后的字符串保存到argv中,这里用argv[0]保存命令名称,argv[1]开始为命令的参数。

分割完成字符后,可以进行一些基本命令的处理,比如exit(退出shell),help(打印帮助信息)等。
图16:基本内部命令exit,help的处理

图17:cd命令的的处理
可以从图17看到,展示了如何处理cd命令。需要注意的是,cd命令只能为myShell的内部命令,也就是说,不能fork子进程去执行“cd”命令。因为fork子进程执行完cd后,再返回父进程,当前目录会变为父进程的目录,达不到切换路径的效果。切换路径需要使用函数chdir()。

测试一下此部分的运行情况:
图18:命令分析
从图18结果可以看出,命令分析看起来没有太大的问题。cd命令也能正常地工作。不过,外部命令能否利用我们分析的命令正常工作还为未可知,我们接着下一步的实验。

命令处理

此部分的关键是“exec”函数的使用,当然,如果要实现管道重定向等扩展功能,还需要对进程同步和通信等知识有一定了解。

普通命令

事实上,Linux中并没有一个名为“exec”的函数,而是六个以exec开头的函数族,它们是:

头文件#include<unistd.h>
函数原型int execl(const char *path, const char *arg, …)
int execv(const char *path, char *const argv[])
int execle(const char *path, const char *arg, …, char *const envp[])
int execve(const char *path, char *const argv[], char *const envp[])
int execlp(const char *file, const char *arg, …)
int execvp(const char *file, char *const argv[])
返回值成功:不返回
失败:返回-1

表中前四个函数以完整的文件路径进行文件查找,后两个以p结尾的函数,可以直接给出文件名,由系统从$PATH中指定的路径进行查找。这里不同的函数后缀,代表着的含义是:

后缀含义
l接收以逗号分隔的参数列表,列表以NULL指针作为结束标志
v接收到一个以NULL结尾的字符串数组的指针
p是一个以NULL结尾的字符串数组指针,函数可以通过$PATH变量查找文件
e函数传递指定参数envp,允许改变子进程的环境,无后缀e时,子进程使用当前程序的环境

值得注意的是:这六个函数中真正的系统调用只有execve(),其他的都是库函数,它们最终都会调用到execve();exec函数常常会因为找不到文件,或者没有对应文件的运行权限等原因而执行失败,所以,在使用是最好加上错误判断语句。

这里为了简单些,直接使用execvp(),然后我们需要传入待执行命令,待执行命令参数作为该函数的参数。需要注意的是,根据execvp函数的要求,这里的待执行命令的参数必须以NULL结尾,而且这个参数的第0个字符串为待执行命令,第一个字符串为待执行命令的第一个参数….
图19:执行命令
图19展示了myShell的命令执行情况,可以看到,内部命令,外部命令和可执行文件均正常执行,不过还存在一些bug,这里没有展示出来,将在调试与结果部分对此进行说明和处理。

管道支持

接着是实现对管道的支持。
图20:shell管道命令
图20展示了shell中管道命令的使用,“|”前的表示第一条执行的命令,它的输出结果将通过管道传送给第二条命令,作为第二条命令的参数。

我们可以简单地使用无名管道(仅能在具有亲缘关系的进程间使用)来在myShell中实现对这种管道命令的支持。这部分的设计伪代码如下(备注:该伪代码有误,具体在调试与结果部分进行说明):

BEGIN:
pipe(fd);
pid = fork();
if(pid < 0)
 print(“ERROR”);
else if(pid == 0)
 close(fd[0]);//close read
 dup2(fd[1],stdout);//duplicate the file handle
 exec(command1,parameters);
else if(pid > 0)
 close(fd[1]);//close write
 dup2(fd[0],stdin);
 exec(command2.parameters);
 wait(NULL);
END

图21:dup2函数
dup2函数用来复制文件描述符。在子进程(执行第一条命令)中,我们复制stdout描述符到管道写端,表示子进程将输出它的结果,类似地在父进程(执行第二条命令)(注:此处有误,不应该在父进程,而应该在另外一个子进程中执行),读端的描述符现在变为stdin,它用来接收第一条命令的结果。如此我们达到了通过管道在第一条命令和第二条命令间通信的目的。

需要说明的是,具体实现中,命令的保存问题。管道命令不能当成一条命令来执行,所以,在命令分析阶段通过判断“|”的存在来将命令分割成两部分,然后再分别在父子进程中调用execvp来执行。
图22:myShell管道命令
图22展示了myShell的运行效果,可以看到“ls | grep my”这条命令正常执行,但是执行完这条命令后,myShell就退出了(注意到命令提示符变为真正的shell的蓝色),这可不是我们想要的结果,应该是在进程处理部分出了bug,将在调试与结果部分对此进行说明和处理。

重定向支持

最后是重定向,重定向包含输入和输出重定向等,这里仅简单实现输出重定向。

首先是命令的分析,和管道命令类似,扫描一下整个命令字符串,看看有没有输出重定向符号“>”,如果有,则把它当做一个重定向命令,重定向符号前面部分为命令,后面部分为重定向的目标(保存在另一个字符串redirect_target中),重定向的关键函数为freopen(),它的原型为:
FILE *freopen( const char *path, const char *mode, FILE *stream )

此部分设计的伪代码为:

BEGIN:
freopen(redirect_target,”w”,stdout);//redirect stdout to redirect_target
execvp(redirect_command,parameters);
fclose(stdout);
END

图23:重定向命令运行效果
图23展示了重定向命令的运行效果。我们执行ls命令并将结果重定向到data.txt文件中,然后使用cat命令查看data.txt的内容,可以看出,重定向命令工作基本正常。

调试与结果

上一部分说明了myShell如何进行命令分析,内部命令处理,外部命令处理以及如何实现管道和重定向支持,但没有对程序中一些bug以及本实验用到的第三方库readline的使用问题等进行具体的说明,在此部分将对已经发现的bug进行处理,并总结本次实验中的要点。

  • 首先要解决的便是图22中所示的myShell异常退出的问题:
    分析一下之前的管道命令处理的伪代码,我们发现,管道的第二条命令被我们放到父进程(myShell所在的进程)执行,当这条命令执行完正常退出时,我们的父进程也跟着“正常退出”了,而这不是我们想要的结果。所以,我们应该将第二条命令同样放到一个子进程中去完成,这样才能达到执行完这条命令后myShell依然运行的效果。

    根据分析,得到如下的伪代码:

BEGIN:
pipe(fd);
pid1 = fork();
if(pid1 < 0)
 print(“ERROR”);
else if(pid1 == 0)
 close(fd[0]);//close read
 dup2(fd[1],stdout);//duplicate the file handle
 exec(command1,parameters);
else if(pid1 > 0)
 waitpid(pid1);
        pid2 = fork();
        if(pid2 < 0)
           printf(“ERROR”);
        else if(pid2 == 0)
          close(fd[1]);//close write
          dup2(fd[0],stdin);
          exec(command2.parameters);
else
close(fd);
            waitpid(pid2);
END

修改此部分实验代码,再次执行。
图24:修改后的管道命名
从图24可以看出,myShell正常执行了管道命令“ls -l | grep my”,输出了正确的结果,并且没有出现图22中的异常退出问题。

  • 其他程序bug:
    myShell程序还有许多的bug,这是因为没有像真的shell一样考虑各种可能的情况,做到面面俱到。比如当用户输入“ls -l|grep my”这样的命令时,会出现问题,因为myShell以单个空格为命令分割符号,直接使用“|grep”会导致命令无法正确分析,从而出错。再比如真实的shell中(Ubuntu16.04系统),普通用户也可以切换到根目录下,而myShell切换到用户主目录外的就会出现core dumped的错误。
  • readline库使用时会出现些问题,这里进行总结
    myShell因为使用了第三方库,readline,使得用户输入命令行的交互得到了极大的改善,可以光标移动修改命令,可以tab补全命令,支持历史命令等。不过这个库的安装使用可能会遇到一些问题,这里进行总结。

    本次实验中使用的readline版本为7.0。推荐从官网下载源码进行编译安装。下载readline源码后,解压,然后进入readline所在目录,先configure,再make,然后make install,和其他库的源码安装步骤差不多。

    在我们的C程序中,包含
    #include <readline/readline.h>
    #include <readline/history.h>
    这两个头文件即可,第一个头文件提供的readline()可以获取用户输入,第二个头文件是我们实现历史命令时需要的。

    使用时:
    if(!(line = readline(prompt)))
    其中prompt为我们的命令提示符字符串,如果在这个prompt中使用了转义的话,最好用/001***/002这样的方式把这部分括起来,否则可能出错。

    编译时使用gcc myShell.c -o myShell -lreadline -lncurses这样的方式,ncurses为readline依赖的一个底层输入输出的库,所以要放在readline的后面。

    此时还可能会出现无法法找到libreadline.so.7这样的错误,我们需要去ld.so.conf中添加一行,/usr/local/lib(libreadline.so.7所在的位置),然后使用ldconfig来使我们添加的信息生效。

  • 最后是myShell的运行展示
    比较有意思的是,myShell可以被当做一个真的shell(它实际上只是我们写的一个C程序),我们可以在myShell中执行“./myShell”这个可执行程序,这个可执行程序会再产生一个myShell,我们可以在这个myShell中再执行“./myShell”…我们会得到类似图25的进程关系。当我们多次执行./myShell的时候,有时候可能我们自己都记不清我们到底处在哪一层myShell中了,因为看起来它们都像是“真的”。有点类似电影《盗梦空间》或者是《异次元骇客》中的多层空间。事实上,多线程多进程的使用有时候确实会使我们有种迷惑的感觉。

图25:myShell help命令

以上就是myShell的全部实现过程。

reference

编写自己的Shell解释器
手把手教你编写一个具有基本功能的shell(已开源)
Linux shell的实现——execvp

实验源码

实验源码已上传至Github
myShell

  • 35
    点赞
  • 157
    收藏
    觉得还不错? 一键收藏
  • 9
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 9
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值