让我们构建一个Linux Shell [第一部分]

从Unix的早期开始,shell就已经成为用户与操作系统的接口的一部分。 第一个Unix shell( Thompson shell )具有非常有限的功能,主要是I / O重定向和命令管道。 后来的shell在那个早期的shell上进行了扩展,并增加了越来越多的功能,这给了我们强大的功能,包括单词扩展,历史替换,循环和条件表达式等。

为什么使用本教程

在过去的20年中,我一直使用GNU / Linux作为主要操作系统。 我使用了许多GNU / Linux shell,包括但不限于bashkshzsh 。 但是,我一直被这个问题困扰: 是什么使shell打勾? 例如,例如:

  • Shell如何解析我的命令,将它们转换为可执行指令,然后执行这些命令?
  • Shell如何执行不同的单词扩展过程,例如参数扩展,命令替换和算术扩展?
  • Shell如何实现I / O重定向?
  • ... 等等。

由于大多数GNU / Linux外壳都是开源的,因此,如果您想学习外壳的内部工作原理,可以在线搜索源代码并开始深入研究(这就是我的实际工作)。 但是,这个建议实际上说起来容易做起来难。 例如,您应该从哪里开始阅读代码? 哪些源文件包含实现I / O重定向的代码? 在哪里可以找到解析用户命令的代码? 我想你明白了。

这就是为什么我决定编写本教程的原因,以帮助Linux用户和程序员更好地理解其shell。 我们将一起从头开始实现一个功能齐全的Linux Shell 在此过程中,我们将看到Linux shell如何通过实际编写执行上述任务的C代码来管理解析和执行命令,循环和条件表达式。 我们将讨论字扩展和I / O重定向,并看到执行功能的代码。

在本教程结束时,我们将拥有一个基本的Linux shell,目前尚不能做很多事情,但是在接下来的部分中我们将对其进行扩展和改进。 在本系列的最后,我们将提供一个功能齐全的Linux shell,该shell可以解析和执行一组相当复杂的命令,循环和表达式。

您将需要什么

为了遵循本教程,您将需要以下内容:

  • 一个运行良好的GNU / Linux系统(我个人使用UbuntuFedora ,但可以随意使用自己喜欢的Linux发行版)。
  • GCC (GNU编译器集合)来编译代码。
  • 编写代码的文本编辑器(我个人使用GEdit ,但您也可以使用VimEmacs或任何其他编辑器)。
  • 如何用C编程

我不会在这里详细介绍安装所需软件的细节。 如果不确定如何使系统运行上述任何软件包,请参考Linux发行版的文档,并确保在进行下一步操作之前已完成所有设置。

现在让我们开始做生意。 我们将从对构成Linux shell的鸟瞰图开始。

Linux Shell的组件

Shell是一个复杂的软件,包含许多不同的部分。

任何Linux Shell的核心部分是命令行解释器( CLI) 。 这部分有两个目的:读取和解析用户命令,然后执行解析的命令。 您可以将CLI本身分为两部分: 解析器 (或前端)和执行器 (或后端)。

解析器扫描输入并将其分解为令牌。 令牌由一个或多个字符(字母,数字,符号)组成,表示单个输入单位。 例如,令牌可以是变量名,关键字,数字或算术运算符。

解析器获取这些标记,将它们分组在一起,并创建一个特殊的结构,我们称为抽象语法树AST 。 您可以将AST视为您提供给Shell的命令行的高级表示。 解析器获取AST并将其传递给执行器 ,该执行器读取AST并执行解析后的命令。

Shell的另一部分是用户界面,通常在Shell处于交互模式时(例如,在Shell提示符下输入命令时)操作。 在这里,shell循环运行,我们称为Read-Eval-Print-LoopREPL。

就像循环名所示,shell读取输入,解析并执行它,然后循环读取下一个命令,依此类推,直到输入诸如exitshutdownreboot的命令。

大多数外壳程序实现一种称为符号表的结构,该外壳程序用于存储有关变量及其值和属性的信息。 我们将在本教程的第二部分中实现符号表。

Linux Shell还具有历史记录功能,该功能使用户可以访问最新输入的命令,然后无需过多输入即可编辑和重新执行命令。 Shell也可以包含内置实用程序 ,它们是作为Shell程序本身的一部分实现的一组特殊命令。

