Linux - shell

218 阅读11分钟

如exit会退出程序等。

另外还能捕捉一些信号,如:ctrl+c,也能忽略一些信号,如:ctrl+\,禁止退出自己的程序。

-------main.c //这个是一个主控程序

-------parse.c、parse.h //这个主要是用来进行shell命令的解析的

再编写一个Makefile,由于项目中会由多个.c文件构成,所以很有必要进行整体编译

这个Makefile的编写是比较简单的,这里就不详述了,里面内容如下:

这样,总体的项目编译环境就已经搭建好了,另外说明一下实现的思路:先搭建好一个整体的框架,然后于对其每个模块进行一一细化,最终完善整个功能,所有功能的实现都会按这个思路来,而且很重要的一条就是:步步为营,也就是当写完一段代码后,就立马进行编译运行,来确保每小段代码都成功,这样的话,一点点功能进行拆分,最终实现一个项目,所以接下来,先要实现一个简单的框架功能:

shell循环:也就是当我们在执行完一个命令后,可以接着再进行敲下一个命令,而不是敲一个就立马进行程序退出了。

另外,还要说明一下,为了编译方便,这次项目的编写采用EditPlus编辑器,因为它会有一些提示功能,比直接用vim编译要来得方便一些,使用这种方法的前提,是需要Editpus连接到Linux虚拟机上。

首先先在parse.h头文件中声明shell循环需要的函数:

然后在parse.c中实现这些函数,当然先都空实现,一步步来:

然后main.c中去调用主loop循环:

【说明】:这里都是面向函数进行编程,也就是通常都是有一个.h头文件,一个.c实现文件,学习一下c语言编写的一些规范~ 这时,先不管具体实现,先编译一下:

可见木有问题,但是没有shell循环,所以接下来进行修改,当执行程序时,能够进行循环:

这时再次运行:

发生死循环了,这是为什么呢?因为read_command一直是0,还没有去实现任何代码,所以接下来,需要在这个函数中接收用户敲入的命令:

再次编译运行:

最后,是由于敲入了"ctrl+d"传入了一个结束符,导至fgets获取为null,则退出了整个循环,这样shell循环效果就出来了,也就是shell命令的基本框架就已经搭建好了! 但是如果现在按ctrl+c整个程序会退出,如下:

现在,我们不想按ctrl+c时,shell程序退出,则需要进行信号捕获:

由于这种类似的操作是属于初始化的,所以将其实现放到专门的.c文件中进行:

下面来编写setup函数,里面注册ctrl+c信号来防止用户按其退出:

开始编译运行看看效果,在运行之前,需要将init.c文件加入下Makefile当中:

开始运行:

从中可以看到,ctrl+c,ctrl+\都不会让其程序退出了,只了按了ctrl+d才会,所以已经成功通过信号来改变了其默认行为 【说明】:由于我虚拟机的原因,在按下ctrl+c时会显示^c字符,按下ctrl+\会显示^\字符,实际上信号是起作用了。 接下来,要来进行命令的解析:

在实现之前,需要进行命令的读取,这个在parse.c中已经简单实现了:

那常量的定义,这里放到统一的头文件中,便于集中管理:

这时在main.c中包含其def.h头文件:

由于现在行只解析一个简单命令,而不包含多个管道命令,所以先在main.c中声明一下命令结构体:

这时,在parse.c中需要使用在main.c中声明的全局变量,当然得用extern关键字喽:

在项目中会用到很多extern的全局变量,如果不封装一下可能每个使用的.c文件都得要声明一下,所以这个做法不是太好,应该还是得跟常量定义的def.h文件一样,得用专门一个头文件来存放extern的全局变量,如下:

这样的话,对于想引用全局变量的地方,就只要包含这个externs.h头文件既可,所以parse.c包含它:

这时,需要修改一下parse.c中的读取命令的函数了:

这个初始化工作应该放在init.c中,于是定义一个初始化的函数,对其变量进行初使化:

