如何写一个命令行解释器(SHELL)

Posted 正义的伙伴啊

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了如何写一个命令行解释器(SHELL)相关的知识,希望对你有一定的参考价值。

文章目录

前言

本shell设计的框架参考的是Tutorial - Write a Shell in C,但是代码都是自己写的,还加了一些新的功能😆

什么是命令行解释器 ——SHELL

关于什么是shell前面的文章深入理解 shell/bash已经说过了,我这里就不多赘述了。

SHELL用到的知识点: 进程替换、字符串操作库函数、文件系统、进程等待、动态增容

SHELL的结构

关于一个shell我们首先他是一个循环,重复的接受我们输入的命令行,直到收到退出指令,每一个循环我们都要执行以下任务:

  1. 获取终端的信息并打印在屏幕上,包括主机名称、用户名称、当前目录
  2. 读取屏幕上用户输入的命令行
  3. 将读取的命令行,按照可执行指令参数的形式切割成若干个字符串
  4. 执行指令
  5. 接受指令执行成果,并判断循环是否继续

这个结构是贯穿整个shell执行周期的根本,所以我们先将主函数写出来,然后再实现每一步的具体功能:

int main(int argc, char **argv, char **envp)

    while (1)
    
        char *buffer = NULL; // 存储 输入的命令行

        print_info(envp); // 输出命令行前面的主机信息

        read_comand(&buffer); // 读取 屏幕上输入的指令

        int flag = 0;

        char **p = split_line(buffer, &flag); // 将字符串裂项成 字符串数组

        int ret = excute_line(p, flag); // 执行 指令

        if (ret == EXIT_SHELL) // 接受返回值并判断是否要退出SHELL
            break;
    

void print_info(char ** env) //打印命令行信息函数

这个函数主要是获取主机信息并打印在屏幕上,获取内容包括主机名称、用户名称、当前目录。
我的实现方法是从环境变量中获取,环境变量指针数组是通过main函数的参数列表传入(如果想看指针数组内容,命令行键入env即可查看),主体思想就是遍历指针数组找到我们想要的信息,以我的终端为例,我们要找的环境变量为:

所以我们要寻找的环境变量名称为:HOSTNAMEUSERPWD,找到后截取等号后面的内容存储下来,思路简单,但是对string库函数使用有一定熟练度要求,代码实现:

void print_info(char **envp)

    char **cur = envp;
    char HOST_NAME[100]; // 主机型号和名称
    char USER[100];      // 用户名
    char PWD[100];      //路径
    char CUR_dic[20];   //当前目录
    while ((*cur) != NULL)    //env最后一个元素为NULL
    
        char tmp[30];   //用来存储截取每个用户变量名
        int j = 0;
        for (j = 0; j < strlen(*cur); j++)   // 等号前面的一定是变量名
        
            if ((*cur)[j] == '=')
                break;
            else
                tmp[j] = (*cur)[j];
        
        tmp[j] = '\\0';

        if (strcmp(tmp, "HOSTNAME") == 0)
            strcpy(HOST_NAME, strstr(*cur, "=") + 1);

        if (strcmp(tmp, "USER") == 0)
            strcpy(USER, strstr(*cur, "=") + 1);

        if (strcmp(tmp, "PWD") == 0)   //pwd作为路径还需要特殊处理,才能得到当前目录
        
            strcpy(PWD, strstr(*cur, "=") + 1);
            int i = 0;
            for (i = strlen(PWD); i >= 0; i--)
            
                if (PWD[i] == '/')
                    break;
            
            strcpy(CUR_dic, PWD + i + 1);
        
        cur++;
    
    if (strcmp(USER, "root") == 0)   //区分 超级用户 和 普通用户
        printf("[%s@%s %s]# ", USER, HOST_NAME, CUR_dic);
    else
        printf("[%s@%s %s]$ ", USER, HOST_NAME, CUR_dic);

最后还有一个小细节是:超级用户和普通用户最后一个字符有点区别,要加以区分

void read_comand(char **buffer) //读取指令函数

这个函数目的是读取用户输入的命令行,采取动态增容的模式,buffer为

读取屏幕上的字符我们是一个一个的读直到读取到EOF或者换行符为止