内置实用程序包括常用命令,例如cdfgbg 。 在学习本教程时,我们将实现许多内置实用程序。

现在我们知道了典型Linux shell的基本组件,让我们开始构建自己的shell。

我们的第一个壳

我们第一个版本的shell不会做任何花哨的事情。 它只会打印一个提示字符串,读取一行输入,然后将输入回显到屏幕上。 在本教程的后续部分中,我们将添加解析和执行命令,循环,条件表达式等的功能。

让我们从为该项目创建目录开始。 我通常在新项目中使用~/projects/ ,但是可以随意使用任何您喜欢的路径。

我们要做的第一件事是编写我们的基本REPL循环。 创建一个名为main.c的文件(使用touch main.c ),然后使用您喜欢的文本编辑器将其打开。 在main.c文件中输入以下代码:

# include <stdio.h>
# include <stdlib.h>
# include <errno.h>
# include <string.h>
# include "shell.h"

int main ( int argc, char **argv)
 {
    char *cmd;

    do
    {
        print_prompt1();

        cmd = read_cmd();

        if (!cmd)
        {
            exit (EXIT_SUCCESS);
        }

        if (cmd[ 0 ] == '\0' || strcmp (cmd, "\n" ) == 0 )
        {
            free (cmd);
            continue ;
        }

        if ( strcmp (cmd, "exit\n" ) == 0 )
        {
            free (cmd);
            break ;
        }

        printf ( "%s\n" , cmd);

        free (cmd);

    } while ( 1 );

    exit (EXIT_SUCCESS);
}

我们的main()函数非常简单,因为它只需要实现REPL循环。 我们首先打印外壳程序的提示符,然后读取命令(现在,让我们将命令定义为以\n结尾的输入行)。 如果读取命令时出错,则退出外壳。 如果命令为空(即用户没有输入任何内容就按ENTER ),则跳过此输入并继续循环。

如果命令为exit ,则退出外壳。 否则,我们将回显命令,释放用于存储命令的内存,然后继续循环。 很简单,不是吗?

我们的main()函数调用了两个自定义函数print_prompt1()read_cmd() 。 第一个函数输出提示字符串,第二个函数读取输入的下一行。 让我们仔细看看这两个函数。

打印提示字符串

我们说过,shell在读取每个命令之前会打印一个提示字符串。 实际上,有五种不同类型的提示字符串: PS0PS1PS2PS3PS4 。 第零个字符串PS0仅由bash使用 ,因此我们在这里不考虑它。 当外壳要将某些消息传达给用户时,其他四个字符串会在特定时间打印。

在本节中,我们将讨论PS1PS2 。 其余的将在以后讨论更高级的Shell主题时出现。

现在创建源文件prompt.c并输入以下代码:

# include <stdio.h>
# include "shell.h"

void print_prompt1 ( void )
 {
    fprintf ( stderr , "$ " );
}

void print_prompt2 ( void )
 {
    fprintf ( stderr , "> " );
}

第一个函数显示第一个提示字符串,PS1 ,在外壳程序等待您输入命令时通常会看到它。 第二个函数将打印第二个提示字符串,PS2 ,当您输入多行命令时,该字符串将由外壳程序打印(有关更多信息,请参见下文)。

接下来,让我们阅读一些用户输入。

读取用户输入

打开文件main.c并在main()函数之后紧随其后输入以下代码:

char * read_cmd ( void )
 {
    char buf[ 1024 ];
    char *ptr = NULL ;
    char ptrlen = 0 ;

    while (fgets(buf, 1024 , stdin ))
    {
        int buflen = strlen (buf);

        if (!ptr)
        {
            ptr = malloc (buflen+ 1 );
        }
        else
        {
            char *ptr2 = realloc (ptr, ptrlen+buflen+ 1 );

            if (ptr2)
            {
                ptr = ptr2;
            }
            else
            {
                free (ptr);
                ptr = NULL ;
            }
        }

        if (!ptr)
        {
            fprintf ( stderr , "error: failed to alloc buffer: %s\n" , strerror(errno));
            return NULL ;
        }

        strcpy (ptr+ptrlen, buf);

        if (buf[buflen -1 ] == '\n' )
        {
            if (buflen == 1 || buf[buflen -2 ] != '\\' )
            {
                return ptr;
            }

            ptr[ptrlen+buflen -2 ] = '\0' ;
            buflen -= 2 ;
            print_prompt2();
        }

        ptrlen += buflen;
    }

    return ptr;
}