这时,应该是在每次执行一次命令时,进行初始化,所以,需修改parse.c中的shell_loop():

并且将读取命令行至全局变量中:

已经改了这么多,这时先来检查一下是否能顺利编译:

原因是由于没有包含init.h头文件,修改下次编译:

下面,则正式开始对命令进行解析,也就是编写parse_command()函数了:

第一步,将我们输入带有参数的命令折分,如下效果:

怎么样来实现呢?下面一点点来实现: 首先将变量指针指向我们解析的总命令字串:

接着,在开始解析之前,需再定义一个全局变量,主要作用如下:

但是又不会去改变cmdline,所以需用另外一个变量来存放,所以在main.c中定义一个新的全局变量:

另外还是在externs.h中进行声明:

下面正式开始解析: 由于可以左边会有空格,所以先将左空格去掉:

下面解析一个命令,最终放到cmd中的args参数里:

为了看到折分命令的效果,每解析到一个命令参数,则将其打印一下:

好了,先编译运行一下:

下面查找一下程序,原来是一个逻辑写错了:

再次执行:

成功解析了第一步,接下来,得执行命令了,这时因为命令的参数都已经解析完了,所以转到执行函数来对这些命令进行调用:

这时看下效果:

这是为什么呢?因为我们的shell进程被execvp给替换成执行系统命令了,而系统命令执行完则会退出整个进程,这时怎么解决这个问题呢?

关于这个函数创建进程,没有判断进程创建失败的情况,所以还需完善一下:

parse.c:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include "parse.h"
#include "externs.h"
#include "init.h"


/*
 * shell主循环
 */
void shell_loop(void){
    while(1){
        printf("[myshell]$ ");
        fflush(stdout);
        /* 初始化环境 */
        init();
        if(read_command() == -1)
            break;
        parse_command();
        execute_command();    
    }
    printf("\nexit\n");
}

/*
 * 读取命令
 * 成功返回0,失败或者读取到文件结束符(EOF)返回-1
 */
int read_command(void){
    if(fgets(cmdline,MAXLINE,stdin) == NULL){
        return -1;
    }
    return 0;
}

/*
 * 解析命令
 * 成功返回解析到的命令个数,失败返回-1
 */
int parse_command(void){
    char *cp = cmdline;
    char *avp = avline;

    int i = 0;
    while(*cp != '\0'){
        /** 去除左空格 **/
        while(*cp == ' ' || *cp == '\t')
            cp++;
        /* 如果到了行尾,跳出循环 */
        if(*cp == '\0' || *cp == '\n')
            break;
        cmd.args[i] = avp;
        
        while (*cp != '\0'
            && *cp != ' '
            && *cp != '\t'
            && *cp != '\n')
            *avp++ = *cp++;
        //printf("[%s]\n",cmd.args[i]);
        *avp++ = '\0';
        i++;
    }

    return 0;
}

/*
 * 执行命令
 * 成功返回0,失败返回-1
 */
int execute_command(void){
    pid_t pid = fork();
    if(pid == -1){
        //进程创建失败
        ERR_EXIT("fork");
    }
    if(pid == 0) {
        //子进程去执行替换函数
        execvp(cmd.args[0],cmd.args);
    } 
    //父进程等待子进程的退出,这样并不会改变父进程本身的行为,所以进程就不会退出了
    wait(NULL);
    return 0;
}

这节的最终运行效果如下:

已经初步实现了一个简单命令的解析,这节来继续对更加复杂命令进行解析,包含:输入重定向的解析、管道行的解析、输出重定向的解析以及是否有后台作业的解析,如下:

下面对其进行实现,上节中实现了对单条命令的解析,如下:

这节因为是多条,所以解析命令的实现也得重新开始写,在写之前,先列一个实现步骤:

先写一个流程伪代码,交其框架定出来,然后再去实现一个个功能函数,最后整个功能完成,这是一个比较好的编码习惯,先全局,先局部:

下面先定义未实现的函数:

然后再定义用到的全局变量:

并且在extends.h文件中进行声明:

这时,先来make一下,看这些修改能否正常编译:

从中来看,目前这个简单命令的解析框架已经搭建完毕,接下来,则是一个个函数进行实现:

void get_command(int i):获取第几条命令:

在实现解析方法之前,需要重新定义一下我们的命令数据结构,因为这一次是由多个命令组成,而不是单个命令,那要定义成一个什么样的结构呢?

所以,我们的命令数据结构需调整为:

由于现在是多条命令解析,所以cmd需要将其声明为数组:

另外,对于是cmd的初始化操作也得进行变化:

在继续编写前,咱们先来编译一下,看是否能编译过,一步一步脚印,步步为营,这样编写能减少出错的机率:

出错了,这也说明好的编码习惯,得改一点,立马来确认是否能过编译通过,一点点往上加功能,这样也会比较踏实,好了解决错误,是由于在execute_command还是执行的单条命令,所以肯定会出错,先将其注释掉:

再来编译这次就ok了,下面开始进行解析,根据解析的示例图,需要将cmdline中的命令参数提取到avline数组中,所以声明两个变量来分别指向cmdline和avline:

下面开始一步步进行解析:

也就是这一步:

其实,这个解析还是有点问题,比如命令"cat < test.txt",依照上面编写的代码来分析,当解析完cat之后,因为遇到了' ',所以j++:

再次循环:

也就是cmd[i].args[1] = ' ';而实际上只有一个cat命令,并没有第二个参数,所以需要做如下处理:

/*
* 解析简单命令至cmd[i]
* 提取cmdline中的命令参数到avline数组中
* 并且将COMMAND结构中的args[]中的每个指针指向这些字符串
*/
void get_command(int i){
    /* cat < test.txt | grep -n public > test2.txt & */
    int j = 0;//代表命令中的参数个数
    int inword;//是否在单词中
    while(*lineptr != '\0'){
        /* 去除空格 */
        while(*lineptr == ' ' || *lineptr == '\t')
            lineptr++;
&emsp;&emsp;&emsp;&emsp;&emsp;/* 将第i条命令第j个参数指向avptr */
        cmd[i].args[j] = avptr;
&emsp;&emsp;&emsp;&emsp;&emsp;/* 提取参数 */
        while(*lineptr != '\0'
            && *lineptr != ' '
            && *lineptr != '\t'
            && *lineptr != '>'
            && *lineptr != '<'
            && *lineptr != '|'
            && *lineptr != '&'
            && *lineptr != '\n'){
&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;&emsp;/* 参数提取至avptr指针指向的数组 */
            *avptr++ = *lineptr++;
            inword = 1;
        }
        *avptr++ = '\0';
        switch (*lineptr)//说明命令中的一个单词解析完
        {
        case ' ':
        case '\t'://表示一个命令的第一个参数已经解析完,应该继续解析其第二个参数,如:grep -n public,当解析完grep后,则会遇到空格,应该继续解析参数-n
            inword = 0;
            j++;
            break;
        case '<':
        case '>':
        case '|':
        case '&':
        case '\n'://说明命令解析结束了
            if(inword == 0)
                cmd[i].args[j] = NULL;//这时需要将空格参数去掉return;
        default: /* for '\0' */
            return;
        }
    }
}

这时,写了这么多代码,先编译一下:

可见代码现在没有什么问题,接下来实现check函数

int check(const char *str):判断命令是否包含某个字符:

怎么来理解上面的写法呢?

理解起来有点绕,可以好好消化下,接下来实现另外一个函数

void getname(char *name):获得重定向后的文件名:

另外,在parse_command()中,需要修改一个地方,就是对于输出重定向,可以有>>,表示追加的方式,所以改变如下:

需要定义一下append全局变量:

对于现在的代码,得验证其是否是正确的,所以,在程序还没有写完之前,可以写一个函数来打印解析的值进行验证程序的合法性,如下:

具体代码实现,比较简单,这里就不多解释了:

运行来验证下:

但是,如果直接回车,看结果:

所以,在init方法中,需将全局变量全部进行一次初始化:

这时再来看:

这时,应该是代码逻辑的问题,来看下代码parse_command()确实是有问题:

怎么修复此问题呢?其实在解析命令最后前,做一个判断既可:

这时,再来试验:

上节中已经实现了对普通命令的解析,包括输入重定向,输出重定向,管道,后台作业,这次就来执行已经解析好的命令,对应的函数为:execute_command(),首先对带有管道的命令进行执行:

比如:"ls | grep init | wc -w"这条命令,有两条管道,其中最后一条命令是不需要管道的:

另外我们知道,对于创建的管道fds,其中fds[0]表示读端,fds[1]表示写端,会有如下关系:

所以可以联想到,默认命令的输入是标准输入,输出是标准输出,所以可以在init中对所有命令进行初始化:

接下来,执行命令,这个最初已经实现了,就是fork出一个进程来用execvp函数来进行进程替换既可,这里将命令的执行封装成一个函数:

所以需要在头部声明且实现:

好了,先来编译一下所编写的代码:

很遗憾,木能一次到位,啥问题呢?

所以修改如下:

再次编译:

这次就成功了,另外在运行之前,还需加一个,就是需将3以上的文件描述符全给关掉,因为描述符0、1已经被使用了,之后由于会有重定向一个文件,所以留一个文件描述符2,具体代码如下:

编译运行:

另外,在execute_command命令中,需要关闭命令的描述符:

接下来看下运行效果:

这是因为父进程已经退出了,子进程运行在父进程之后了,所以要解决此问题,则父进程需要等待子进程的执行,修改如下: 定义一个变量来记录最后运行的父进程:

在extends.h中进行声明:

并对变量进行初始化:

当fork一个进程时,则对lastpid进行赋值:

这时,再来看效果:

下面开始解析带输入输出重定向的命令:

另外需要处理一下后台作业的情况:

但是如果是后台作业的话,则会引起僵尸进程,所以说需要解决一下:

下面来make一下:

查看man帮助:

再次编译:

下面来看下是否支持输入和输出重定向:

下面采用输入重定向来输出同样的效果:

下面来看下输出重定向:

可见,现在已经支持输入输出重定向了,下面还需看一种异常情况:

当输入不带参数的cat命令时,表是从键盘获取输入,当我们按下ctrl+c时,居然打印出了两个[myshell],这个有异常了,我们知道,ctrl+c是向当前进程发送sigint信号,由于我们在setup()已经注册了sigint信号,而且行为为打印[myshell]

而由于父进程和子进程都能收到sigint信号,因为sigint是向进程组发送的,所以组里面的所有子进程都能收到,所以要解决此问题,需要做如下操作:

因为在后台作业时,已经忽略SIGINT信号了,所以如果是前台作业,则需要恢复,编译再来看效果:

这时为啥呢?这个可能是进程组的关系,可以查看一下关系:

但是进入我们自己的shell来查看一下进程组关系:

所以,SIGINT发送给进程组26945时,也就发送了该进程的父进程,因为当前该父进程为进程组,而子进程26946同样也会收到SIGINT信号,所以就打印了两次。 这就涉及到会话期的概念,其中创建新的会话期可以通以以下函数:

所以,在第一个命令fork时,则将这个进程做为进程组组长既可,做法如下:

所以,函数的参数应该发生变化:

然后在子进程中做一个判断,创建新的会话期:

这次再来编译下:

这时再来看一下刚才的问题是否还存在?

这时按下ctrl+c就没有出现两个[myshell]$了,这是为什么呢? 因为ctrl+c是将SIGINT信号发送给当前进程组,也就是对应于上面的27278,那么该进程组下面的所有进程都会收到该信号,由于在前台进程时,将SIGINT信号还原成了默认值:

所以,这时ppid父进程是不会收到该信号的,因为该信号只会发送给当前进程组成里面的所有进程,所以这次就只会打印一次了。 接下来,我们来看一下后台作业:

这里来看,后台作业有问题,由于这里并没有实现作业控制(bg,fg),所以先屏蔽后台作业,等之后有时间再来研究,如下:

这时再来看下之前的bug是否还存在?

看样子还是有问题,还没有屏蔽成功,在屏蔽之前,先来解决一个很明显的bug:

也就是当cmd_count=0时,则没反应了,这时应该做一个容错处理,当为0时不应该执行命令:

这时再来看效果:

下面再来解决屏蔽后台作业的bug,该bug就是如果先敲了一个后台作业命令,之后再执行一个简单命令就会卡住,这是为什么呢,原因其实比较简单:

这时再来看效果:

这样这个bug就成功被解决,另外我们来看下真实的后台作业的输出是怎么样的:

所以,我们也可以给打印一下当前的pid,虽说后台作业的功能没有完全实现:

给代码加入适当的注释

而这个信号安装函数是在init.c中实现的:

接下来进行shell循环:

它的实现是在parse.c中:

如注释所示,可以挪至init.c中:

接下来,获取命令:

然后解析命令:

接下来的这句,是为了测试,在发布时可以注释掉了:

最后执行命令:

这个方法里面的代码有点乱,下面将其实现抽取到另外一个文件中,使得该函数要看起来清爽一些:

其实现execute.c:

#include "execute.h"
#include "def.h"
#include "externs.h"
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <linux/limits.h>
#include <fcntl.h>

void forkexec(int i){
    pid_t pid;
    pid = fork();
    if(pid == -1) {
        /* 创建进程失败了 */
        ERR_EXIT("fork");
    }

    if(pid > 0) {
        /* 父进程 */
        if (backgnd == 1)
            printf("%d\n", pid);
        lastpid = pid;
    } else if(pid == 0) {
        /* 子进程 */

        /* 表示将第一条简单命令的infd重定向至/dev/null,其中cmd[i].infd == 0只有可能是第一条简单命令 */
        /* 当第一条命令试图从标准输入获取数据的时候立既返回EOF */
        if(cmd[i].infd == 0 && backgnd == 1){
            //屏蔽后台作业,因为没有实现作业控制
            cmd[i].infd = open("/dev/null", O_RDONLY);
        }

        /* 将第一个简单命令进程作为进程组组长 */
        if(i == 0){
            setpgid(0, 0);
        }
        if(cmd[i].infd != 0){
            //说明该命令的输入是指向管道的读端
            close(0);
            dup(cmd[i].infd);
        }
        if(cmd[i].outfd != 1){
            //说明该命令的输出指向的是管道的写端
            close(1);
            dup(cmd[i].outfd);
        }
        /* 关闭3以上的所有文件描述符 */
        /*int i;
        for(i=3; i<OPEN_MAX; ++i){
            close(i);
        }*/

        /*前台作业能够接收SIGINT,SIGQUIT信号,这两个信号就要恢复成默认操作*/
        if(backgnd == 0){//非后台作业
            signal(SIGINT, SIG_DFL);
            signal(SIGQUIT, SIG_DFL);
        }

        /* 开始替换进程 */
        execvp(cmd[i].args[0], cmd[i].args);
        /* 如果执行到这句,则证明替换失败了 */
        exit(EXIT_FAILURE);
    }
}

