IT运维

如何构建一个Linux Shell(一)

2020-07-23 18:22:47 | 来源:中培企业IT培训网

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

  为什么使用本教程?

在过去的20年中,我一直使用GNU / Linux作为主要操作系统。我使用了许多GNU / Linux shell,包括但不限于bash,ksh和zsh。但是,我一直被这个问题困扰:是什么使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系统。

GCC来编译代码。

编写代码的文本编辑器。

  如何用C编程

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

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

  Linux Shell的组件

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

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

该解析器将扫描输入,将其分解到令牌。甲令牌由一个或多个字符,和表示输入的单个单元。例如,令牌可以是变量名,关键字,数字或算术运算符。

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

Shell的另一部分是用户界面,通常在Shell处于交互模式时操作。在这里,shell循环运行,我们称为Read-Eval-Print-Loop或REPL。

就像循环的名称所示,shell读取输入,解析并执行输入,然后循环读取下一个命令,依此类推,直到输入以下命令为止: exit , shutdown, 要么 reboot。

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

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

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

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

  我们的第一个壳

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

让我们从为该项目创建目录开始。我通常使用~/projects/ 用于我的新项目,但是请随意使用您喜欢的任何方式。

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

#include

#include

#include

#include

#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] == '' || strcmp(cmd, " ") == 0)

{

free(cmd);

continue;

}

if(strcmp(cmd, "exit ") == 0)

{

free(cmd);

break;

}

printf("%s ", cmd);

free(cmd);

} while(1);

exit(EXIT_SUCCESS);

}

我们的 main()函数非常简单,因为它只需要实现REPL循环。我们首先打印外壳程序的提示符,然后读取命令。如果读取命令时出错,则退出外壳。如果命令为空,我们跳过此输入并继续循环。

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

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

打印提示字符串

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

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

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

#include

#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 ", strerror(errno));

return NULL;

}

strcpy(ptr+ptrlen, buf);

if(buf[buflen-1] == ' ')

{

if(buflen == 1 || buf[buflen-2] != '\')

{

return ptr;

}

ptr[ptrlen+buflen-2] = '';

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] == ' ')

{

if(buflen == 1 || buf[buflen-2] != '\')

{

return ptr;

}

ptr[ptrlen+buflen-2] = '';

buflen -= 2;

print_prompt2();

}

在这里,我们检查缓冲区中输入的内容是否以 如果是的话 是逃脱通过一个反斜杠字符\。如果最后 未转义,输入行已完成,我们将其返回到 main()功能。否则,我们删除两个字符(\ 和 ),打印出PS2,然后继续读取输入。

  编译外壳

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

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

#ifndef SHELL_H

#define SHELL_H

void print_prompt1(void);

void print_prompt2(void);

char *read_cmd(void);

#endif

现在让我们编译外壳。打开您喜欢的终端仿真器。导航到您的源目录,并确保其中包含3个文件:

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

gcc -o shell main.c prompt.c

如果一切顺利 gcc 应该不输出任何东西,并且应该有一个名为 shell 在当前目录中:

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

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

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

要退出外壳,请键入 exit, 其次是 ENTER:

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

下一步是什么

尽管我们的shell目前可以使用,但是它没有任何用处。在下一部分中,我们将修复外壳,使其能够解析和执行简单命令。更多关于Linux的信息,请继续关注中培伟业。

标签: Linux IT运维