在这里,我们以1024字节的块大小从stdin中读取输入,并将输入存储在缓冲区中。 第一次读取输入(当前命令的第一个块)时,我们使用malloc()创建缓冲区。 对于后续块,我们使用realloc()扩展缓冲区。 我们在这里不应该遇到任何内存问题,但是如果发生错误,我们将输出一条错误消息并返回NULL 。 如果一切顺利,我们会将刚从用户读取的输入块复制到缓冲区中,并相应地调整指针。

最后的代码块很有趣。 为了理解为什么我们需要此代码块,让我们考虑以下示例。 假设您要输入非常长的输入行:

echo "This is a very long line of input, one that needs to span two, three, or perhaps even more lines of input, so that we can feed it to the shell"

这是一个愚蠢的例子,但它完美地展示了我们在说什么。 要输入这么长的命令,我们可以将整个内容写在一行中(就像我们在此处所做的那样),这是一个繁琐而丑陋的过程。 或者,我们可以将线切成较小的部分,然后将这些部分一次放入外壳中:

echo "This is a very long line of input, \
      one that needs to span two, three, \
      or perhaps even more lines of input, \
      so that we can feed it to the shell"

键入第一行后,为了让外壳程序知道我们还没有完成输入,我们在每行前都使用反斜杠字符\\ ,并在其后加上换行符(我也对行进行了缩进以使它们更具可读性)。 我们称这个转义换行符。 当外壳程序看到转义的换行符时,它知道需要丢弃这两个字符并继续读取输入。

现在,让我们回到read_cmd()函数。 我们正在讨论最后的代码块,内容为:

if (buf[buflen -1 ] == '\n' )
        {
            if (buflen == 1 || buf[buflen -2 ] != '\\' )
            {
                return ptr;
            }

            ptr[ptrlen+buflen -2 ] = '\0' ;
            buflen -= 2 ;
            print_prompt2();
        }

在这里,我们检查缓冲区中输入的内容是否以\n结尾,如果是,则以反斜杠字符\\转义 \n 。 如果最后一个\n没有转义,则输入行已完成,我们将其返回给main()函数。 否则,我们将删除两个字符( \\\n ),打印出PS2 ,然后继续读取输入。

编译外壳

使用以上代码,我们的利基外壳几乎可以编译了。 在继续编译外壳程序之前,我们将只添加带有函数原型的头文件。 此步骤是可选的,但它可以大大提高我们的代码可读性,并防止一些编译器警告。

创建源文件shell.h ,然后输入以下代码:

# ifndef SHELL_H
# define SHELL_H

void print_prompt1 ( void ) ;
void print_prompt2 ( void ) ;

char * read_cmd ( void ) ;

# endif

现在让我们编译外壳。 打开您喜欢的终端仿真器(我使用GNOME TerminalKonsole测试了我的命令行项目,但是您也可以使用XTerm ,其他终端仿真器或Linux的虚拟控制台之一 )。 导航到您的源目录,并确保其中包含3个文件:

现在,使用以下命令编译shell:

gcc -o shell main.c prompt.c

如果一切顺利, gcc将不会输出任何内容,并且在当前目录中应该有一个名为shell的可执行文件:

现在,通过运行./shell调用Shell,并尝试输入一些命令:

在第一种情况下,外壳程序会打印PS1 ,默认为$和一个空格。 我们输入命令echo Hello World ,外壳程序将其回显给我们(我们将在第二部分中扩展外壳程序,以使其能够解析并执行此(以及其他)简单命令)。

在第二种情况下,shell再次回显我们的命令(稍长)。 在第三种情况下,我们将long命令分为4行。 请注意,每次我们在输入反斜杠后按ENTER ,shell都会打印PS2并继续读取输入。 输入最后一行后,shell合并所有行,删除所有转义的换行符,然后将命令回显给我们。

要从外壳exit ,请键入exit ,然后ENTER

就是这样! 我们刚刚完成了第一个Linux shell的编写。 好极了!

下一步是什么

尽管我们的shell目前可以使用,但是它没有任何用处。 在下一部分中,我们将修复外壳,使其能够解析和执行简单
命令。

敬请关注!

翻译自: https://hackernoon.com/lets-build-a-linux-shell-part-i-bz3n3vg1

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值