int execute_disk_command(void){
    /* ls | grep init | wc -w */
    if(cmd_count == 0) {
        return 0;
    }
    if(infile[0] != '\0'){
        cmd[0].infd = open(infile, O_RDONLY);
    }

    if(outfile[0] != '\0'){
        if(append)//说明是以追加的方式
            cmd[cmd_count-1].outfd = open(outfile, O_WRONLY | O_CREAT | O_APPEND, 0666);
        else 
            cmd[cmd_count-1].outfd = open(outfile, O_WRONLY | O_CREAT | O_TRUNC, 0666);
    }

    /* 因为后台作业不会调用wait等待子进程退出,为避免僵尸进程,可以忽略SIGCHLD信号 */
    if(backgnd == 1){
        signal(SIGCHLD, SIG_IGN);
    }else{
        signal(SIGCHLD, SIG_DFL);
    }
    int i;
    /* 管道描述符 */
    int fds[2];
    int fd;
    for(i=0; i<cmd_count; ++i){
        /* 如果不是最后一条命令,则需要创建管道 */
        if(i < cmd_count-1){
            pipe(fds);
            /* 第一条命令的输出不再是标准输出,而是管道的写端 */
            cmd[i].outfd = fds[1];
            /* 第二条命令的输入不再是标准输入,而是管道的读端 */
            cmd[i+1].infd = fds[0];
        }
        
        /* 创建一个进程,并且替换成系统命令 */
        forkexec(i);
        
        if((fd = cmd[i].infd) != 0)
            close(fd);
        if((fd = cmd[i].outfd) != 1)
            close(fd);
    }

    if(backgnd == 0){//如果是非后台作业
        while(wait(NULL) != lastpid)
            ;    
    }
}

将其forkexec函数也抽取到execute.c文件中,下面来进行编译一下:

另外在编译成,需要修改一下Makefile:

修改这么多后下面编译一下:

好了,对于上面的实现都解释的外部命令,那对于系统的内部命令还需要兼容,下面主要是来实现内部命令的解析: 首先要判断是否是内部命令,如果是,则执行内部命令:

另外需要将builtin.h包含在parse.c文件中:

下面来编译一下:

说明忘了将builtin.o文件加入到Makefile中了,修改并再编译:

一大堆错,不用着急,一个个解决,首先builtin.c文件中也需要用到check函数,而之前是定义在parse.c中,这时应该将其定义在parse.h中,让builtin.c来包含它既可,另外还需包含一些系统头文件:

在builtin.c中去包含parse.h文件:

下面再来make并执行:

思考一个问题:系统有大量的内部命令,那是不是每解析一个内部命令,我们都要在builtin.c中加一个判断语句,这样会造成builtin函数会越来越庞大,所以这种实现方式还是不太灵活,下面改用数组来避免这种情况的发生:

最后builtin.c的代码如下:

#include "builtin.h"
#include "parse.h"
#include "externs.h"
#include <stdlib.h>
#include <stdio.h>

typedef void (*CMD_HANDLER)(void);

typedef struct builtin_cmd
{
    char *name;
    CMD_HANDLER handler;

} BUILTIN_CMD;


void do_exit(void);
void do_cd(void);
void do_type(void);

BUILTIN_CMD builtins[] = 
{
    {"exit", do_exit},
    {"cd", do_cd},
    {"type", do_type},
    {NULL, NULL}
};

/*
 * 内部命令解析
 * 返回1表示为内部命令,0表示不是内部命令
 */
int builtin(void)
{
    /*
    if (check("exit"))
        do_exit();
    else if (check("cd"))
        do_cd();
    else
        return 0;

    return 1;
    */

    int i = 0;
    int found = 0;
    while (builtins[i].name != NULL)
    {
        if (check(builtins[i].name))
        {
            builtins[i].handler();
            found = 1;
            break;
        }
        i++;
    }

    return found;
}

void do_exit(void)
{
    printf("exit\n");
    exit(EXIT_SUCCESS);
}

void do_cd(void)
{
    printf("do_cd ... \n");
}

void do_type(void)
{
    printf("do_type ... \n");
}

编译运行:

下面列一下该程序中使用到的各个文件的作用:

main.c----主调程序

def:h----定义常量,结构体

externs.h----定义extern的变量

init.h/init.c----做一些初始化操作

parse.h/parse.c----做命令的解析

execute.h/execute.c----做外部命令的执行

builtin.h/builtin.c----做内部命令的执行【只实现其原理】