void read_comand(char **buffer)

    // 动态开辟内存
    char *p = (char *)malloc(BUFFER_SIZE * sizeof(char));
    int buffer_size = BUFFER_SIZE;

    if (p == NULL) // 开辟内存失败
    
        fprintf(stderr, "read_line error\\n");
        exit(0);
    
    int i = 0;
    while (1)
    
        char c = getchar();

        if (c == EOF || c == '\\n')
        
            p[i] = '\\0';
            break;
        
        else
        
            p[i] = c;
            i++;
        
        if (i >= buffer_size) // 如果空间不够 , 追加初始一倍 的空间
        
            buffer_size += BUFFER_SIZE;
            p = realloc(p, buffer_size);
        
    
    *buffer = p;

char **split_line(char *buffer, int *flag) //分割字符串函数

切割字符串分三种情况: p为我们要返回的指针数组

  1. 单指令
    切割字符串这个函数就有点学问了,例如我们在命令行输入ls -l -a,经过这个函数就要变成一个指针数组:

这个数组元素的类型是指向字符串的指针,数组结束的标志是出现NULL指针。那么如何将buffer数组切成一小块一小块的命令行参数呢?
这就要用到strtok函数来帮我们来干一些脏活和累活,用strtok按照空格来切分buffer,再将切出来的指针存储到指针数组中,最后strtok没得切的时候还会贴心的返回NULL来作为指针数组的结尾(strtok不会用的参考字符库函数总结

  1. 管道
    管道实际上是两个指令用字符|隔开,所以我们只要对buffer稍加处理就可以变成对两个单指令切割

    定义一个指针找到管道标识符|,将该位置的字符改成\\0然后移一位,此时buffer和p2分别指向指令1和指令2的起始位置,这时就转换成两个单字符切割。对buffer先进行切割存入p指针数组,然后用NULL作为两个字符串切割结果的分割,然后再对p2数组切割存入p指针数组
    例如:如果我们键入ls -al | grep a.out ,最后的p指针数组应该为;

  2. 重定向
    和管道一个道理,只是重定向第二个是文件名而不是指令所以不用切割,例如:我们输入指令ls -al > log.txt
    理论上,最后的p指针数组应该为:
    char ** p="ls","-al",NULL,"log.txt";

同时我们在函数列表中传入了一个输出型参数用来标定该指令是普通指令还是重定向指令或管道指令,这对于我们下面的函数至关重要,不同类型的指令对应不同的执行策略
代码:

char **split_line(char *buffer, int *flag) // 将buffer分割成若干个指令数组 1 重定向 -1 管道  0 啥都不是


    char **p = (char **)malloc(6 * sizeof(char **));
    int size = 6;

    if (strstr(buffer, ">")) // 重定向
    
        // 将buffer按照重定向符切成两个字符串
        *flag = 1;
        char *p2 = strstr(buffer, ">"); // 重定向符 后面的字符串
        *p2 = '\\0';
        p2 += 1;

        int i = 0;
        p[i++] = strtok(buffer, " ");
        while ((p[i] = strtok(NULL, " ")) != NULL)
        
            i++;
            if (i >= size)
            
                p = append_space(p, i);
            
        

        i++;           // 两个 切成块的 字符串 用 NULL来分割
        if (i >= size) // 检查是否要扩容
        
            p = append_space(p, i);
        

        p[i++] = strtok(p2, " ");
        while ((p[i] = strtok(NULL, " ")) != NULL)
        
            i++;
            if (i >= size) // 检查是否要扩容
            
                p = append_space(p, i);
            
        
        return p;
    
    else if (strstr(buffer, "|"))
    
        // 将buffer按照重定向符切成两个字符串
        *flag = -1;
        char *p2 = strstr(buffer, "|"); // 重定向符 后面的字符串
        *p2 = '\\0';
        p2 += 1;

        int i = 0;
        p[i++] = strtok(buffer, " ");
        while ((p[i] = strtok(NULL, " ")) != NULL)
        
            i++;
            if (i >= size) // 检查是否要扩容
            
                p = append_space(p, i);
            
        

        i++;           // 两个 切成块的 字符串 用 NULL来分割
        if (i >= size) // 检查是否要扩容
        
            p = append_space(p, i);
        

        p[i++] = strtok(p2, " ");
        while ((p[i] = strtok(NULL, " ")) != NULL)
        
            i++;
            if (i >= size) // 检查是否要扩容
            
                p = append_space(p, i);
            
        
        return p;
    
    else // 没有管道 也没有重定向
    
        *flag = 0;
        int i = 0;
        p[i++] = strtok(buffer, " ");
        while ((p[i] = strtok(NULL, " ")) != NULL)
        
            i++;
            if (i >= size) // 检查是否要扩容
            
                p = append_space(p, i);
            
        
        return p;
    

int excute_line(char **buffer, int flag) // 执行指令的函数

由于切割时分成了三类,所以执行的时候也要分成三类😭

  1. 普通指令
    普通指令执行流程图大概是:

    一个进程替换就完事了
    但是我们容易忽视的是内置指令(builtin),内置指令不需要创建子进程使用进程替换,直接在main进程内执行,举一个简答的例子:cd .. ,我们如果用子进程去执行,执行完之后子进程所在目录改变了,但是父进程(main)所在的目录并不会改变。对于这些内置指令,功能用函数来定义,并用函数指针数组来表示整体,在执行普通指令之前判断

  2. 重定向:
    重定向的思路是:

    比普通指令多一步文件描述符替换,也就是将原本要打印到屏幕上的信息打印到文件里面,如果文件不存在就建立一个,很简单

  3. 管道:
    管道的思路是:

    管道就比较复杂了,首先要执行指令1,并进行文件描述符替换使其原本要打印到屏幕上的东西输入到管道中,然后再执行指令2,因为指令默认是从屏幕上读取信息,所以我们还要将指令2的标准输入和管道的读端文件描述符替换,最后检查替换完成任务,相对比较复杂

代码

char *builtin_str[] = "cd", "help", "exit"; // 内置指令的名称

int func_cd(const char *args)

    int ret = chdir(args);
    if (ret == -1)
    
        printf("cd is error\\n");
        return -1;
    
    else
        return 0;


int func_help(const char *args)

    printf("这是 石海涛 的shell\\n 我的shell支持重定向、管道、简单内置命令\\n更多内容访问我的博客\\n ");
    return 0;


int func_exit(const char *args)

    return EXIT_SHELL;


int (*func[])(const char *p) = &func_cd, &func_help, &func_exit; // 函数指针数组 数组中的顺序必须和builtin_str中一致

int excute_command(char **args) // 执行单个指令

    for (int i = 0; i < sizeof(builtin_str) / sizeof(char *); i++) // 检查是否是内置命令
    
        if (strcmp(args[0], builtin_str[i]) == 0)
        
            return func[i](args[1]);
        
    
    // 不是内置命令就要开始进程替换了
    __pid_t i = fork();
    if (i == 0)
    
        execvp(args[0], args);
    

    int status = 0;
    waitpid(i, &status, 0);

    return status >> 8;


int excute_line(char **buffer, int flag) // 执行指令的函数


    if (flag == 0)
    
        int ret = excute_command(buffer);
        return ret;
    
    else if (flag == 1) // 重定向
    

        char **file_name = buffer;
        while (*file_name != NULL)
        
            file_name++;
        

        file_name++; // 找到文件名

        pid_t i = fork();
        if (i == 0)
        
            int fd = open(*file_name, O_CREAT | O_RDWR, 0644);
            dup2(fd, 1);
            execvp(buffer[0], buffer);
        
        int status;
        waitpid(i, &status, 0);
        return status >> 8;
    
    else // 管道
    

        char **command2 = buffer;
        while (*command2 != NULL)
        
            command2++;
        

        command2++; // 找到指令2的起始位置
        // printf("%s\\n", *command2);

        pid_t i = fork();
        if (i == 0)
        
            int fd[2];
            if (pipe(fd) == -1)
                fprintf(stderr, "create pipe fail\\n");
            pid_t son = fork();
            if (son == 0)
            
                close(fd[0]);
                dup2(fd[1], 1);
                // printf("1\\n");
                execvp(buffer[0], buffer);
            
            // 父进程

            waitpid(son, NULL, 0);
            close(fd[1]);
            dup2(fd[0], 0);
            // printf("2\\n");
            execvp(command2[0], command2);
        
        int status;
        waitpid(i, &status, 0);
        return status >> 8;
    

总结

到此整个shell就差不多,语言层面对C语言字符串操作函数、读写文件函数要求比较高。完整代码放在gitee上了仓库

以上是关于如何写一个命令行解释器(SHELL)的主要内容,如果未能解决你的问题,请参考以下文章

linux下如何写个SHELL脚本,每天执行这么几句命令:

shell 编程

shell脚本入门

Linux进阶第七天

简单介绍linux中的shell脚本

Hadoop初级之shell脚本